Unverified Commit 96326d47 authored by Natalie Sampsell's avatar Natalie Sampsell Committed by GitHub

CupertinoActionSheet (#19232)

Adding CupertinoActionSheet, showCupertinoModalPopup
parent 21f22ed3
......@@ -7,6 +7,7 @@
/// To use, import `package:flutter/cupertino.dart`.
library cupertino;
export 'src/cupertino/action_sheet.dart';
export 'src/cupertino/activity_indicator.dart';
export 'src/cupertino/app.dart';
export 'src/cupertino/bottom_tab_bar.dart';
......
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' show ImageFilter;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'scrollbar.dart';
const TextStyle _kActionSheetActionStyle = TextStyle(
fontFamily: '.SF UI Text',
inherit: false,
fontSize: 20.0,
fontWeight: FontWeight.w400,
color: CupertinoColors.activeBlue,
textBaseline: TextBaseline.alphabetic,
);
const TextStyle _kActionSheetContentStyle = TextStyle(
fontFamily: '.SF UI Text',
inherit: false,
fontSize: 13.0,
fontWeight: FontWeight.w400,
color: _kContentTextColor,
textBaseline: TextBaseline.alphabetic,
);
// This decoration is applied to the blurred backdrop to lighten the blurred
// image. Brightening is done to counteract the dark modal barrier that
// appears behind the alert. 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 _kAlertBlurOverlayDecoration = BoxDecoration(
color: CupertinoColors.white,
backgroundBlendMode: BlendMode.overlay,
);
// Translucent, very light gray that is painted on top of the blurred backdrop
// as the action sheet's background color.
const Color _kBackgroundColor = Color(0xD1F8F8F8);
// Translucent, light gray that is painted on top of the blurred backdrop as
// the background color of a pressed button.
const Color _kPressedColor = Color(0xA6E5E5EA);
// Translucent gray 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(0x403F3F3F);
const Color _kContentTextColor = Color(0xFF8F8F8F);
const Color _kCancelButtonPressedColor = Color(0xFFEAEAEA);
const double _kBlurAmount = 20.0;
const double _kEdgeHorizontalPadding = 8.0;
const double _kCancelButtonPadding = 8.0;
const double _kEdgeVerticalPadding = 10.0;
const double _kContentHorizontalPadding = 40.0;
const double _kContentVerticalPadding = 14.0;
const double _kButtonHeight = 56.0;
const double _kCornerRadius = 14.0;
const double _kDividerThickness = 1.0;
/// An iOS-style action sheet.
///
/// An action sheet is a specific style of alert that presents the user
/// with a set of two or more choices related to the current context.
/// An action sheet can have a title, an additional message, and a list
/// of actions. The title is displayed above the message and the actions
/// are displayed below this content.
///
/// This action sheet styles its title and message to match standard iOS action
/// sheet title and message text style.
///
/// To display action buttons that look like standard iOS action sheet buttons,
/// provide [CupertinoActionSheetAction]s for the [actions] given to this action sheet.
///
/// To include a iOS-style cancel button separate from the other buttons,
/// provide an [CupertinoActionSheetAction] for the [cancelButton] given to this
/// action sheet.
///
/// An action sheet is typically passed as the child widget to
/// [showCupertinoModalPopup], which displays the action sheet by sliding it up
/// from the bottom of the screen.
///
/// See also:
///
/// * [CupertinoActionSheetAction], which is an iOS-style action sheet button.
/// * <https://developer.apple.com/design/human-interface-guidelines/ios/views/action-sheets/>
class CupertinoActionSheet extends StatelessWidget {
/// Creates an iOS-style action sheet.
///
/// An action sheet must have a non-null value for at least one of the
/// following arguments: [actions], [title], [message], or [cancelButton].
///
/// Generally, action sheets are used to give the user a choice between
/// two or more choices for the current context.
const CupertinoActionSheet({
Key key,
this.title,
this.message,
this.actions,
this.messageScrollController,
this.actionScrollController,
this.cancelButton,
}) : assert(actions != null || title != null || message != null || cancelButton != null,
'An action sheet must have a non-null value for at least one of the following arguments: '
'actions, title, message, or cancelButton'),
super(key: key);
/// An optional title of the action sheet. When the [message] is non-null,
/// the font of the [title] is bold.
///
/// Typically a [Text] widget.
final Widget title;
/// An optional descriptive message that provides more details about the
/// reason for the alert.
///
/// Typically a [Text] widget.
final Widget message;
/// The set of actions that are displayed for the user to select.
///
/// Typically this is a list of [CupertinoActionSheetAction] widgets.
final List<Widget> actions;
/// A scroll controller that can be used to control the scrolling of the
/// [message] in the action sheet.
///
/// This attribute is typically not needed, as alert messages should be
/// short.
final ScrollController messageScrollController;
/// A scroll controller that can be used to control the scrolling of the
/// [actions] in the action sheet.
///
/// This attribute is typically not needed.
final ScrollController actionScrollController;
/// The optional cancel button that is grouped separately from the other
/// actions.
///
/// Typically this is an [CupertinoActionSheetAction] widget.
final Widget cancelButton;
Widget _buildContent() {
final List<Widget> content = <Widget>[];
if (title != null || message != null) {
final Widget titleSection = new _CupertinoAlertContentSection(
title: title,
message: message,
scrollController: messageScrollController,
);
content.add(new Flexible(child: titleSection));
}
return new Container(
color: _kBackgroundColor,
child: new Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: content,
),
);
}
Widget _buildActions() {
if (actions == null || actions.isEmpty) {
return new Container(
height: 0.0,
);
}
return new Container(
child: new _CupertinoAlertActionSection(
children: actions,
scrollController: actionScrollController,
hasCancelButton: cancelButton != null,
),
);
}
Widget _buildCancelButton() {
final double cancelPadding = (actions != null || message != null || title != null)
? _kCancelButtonPadding : 0.0;
return Padding(
padding: new EdgeInsets.only(top: cancelPadding),
child: new _CupertinoActionSheetCancelButton(
child: cancelButton,
),
);
}
@override
Widget build(BuildContext context) {
final List<Widget> children = <Widget>[
new Flexible(child: new ClipRRect(
borderRadius: new BorderRadius.circular(12.0),
child: new BackdropFilter(
filter: new ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount),
child: new Container(
decoration: _kAlertBlurOverlayDecoration,
child: new _CupertinoAlertRenderWidget(
contentSection: _buildContent(),
actionsSection: _buildActions(),
),
),
),
),
),
];
if (cancelButton != null) {
children.add(
_buildCancelButton(),
);
}
final Orientation orientation = MediaQuery.of(context).orientation;
double actionSheetWidth;
if (orientation == Orientation.portrait) {
actionSheetWidth = MediaQuery.of(context).size.width - (_kEdgeHorizontalPadding * 2);
} else {
actionSheetWidth = MediaQuery.of(context).size.height - (_kEdgeHorizontalPadding * 2);
}
return new SafeArea(
child: new Semantics(
namesRoute: true,
scopesRoute: true,
explicitChildNodes: true,
label: 'Alert',
child: new Container(
width: actionSheetWidth,
margin: const EdgeInsets.symmetric(
horizontal: _kEdgeHorizontalPadding,
vertical: _kEdgeVerticalPadding,
),
child: new Column(
children: children,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
),
),
),
);
}
}
/// A button typically used in a [CupertinoActionSheet].
///
/// See also:
///
/// * [CupertinoActionSheet], an alert that presents the user with a set of two or
/// more choices related to the current context.
class CupertinoActionSheetAction extends StatelessWidget {
///Creates an action for an iOS-style action sheet.
///
/// The [child] and [onPressed] arguments must not be null.
const CupertinoActionSheetAction({
@required this.onPressed,
this.isDefaultAction = false,
this.isDestructiveAction = false,
@required this.child,
}) : assert(child != null),
assert(onPressed != null);
/// The callback that is called when the button is tapped.
///
/// This attribute must not be null.
final VoidCallback onPressed;
/// Whether this action is the default choice in the action sheet.
///
/// Default buttons have bold text.
final bool isDefaultAction;
/// Whether this action might change or delete data.
///
/// Destructive buttons have red text.
final bool isDestructiveAction;
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
@override
Widget build(BuildContext context) {
TextStyle style = _kActionSheetActionStyle;
if (isDefaultAction) {
style = style.copyWith(fontWeight: FontWeight.w600);
}
if (isDestructiveAction) {
style = style.copyWith(color: CupertinoColors.destructiveRed);
}
return new GestureDetector(
onTap: onPressed,
behavior: HitTestBehavior.opaque,
child: new ConstrainedBox(
constraints: const BoxConstraints(
minHeight: _kButtonHeight,
),
child: new Semantics(
button: true,
child: new Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 10.0,
),
child: new DefaultTextStyle(
style: style,
child: child,
textAlign: TextAlign.center,
),
),
),
),
);
}
}
class _CupertinoActionSheetCancelButton extends StatefulWidget {
const _CupertinoActionSheetCancelButton({
Key key,
this.child,
}) : super(key: key);
final Widget child;
@override
_CupertinoActionSheetCancelButtonState createState() => _CupertinoActionSheetCancelButtonState();
}
class _CupertinoActionSheetCancelButtonState extends State<_CupertinoActionSheetCancelButton> {
Color _backgroundColor;
@override
void initState() {
_backgroundColor = CupertinoColors.white;
super.initState();
}
void _onTapDown(TapDownDetails event) {
setState(() {
_backgroundColor = _kCancelButtonPressedColor;
});
}
void _onTapUp(TapUpDetails event) {
setState(() {
_backgroundColor = CupertinoColors.white;
});
}
void _onTapCancel() {
setState(() {
_backgroundColor = CupertinoColors.white;
});
}
@override
Widget build(BuildContext context) {
return new GestureDetector(
excludeFromSemantics: true,
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
child: new Container(
decoration: new BoxDecoration(
color: _backgroundColor,
borderRadius: new BorderRadius.circular(_kCornerRadius),
),
child: widget.child,
),
);
}
}
class _CupertinoAlertRenderWidget extends RenderObjectWidget {
const _CupertinoAlertRenderWidget({
Key key,
@required this.contentSection,
@required this.actionsSection,
}) : super(key: key);
final Widget contentSection;
final Widget actionsSection;
@override
RenderObject createRenderObject(BuildContext context) {
return new _RenderCupertinoAlert(
dividerThickness: _kDividerThickness / MediaQuery.of(context).devicePixelRatio,
);
}
@override
RenderObjectElement createElement() {
return _CupertinoAlertRenderElement(this);
}
}
class _CupertinoAlertRenderElement extends RenderObjectElement {
_CupertinoAlertRenderElement(_CupertinoAlertRenderWidget widget) : super(widget);
Element _contentElement;
Element _actionsElement;
@override
_CupertinoAlertRenderWidget get widget => super.widget;
@override
_RenderCupertinoAlert get renderObject => super.renderObject;
@override
void visitChildren(ElementVisitor visitor) {
if (_contentElement != null) {
visitor(_contentElement);
}
if (_actionsElement != null) {
visitor(_actionsElement);
}
}
@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
_contentElement = updateChild(_contentElement,
widget.contentSection, _AlertSections.contentSection);
_actionsElement = updateChild(_actionsElement,
widget.actionsSection, _AlertSections.actionsSection);
}
@override
void insertChildRenderObject(RenderObject child, _AlertSections slot) {
_placeChildInSlot(child, slot);
}
@override
void moveChildRenderObject(RenderObject child, _AlertSections slot) {
_placeChildInSlot(child, slot);
}
@override
void update(RenderObjectWidget newWidget) {
super.update(newWidget);
_contentElement = updateChild(_contentElement,
widget.contentSection, _AlertSections.contentSection);
_actionsElement = updateChild(_actionsElement,
widget.actionsSection, _AlertSections.actionsSection);
}
@override
void forgetChild(Element child) {
assert(child == _contentElement || child == _actionsElement);
if (_contentElement == child) {
_contentElement = null;
} else if (_actionsElement == child) {
_actionsElement = null;
}
}
@override
void removeChildRenderObject(RenderObject child) {
assert(child == renderObject.contentSection || child == renderObject.actionsSection);
if (renderObject.contentSection == child) {
renderObject.contentSection = null;
} else if (renderObject.actionsSection == child) {
renderObject.actionsSection = null;
}
}
void _placeChildInSlot(RenderObject child, _AlertSections slot) {
assert(slot != null);
switch (slot) {
case _AlertSections.contentSection:
renderObject.contentSection = child;
break;
case _AlertSections.actionsSection:
renderObject.actionsSection = child;
break;
}
}
}
// An iOS-style layout policy for sizing an alert's content section and action
// button section.
//
// The policy is as follows:
//
// If all content and buttons fit on the screen:
// The content section and action button section are sized intrinsically.
//
// If all content and buttons do not fit on the screen:
// A minimum height for the action button section is calculated. The action
// button section will not be rendered shorter than this minimum. See
// _RenderCupertinoAlertActions for the minimum height calculation.
//
// With the minimum action button section calculated, the content section can
// take up as much of the remaining space as it needs.
//
// 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.
class _RenderCupertinoAlert extends RenderBox {
_RenderCupertinoAlert({
RenderBox contentSection,
RenderBox actionsSection,
double dividerThickness = 0.0,
}) : _contentSection = contentSection,
_actionsSection = actionsSection,
_dividerThickness = dividerThickness;
RenderBox get contentSection => _contentSection;
RenderBox _contentSection;
set contentSection(RenderBox newContentSection) {
if (newContentSection != _contentSection) {
if (null != _contentSection) {
dropChild(_contentSection);
}
_contentSection = newContentSection;
if (null != _contentSection) {
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);
}
}
}
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! MultiChildLayoutParentData) {
child.parentData = new MultiChildLayoutParentData();
}
}
@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 constraints.minWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
return constraints.maxWidth;
}
@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;
double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight;
if (actionsHeight > 0 || contentHeight > 0)
height -= 2 * _kEdgeVerticalPadding;
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;
double height = contentHeight + (hasDivider ? _dividerThickness : 0.0) + actionsHeight;
if (actionsHeight > 0 || contentHeight > 0)
height -= 2 * _kEdgeVerticalPadding;
if (height.isFinite)
return height;
return 0.0;
}
@override
void performLayout() {
final bool hasDivider = contentSection.getMaxIntrinsicHeight(constraints.maxWidth) > 0.0
&& actionsSection.getMaxIntrinsicHeight(constraints.maxWidth) > 0.0;
final double dividerThickness = hasDivider ? _dividerThickness : 0.0;
final double minActionsHeight = actionsSection.getMinIntrinsicHeight(constraints.maxWidth);
// Size alert content.
contentSection.layout(
constraints.deflate(new EdgeInsets.only(bottom: minActionsHeight + dividerThickness)),
parentUsesSize: true,
);
final Size contentSize = contentSection.size;
// Size alert actions.
actionsSection.layout(
constraints.deflate(new EdgeInsets.only(top: contentSize.height + dividerThickness)),
parentUsesSize: true,
);
final Size actionsSize = actionsSection.size;
// Calculate overall alert height.
final double actionSheetHeight = contentSize.height + dividerThickness + actionsSize.height;
// Set our size now that layout calculations are complete.
size = new Size(constraints.maxWidth, actionSheetHeight);
// Set the position of the actions box to sit at the bottom of the alert.
// The content box defaults to the top left, which is where we want it.
assert(actionsSection.parentData is MultiChildLayoutParentData);
final MultiChildLayoutParentData actionParentData = actionsSection.parentData;
actionParentData.offset = new Offset(0.0, contentSize.height + dividerThickness);
}
@override
void paint(PaintingContext context, Offset offset) {
final MultiChildLayoutParentData 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 MultiChildLayoutParentData 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 MultiChildLayoutParentData contentSectionParentData = contentSection.parentData;
final MultiChildLayoutParentData 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 that need to be explicitly sized and
// laid out at runtime.
enum _AlertSections {
contentSection,
actionsSection,
}
// The "content section" of a CupertinoActionSheet.
//
// If title is missing, then only content is added. If content is
// missing, then only a title is added. If both are missing, then it returns
// a SingleChildScrollView with a zero-sized Container.
class _CupertinoAlertContentSection extends StatelessWidget {
const _CupertinoAlertContentSection({
Key key,
this.title,
this.message,
this.scrollController,
}) : super(key: key);
// An optional title of the action sheet. When the message is non-null,
// the font of the title is bold.
//
// Typically a Text widget.
final Widget title;
// An optional descriptive message that provides more details about the
// reason for the alert.
//
// Typically a Text widget.
final Widget message;
// A scroll controller that can be used to control the scrolling of the
// content in the action sheet.
//
// Defaults to null, and is typically not needed, since most alert contents
// are short.
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
final List<Widget> titleContentGroup = <Widget>[];
if (title != null) {
titleContentGroup.add(new Padding(
padding: const EdgeInsets.only(
left: _kContentHorizontalPadding,
right: _kContentHorizontalPadding,
bottom: _kContentVerticalPadding,
top: _kContentVerticalPadding,
),
child: new DefaultTextStyle(
style: message == null ? _kActionSheetContentStyle
: _kActionSheetContentStyle.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
child: title,
),
));
}
if (message != null) {
titleContentGroup.add(
new Padding(
padding: new EdgeInsets.only(
left: _kContentHorizontalPadding,
right: _kContentHorizontalPadding,
bottom: title == null ? _kContentVerticalPadding : 22.0,
top: title == null ? _kContentVerticalPadding : 0.0,
),
child: new DefaultTextStyle(
style: title == null ? _kActionSheetContentStyle.copyWith(fontWeight: FontWeight.w600)
: _kActionSheetContentStyle,
textAlign: TextAlign.center,
child: message,
),
),
);
}
if (titleContentGroup.isEmpty) {
return new SingleChildScrollView(
controller: scrollController,
child: new Container(
width: 0.0,
height: 0.0,
),
);
}
// 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,
child: new Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: titleContentGroup,
),
),
);
}
}
// The "actions section" of a CupertinoActionSheet.
//
// See _RenderCupertinoAlertActions for details about action button sizing
// and layout.
class _CupertinoAlertActionSection extends StatefulWidget {
const _CupertinoAlertActionSection({
Key key,
@required this.children,
this.scrollController,
this.hasCancelButton,
}) : assert(children != null),
super(key: key);
final List<Widget> children;
// A scroll controller that can be used to control the scrolling of the
// actions in the action sheet.
//
// Defaults to null, and is typically not needed, since most alerts
// don't have many actions.
final ScrollController scrollController;
final bool hasCancelButton;
@override
_CupertinoAlertActionSectionState createState() => new _CupertinoAlertActionSectionState();
}
class _CupertinoAlertActionSectionState extends State<_CupertinoAlertActionSection> {
@override
Widget build(BuildContext context) {
final double devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
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: widget.scrollController,
child: new _CupertinoAlertActionsRenderWidget(
actionButtons: interactiveButtons,
dividerThickness: _kDividerThickness / devicePixelRatio,
hasCancelButton: widget.hasCancelButton,
),
),
);
}
}
// A 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 _RenderCupertinoAlertActions.
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(
excludeFromSemantics: true,
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 alert 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<_CupertinoAlertActionsRenderWidget> {
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;
}
// An iOS-style alert action button layout.
//
// See _RenderCupertinoAlertActions for specific layout policy details.
class _CupertinoAlertActionsRenderWidget extends MultiChildRenderObjectWidget {
_CupertinoAlertActionsRenderWidget({
Key key,
@required List<Widget> actionButtons,
double dividerThickness = 0.0,
bool hasCancelButton = false,
}) : _dividerThickness = dividerThickness,
_hasCancelButton = hasCancelButton,
super(key: key, children: actionButtons);
final double _dividerThickness;
final bool _hasCancelButton;
@override
RenderObject createRenderObject(BuildContext context) {
return new _RenderCupertinoAlertActions(
dividerThickness: _dividerThickness,
hasCancelButton: _hasCancelButton,
);
}
@override
void updateRenderObject(BuildContext context, _RenderCupertinoAlertActions renderObject) {
renderObject.dividerThickness = _dividerThickness;
renderObject.hasCancelButton = _hasCancelButton;
}
}
// An iOS-style layout policy for sizing and positioning an action sheet's
// buttons.
//
// The policy is as follows:
//
// Action sheet buttons are always stacked vertically. In the case where the
// content section and the action section combined can not fit on the screen
// without scrolling, the height of the action section is determined as
// follows.
//
// If the user has included a separate cancel button, the height of the action
// section can be up to the height of 3 action buttons (i.e., the user can
// include 1, 2, or 3 action buttons and they will appear without needing to
// be scrolled). If 4+ action buttons are provided, the height of the action
// section shrinks to 1.5 buttons tall, and is scrollable.
//
// If the user has not included a separate cancel button, the height of the
// action section is at most 1.5 buttons tall.
class _RenderCupertinoAlertActions extends RenderBox
with ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
_RenderCupertinoAlertActions({
List<RenderBox> children,
double dividerThickness = 0.0,
bool hasCancelButton = false,
}) : _dividerThickness = dividerThickness,
_hasCancelButton = hasCancelButton {
addAll(children);
}
// The thickness of the divider between buttons.
double get dividerThickness => _dividerThickness;
double _dividerThickness;
set dividerThickness(double newValue) {
if (newValue == _dividerThickness) {
return;
}
_dividerThickness = newValue;
markNeedsLayout();
}
bool _hasCancelButton;
bool get hasCancelButton => _hasCancelButton;
set hasCancelButton(bool newValue) {
if (newValue == _hasCancelButton) {
return;
}
_hasCancelButton = newValue;
markNeedsLayout();
}
final Paint _buttonBackgroundPaint = new Paint()
..color = _kBackgroundColor
..style = PaintingStyle.fill;
final Paint _pressedButtonBackgroundPaint = new Paint()
..color = _kPressedColor
..style = PaintingStyle.fill;
final Paint _dividerPaint = new Paint()
..color = _kButtonDividerColor
..style = PaintingStyle.fill;
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _ActionButtonParentData)
child.parentData = new _ActionButtonParentData();
}
@override
double computeMinIntrinsicWidth(double height) {
return constraints.minWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
return constraints.maxWidth;
}
@override
double computeMinIntrinsicHeight(double width) {
if (childCount == 0)
return 0.0;
if (childCount == 1)
return firstChild.computeMaxIntrinsicHeight(width) + dividerThickness;
if (hasCancelButton && childCount < 4)
return _computeMinIntrinsicHeightWithCancel(width);
return _computeMinIntrinsicHeightWithoutCancel(width);
}
// The minimum height for more than 2-3 buttons when a cancel button is
// included is the full height of button stack.
double _computeMinIntrinsicHeightWithCancel(double width) {
assert(childCount == 2 || childCount == 3);
if (childCount == 2) {
return firstChild.getMinIntrinsicHeight(width)
+ childAfter(firstChild).getMinIntrinsicHeight(width)
+ dividerThickness;
}
return firstChild.getMinIntrinsicHeight(width)
+ childAfter(firstChild).getMinIntrinsicHeight(width)
+ childAfter(childAfter(firstChild)).getMinIntrinsicHeight(width)
+ (dividerThickness * 2);
}
// The minimum height for more than 2 buttons when no cancel button or 4+
// buttons when a cancel button is included is the height of the 1st button
// + 50% the height of the 2nd button + 2 dividers.
double _computeMinIntrinsicHeightWithoutCancel(double width) {
assert(childCount >= 2);
return firstChild.getMinIntrinsicHeight(width)
+ dividerThickness
+ (0.5 * childAfter(firstChild).getMinIntrinsicHeight(width));
}
@override
double computeMaxIntrinsicHeight(double width) {
if (childCount == 0)
return 0.0;
if (childCount == 1)
return firstChild.computeMaxIntrinsicHeight(width) + dividerThickness;
return _computeMaxIntrinsicHeightStacked(width);
}
// 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;
}
@override
void performLayout() {
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);
}
size = constraints.constrain(
new Size(constraints.maxWidth, verticalOffset)
);
}
@override
void paint(PaintingContext context, Offset offset) {
final Canvas canvas = context.canvas;
_drawButtonBackgroundsAndDividersStacked(canvas, offset);
_drawButtons(context, offset);
}
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);
}
}
......@@ -14,6 +14,9 @@ const double _kMinFlingVelocity = 1.0; // Screen widths per second.
// Barrier color for a Cupertino modal barrier.
const Color _kModalBarrierColor = Color(0x6604040F);
// The duration of the transition used when a modal popup is shown.
const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335);
// Offset from offscreen to the right to fully on screen.
final Tween<Offset> _kRightMiddleTween = new Tween<Offset>(
begin: const Offset(1.0, 0.0),
......@@ -715,6 +718,102 @@ class _CupertinoEdgeShadowPainter extends BoxPainter {
}
}
class _CupertinoModalPopupRoute<T> extends PopupRoute<T> {
_CupertinoModalPopupRoute({
this.builder,
this.barrierLabel,
RouteSettings settings,
}) : super(settings: settings);
final WidgetBuilder builder;
@override
final String barrierLabel;
@override
Color get barrierColor => _kModalBarrierColor;
@override
bool get barrierDismissible => true;
@override
bool get semanticsDismissible => false;
@override
Duration get transitionDuration => _kModalPopupTransitionDuration;
Animation<double> _animation;
Tween<Offset> _offsetTween;
@override
Animation<double> createAnimation() {
assert(_animation == null);
_animation = new CurvedAnimation(
parent: super.createAnimation(),
curve: Curves.ease,
reverseCurve: Curves.ease.flipped,
);
_offsetTween = new Tween<Offset>(
begin: const Offset(0.0, 1.0),
end: const Offset(0.0, 0.0),
);
return _animation;
}
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return builder(context);
}
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return new Align(
alignment: Alignment.bottomCenter,
child: new FractionalTranslation(
translation: _offsetTween.evaluate(_animation),
child: child,
),
);
}
}
/// Shows a modal iOS-style popup that slides up from the bottom of the screen.
///
/// Such a popup is an alternative to a menu or a dialog and prevents the user
/// from interacting with the rest of the app.
///
/// The `context` argument is used to look up the [Navigator] for the popup.
/// It is only used when the method is called. Its corresponding widget can be
/// safely removed from the tree before the popup is closed.
///
/// The `builder` argument typically builds a [CupertinoActionSheet] widget.
/// Content below the widget is dimmed with a [ModalBarrier]. The widget built
/// by the `builder` does not share a context with the location that
/// `showCupertinoModalPopup` is originally called from. Use a
/// [StatefulBuilder] or a custom [StatefulWidget] if the widget needs to
/// update dynamically.
///
/// Returns a `Future` that resolves to the value that was passed to
/// [Navigator.pop] when the popup was closed.
///
/// See also:
///
/// * [ActionSheet], which is the widget usually returned by the `builder`
/// argument to [showCupertinoModalPopup].
/// * <https://developer.apple.com/design/human-interface-guidelines/ios/views/action-sheets/>
Future<T> showCupertinoModalPopup<T>({
@required BuildContext context,
@required WidgetBuilder builder,
}) {
return Navigator.of(context, rootNavigator: true).push(
new _CupertinoModalPopupRoute<T>(
builder: builder,
barrierLabel: 'Dismiss',
),
);
}
Widget _buildCupertinoDialogTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
final CurvedAnimation fadeAnimation = new CurvedAnimation(
parent: animation,
......
......@@ -70,6 +70,7 @@ class BoxDecoration extends Decoration {
/// [BoxShape.circle].
/// * If [boxShadow] is null, this decoration does not paint a shadow.
/// * If [gradient] is null, this decoration does not paint gradients.
/// * If [backgroundBlendMode] is null, this decoration paints with [BlendMode.srcOver]
///
/// The [shape] argument must not be null.
const BoxDecoration({
......@@ -79,13 +80,20 @@ class BoxDecoration extends Decoration {
this.borderRadius,
this.boxShadow,
this.gradient,
this.backgroundBlendMode,
this.shape = BoxShape.rectangle,
}) : assert(shape != null);
}) : assert(shape != null),
// TODO(mattcarroll): Use "backgroundBlendMode == null" when Dart #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.'
);
@override
bool debugAssertIsValid() {
assert(shape != BoxShape.circle ||
borderRadius == null); // Can't have a border radius if you're a circle.
borderRadius == null); // Can't have a border radius if you're a circle.
return super.debugAssertIsValid();
}
......@@ -136,6 +144,14 @@ class BoxDecoration extends Decoration {
/// The [gradient] is drawn under the [image].
final Gradient gradient;
/// The blend mode applied to the [color] or [gradient] background of the box.
///
/// 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.
final BlendMode backgroundBlendMode;
/// The shape to fill the background [color], [gradient], and [image] into and
/// to cast as the [boxShadow].
///
......@@ -332,6 +348,8 @@ class _BoxDecorationPainter extends BoxPainter {
if (_cachedBackgroundPaint == null ||
(_decoration.gradient != null && _rectForCachedBackgroundPaint != rect)) {
final Paint paint = new Paint();
if (_decoration.backgroundBlendMode != null)
paint.blendMode = _decoration.backgroundBlendMode;
if (_decoration.color != null)
paint.color = _decoration.color;
if (_decoration.gradient != null) {
......
......@@ -33,6 +33,7 @@ class ModalBarrier extends StatelessWidget {
this.color,
this.dismissible = true,
this.semanticsLabel,
this.barrierSemanticsDismissible = true,
}) : super(key: key);
/// If non-null, fill the barrier with this color.
......@@ -51,6 +52,13 @@ class ModalBarrier extends StatelessWidget {
/// [ModalBarrier] built by [ModalRoute] pages.
final bool dismissible;
/// Whether the modal barrier semantics are included in the semantics tree.
///
/// See also:
/// * [ModalRoute.semanticsDismissible], which controls this property for
/// the [ModalBarrier] built by [ModalRoute] pages.
final bool barrierSemanticsDismissible;
/// Semantics label used for the barrier if it is [dismissable].
///
/// The semantics label is read out by accessibility tools (e.g. TalkBack
......@@ -66,10 +74,12 @@ class ModalBarrier extends StatelessWidget {
Widget build(BuildContext context) {
assert(!dismissible || semanticsLabel == null || debugCheckHasDirectionality(context));
final bool semanticsDismissible = dismissible && defaultTargetPlatform != TargetPlatform.android;
final bool modalBarrierSemanticsDismissible = barrierSemanticsDismissible ?? semanticsDismissible;
return new BlockSemantics(
child: new ExcludeSemantics(
// On Android, the back button is used to dismiss a modal.
excluding: !semanticsDismissible,
// On Android, the back button is used to dismiss a modal. On iOS, some
// modal barriers are not dismissible in accessibility mode.
excluding: !semanticsDismissible || !modalBarrierSemanticsDismissible,
child: new GestureDetector(
onTapDown: (TapDownDetails details) {
if (dismissible)
......@@ -117,6 +127,7 @@ class AnimatedModalBarrier extends AnimatedWidget {
Animation<Color> color,
this.dismissible = true,
this.semanticsLabel,
this.barrierSemanticsDismissible,
}) : super(key: key, listenable: color);
/// If non-null, fill the barrier with this color.
......@@ -145,12 +156,20 @@ class AnimatedModalBarrier extends AnimatedWidget {
/// [ModalBarrier] built by [ModalRoute] pages.
final String semanticsLabel;
/// Whether the modal barrier semantics are included in the semantics tree.
///
/// See also:
/// * [ModalRoute.semanticsDismissible], which controls this property for
/// the [ModalBarrier] built by [ModalRoute] pages.
final bool barrierSemanticsDismissible;
@override
Widget build(BuildContext context) {
return new ModalBarrier(
color: color?.value,
dismissible: dismissible,
semanticsLabel: semanticsLabel,
barrierSemanticsDismissible: barrierSemanticsDismissible,
);
}
}
......@@ -898,6 +898,21 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// * [ModalBarrier], the widget that implements this feature.
bool get barrierDismissible;
/// Whether the semantics of the modal barrier are included in the
/// semantics tree.
///
/// The modal barrier is the scrim that is rendered behind each route, which
/// generally prevents the user from interacting with the route below the
/// current route, and normally partially obscures such routes.
///
/// If [semanticsDismissible] is true, then modal barrier semantics are
/// included in the semantics tree.
///
/// If [semanticsDismissible] is false, then modal barrier semantics are
/// excluded from the the semantics tree and tapping on the modal barrier
/// has no effect.
bool get semanticsDismissible => true;
/// The color to use for the modal barrier. If this is null, the barrier will
/// be transparent.
///
......@@ -1173,11 +1188,13 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
color: color,
dismissible: barrierDismissible, // changedInternalState is called if this updates
semanticsLabel: barrierLabel, // changedInternalState is called if this updates
barrierSemanticsDismissible: semanticsDismissible,
);
} else {
barrier = new ModalBarrier(
dismissible: barrierDismissible, // changedInternalState is called if this updates
semanticsLabel: barrierLabel, // changedInternalState is called if this updates
barrierSemanticsDismissible: semanticsDismissible,
);
}
return new IgnorePointer(
......
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/semantics_tester.dart';
void main() {
testWidgets('Verify that a tap on modal barrier dismisses an action sheet',
(WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
const CupertinoActionSheet(
title: Text('Action Sheet'),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(find.text('Action Sheet'), findsOneWidget);
await tester.tapAt(const Offset(20.0, 20.0));
await tester.pump();
expect(find.text('Action Sheet'), findsNothing);
});
testWidgets('Verify that a tap on title section (not buttons) does not dismiss an action sheet',
(WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
const CupertinoActionSheet(
title: Text('Action Sheet'),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(find.text('Action Sheet'), findsOneWidget);
await tester.tap(find.text('Action Sheet'));
await tester.pump();
expect(find.text('Action Sheet'), findsOneWidget);
});
testWidgets('Action sheet destructive text style', (WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
new CupertinoActionSheetAction(
isDestructiveAction: true,
child: const Text('Ok'),
onPressed: () {},
),
),
);
final DefaultTextStyle widget = tester.widget(find.widgetWithText(DefaultTextStyle, 'Ok'));
expect(widget.style.color, CupertinoColors.destructiveRed);
});
testWidgets('Action sheet default text style', (WidgetTester tester) async {
await tester.pumpWidget(
boilerplate(
new CupertinoActionSheetAction(
isDefaultAction: true,
child: const Text('Ok'),
onPressed: () {},
),
),
);
final DefaultTextStyle widget = tester.widget(find.widgetWithText(DefaultTextStyle, 'Ok'));
expect(widget.style.fontWeight, equals(FontWeight.w600));
});
testWidgets('Action sheet text styles are correct when both title and message are included',
(WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
const CupertinoActionSheet(
title: Text('Action Sheet'),
message: Text('An action sheet')
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
final DefaultTextStyle titleStyle = tester.firstWidget(find.widgetWithText(DefaultTextStyle,
'Action Sheet'));
final DefaultTextStyle messageStyle = tester.firstWidget(find.widgetWithText(DefaultTextStyle,
'An action sheet'));
expect(titleStyle.style.fontWeight, FontWeight.w600);
expect(messageStyle.style.fontWeight, FontWeight.w400);
});
testWidgets('Action sheet text styles are correct when title but no message is included',
(WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
const CupertinoActionSheet(
title: Text('Action Sheet'),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
final DefaultTextStyle titleStyle = tester.firstWidget(find.widgetWithText(DefaultTextStyle,
'Action Sheet'));
expect(titleStyle.style.fontWeight, FontWeight.w400);
});
testWidgets('Action sheet text styles are correct when message but no title is included',
(WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
const CupertinoActionSheet(
message: Text('An action sheet'),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
final DefaultTextStyle messageStyle = tester.firstWidget(find.widgetWithText(DefaultTextStyle,
'An action sheet'));
expect(messageStyle.style.fontWeight, FontWeight.w600);
});
testWidgets('Content section but no actions', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: const Text('The message.'),
messageScrollController: scrollController,
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Content section should be at the bottom left of action sheet
// (minus padding).
expect(tester.getBottomLeft(find.byType(ClipRRect)),
tester.getBottomLeft(find.byType(CupertinoActionSheet)) - const Offset(-8.0, 10.0));
// Check that the dialog size is the same as the content section size
// (minus padding).
expect(
tester.getSize(find.byType(ClipRRect)).height,
tester.getSize(find.byType(CupertinoActionSheet)).height - 20.0,
);
expect(
tester.getSize(find.byType(ClipRRect)).width,
tester.getSize(find.byType(CupertinoActionSheet)).width - 16.0,
);
});
testWidgets('Actions but no content section', (WidgetTester tester) async {
final ScrollController actionScrollController = new ScrollController();
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
actionScrollController: actionScrollController,
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final Finder finder = find.byElementPredicate(
(Element element) {
return element.widget.runtimeType.toString() == '_CupertinoAlertActionSection';
},
);
// Check that the title/message section is not displayed (action section is
// at the top of the action sheet + padding).
expect(tester.getTopLeft(finder),
tester.getTopLeft(find.byType(CupertinoActionSheet)) + const Offset(8.0, 10.0));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)) + const Offset(8.0, 10.0),
tester.getTopLeft(find.widgetWithText(CupertinoActionSheetAction, 'One')));
expect(tester.getBottomLeft(find.byType(CupertinoActionSheet)) + const Offset(8.0, -10.0),
tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Two')));
});
testWidgets('Action section is scrollable', (WidgetTester tester) async {
final ScrollController actionScrollController = new ScrollController();
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new Builder(builder: (BuildContext context) {
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0),
child: new CupertinoActionSheet(
title: const Text('The title'),
message: const Text('The message.'),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Three'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Four'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Five'),
onPressed: () {},
),
],
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(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(CupertinoActionSheetAction, 'One')).dx, equals(400.0));
expect(tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Two')).dx, equals(400.0));
expect(tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Three')).dx, equals(400.0));
expect(tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Four')).dx, equals(400.0));
expect(tester.getCenter(find.widgetWithText(CupertinoActionSheetAction, 'Five')).dx, equals(400.0));
// Check that the action buttons are the correct heights.
expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'One')).height, equals(92.0));
expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Two')).height, equals(92.0));
expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Three')).height, equals(92.0));
expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Four')).height, equals(92.0));
expect(tester.getSize(find.widgetWithText(CupertinoActionSheetAction, 'Five')).height, equals(92.0));
});
testWidgets('Content section is scrollable', (WidgetTester tester) async {
final ScrollController messageScrollController = new ScrollController();
double screenHeight;
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new Builder(builder: (BuildContext context) {
screenHeight = MediaQuery.of(context).size.height;
return new MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: 3.0),
child: new CupertinoActionSheet(
title: const Text('The title'),
message: new Text('Very long content' * 200),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
messageScrollController: messageScrollController,
),
);
}),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(messageScrollController.offset, 0.0);
messageScrollController.jumpTo(100.0);
expect(messageScrollController.offset, 100.0);
// Set the scroll position back to zero.
messageScrollController.jumpTo(0.0);
// Expect the action sheet to take all available height.
expect(tester.getSize(find.byType(CupertinoActionSheet)).height, screenHeight);
});
testWidgets('Tap on button calls onPressed', (WidgetTester tester) async {
bool wasPressed = false;
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new Builder(builder: (BuildContext context) {
return new CupertinoActionSheet(
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {
wasPressed = true;
Navigator.pop(context);
},
),
],
);
}),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(wasPressed, isFalse);
await tester.tap(find.text('One'));
expect(wasPressed, isTrue);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('One'), findsNothing);
});
testWidgets('Action sheet width is correct when given infinite horizontal space',
(WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new Row(
children: <Widget>[
new CupertinoActionSheet(
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
),
],
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(tester.getSize(find.byType(CupertinoActionSheet)).width, 600.0);
});
testWidgets('Action sheet height is correct when given infinite vertical space',
(WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new Column(
children: <Widget>[
new CupertinoActionSheet(
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
),
],
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(tester.getSize(find.byType(CupertinoActionSheet)).height,
moreOrLessEquals(132.33333333333334));
});
testWidgets('1 action button with cancel button', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: new Text('Very long content' * 200),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
],
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: () {},
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
// Action section is size of one action button.
expect(findScrollableActionsSectionRenderBox(tester).size.height, 56.0);
});
testWidgets('2 action buttons with cancel button', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: new Text('Very long content' * 200),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: () {},
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(findScrollableActionsSectionRenderBox(tester).size.height,
moreOrLessEquals(112.33333333333331));
});
testWidgets('3 action buttons with cancel button', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: new Text('Very long content' * 200),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Three'),
onPressed: () {},
),
],
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: () {},
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(findScrollableActionsSectionRenderBox(tester).size.height,
moreOrLessEquals(168.66666666666669));
});
testWidgets('4+ action buttons with cancel button', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: new Text('Very long content' * 200),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Three'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Four'),
onPressed: () {},
),
],
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: () {},
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(findScrollableActionsSectionRenderBox(tester).size.height,
moreOrLessEquals(84.33333333333337));
});
testWidgets('1 action button without cancel button', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: new Text('Very long content' * 200),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
],
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(findScrollableActionsSectionRenderBox(tester).size.height, 56.0);
});
testWidgets('2+ action buttons without cancel button', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: new Text('Very long content' * 200),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(findScrollableActionsSectionRenderBox(tester).size.height,
moreOrLessEquals(84.33333333333337));
});
testWidgets('Action sheet with just cancel button is correct', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: (){},
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
// Height should be cancel button height + padding
expect(tester.getSize(find.byType(CupertinoActionSheet)).height, 76.0);
expect(tester.getSize(find.byType(CupertinoActionSheet)).width, 600.0);
});
testWidgets('Cancel button tap calls onPressed', (WidgetTester tester) async {
bool wasPressed = false;
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new Builder(builder: (BuildContext context) {
return new CupertinoActionSheet(
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: () {
wasPressed = true;
Navigator.pop(context);
},
),
);
}),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(wasPressed, isFalse);
await tester.tap(find.text('Cancel'));
expect(wasPressed, isTrue);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Cancel'), findsNothing);
});
testWidgets('Layout is correct when cancel button is present', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: const Text('The message'),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: () {},
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Cancel')).dy, 590.0);
expect(tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'One')).dy,
moreOrLessEquals(469.66666666666663));
expect(tester.getBottomLeft(find.widgetWithText(CupertinoActionSheetAction, 'Two')).dy, 526.0);
});
testWidgets('Enter/exit animation is correct', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: const Text('The message'),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: () {},
),
),
),
);
// Enter animation
await tester.tap(find.text('Go'));
await tester.pump();
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, 600.0);
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(530.9, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(426.7, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(365.0, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(334.0, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(321.0, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(319.3, 0.1));
// Action sheet has reached final height
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(319.3, 0.1));
// Exit animation
await tester.tapAt(const Offset(20.0, 20.0));
await tester.pump();
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(319.3, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(388.4, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(492.6, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(554.2, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(585.2, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(598.2, 0.1));
// Action sheet has disappeared
await tester.pump(const Duration(milliseconds: 60));
expect(find.byType(CupertinoActionSheet), findsNothing);
});
testWidgets('Modal barrier is pressed during transition', (WidgetTester tester) async {
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: const Text('The message'),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: () {},
),
),
),
);
// Enter animation
await tester.tap(find.text('Go'));
await tester.pump();
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, 600.0);
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(530.9, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(426.7, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(365.0, 0.1));
// Exit animation
await tester.tapAt(const Offset(20.0, 20.0));
await tester.pump(const Duration(milliseconds: 60));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(426.7, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, closeTo(530.9, 0.1));
await tester.pump(const Duration(milliseconds: 60));
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, 600.0);
// Action sheet has disappeared
await tester.pump(const Duration(milliseconds: 60));
expect(find.byType(CupertinoActionSheet), findsNothing);
});
testWidgets('Action sheet semantics', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await tester.pumpWidget(
createAppWithButtonThatLaunchesActionSheet(
new CupertinoActionSheet(
title: const Text('The title'),
message: const Text('The message'),
actions: <Widget>[
new CupertinoActionSheetAction(
child: const Text('One'),
onPressed: () {},
),
new CupertinoActionSheetAction(
child: const Text('Two'),
onPressed: () {},
),
],
cancelButton: new CupertinoActionSheetAction(
child: const Text('Cancel'),
onPressed: () {},
),
),
),
);
await tester.tap(find.text('Go'));
await tester.pump();
expect(
semantics,
hasSemantics(
new TestSemantics.root(
children: <TestSemantics>[
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
flags: <SemanticsFlag>[
SemanticsFlag.scopesRoute,
SemanticsFlag.namesRoute,
],
label: 'Alert',
children: <TestSemantics>[
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
label: 'The title',
),
new TestSemantics(
label: 'The message',
),
],
),
new TestSemantics(
children: <TestSemantics>[
new TestSemantics(
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
label: 'One',
),
new TestSemantics(
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
label: 'Two',
),
],
),
new TestSemantics(
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
],
actions: <SemanticsAction>[
SemanticsAction.tap,
],
label: 'Cancel',
)
],
),
],
),
],
),
ignoreId: true,
ignoreRect: true,
ignoreTransform: true,
),
);
semantics.dispose();
});
}
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 createAppWithButtonThatLaunchesActionSheet(Widget actionSheet) {
return new CupertinoApp(
home: new Center(
child: new Builder(builder: (BuildContext context) {
return new CupertinoButton(
onPressed: () {
showCupertinoModalPopup<void>(
context: context,
builder: (BuildContext context) {
return actionSheet;
},
);
},
child: const Text('Go'),
);
}),
),
);
}
Widget boilerplate(Widget child) {
return new Directionality(
textDirection: TextDirection.ltr,
child: child,
);
}
\ No newline at end of file
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