Unverified Commit 21bc9f1b authored by matthew-carroll's avatar matthew-carroll Committed by GitHub

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)
parent 25ba90aa
......@@ -2,7 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
......@@ -14,9 +18,8 @@ const TextStyle _kCupertinoDialogTitleStyle = TextStyle(
fontFamily: '.SF UI Display',
inherit: false,
fontSize: 18.0,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
color: CupertinoColors.black,
height: 1.06,
letterSpacing: 0.48,
textBaseline: TextBaseline.alphabetic,
);
......@@ -25,9 +28,10 @@ const TextStyle _kCupertinoDialogContentStyle = TextStyle(
fontFamily: '.SF UI Text',
inherit: false,
fontSize: 13.4,
fontWeight: FontWeight.w300,
fontWeight: FontWeight.w400,
color: CupertinoColors.black,
height: 1.036,
letterSpacing: -0.25,
textBaseline: TextBaseline.alphabetic,
);
......@@ -40,20 +44,62 @@ const TextStyle _kCupertinoDialogActionStyle = TextStyle(
textBaseline: TextBaseline.alphabetic,
);
// iOS dialogs have a normal display width and another display width that is
// used when the device is in accessibility mode. Each of these widths are
// listed below.
const double _kCupertinoDialogWidth = 270.0;
const BoxDecoration _kCupertinoDialogFrontFillDecoration = BoxDecoration(
color: Color(0xccffffff),
);
const BoxDecoration _kCupertinoDialogBackFill = BoxDecoration(
color: Color(0x77ffffff),
const double _kAccessibilityCupertinoDialogWidth = 310.0;
// _kCupertinoDialogBlurOverlayDecoration is applied to the blurred backdrop to
// lighten the blurred image. Brightening is done to counteract the dark modal
// barrier that appears behind the dialog. The overlay blend mode does the
// brightening. The white color doesn't paint any white, it's just the basis
// for the overlay blend mode.
const BoxDecoration _kCupertinoDialogBlurOverlayDecoration = BoxDecoration(
color: CupertinoColors.white,
backgroundBlendMode: BlendMode.overlay,
);
const double _kBlurAmount = 20.0;
const double _kEdgePadding = 20.0;
const double _kButtonHeight = 45.0;
const double _kMinButtonHeight = 45.0;
const double _kMinButtonFontSize = 10.0;
const double _kDialogCornerRadius = 12.0;
const double _kDividerThickness = 1.0;
// Translucent white that is painted on top of the blurred backdrop as the
// dialog's background color.
const Color _kDialogColor = Color(0xC0FFFFFF);
// Translucent white that is painted on top of the blurred backdrop as the
// background color of a pressed button.
const Color _kDialogPressedColor = Color(0x90FFFFFF);
// Translucent white that is painted on top of the blurred backdrop in the
// gap areas between the content section and actions section, as well as between
// buttons.
const Color _kButtonDividerColor = Color(0x40FFFFFF);
// The alert dialog layout policy changes depending on whether the user is using
// a "regular" font size vs a "large" font size. This is a spectrum. There are
// many "regular" font sizes and many "large" font sizes. But depending on which
// policy is currently being used, a dialog is laid out differently.
//
// Empirically, the jump from one policy to the other occurs at the following text
// scale factors:
// Largest regular scale factor: 1.3529411764705883
// Smallest large scale factor: 1.6470588235294117
//
// The following constant represents a division in text scale factor beyond which
// we want to change how the dialog is laid out.
const double _kMaxRegularTextScaleFactor = 1.4;
// TODO(gspencer): This color isn't correct. Instead, we should carve a hole in
// the dialog and show more of the background.
const Color _kButtonDividerColor = Color(0xffd5d5d5);
// Accessibility mode on iOS is determined by the text scale factor that the
// user has selected.
bool _isInAccessibilityMode(BuildContext context) {
final MediaQueryData data = MediaQuery.of(context, nullOk: true);
return data != null && data.textScaleFactor > _kMaxRegularTextScaleFactor;
}
/// An iOS-style dialog.
///
......@@ -83,17 +129,14 @@ class CupertinoDialog extends StatelessWidget {
Widget build(BuildContext context) {
return new Center(
child: new ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
child: new DecoratedBox(
// To get the effect, 2 white fills are needed. One blended with the
// background before applying the blur and one overlaid on top of
// the blur.
decoration: _kCupertinoDialogBackFill,
borderRadius: BorderRadius.circular(_kDialogCornerRadius),
child: new BackdropFilter(
filter: new ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
filter: new ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount),
child: new Container(
width: _kCupertinoDialogWidth,
decoration: _kCupertinoDialogFrontFillDecoration,
decoration: _kCupertinoDialogBlurOverlayDecoration,
child: new Container(
color: _kDialogColor,
child: child,
),
),
......@@ -181,125 +224,537 @@ class CupertinoAlertDialog extends StatelessWidget {
/// section when it is long.
final ScrollController actionScrollController;
@override
Widget build(BuildContext context) {
Widget _buildContent() {
final List<Widget> children = <Widget>[];
if (title != null || content != null) {
final Widget titleSection = new _CupertinoAlertTitleSection(
final Widget titleSection = new _CupertinoAlertContentSection(
title: title,
content: content,
scrollController: scrollController,
);
children.add(new Flexible(flex: 3, child: titleSection));
// Add padding between the sections.
children.add(const Padding(padding: EdgeInsets.only(top: 8.0)));
}
return new Container(
color: _kDialogColor,
child: new Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
}
Widget _buildActions() {
Widget actionSection = new Container(
height: 0.0,
);
if (actions.isNotEmpty) {
final Widget actionSection = new _CupertinoAlertActionSection(
actionSection = new _CupertinoAlertActionSection(
children: actions,
scrollController: actionScrollController,
);
children.add(
new Flexible(child: actionSection),
);
}
return new Padding(
padding: const EdgeInsets.symmetric(vertical: _kEdgePadding),
child: new CupertinoDialog(
child: new Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
return actionSection;
}
@override
Widget build(BuildContext context) {
final bool isInAccessibilityMode = _isInAccessibilityMode(context);
final double textScaleFactor = MediaQuery.of(context).textScaleFactor;
return new MediaQuery(
data: MediaQuery.of(context).copyWith(
// iOS does not shrink dialog content below a 1.0 scale factor
textScaleFactor: math.max(textScaleFactor, 1.0),
),
child: new LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return new Center(
child: new Container(
margin: const EdgeInsets.symmetric(vertical: _kEdgePadding),
width: isInAccessibilityMode
? _kAccessibilityCupertinoDialogWidth
: _kCupertinoDialogWidth,
// The following clip is critical. The BackdropFilter needs to have
// rounded corners, but Skia cannot internally create a blurred rounded
// rect. Therefore, we have no choice but to clip, ourselves.
// TODO(mattcarroll): Skia bug filed: https://bugs.chromium.org/p/skia/issues/detail?id=8238
child: ClipRRect(
borderRadius: BorderRadius.circular(_kDialogCornerRadius),
child: new BackdropFilter(
filter: new ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount),
child: new Container(
decoration: _kCupertinoDialogBlurOverlayDecoration,
child: new _CupertinoDialogRenderWidget(
contentSection: _buildContent(),
actionsSection: _buildActions(),
),
),
),
),
),
);
},
),
);
}
}
/// A button typically used in a [CupertinoAlertDialog].
///
/// See also:
///
/// * [CupertinoAlertDialog], a dialog that informs the user about situations
/// that require acknowledgement
class CupertinoDialogAction extends StatelessWidget {
/// Creates an action for an iOS-style dialog.
const CupertinoDialogAction({
this.onPressed,
this.isDefaultAction = false,
this.isDestructiveAction = false,
@required this.child,
}) : assert(child != null);
// iOS style layout policy widget for sizing an alert dialog's content section and
// action button section.
//
// See [_RenderCupertinoDialog] for specific layout policy details.
class _CupertinoDialogRenderWidget extends RenderObjectWidget {
const _CupertinoDialogRenderWidget({
Key key,
@required this.contentSection,
@required this.actionsSection,
}) : super(key: key);
/// The callback that is called when the button is tapped or otherwise
/// activated.
///
/// If this is set to null, the button will be disabled.
final VoidCallback onPressed;
final Widget contentSection;
final Widget actionsSection;
/// Set to true if button is the default choice in the dialog.
///
/// Default buttons are bold.
final bool isDefaultAction;
@override
RenderObject createRenderObject(BuildContext context) {
return new _RenderCupertinoDialog(
dividerThickness: _kDividerThickness / MediaQuery.of(context).devicePixelRatio,
isInAccessibilityMode: _isInAccessibilityMode(context),
);
}
/// Whether this action destroys an object.
///
/// For example, an action that deletes an email is destructive.
final bool isDestructiveAction;
@override
void updateRenderObject(BuildContext context, _RenderCupertinoDialog renderObject) {
renderObject.isInAccessibilityMode = _isInAccessibilityMode(context);
}
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
@override
RenderObjectElement createElement() {
return _CupertinoDialogRenderElement(this);
}
}
/// Whether the button is enabled or disabled. Buttons are disabled by
/// default. To enable a button, set its [onPressed] property to a non-null
/// value.
bool get enabled => onPressed != null;
class _CupertinoDialogRenderElement extends RenderObjectElement {
_CupertinoDialogRenderElement(_CupertinoDialogRenderWidget widget) : super(widget);
Element _contentElement;
Element _actionsElement;
@override
Widget build(BuildContext context) {
TextStyle style = _kCupertinoDialogActionStyle;
_CupertinoDialogRenderWidget get widget => super.widget;
@override
_RenderCupertinoDialog get renderObject => super.renderObject;
@override
void visitChildren(ElementVisitor visitor) {
if (_contentElement != null) {
visitor(_contentElement);
}
if (_actionsElement != null) {
visitor(_actionsElement);
}
}
if (isDefaultAction) {
style = style.copyWith(fontWeight: FontWeight.w600);
@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
_contentElement = updateChild(_contentElement, widget.contentSection, _AlertDialogSections.contentSection);
_actionsElement = updateChild(_actionsElement, widget.actionsSection, _AlertDialogSections.actionsSection);
}
if (isDestructiveAction) {
style = style.copyWith(color: CupertinoColors.destructiveRed);
@override
void insertChildRenderObject(RenderObject child, _AlertDialogSections slot) {
assert(slot != null);
switch (slot) {
case _AlertDialogSections.contentSection:
renderObject.contentSection = child;
break;
case _AlertDialogSections.actionsSection:
renderObject.actionsSection = child;
break;
}
}
if (!enabled) {
style = style.copyWith(color: style.color.withOpacity(0.5));
@override
void moveChildRenderObject(RenderObject child, _AlertDialogSections slot) {
assert(false);
}
final double textScaleFactor = MediaQuery.textScaleFactorOf(context);
return new GestureDetector(
onTap: onPressed,
behavior: HitTestBehavior.opaque,
child: new Container(
alignment: Alignment.center,
padding: new EdgeInsets.all(8.0 * textScaleFactor),
child: new DefaultTextStyle(
style: style,
child: child,
textAlign: TextAlign.center,
),
@override
void update(RenderObjectWidget newWidget) {
super.update(newWidget);
_contentElement = updateChild(_contentElement, widget.contentSection, _AlertDialogSections.contentSection);
_actionsElement = updateChild(_actionsElement, widget.actionsSection, _AlertDialogSections.actionsSection);
}
@override
void forgetChild(Element child) {
assert(child == _contentElement || child == _actionsElement);
if (_contentElement == child) {
_contentElement = null;
} else {
assert(_actionsElement == child);
_actionsElement = null;
}
}
@override
void removeChildRenderObject(RenderObject child) {
assert(child == renderObject.contentSection || child == renderObject.actionsSection);
if (renderObject.contentSection == child) {
renderObject.contentSection = null;
} else {
assert(renderObject.actionsSection == child);
renderObject.actionsSection = null;
}
}
}
// iOS style layout policy for sizing an alert dialog's content section and action
// button section.
//
// The policy is as follows:
//
// If all content and buttons fit on screen:
// The content section and action button section are sized intrinsically and centered
// vertically on screen.
//
// If all content and buttons do not fit on screen, and iOS is NOT in accessibility mode:
// A minimum height for the action button section is calculated. The action
// button section will not be rendered shorter than this minimum. See
// [_RenderCupertinoDialogActions] for the minimum height calculation.
//
// With the minimum action button section calculated, the content section can
// take up as much space as is available, up to the point that it hits the
// minimum button height at the bottom.
//
// After the content section is laid out, the action button section is allowed
// to take up any remaining space that was not consumed by the content section.
//
// If all content and buttons do not fit on screen, and iOS IS in accessibility mode:
// The button section is given up to 50% of the available height. Then the content
// section is given whatever height remains.
class _RenderCupertinoDialog extends RenderBox {
_RenderCupertinoDialog({
RenderBox contentSection,
RenderBox actionsSection,
double dividerThickness = 0.0,
bool isInAccessibilityMode = false,
}) : _contentSection = contentSection,
_actionsSection = actionsSection,
_dividerThickness = dividerThickness,
_isInAccessibilityMode = isInAccessibilityMode;
RenderBox get contentSection => _contentSection;
RenderBox _contentSection;
set contentSection(RenderBox newContentSection) {
if (newContentSection != _contentSection) {
if (_contentSection != null) {
dropChild(_contentSection);
}
_contentSection = newContentSection;
if (_contentSection != null) {
adoptChild(_contentSection);
}
}
}
RenderBox get actionsSection => _actionsSection;
RenderBox _actionsSection;
set actionsSection(RenderBox newActionsSection) {
if (newActionsSection != _actionsSection) {
if (null != _actionsSection) {
dropChild(_actionsSection);
}
_actionsSection = newActionsSection;
if (null != _actionsSection) {
adoptChild(_actionsSection);
}
}
}
bool get isInAccessibilityMode => _isInAccessibilityMode;
bool _isInAccessibilityMode;
set isInAccessibilityMode(bool newValue) {
if (newValue != _isInAccessibilityMode) {
_isInAccessibilityMode = newValue;
markNeedsLayout();
}
}
double get _dialogWidth => isInAccessibilityMode
? _kAccessibilityCupertinoDialogWidth
: _kCupertinoDialogWidth;
final double _dividerThickness;
final Paint _dividerPaint = new Paint()
..color = _kButtonDividerColor
..style = PaintingStyle.fill;
@override
void attach(PipelineOwner owner) {
super.attach(owner);
if (null != contentSection) {
contentSection.attach(owner);
}
if (null != actionsSection) {
actionsSection.attach(owner);
}
}
@override
void detach() {
super.detach();
if (null != contentSection) {
contentSection.detach();
}
if (null != actionsSection) {
actionsSection.detach();
}
}
@override
void redepthChildren() {
if (null != contentSection) {
redepthChild(contentSection);
}
if (null != actionsSection) {
redepthChild(actionsSection);
}
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! BoxParentData) {
child.parentData = new BoxParentData();
}
}
@override
void visitChildren(RenderObjectVisitor visitor) {
if (contentSection != null) {
visitor(contentSection);
}
if (actionsSection != null) {
visitor(actionsSection);
}
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
final List<DiagnosticsNode> value = <DiagnosticsNode>[];
if (contentSection != null) {
value.add(contentSection.toDiagnosticsNode(name: 'content'));
}
if (actionsSection != null) {
value.add(actionsSection.toDiagnosticsNode(name: 'actions'));
}
return value;
}
@override
double computeMinIntrinsicWidth(double height) {
return _dialogWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
return _dialogWidth;
}
@override
double computeMinIntrinsicHeight(double width) {
final double contentHeight = contentSection.getMinIntrinsicHeight(width);
final double actionsHeight = actionsSection.getMinIntrinsicHeight(width);
final bool hasDivider = contentHeight > 0.0 && actionsHeight > 0.0;
final double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight;
if (height.isFinite)
return height;
return 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
final double contentHeight = contentSection.getMaxIntrinsicHeight(width);
final double actionsHeight = actionsSection.getMaxIntrinsicHeight(width);
final bool hasDivider = contentHeight > 0.0 && actionsHeight > 0.0;
final double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight;
if (height.isFinite)
return height;
return 0.0;
}
@override
void performLayout() {
if (isInAccessibilityMode) {
// When in accessibility mode, an alert dialog will allow buttons to take
// up to 50% of the dialog height, even if the content exceeds available space.
performAccessibilityLayout();
} else {
// When not in accessibility mode, an alert dialog might reduce the space
// for buttons to just over 1 button's height to make room for the content
// section.
performRegularLayout();
}
}
void performRegularLayout() {
final bool hasDivider = contentSection.getMaxIntrinsicHeight(_dialogWidth) > 0.0
&& actionsSection.getMaxIntrinsicHeight(_dialogWidth) > 0.0;
final double dividerThickness = hasDivider ? _dividerThickness : 0.0;
final double minActionsHeight = actionsSection.getMinIntrinsicHeight(_dialogWidth);
// Size alert dialog content.
contentSection.layout(
constraints.deflate(new EdgeInsets.only(bottom: minActionsHeight + dividerThickness)),
parentUsesSize: true,
);
final Size contentSize = contentSection.size;
// Size alert dialog actions.
actionsSection.layout(
constraints.deflate(new EdgeInsets.only(top: contentSize.height + dividerThickness)),
parentUsesSize: true,
);
final Size actionsSize = actionsSection.size;
// Calculate overall dialog height.
final double dialogHeight = contentSize.height + dividerThickness + actionsSize.height;
// Set our size now that layout calculations are complete.
size = constraints.constrain(
new Size(_dialogWidth, dialogHeight)
);
// Set the position of the actions box to sit at the bottom of the dialog.
// The content box defaults to the top left, which is where we want it.
assert(actionsSection.parentData is BoxParentData);
final BoxParentData actionParentData = actionsSection.parentData;
actionParentData.offset = new Offset(0.0, contentSize.height + dividerThickness);
}
void performAccessibilityLayout() {
final bool hasDivider = contentSection.getMaxIntrinsicHeight(_dialogWidth) > 0.0
&& actionsSection.getMaxIntrinsicHeight(_dialogWidth) > 0.0;
final double dividerThickness = hasDivider ? _dividerThickness : 0.0;
final double maxContentHeight = contentSection.getMaxIntrinsicHeight(_dialogWidth);
final double maxActionsHeight = actionsSection.getMaxIntrinsicHeight(_dialogWidth);
Size contentSize;
Size actionsSize;
if (maxContentHeight + dividerThickness + maxActionsHeight > constraints.maxHeight) {
// There isn't enough room for everything. Following iOS's accessibility dialog
// layout policy, first we allow the actions to take up to 50% of the dialog
// height. Second we fill the rest of the available space with the content
// section.
// Size alert dialog actions.
actionsSection.layout(
constraints.deflate(new EdgeInsets.only(top: constraints.maxHeight / 2.0)),
parentUsesSize: true,
);
actionsSize = actionsSection.size;
// Size alert dialog content.
contentSection.layout(
constraints.deflate(new EdgeInsets.only(bottom: actionsSize.height + dividerThickness)),
parentUsesSize: true,
);
contentSize = contentSection.size;
} else {
// Everything fits. Give content and actions all the space they want.
// Size alert dialog content.
contentSection.layout(
constraints,
parentUsesSize: true,
);
contentSize = contentSection.size;
// Size alert dialog actions.
actionsSection.layout(
constraints.deflate(new EdgeInsets.only(top: contentSize.height)),
parentUsesSize: true,
);
actionsSize = actionsSection.size;
}
// Calculate overall dialog height.
final double dialogHeight = contentSize.height + dividerThickness + actionsSize.height;
// Set our size now that layout calculations are complete.
size = constraints.constrain(
new Size(_dialogWidth, dialogHeight)
);
// Set the position of the actions box to sit at the bottom of the dialog.
// The content box defaults to the top left, which is where we want it.
assert(actionsSection.parentData is BoxParentData);
final BoxParentData actionParentData = actionsSection.parentData;
actionParentData.offset = new Offset(0.0, contentSize.height + dividerThickness);
}
@override
void paint(PaintingContext context, Offset offset) {
final BoxParentData contentParentData = contentSection.parentData;
contentSection.paint(context, offset + contentParentData.offset);
final bool hasDivider = contentSection.size.height > 0.0 && actionsSection.size.height > 0.0;
if (hasDivider) {
_paintDividerBetweenContentAndActions(context.canvas, offset);
}
final BoxParentData actionsParentData = actionsSection.parentData;
actionsSection.paint(context, offset + actionsParentData.offset);
}
void _paintDividerBetweenContentAndActions(Canvas canvas, Offset offset) {
canvas.drawRect(
Rect.fromLTWH(
offset.dx,
offset.dy + contentSection.size.height,
size.width,
_dividerThickness,
),
_dividerPaint,
);
}
@override
bool hitTestChildren(HitTestResult result, { Offset position }) {
bool isHit = false;
final BoxParentData contentSectionParentData = contentSection.parentData;
final BoxParentData actionsSectionParentData = actionsSection.parentData;
if (contentSection.hitTest(result, position: position - contentSectionParentData.offset)) {
isHit = true;
} else if (actionsSection.hitTest(result, position: position - actionsSectionParentData.offset)) {
isHit = true;
}
return isHit;
}
}
// Visual components of an alert dialog that need to be explicitly sized and
// laid out at runtime.
enum _AlertDialogSections {
contentSection,
actionsSection,
}
// Constructs a text content section typically used in a CupertinoAlertDialog.
// The "content section" of a CupertinoAlertDialog.
//
// If title is missing, then only content is added. If content is
// missing, then only title is added. If both are missing, then it returns
// a SingleChildScrollView with a zero-sized Container.
class _CupertinoAlertTitleSection extends StatelessWidget {
const _CupertinoAlertTitleSection({
class _CupertinoAlertContentSection extends StatelessWidget {
const _CupertinoAlertContentSection({
Key key,
this.title,
this.content,
......@@ -327,6 +782,7 @@ class _CupertinoAlertTitleSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final double textScaleFactor = MediaQuery.of(context).textScaleFactor;
final List<Widget> titleContentGroup = <Widget>[];
if (title != null) {
titleContentGroup.add(new Padding(
......@@ -334,7 +790,7 @@ class _CupertinoAlertTitleSection extends StatelessWidget {
left: _kEdgePadding,
right: _kEdgePadding,
bottom: content == null ? _kEdgePadding : 1.0,
top: _kEdgePadding,
top: _kEdgePadding * textScaleFactor,
),
child: new DefaultTextStyle(
style: _kCupertinoDialogTitleStyle,
......@@ -350,7 +806,7 @@ class _CupertinoAlertTitleSection extends StatelessWidget {
padding: new EdgeInsets.only(
left: _kEdgePadding,
right: _kEdgePadding,
bottom: _kEdgePadding,
bottom: _kEdgePadding * textScaleFactor,
top: title == null ? _kEdgePadding : 1.0,
),
child: new DefaultTextStyle(
......@@ -369,11 +825,6 @@ class _CupertinoAlertTitleSection extends StatelessWidget {
);
}
// Add padding between the widgets if necessary.
if (titleContentGroup.length > 1) {
titleContentGroup.insert(1, const Padding(padding: EdgeInsets.only(top: 8.0)));
}
return new CupertinoScrollbar(
child: new SingleChildScrollView(
controller: scrollController,
......@@ -387,13 +838,11 @@ class _CupertinoAlertTitleSection extends StatelessWidget {
}
}
// An Action Items section typically used in a CupertinoAlertDialog.
// The "actions section" of a [CupertinoAlertDialog].
//
// If _layoutActionsVertically is true, they are laid out vertically
// in a column; else they are laid out horizontally in a row. If there isn't
// enough room to show all the children vertically, they are wrapped in a
// CupertinoScrollbar widget. If children is null or empty, it returns null.
class _CupertinoAlertActionSection extends StatelessWidget {
// See [_RenderCupertinoDialogActions] for details about action button sizing
// and layout.
class _CupertinoAlertActionSection extends StatefulWidget {
const _CupertinoAlertActionSection({
Key key,
@required this.children,
......@@ -410,106 +859,786 @@ class _CupertinoAlertActionSection extends StatelessWidget {
// don't have many actions.
final ScrollController scrollController;
bool get _layoutActionsVertically => children.length > 2;
@override
_CupertinoAlertActionSectionState createState() => new _CupertinoAlertActionSectionState();
}
class _CupertinoAlertActionSectionState extends State<_CupertinoAlertActionSection> {
@override
Widget build(BuildContext context) {
if (children.isEmpty) {
return new SingleChildScrollView(
controller: scrollController,
child: new Container(width: 0.0, height: 0.0),
);
}
final double devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
// TODO(abarth): Listen for the buttons being highlighted.
if (_layoutActionsVertically) {
// Skip the first divider
final List<Widget> buttons = <Widget>[children.first];
buttons.addAll(
children.sublist(1).map<Widget>(
(Widget child) {
return new CustomPaint(
painter: new _CupertinoVerticalDividerPainter(),
child: child,
);
},
final List<Widget> interactiveButtons = <Widget>[];
for (int i = 0; i < widget.children.length; i += 1) {
interactiveButtons.add(
new _PressableActionButton(
child: widget.children[i],
),
);
}
return new CupertinoScrollbar(
child: new SingleChildScrollView(
controller: scrollController,
child: new Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: buttons,
controller: widget.scrollController,
child: new _CupertinoDialogActionsRenderWidget(
actionButtons: interactiveButtons,
dividerThickness: _kDividerThickness / devicePixelRatio,
),
),
);
} else {
// For a horizontal layout, we don't need the scrollController in most
// cases, but it still has to be always attached to a scroll view.
return new CupertinoScrollbar(
child: new SingleChildScrollView(
controller: scrollController,
child: new CustomPaint(
painter: new _CupertinoHorizontalDividerPainter(children.length),
child: new UnconstrainedBox(
constrainedAxis: Axis.horizontal,
}
}
// Button that updates its render state when pressed.
//
// The pressed state is forwarded to an _ActionButtonParentDataWidget. The
// corresponding _ActionButtonParentData is then interpreted and rendered
// appropriately by _RenderCupertinoDialogActions.
class _PressableActionButton extends StatefulWidget {
const _PressableActionButton({
@required this.child,
});
final Widget child;
@override
_PressableActionButtonState createState() => new _PressableActionButtonState();
}
class _PressableActionButtonState extends State<_PressableActionButton> {
bool _isPressed = false;
@override
Widget build(BuildContext context) {
return new _ActionButtonParentDataWidget(
isPressed: _isPressed,
// TODO(mattcarroll): Button press dynamics need overhaul for iOS: https://github.com/flutter/flutter/issues/19786
child: new GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (TapDownDetails details) => setState(() {
_isPressed = true;
}),
onTapUp: (TapUpDetails details) => setState(() {
_isPressed = false;
}),
// TODO(mattcarroll): Cancel is currently triggered when user moves past slop instead of off button: https://github.com/flutter/flutter/issues/19783
onTapCancel: () => setState(() => _isPressed = false),
child: widget.child,
),
);
}
}
// ParentDataWidget that updates _ActionButtonParentData for an action button.
//
// Each action button requires knowledge of whether or not it is pressed so that
// the dialog can correctly render the button. The pressed state is held within
// _ActionButtonParentData. _ActionButtonParentDataWidget is responsible for
// updating the pressed state of an _ActionButtonParentData based on the
// incoming [isPressed] property.
class _ActionButtonParentDataWidget extends ParentDataWidget<_CupertinoDialogActionsRenderWidget> {
const _ActionButtonParentDataWidget({
Key key,
this.isPressed,
@required Widget child,
}) : super(key: key, child: child);
final bool isPressed;
@override
void applyParentData(RenderObject renderObject) {
assert(renderObject.parentData is _ActionButtonParentData);
final _ActionButtonParentData parentData = renderObject.parentData;
if (parentData.isPressed != isPressed) {
parentData.isPressed = isPressed;
// Force a repaint.
final AbstractNode targetParent = renderObject.parent;
if (targetParent is RenderObject)
targetParent.markNeedsPaint();
}
}
}
// ParentData applied to individual action buttons that report whether or not
// that button is currently pressed by the user.
class _ActionButtonParentData extends MultiChildLayoutParentData {
_ActionButtonParentData({
this.isPressed = false,
});
bool isPressed;
}
/// A button typically used in a [CupertinoAlertDialog].
///
/// See also:
///
/// * [CupertinoAlertDialog], a dialog that informs the user about situations
/// that require acknowledgement
class CupertinoDialogAction extends StatelessWidget {
/// Creates an action for an iOS-style dialog.
const CupertinoDialogAction({
this.onPressed,
this.isDefaultAction = false,
this.isDestructiveAction = false,
this.textStyle,
@required this.child,
}) : assert(child != null);
/// The callback that is called when the button is tapped or otherwise
/// activated.
///
/// If this is set to null, the button will be disabled.
final VoidCallback onPressed;
/// Set to true if button is the default choice in the dialog.
///
/// Default buttons are bold.
final bool isDefaultAction;
/// Whether this action destroys an object.
///
/// For example, an action that deletes an email is destructive.
final bool isDestructiveAction;
/// [TextStyle] to apply to any text that appears in this button.
///
/// Dialog actions have a built-in text resizing policy for long text. To
/// ensure that this resizing policy always works as expected, [textStyle]
/// must be used if a text size is desired other than that specified in
/// [_kCupertinoDialogActionStyle].
final TextStyle textStyle;
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
/// Whether the button is enabled or disabled. Buttons are disabled by
/// default. To enable a button, set its [onPressed] property to a non-null
/// value.
bool get enabled => onPressed != null;
double _calculatePadding(BuildContext context) {
return 8.0 * MediaQuery.textScaleFactorOf(context);
}
// Dialog action content shrinks to fit, up to a certain point, and if it still
// cannot fit at the minimum size, the text content is ellipsized.
//
// This policy only applies when the device is not in accessibility mode.
Widget _buildContentWithRegularSizingPolicy({
@required BuildContext context,
@required TextStyle textStyle,
@required Widget content,
}) {
final bool isInAccessibilityMode = _isInAccessibilityMode(context);
final double dialogWidth = isInAccessibilityMode
? _kAccessibilityCupertinoDialogWidth
: _kCupertinoDialogWidth;
final double textScaleFactor = MediaQuery.textScaleFactorOf(context);
// The fontSizeRatio is the ratio of the current text size (including any
// iOS scale factor) vs the minimum text size that we allow in action
// buttons. This ratio information is used to automatically scale down action
// button text to fit the available space.
final double fontSizeRatio = (textScaleFactor * textStyle.fontSize) / _kMinButtonFontSize;
final double padding = _calculatePadding(context);
return new IntrinsicHeight(
child: new SizedBox(
width: double.infinity,
child: new FittedBox(
fit: BoxFit.scaleDown,
child: new ConstrainedBox(
constraints: const BoxConstraints(minHeight: _kButtonHeight),
child: new Row(
children: children.map<Widget>((Widget button) {
return new Expanded(child: button);
}).toList(),
constraints: new BoxConstraints(
maxWidth: fontSizeRatio * (dialogWidth - (2 * padding)),
),
child: new DefaultTextStyle(
style: textStyle,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: content,
),
),
),
),
);
}
// Dialog action content is permitted to be as large as it wants when in
// accessibility mode. If text is used as the content, the text wraps instead
// of ellipsizing.
Widget _buildContentWithAccessibilitySizingPolicy({
@required TextStyle textStyle,
@required Widget content,
}) {
return new DefaultTextStyle(
style: textStyle,
textAlign: TextAlign.center,
child: content,
);
}
@override
Widget build(BuildContext context) {
TextStyle style = _kCupertinoDialogActionStyle;
style = style.merge(textStyle);
if (isDestructiveAction) {
style = style.copyWith(color: CupertinoColors.destructiveRed);
}
if (!enabled) {
style = style.copyWith(color: style.color.withOpacity(0.5));
}
// Apply a sizing policy to the action button's content based on whether or
// not the device is in accessibility mode.
// TODO(mattcarroll): The following logic is not entirely correct. It is also
// the case that if content text does not contain a space, it should also
// wrap instead of ellipsizing. We are consciously not implementing that
// now due to complexity.
final Widget sizedContent = _isInAccessibilityMode(context)
? _buildContentWithAccessibilitySizingPolicy(
textStyle: style,
content: child,
)
: _buildContentWithRegularSizingPolicy(
context: context,
textStyle: style,
content: child,
);
return new GestureDetector(
onTap: onPressed,
behavior: HitTestBehavior.opaque,
child: new ConstrainedBox(
constraints: const BoxConstraints(
minHeight: _kMinButtonHeight,
),
child: new Container(
alignment: Alignment.center,
padding: new EdgeInsets.all(_calculatePadding(context)),
child: sizedContent,
),
),
);
}
}
// A CustomPainter to draw the divider lines.
// iOS style dialog action button layout.
//
// [_CupertinoDialogActionsRenderWidget] does not provide any scrolling
// behavior for its buttons. It only handles the sizing and layout of buttons.
// Scrolling behavior can be composed on top of this widget, if desired.
//
// Draws the cross-axis divider lines, used when the layout is horizontal.
class _CupertinoHorizontalDividerPainter extends CustomPainter {
_CupertinoHorizontalDividerPainter(this.count);
// See [_RenderCupertinoDialogActions] for specific layout policy details.
class _CupertinoDialogActionsRenderWidget extends MultiChildRenderObjectWidget {
_CupertinoDialogActionsRenderWidget({
Key key,
@required List<Widget> actionButtons,
double dividerThickness = 0.0,
}) : _dividerThickness = dividerThickness,
super(key: key, children: actionButtons);
final int count;
final double _dividerThickness;
@override
void paint(Canvas canvas, Size size) {
final Paint paint = new Paint()..color = _kButtonDividerColor;
canvas.drawLine(Offset.zero, new Offset(size.width, 0.0), paint);
for (int i = 1; i < count; ++i) {
// TODO(abarth): Hide the divider when one of the adjacent buttons is
// highlighted.
final double x = size.width * i / count;
canvas.drawLine(new Offset(x, 0.0), new Offset(x, size.height), paint);
}
RenderObject createRenderObject(BuildContext context) {
return new _RenderCupertinoDialogActions(
dialogWidth: _isInAccessibilityMode(context)
? _kAccessibilityCupertinoDialogWidth
: _kCupertinoDialogWidth,
dividerThickness: _dividerThickness,
);
}
@override
bool shouldRepaint(_CupertinoHorizontalDividerPainter other) => count != other.count;
void updateRenderObject(BuildContext context, _RenderCupertinoDialogActions renderObject) {
renderObject.dialogWidth = _isInAccessibilityMode(context)
? _kAccessibilityCupertinoDialogWidth
: _kCupertinoDialogWidth;
renderObject.dividerThickness = _dividerThickness;
}
}
// A CustomPainter to draw the divider lines.
// iOS style layout policy for sizing and positioning an alert dialog's action
// buttons.
//
// The policy is as follows:
//
// Draws the cross-axis divider lines, used when the layout is vertical.
class _CupertinoVerticalDividerPainter extends CustomPainter {
_CupertinoVerticalDividerPainter();
// If a single action button is provided, or if 2 action buttons are provided
// that can fit side-by-side, then action buttons are sized and laid out in a
// single horizontal row. The row is exactly as wide as the dialog, and the row
// is as tall as the tallest action button. A horizontal divider is drawn above
// the button row. If 2 action buttons are provided, a vertical divider is
// drawn between them. The thickness of the divider is set by [dividerThickness].
//
// If 2 action buttons are provided but they cannot fit side-by-side, then the
// 2 buttons are stacked vertically. A horizontal divider is drawn above each
// button. The thickness of the divider is set by [dividerThickness]. The minimum
// height of this [RenderBox] in the case of 2 stacked buttons is as tall as
// the 2 buttons stacked. This is different than the 3+ button case where the
// minimum height is only 1.5 buttons tall. See the 3+ button explanation for
// more info.
//
// If 3+ action buttons are provided then they are all stacked vertically. A
// horizontal divider is drawn above each button. The thickness of the divider
// is set by [dividerThickness]. The minimum height of this [RenderBox] in the case
// of 3+ stacked buttons is as tall as the 1st button + 50% the height of the
// 2nd button. In other words, the minimum height is 1.5 buttons tall. This
// minimum height of 1.5 buttons is expected to work in tandem with a surrounding
// [ScrollView] to match the iOS dialog behavior.
//
// Each button is expected to have an _ActionButtonParentData which reports
// whether or not that button is currently pressed. If a button is pressed,
// then the dividers above and below that pressed button are not drawn - instead
// they are filled with the standard white dialog background color. The one
// exception is the very 1st divider which is always rendered. This policy comes
// from observation of native iOS dialogs.
class _RenderCupertinoDialogActions extends RenderBox
with ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
_RenderCupertinoDialogActions({
List<RenderBox> children,
@required double dialogWidth,
double dividerThickness = 0.0,
}) : _dialogWidth = dialogWidth,
_dividerThickness = dividerThickness {
addAll(children);
}
double get dialogWidth => _dialogWidth;
double _dialogWidth;
set dialogWidth(double newWidth) {
if (newWidth != _dialogWidth) {
_dialogWidth = newWidth;
markNeedsLayout();
}
}
// The thickness of the divider between buttons.
double get dividerThickness => _dividerThickness;
double _dividerThickness;
set dividerThickness(double newValue) {
if (newValue != _dividerThickness) {
_dividerThickness = newValue;
markNeedsLayout();
}
}
final Paint _buttonBackgroundPaint = new Paint()
..color = _kDialogColor
..style = PaintingStyle.fill;
final Paint _pressedButtonBackgroundPaint = new Paint()
..color = _kDialogPressedColor
..style = PaintingStyle.fill;
final Paint _dividerPaint = new Paint()
..color = _kButtonDividerColor
..style = PaintingStyle.fill;
Iterable<RenderBox> get _pressedButtons sync* {
RenderBox currentChild = firstChild;
while (currentChild != null) {
assert(currentChild.parentData is _ActionButtonParentData);
final _ActionButtonParentData parentData = currentChild.parentData;
if (parentData.isPressed) {
yield currentChild;
}
currentChild = childAfter(currentChild);
}
}
bool get _isButtonPressed {
RenderBox currentChild = firstChild;
while (currentChild != null) {
assert(currentChild.parentData is _ActionButtonParentData);
final _ActionButtonParentData parentData = currentChild.parentData;
if (parentData.isPressed) {
return true;
}
currentChild = childAfter(currentChild);
}
return false;
}
@override
void paint(Canvas canvas, Size size) {
final Paint paint = new Paint()..color = _kButtonDividerColor;
canvas.drawLine(const Offset(0.0, 0.0), new Offset(size.width, 0.0), paint);
void setupParentData(RenderBox child) {
if (child.parentData is! _ActionButtonParentData)
child.parentData = new _ActionButtonParentData();
}
@override
bool shouldRepaint(_CupertinoVerticalDividerPainter other) => false;
double computeMinIntrinsicWidth(double height) {
return dialogWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
return dialogWidth;
}
@override
double computeMinIntrinsicHeight(double width) {
double minHeight;
if (childCount == 0) {
minHeight = 0.0;
} else if (childCount == 1) {
// If only 1 button, display the button across the entire dialog.
minHeight = _computeMinIntrinsicHeightSideBySide(width);
} else {
if (childCount == 2 && _isSingleButtonRow(width)) {
// The first 2 buttons fit side-by-side. Display them horizontally.
minHeight = _computeMinIntrinsicHeightSideBySide(width);
} else {
// 3+ buttons are always stacked. The minimum height when stacked is
// 1.5 buttons tall.
minHeight = _computeMinIntrinsicHeightStacked(width);
}
}
return minHeight;
}
// The minimum height for a single row of buttons is the larger of the buttons'
// min intrinsic heights.
double _computeMinIntrinsicHeightSideBySide(double width) {
assert(childCount >= 1 && childCount <= 2);
double minHeight;
if (childCount == 1) {
minHeight = firstChild.getMinIntrinsicHeight(width);
} else {
final double perButtonWidth = (width - dividerThickness) / 2.0;
minHeight = math.max(
firstChild.getMinIntrinsicHeight(perButtonWidth),
lastChild.getMinIntrinsicHeight(perButtonWidth),
);
}
return minHeight;
}
// The minimum height for 2+ stacked buttons is the height of the 1st button
// + 50% the height of the 2nd button + the divider between the two.
double _computeMinIntrinsicHeightStacked(double width) {
assert(childCount >= 2);
return firstChild.getMinIntrinsicHeight(width)
+ dividerThickness
+ (0.5 * childAfter(firstChild).getMinIntrinsicHeight(width));
}
@override
double computeMaxIntrinsicHeight(double width) {
double maxHeight;
if (childCount == 0) {
// No buttons. Zero height.
maxHeight = 0.0;
} else if (childCount == 1) {
// One button. Our max intrinsic height is equal to the button's.
maxHeight = firstChild.getMaxIntrinsicHeight(width);
} else if (childCount == 2) {
// Two buttons...
if (_isSingleButtonRow(width)) {
// The 2 buttons fit side by side so our max intrinsic height is equal
// to the taller of the 2 buttons.
final double perButtonWidth = (width - dividerThickness) / 2.0;
maxHeight = math.max(
firstChild.getMaxIntrinsicHeight(perButtonWidth),
lastChild.getMaxIntrinsicHeight(perButtonWidth),
);
} else {
// The 2 buttons do not fit side by side. Measure total height as a
// vertical stack.
maxHeight = _computeMaxIntrinsicHeightStacked(width);
}
} else {
// Three+ buttons. Stack the buttons vertically with dividers and measure
// the overall height.
maxHeight = _computeMaxIntrinsicHeightStacked(width);
}
return maxHeight;
}
// Max height of a stack of buttons is the sum of all button heights + a
// divider for each button.
double _computeMaxIntrinsicHeightStacked(double width) {
assert(childCount >= 2);
final double allDividersHeight = (childCount - 1) * dividerThickness;
double heightAccumulation = allDividersHeight;
RenderBox button = firstChild;
while (button != null) {
heightAccumulation += button.getMaxIntrinsicHeight(width);
button = childAfter(button);
}
return heightAccumulation;
}
bool _isSingleButtonRow(double width) {
bool isSingleButtonRow;
if (childCount == 1) {
isSingleButtonRow = true;
} else if (childCount == 2) {
// There are 2 buttons. If they can fit side-by-side then that's what
// we want to do. Otherwise, stack them vertically.
final double sideBySideWidth = firstChild.getMaxIntrinsicWidth(double.infinity)
+ dividerThickness
+ lastChild.getMaxIntrinsicWidth(double.infinity);
isSingleButtonRow = sideBySideWidth <= width;
} else {
isSingleButtonRow = false;
}
return isSingleButtonRow;
}
@override
void performLayout() {
if (_isSingleButtonRow(dialogWidth)) {
if (childCount == 1) {
// We have 1 button. Our size is the width of the dialog and the height
// of the single button.
firstChild.layout(
constraints,
parentUsesSize: true,
);
size = constraints.constrain(
new Size(dialogWidth, firstChild.size.height)
);
} else {
// Each button gets half the available width, minus a single divider.
final BoxConstraints perButtonConstraints = new BoxConstraints(
minWidth: (constraints.minWidth - dividerThickness) / 2.0,
maxWidth: (constraints.maxWidth - dividerThickness) / 2.0,
minHeight: 0.0,
maxHeight: double.infinity,
);
// Layout the 2 buttons.
firstChild.layout(
perButtonConstraints,
parentUsesSize: true,
);
lastChild.layout(
perButtonConstraints,
parentUsesSize: true,
);
// The 2nd button needs to be offset to the right.
assert(lastChild.parentData is MultiChildLayoutParentData);
final MultiChildLayoutParentData secondButtonParentData = lastChild.parentData;
secondButtonParentData.offset = new Offset(firstChild.size.width + dividerThickness, 0.0);
// Calculate our size based on the button sizes.
size = constraints.constrain(
new Size(
dialogWidth,
math.max(
firstChild.size.height,
lastChild.size.height,
),
)
);
}
} else {
// We need to stack buttons vertically, plus dividers above each button (except the 1st).
final BoxConstraints perButtonConstraints = constraints.copyWith(
minHeight: 0.0,
maxHeight: double.infinity,
);
RenderBox child = firstChild;
int index = 0;
double verticalOffset = 0.0;
while (child != null) {
child.layout(
perButtonConstraints,
parentUsesSize: true,
);
assert(child.parentData is MultiChildLayoutParentData);
final MultiChildLayoutParentData parentData = child.parentData;
parentData.offset = new Offset(0.0, verticalOffset);
verticalOffset += child.size.height;
if (index < childCount - 1) {
// Add a gap for the next divider.
verticalOffset += dividerThickness;
}
index += 1;
child = childAfter(child);
}
// Our height is the accumulated height of all buttons and dividers.
size = constraints.constrain(
new Size(dialogWidth, verticalOffset)
);
}
}
@override
void paint(PaintingContext context, Offset offset) {
final Canvas canvas = context.canvas;
if (_isSingleButtonRow(size.width)) {
_drawButtonBackgroundsAndDividersSingleRow(canvas, offset);
} else {
_drawButtonBackgroundsAndDividersStacked(canvas, offset);
}
_drawButtons(context, offset);
}
void _drawButtonBackgroundsAndDividersSingleRow(Canvas canvas, Offset offset) {
// The vertical divider sits between the left button and right button (if
// the dialog has 2 buttons). The vertical divider is hidden if either the
// left or right button is pressed.
final Rect verticalDivider = childCount == 2 && !_isButtonPressed
? new Rect.fromLTWH(
offset.dx + firstChild.size.width,
offset.dy,
dividerThickness,
math.max(
firstChild.size.height,
lastChild.size.height,
),
)
: Rect.zero;
final List<Rect> pressedButtonRects = _pressedButtons.map((RenderBox pressedButton) {
final MultiChildLayoutParentData buttonParentData = pressedButton.parentData;
return new Rect.fromLTWH(
offset.dx + buttonParentData.offset.dx,
offset.dy + buttonParentData.offset.dy,
pressedButton.size.width,
pressedButton.size.height,
);
}).toList();
// Create the button backgrounds path and paint it.
final Path backgroundFillPath = new Path()
..fillType = PathFillType.evenOdd
..addRect(Rect.largest)
..addRect(verticalDivider);
for (int i = 0; i < pressedButtonRects.length; i += 1) {
backgroundFillPath.addRect(pressedButtonRects[i]);
}
canvas.drawPath(
backgroundFillPath,
_buttonBackgroundPaint,
);
// Create the pressed buttons background path and paint it.
final Path pressedBackgroundFillPath = new Path();
for (int i = 0; i < pressedButtonRects.length; i += 1) {
pressedBackgroundFillPath.addRect(pressedButtonRects[i]);
}
canvas.drawPath(
pressedBackgroundFillPath,
_pressedButtonBackgroundPaint,
);
// Create the dividers path and paint it.
final Path dividersPath = new Path()
..addRect(verticalDivider);
canvas.drawPath(
dividersPath,
_dividerPaint,
);
}
void _drawButtonBackgroundsAndDividersStacked(Canvas canvas, Offset offset) {
final Offset dividerOffset = new Offset(0.0, dividerThickness);
final Path backgroundFillPath = new Path()
..fillType = PathFillType.evenOdd
..addRect(Rect.largest);
final Path pressedBackgroundFillPath = new Path();
final Path dividersPath = new Path();
Offset accumulatingOffset = offset;
RenderBox child = firstChild;
RenderBox prevChild;
while (child != null) {
assert(child.parentData is _ActionButtonParentData);
final _ActionButtonParentData currentButtonParentData = child.parentData;
final bool isButtonPressed = currentButtonParentData.isPressed;
bool isPrevButtonPressed = false;
if (prevChild != null) {
assert(prevChild.parentData is _ActionButtonParentData);
final _ActionButtonParentData previousButtonParentData = prevChild
.parentData;
isPrevButtonPressed = previousButtonParentData.isPressed;
}
final bool isDividerPresent = child != firstChild;
final bool isDividerPainted = isDividerPresent && !(isButtonPressed || isPrevButtonPressed);
final Rect dividerRect = new Rect.fromLTWH(
accumulatingOffset.dx,
accumulatingOffset.dy,
size.width,
dividerThickness,
);
final Rect buttonBackgroundRect = new Rect.fromLTWH(
accumulatingOffset.dx,
accumulatingOffset.dy + (isDividerPresent ? dividerThickness : 0.0),
size.width,
child.size.height,
);
// If this button is pressed, then we don't want a white background to be
// painted, so we erase this button from the background path.
if (isButtonPressed) {
backgroundFillPath.addRect(buttonBackgroundRect);
pressedBackgroundFillPath.addRect(buttonBackgroundRect);
}
// If this divider is needed, then we erase the divider area from the
// background path, and on top of that we paint a translucent gray to
// darken the divider area.
if (isDividerPainted) {
backgroundFillPath.addRect(dividerRect);
dividersPath.addRect(dividerRect);
}
accumulatingOffset += (isDividerPresent ? dividerOffset : Offset.zero)
+ new Offset(0.0, child.size.height);
prevChild = child;
child = childAfter(child);
}
canvas.drawPath(backgroundFillPath, _buttonBackgroundPaint);
canvas.drawPath(pressedBackgroundFillPath, _pressedButtonBackgroundPaint);
canvas.drawPath(dividersPath, _dividerPaint);
}
void _drawButtons(PaintingContext context, Offset offset) {
RenderBox child = firstChild;
while (child != null) {
final MultiChildLayoutParentData childParentData = child.parentData;
context.paintChild(child, childParentData.offset + offset);
child = childAfter(child);
}
}
@override
bool hitTestChildren(HitTestResult result, { Offset position }) {
return defaultHitTestChildren(result, position: position);
}
}
\ No newline at end of file
......@@ -83,10 +83,10 @@ 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'
'backgroundBlendMode applies to BoxDecoration\'s background color or '
'gradient, but no color or gradient were provided.'
);
......@@ -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
......
......@@ -2,24 +2,22 @@
// 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(
onPressed: () {
showDialog<void>(
context: context,
builder: (BuildContext context) {
await tester.pumpWidget(
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new CupertinoAlertDialog(
title: const Text('The title'),
content: const Text('The content'),
......@@ -38,30 +36,18 @@ void main() {
],
);
},
);
},
child: const Text('Go'),
);
},
),
),
),
));
);
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'));
expect(didDelete, isTrue);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(didDelete, isTrue);
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,22 +83,15 @@ 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) {
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0),
child: new CupertinoAlertDialog(
......@@ -130,20 +109,12 @@ void main() {
scrollController: scrollController,
),
);
},
);
},
child: const Text('Go'),
);
}),
),
)),
}
)
);
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,36 +122,76 @@ 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) {
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: textScaleFactor),
data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0),
child: new CupertinoAlertDialog(
title: const Text('The title'),
content: const Text('The content.'),
......@@ -202,29 +213,22 @@ void main() {
child: Text('Cancel'),
),
],
actionScrollController: scrollController,
actionScrollController: actionScrollController,
),
);
},
);
},
child: const Text('Go'),
);
}),
),
)),
}
)
);
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,24 +240,17 @@ 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) {
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor),
child: new CupertinoAlertDialog(
......@@ -265,46 +262,46 @@ void main() {
child: Text('Two'),
),
],
actionScrollController: scrollController,
actionScrollController: actionScrollController,
),
);
},
);
},
child: const Text('Go'),
);
}),
}
),
)),
);
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) {
createAppWithButtonThatLaunchesDialog(
dialogBuilder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: textScaleFactor),
child: new CupertinoAlertDialog(
......@@ -314,23 +311,427 @@ void main() {
),
);
},
);
},
child: const Text('Go'),
);
}),
),
)),
);
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,6 +905,42 @@ 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,
......
......@@ -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.
......
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment