iOS Dialog blur, brightness, and layout (#18381)

Rewrote CupertinoAlertDialog to look nearly identical to an alert dialog in iOS. This includes considerations for blur, translucent white background color, button sizing, gap dividers between buttons, and text scaling layout behavior. (#18381)
This commit is contained in:
matthew-carroll 2018-08-15 12:59:40 -07:00 committed by GitHub
parent 25ba90aafa
commit 21bc9f1b02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 2009 additions and 430 deletions

File diff suppressed because it is too large Load diff

View file

@ -83,11 +83,11 @@ class BoxDecoration extends Decoration {
this.backgroundBlendMode,
this.shape = BoxShape.rectangle,
}) : assert(shape != null),
// TODO(mattcarroll): Use "backgroundBlendMode == null" when Dart #31140 is in.
// TODO(mattcarroll): Use "backgroundBlendMode == null" when https://github.com/dart-lang/sdk/issues/31140 is in.
assert(
identical(backgroundBlendMode, null) || color != null || gradient != null,
'backgroundBlendMode applies to BoxDecoration\'s background color or'
'gradient, but no color or gradient were provided.'
identical(backgroundBlendMode, null) || color != null || gradient != null,
'backgroundBlendMode applies to BoxDecoration\'s background color or '
'gradient, but no color or gradient were provided.'
);
@override
@ -146,10 +146,10 @@ class BoxDecoration extends Decoration {
/// The blend mode applied to the [color] or [gradient] background of the box.
///
/// If no [backgroundBlendMode] is provided, then the default painting blend
/// If no [backgroundBlendMode] is provided then the default painting blend
/// mode is used.
///
/// If no [color] or [gradient] is provided, then blend mode has no impact.
/// If no [color] or [gradient] is provided then the blend mode has no impact.
final BlendMode backgroundBlendMode;
/// The shape to fill the background [color], [gradient], and [image] into and

View file

@ -2,66 +2,52 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
void main() {
testWidgets('Alert dialog control test', (WidgetTester tester) async {
bool didDelete = false;
await tester.pumpWidget(new MaterialApp(
home: new Material(
child: new Center(
child: new Builder(
builder: (BuildContext context) {
return new RaisedButton(
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new CupertinoAlertDialog(
title: const Text('The title'),
content: const Text('The content'),
actions: <Widget>[
const CupertinoDialogAction(
child: Text('Cancel'),
),
new CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () {
showDialog<void>(
context: context,
builder: (BuildContext context) {
return new CupertinoAlertDialog(
title: const Text('The title'),
content: const Text('The content'),
actions: <Widget>[
const CupertinoDialogAction(
child: Text('Cancel'),
),
new CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () {
didDelete = true;
Navigator.pop(context);
},
child: const Text('Delete'),
),
],
);
},
);
didDelete = true;
Navigator.pop(context);
},
child: const Text('Go'),
);
},
),
),
child: const Text('Delete'),
),
],
);
},
),
));
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(didDelete, isFalse);
await tester.tap(find.text('Delete'));
await tester.pump();
expect(didDelete, isTrue);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Delete'), findsNothing);
});
@ -85,7 +71,7 @@ void main() {
final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle));
expect(widget.style.fontWeight, equals(FontWeight.w600));
expect(widget.style.fontWeight, equals(FontWeight.w400));
});
testWidgets('Default and destructive style', (WidgetTester tester) async {
@ -97,53 +83,38 @@ void main() {
final DefaultTextStyle widget = tester.widget(find.byType(DefaultTextStyle));
expect(widget.style.fontWeight, equals(FontWeight.w600));
expect(widget.style.fontWeight, equals(FontWeight.w400));
expect(widget.style.color.red, greaterThan(widget.style.color.blue));
});
testWidgets('Message is scrollable, has correct padding with large text sizes',
(WidgetTester tester) async {
final ScrollController scrollController = new ScrollController(keepScrollOffset: true);
testWidgets('Message is scrollable, has correct padding with large text sizes', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
new MaterialApp(home: new Material(
child: new Center(
child: new Builder(builder: (BuildContext context) {
return new RaisedButton(
onPressed: () {
showDialog<void>(
context: context,
builder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0),
child: new CupertinoAlertDialog(
title: const Text('The Title'),
content: new Text('Very long content ' * 20),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('Cancel'),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text('OK'),
),
],
scrollController: scrollController,
),
);
},
);
},
child: const Text('Go'),
);
}),
),
)),
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0),
child: new CupertinoAlertDialog(
title: const Text('The Title'),
content: new Text('Very long content ' * 20),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('Cancel'),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text('OK'),
),
],
scrollController: scrollController,
),
);
}
)
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
scrollController.jumpTo(100.0);
@ -151,80 +122,113 @@ void main() {
// Set the scroll position back to zero.
scrollController.jumpTo(0.0);
// Find the actual dialog box. The first decorated box is the popup barrier.
expect(tester.getSize(find.byType(DecoratedBox).at(1)), equals(const Size(270.0, 560.0)));
await tester.pumpAndSettle();
// Check sizes/locations of the text.
expect(tester.getSize(find.text('The Title')), equals(const Size(230.0, 171.0)));
expect(tester.getSize(find.text('Cancel')), equals(const Size(87.0, 300.0)));
expect(tester.getSize(find.text('OK')), equals(const Size(87.0, 100.0)));
expect(tester.getTopLeft(find.text('The Title')), equals(const Offset(285.0, 40.0)));
// Expect the modal dialog box to take all available height.
expect(
tester.getSize(
find.byType(ClipRRect)
),
equals(const Size(310.0, 560.0)),
);
// The Cancel and OK buttons have different Y values because "Cancel" is
// wrapping (as it should with large text sizes like this).
expect(tester.getTopLeft(find.text('Cancel')), equals(const Offset(289.0, 466.0)));
expect(tester.getTopLeft(find.text('OK')), equals(const Offset(424.0, 566.0)));
// Check sizes/locations of the text. The text is large so these 2 buttons are stacked.
// Visually the "Cancel" button and "OK" button are the same height when using the
// regular font. However, when using the test font, "Cancel" becomes 2 lines which
// is why the height we're verifying for "Cancel" is larger than "OK".
expect(tester.getSize(find.text('The Title')), equals(const Size(270.0, 162.0)));
expect(tester.getTopLeft(find.text('The Title')), equals(const Offset(265.0, 80.0)));
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Cancel')), equals(const Size(310.0, 148.0)));
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'OK')), equals(const Size(310.0, 98.0)));
});
testWidgets('Button list is scrollable, has correct position with large text sizes.',
(WidgetTester tester) async {
const double textScaleFactor = 3.0;
final ScrollController scrollController = new ScrollController(keepScrollOffset: true);
testWidgets('Dialog respects small constraints.', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
new MaterialApp(home: new Material(
child: new Center(
child: new Builder(builder: (BuildContext context) {
return new RaisedButton(
onPressed: () {
showDialog<Null>(
context: context,
builder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor),
child: new CupertinoAlertDialog(
title: const Text('The title'),
content: const Text('The content.'),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('One'),
),
CupertinoDialogAction(
child: Text('Two'),
),
CupertinoDialogAction(
child: Text('Three'),
),
CupertinoDialogAction(
child: Text('Chocolate Brownies'),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text('Cancel'),
),
],
actionScrollController: scrollController,
),
);
},
);
},
child: const Text('Go'),
);
}),
),
)),
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new Center(
child: new ConstrainedBox(
// Constrain the dialog to a tiny size and ensure it respects
// these exact constraints.
constraints: new BoxConstraints.tight(const Size(200.0, 100.0)),
child: new CupertinoAlertDialog(
title: const Text('The Title'),
content: const Text('The message'),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('Option 1'),
),
CupertinoDialogAction(
child: Text('Option 2'),
),
CupertinoDialogAction(
child: Text('Option 3'),
),
],
scrollController: scrollController,
),
),
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pump();
const double topAndBottomMargin = 40.0;
final Finder modalFinder = find.byType(ClipRRect);
expect(
tester.getSize(modalFinder),
equals(const Size(200.0, 100.0 - topAndBottomMargin)),
);
});
testWidgets('Button list is scrollable, has correct position with large text sizes.', (WidgetTester tester) async {
final ScrollController actionScrollController = new ScrollController();
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0),
child: new CupertinoAlertDialog(
title: const Text('The title'),
content: const Text('The content.'),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('One'),
),
CupertinoDialogAction(
child: Text('Two'),
),
CupertinoDialogAction(
child: Text('Three'),
),
CupertinoDialogAction(
child: Text('Chocolate Brownies'),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text('Cancel'),
),
],
actionScrollController: actionScrollController,
),
);
}
)
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Check that the action buttons list is scrollable.
expect(scrollController.offset, 0.0);
scrollController.jumpTo(100.0);
expect(scrollController.offset, 100.0);
scrollController.jumpTo(0.0);
expect(actionScrollController.offset, 0.0);
actionScrollController.jumpTo(100.0);
expect(actionScrollController.offset, 100.0);
actionScrollController.jumpTo(0.0);
// Check that the action buttons are aligned vertically.
expect(tester.getCenter(find.widgetWithText(CupertinoDialogAction, 'One')).dx, equals(400.0));
@ -236,101 +240,498 @@ void main() {
// Check that the action buttons are the correct heights.
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'One')).height, equals(98.0));
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Two')).height, equals(98.0));
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Three')).height, equals(148.0));
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Chocolate Brownies')).height, equals(298.0));
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Three')).height, equals(98.0));
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Chocolate Brownies')).height, equals(248.0));
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Cancel')).height, equals(148.0));
});
testWidgets('Title Section is empty, Button section is not empty.',
(WidgetTester tester) async {
testWidgets('Title Section is empty, Button section is not empty.', (WidgetTester tester) async {
const double textScaleFactor = 1.0;
final ScrollController scrollController = new ScrollController(keepScrollOffset: true);
final ScrollController actionScrollController = new ScrollController();
await tester.pumpWidget(
new MaterialApp(home: new Material(
child: new Center(
child: new Builder(builder: (BuildContext context) {
return new RaisedButton(
onPressed: () {
showDialog<Null>(
context: context,
builder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor),
child: new CupertinoAlertDialog(
actions: const <Widget>[
CupertinoDialogAction(
child: Text('One'),
),
CupertinoDialogAction(
child: Text('Two'),
),
],
actionScrollController: scrollController,
),
);
},
);
},
child: const Text('Go'),
);
}),
),
)),
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor),
child: new CupertinoAlertDialog(
actions: const <Widget>[
CupertinoDialogAction(
child: Text('One'),
),
CupertinoDialogAction(
child: Text('Two'),
),
],
actionScrollController: actionScrollController,
),
);
}
),
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Check that the dialog size is the same as the actions section size. This
// ensures that an empty content section doesn't accidentally render some
// empty space in the dialog.
final Finder contentSectionFinder = find.byElementPredicate((Element element) {
return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection';
});
final Finder modalBoundaryFinder = find.byType(ClipRRect);
expect(
tester.getSize(contentSectionFinder),
tester.getSize(modalBoundaryFinder),
);
// Check that the title/message section is not displayed
expect(scrollController.offset, 0.0);
expect(tester.getTopLeft(find.widgetWithText(CupertinoDialogAction, 'One')).dy, equals(283.5));
expect(actionScrollController.offset, 0.0);
expect(tester.getTopLeft(find.widgetWithText(CupertinoDialogAction, 'One')).dy, equals(277.5));
// Check that the button's vertical size is the same.
expect(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'One')).height,
equals(tester.getSize(find.widgetWithText(CupertinoDialogAction, 'Two')).height));
});
testWidgets('Button section is empty, Title section is not empty.',
(WidgetTester tester) async {
testWidgets('Button section is empty, Title section is not empty.', (WidgetTester tester) async {
const double textScaleFactor = 1.0;
final ScrollController scrollController = new ScrollController(keepScrollOffset: true);
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
new MaterialApp(home: new Material(
child: new Center(
child: new Builder(builder: (BuildContext context) {
return new RaisedButton(
onPressed: () {
showDialog<Null>(
context: context,
builder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor),
child: new CupertinoAlertDialog(
title: const Text('The title'),
content: const Text('The content.'),
scrollController: scrollController,
),
);
},
);
},
child: const Text('Go'),
);
}),
),
)),
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor),
child: new CupertinoAlertDialog(
title: const Text('The title'),
content: const Text('The content.'),
scrollController: scrollController,
),
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Check that there's no button action section.
expect(scrollController.offset, 0.0);
expect(find.widgetWithText(CupertinoDialogAction, 'One'), findsNothing);
// Check that the dialog size is the same as the content section size. This
// ensures that an empty button section doesn't accidentally render some
// empty space in the dialog.
final Finder contentSectionFinder = find.byElementPredicate((Element element) {
return element.widget.runtimeType.toString() == '_CupertinoAlertContentSection';
});
final Finder modalBoundaryFinder = find.byType(ClipRRect);
expect(
tester.getSize(contentSectionFinder),
tester.getSize(modalBoundaryFinder),
);
});
testWidgets('Actions section height for 1 button is height of button.', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new CupertinoAlertDialog(
title: const Text('The Title'),
content: const Text('The message'),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('OK'),
),
],
scrollController: scrollController,
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pump();
final RenderBox okButtonBox = findActionButtonRenderBoxByTitle(tester, 'OK');
final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester);
expect(okButtonBox.size.width, actionsSectionBox.size.width);
expect(okButtonBox.size.height, actionsSectionBox.size.height);
});
testWidgets('Actions section height for 2 side-by-side buttons is height of tallest button.', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
double dividerWidth; // Will be set when the dialog builder runs. Needs a BuildContext.
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
dividerWidth = 1.0 / MediaQuery.of(context).devicePixelRatio;
return new CupertinoAlertDialog(
title: const Text('The Title'),
content: const Text('The message'),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('OK'),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text('Cancel'),
),
],
scrollController: scrollController,
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pump();
final RenderBox okButtonBox = findActionButtonRenderBoxByTitle(tester, 'OK');
final RenderBox cancelButtonBox = findActionButtonRenderBoxByTitle(tester, 'Cancel');
final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester);
expect(okButtonBox.size.width, cancelButtonBox.size.width);
expect(
actionsSectionBox.size.width,
okButtonBox.size.width + cancelButtonBox.size.width + dividerWidth,
);
expect(
actionsSectionBox.size.height,
max(okButtonBox.size.height, cancelButtonBox.size.height),
);
});
testWidgets('Actions section height for 2 stacked buttons with enough room is height of both buttons.', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
double dividerThickness; // Will be set when the dialog builder runs. Needs a BuildContext.
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
dividerThickness = 1.0 / MediaQuery.of(context).devicePixelRatio;
return new CupertinoAlertDialog(
title: const Text('The Title'),
content: const Text('The message'),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('OK'),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text('This is too long to fit'),
),
],
scrollController: scrollController,
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pump();
final RenderBox okButtonBox = findActionButtonRenderBoxByTitle(tester, 'OK');
final RenderBox longButtonBox = findActionButtonRenderBoxByTitle(tester, 'This is too long to fit');
final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester);
expect(okButtonBox.size.width, longButtonBox.size.width);
expect(okButtonBox.size.width, actionsSectionBox.size.width);
expect(
okButtonBox.size.height + dividerThickness + longButtonBox.size.height,
actionsSectionBox.size.height,
);
});
testWidgets('Actions section height for 2 stacked buttons without enough room and regular font is 1.5 buttons tall.', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new CupertinoAlertDialog(
title: const Text('The Title'),
content: new Text('The message\n' * 40),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('OK'),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text('This is too long to fit'),
),
],
scrollController: scrollController,
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pumpAndSettle();
final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester);
expect(
actionsSectionBox.size.height,
67.83333333333337,
);
});
testWidgets('Actions section height for 2 stacked buttons without enough room and large accessibility font is 50% of dialog height.', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0),
child: new CupertinoAlertDialog(
title: const Text('The Title'),
content: new Text('The message\n' * 20),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('This button is multi line'),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text('This button is multi line'),
),
],
scrollController: scrollController,
),
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pumpAndSettle();
final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester);
// The two multi-line buttons with large text are taller than 50% of the
// dialog height, but with the accessibility layout policy, the 2 buttons
// should be in a scrollable area equal to half the dialog height.
expect(
actionsSectionBox.size.height,
280.0,
);
});
testWidgets('Actions section height for 3 buttons without enough room is 1.5 buttons tall.', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new CupertinoAlertDialog(
title: const Text('The Title'),
content: new Text('The message\n' * 40),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('Option 1'),
),
CupertinoDialogAction(
child: Text('Option 2'),
),
CupertinoDialogAction(
child: Text('Option 3'),
),
],
scrollController: scrollController,
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pumpAndSettle();
final RenderBox option1ButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 1');
final RenderBox option2ButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 2');
final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester);
expect(option1ButtonBox.size.width, option2ButtonBox.size.width);
expect(option1ButtonBox.size.width, actionsSectionBox.size.width);
// Expected Height = button 1 + divider + 1/2 button 2 = 67.83333333333334
// Technically the following number is off by 0.00000000000003 but I think it's a
// Dart precision issue. I ran the subtraction directly in dartpad and still
// got 67.83333333333337.
const double expectedHeight = 67.83333333333337;
expect(
actionsSectionBox.size.height,
expectedHeight,
);
});
testWidgets('Actions section overscroll is painted white.', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new CupertinoAlertDialog(
title: const Text('The Title'),
content: const Text('The message'),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('Option 1'),
),
CupertinoDialogAction(
child: Text('Option 2'),
),
CupertinoDialogAction(
child: Text('Option 3'),
),
],
scrollController: scrollController,
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pump();
final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester);
// The way that overscroll white is accomplished in a scrollable action
// section is that the custom RenderBox that lays out the buttons and draws
// the dividers also paints a white background the size of Rect.largest.
// That background ends up being clipped by the containing ScrollView.
//
// Here we test that the largest Rect is contained within the painted Path.
// We don't test for exclusion because for some reason the Path is reporting
// that even points beyond Rect.largest are within the Path. That's not an
// issue for our use-case, so we don't worry about it.
expect(actionsSectionBox, paints..path(
includes: <Offset>[
new Offset(Rect.largest.left, Rect.largest.top),
new Offset(Rect.largest.right, Rect.largest.bottom),
],
));
});
testWidgets('Pressed button changes appearance and dividers disappear.', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
double dividerThickness; // Will be set when the dialog builder runs. Needs a BuildContext.
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
dividerThickness = 1.0 / MediaQuery.of(context).devicePixelRatio;
return new CupertinoAlertDialog(
title: const Text('The Title'),
content: const Text('The message'),
actions: const <Widget>[
CupertinoDialogAction(
child: Text('Option 1'),
),
CupertinoDialogAction(
child: Text('Option 2'),
),
CupertinoDialogAction(
child: Text('Option 3'),
),
],
scrollController: scrollController,
);
},
),
);
await tester.tap(find.text('Go'));
await tester.pump();
const Color normalButtonBackgroundColor = Color(0xc0ffffff);
const Color pressedButtonBackgroundColor = Color(0x90ffffff);
final RenderBox firstButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 1');
final RenderBox secondButtonBox = findActionButtonRenderBoxByTitle(tester, 'Option 2');
final RenderBox actionsSectionBox = findScrollableActionsSectionRenderBox(tester);
final Offset pressedButtonCenter = new Offset(
secondButtonBox.size.width / 2.0,
firstButtonBox.size.height + dividerThickness + (secondButtonBox.size.height / 2.0),
);
final Offset topDividerCenter = new Offset(
secondButtonBox.size.width / 2.0,
firstButtonBox.size.height + (0.5 * dividerThickness),
);
final Offset bottomDividerCenter = new Offset(
secondButtonBox.size.width / 2.0,
firstButtonBox.size.height
+ dividerThickness
+ secondButtonBox.size.height
+ (0.5 * dividerThickness),
);
// Before pressing the button, verify following expectations:
// - Background includes the button that will be pressed
// - Background excludes the divider above and below the button that will be pressed
// - Pressed button background does NOT include the button that will be pressed
expect(actionsSectionBox, paints
..path(
color: normalButtonBackgroundColor,
includes: <Offset>[
pressedButtonCenter,
],
excludes: <Offset>[
topDividerCenter,
bottomDividerCenter,
],
)
..path(
color: pressedButtonBackgroundColor,
excludes: <Offset>[
pressedButtonCenter,
],
),
);
// Press down on the button.
final TestGesture gesture = await tester.press(find.widgetWithText(CupertinoDialogAction, 'Option 2'));
await tester.pump();
// While pressing the button, verify following expectations:
// - Background excludes the pressed button
// - Background includes the divider above and below the pressed button
// - Pressed button background includes the pressed
expect(actionsSectionBox, paints
..path(
color: normalButtonBackgroundColor,
// The background should contain the divider above and below the pressed
// button. While pressed, surrounding dividers disappear, which means
// they become part of the background.
includes: <Offset>[
topDividerCenter,
bottomDividerCenter,
],
// The background path should not include the tapped button background...
excludes: <Offset>[
pressedButtonCenter,
],
)
// For a pressed button, a dedicated path is painted with a pressed button
// background color...
..path(
color: pressedButtonBackgroundColor,
includes: <Offset>[
pressedButtonCenter,
],
),
);
// We must explicitly cause an "up" gesture to avoid a crash.
// todo(mattcarroll) remove this call when #19540 is fixed
await gesture.up();
});
testWidgets('ScaleTransition animation for showCupertinoDialog()', (WidgetTester tester) async {
@ -504,9 +905,45 @@ void main() {
});
}
RenderBox findActionButtonRenderBoxByTitle(WidgetTester tester, String title) {
final RenderObject buttonBox = tester.renderObject(find.widgetWithText(CupertinoDialogAction, title));
assert(buttonBox is RenderBox);
return buttonBox;
}
RenderBox findScrollableActionsSectionRenderBox(WidgetTester tester) {
final RenderObject actionsSection = tester.renderObject(find.byElementPredicate(
(Element element) {
return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection';
}),
);
assert(actionsSection is RenderBox);
return actionsSection;
}
Widget createAppWithButtonThatLaunchesDialog({WidgetBuilder dialogBuilder}) {
return new MaterialApp(
home: new Material(
child: new Center(
child: new Builder(builder: (BuildContext context) {
return new RaisedButton(
onPressed: () {
showDialog<void>(
context: context,
builder: dialogBuilder,
);
},
child: const Text('Go'),
);
}),
),
),
);
}
Widget boilerplate(Widget child) {
return new Directionality(
textDirection: TextDirection.ltr,
child: child,
);
}
}

View file

@ -273,6 +273,17 @@ abstract class WidgetController {
});
}
/// Dispatch a pointer down at the center of the given widget, assuming it is
/// exposed.
///
/// If the center of the widget is not exposed, this might send events to
/// another object.
Future<TestGesture> press(Finder finder, { int pointer }) {
return TestAsyncUtils.guard<TestGesture>(() {
return startGesture(getCenter(finder), pointer: pointer);
});
}
/// Dispatch a pointer down / pointer up sequence (with a delay of
/// [kLongPressTimeout] + [kPressTimeout] between the two events) at the
/// center of the given widget, assuming it is exposed.

View file

@ -57,7 +57,7 @@ class TestAsyncUtils {
/// this one before this one has finished will throw an exception.
///
/// This method first calls [guardSync].
static Future<Null> guard(Future<Null> body()) {
static Future<T> guard<T>(Future<T> body()) {
guardSync();
final Zone zone = Zone.current.fork(
zoneValues: <dynamic, dynamic>{
@ -66,8 +66,9 @@ class TestAsyncUtils {
);
final _AsyncScope scope = new _AsyncScope(StackTrace.current, zone);
_scopeStack.add(scope);
final Future<Null> result = scope.zone.run(body);
Future<Null> completionHandler(dynamic error, StackTrace stack) {
final Future<T> result = scope.zone.run<Future<T>>(body);
T resultValue; // This is set when the body of work completes with a result value.
Future<T> completionHandler(dynamic error, StackTrace stack) {
assert(_scopeStack.isNotEmpty);
assert(_scopeStack.contains(scope));
bool leaked = false;
@ -102,11 +103,12 @@ class TestAsyncUtils {
throw new FlutterError(message.toString().trimRight());
}
if (error != null)
return new Future<Null>.error(error, stack);
return new Future<Null>.value(null);
return new Future<T>.error(error, stack);
return new Future<T>.value(resultValue);
}
return result.then<Null>(
(Null value) {
return result.then<T>(
(T value) {
resultValue = value;
return completionHandler(null, null);
},
onError: completionHandler