Skip to content

Commit c57f99e

Browse files
authored
[CupertinoAlertDialog] Add tap-slide gesture (#154853)
This PR adds "sliding tap" to `CupertinoAlertDialog` and fixes flutter/flutter#19786. Much of the needed infrastructure has been implemented in flutter/flutter#150219, but this time with a new challenge to support disabled buttons, i.e. the button should not show tap highlight when pressed (flutter/flutter#107371). * Why? Because whether a button is disabled is assigned to `CupertinoDialogAction`, while the background is rendered by a private class that wraps the action widget and built by the dialog body. We need a way to pass the boolean "enabled" from the child to the parent when the action is pressed. After much experimentation, I think the best way is to propagate this boolean using the custom gesture callback. * An alternative way is to make the wrapper widget use an inherited widget, which allows the child `CupertinoDialogAction` to place a `ValueGetter<bool> getEnabled` to the parent as soon as it's mounted. However, this is pretty ugly... This PR also fixes flutter/flutter#107371, i.e. disabled `CupertinoDialogAction` no longer triggers the pressing highlight. However, while legacy buttons (custom button classes that are implemented by `GestureDetector.onTap`) still functions (their `onPressed` continues to work), disabled legacy buttons will still show pressing highlight, and there's no plan (actually, no way) to fix it. All tests related to sliding taps in `CupertinoActionSheet` has been copied to `CupertinoAlertDialog`, with additional tests for disabled buttons.
1 parent 93eabf3 commit c57f99e

File tree

3 files changed

+674
-54
lines changed

3 files changed

+674
-54
lines changed

packages/flutter/lib/src/cupertino/dialog.dart

Lines changed: 112 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -462,14 +462,16 @@ class _CupertinoAlertDialogState extends State<CupertinoAlertDialog> {
462462
width: isInAccessibilityMode
463463
? _kAccessibilityCupertinoDialogWidth
464464
: _kCupertinoDialogWidth,
465-
child: CupertinoPopupSurface(
466-
isSurfacePainted: false,
467-
child: Semantics(
468-
namesRoute: true,
469-
scopesRoute: true,
470-
explicitChildNodes: true,
471-
label: localizations.alertDialogLabel,
472-
child: _buildBody(context),
465+
child: _ActionSheetGestureDetector(
466+
child: CupertinoPopupSurface(
467+
isSurfacePainted: false,
468+
child: Semantics(
469+
namesRoute: true,
470+
scopesRoute: true,
471+
explicitChildNodes: true,
472+
label: localizations.alertDialogLabel,
473+
child: _buildBody(context),
474+
),
473475
),
474476
),
475477
),
@@ -666,10 +668,10 @@ class _SlidingTapGestureRecognizer extends VerticalDragGestureRecognizer {
666668
// Multiple `_SlideTarget`s might be nested.
667669
// `_TargetSelectionGestureRecognizer` uses a simple algorithm that only
668670
// compares if the inner-most slide target has changed (which suffices our use
669-
// case). Semantically, this means that all outer targets will be treated as
670-
// identical to the inner-most one, i.e. when the pointer enters or leaves a
671-
// slide target, the corresponding method will be called on all targets that
672-
// nest it.
671+
// case). Semantically, this means that all outer targets will be treated as
672+
// having the identical area as the inner-most one, i.e. when the pointer enters
673+
// or leaves a slide target, the corresponding method will be called on all
674+
// targets that nest it.
673675
abstract class _SlideTarget {
674676
// A pointer has entered this region.
675677
//
@@ -682,7 +684,10 @@ abstract class _SlideTarget {
682684
//
683685
// The `fromPointerDown` should be true if this callback is triggered by a
684686
// PointerDownEvent, i.e. the second case from the list above.
685-
void didEnter({required bool fromPointerDown});
687+
//
688+
// The return value of this method is used as the `innerEnabled` for the next
689+
// target, while `innerEnabled` of the innermost target is true.
690+
bool didEnter({required bool fromPointerDown, required bool innerEnabled});
686691

687692
// A pointer has exited this region.
688693
//
@@ -703,6 +708,10 @@ abstract class _SlideTarget {
703708

704709
// Recognizes sliding taps and thereupon interacts with
705710
// `_SlideTarget`s.
711+
//
712+
// TODO(dkwingsmt): It should recompute hit testing when the app is updated,
713+
// or better, share code with `MouseTracker`.
714+
// https://github.com/flutter/flutter/issues/155266
706715
class _TargetSelectionGestureRecognizer extends GestureRecognizer {
707716
_TargetSelectionGestureRecognizer({super.debugOwner, required this.hitTest})
708717
: _slidingTap = _SlidingTapGestureRecognizer(debugOwner: debugOwner) {
@@ -775,8 +784,12 @@ class _TargetSelectionGestureRecognizer extends GestureRecognizer {
775784
_currentTargets
776785
..clear()
777786
..addAll(foundTargets);
787+
bool enabled = true;
778788
for (final _SlideTarget target in _currentTargets) {
779-
target.didEnter(fromPointerDown: fromPointerDown);
789+
enabled = target.didEnter(
790+
fromPointerDown: fromPointerDown,
791+
innerEnabled: enabled,
792+
);
780793
}
781794
}
782795
}
@@ -1234,7 +1247,9 @@ class _CupertinoActionSheetActionState extends State<CupertinoActionSheetAction>
12341247
implements _SlideTarget {
12351248
// |_SlideTarget|
12361249
@override
1237-
void didEnter({required bool fromPointerDown}) {}
1250+
bool didEnter({required bool fromPointerDown, required bool innerEnabled}) {
1251+
return innerEnabled;
1252+
}
12381253

12391254
// |_SlideTarget|
12401255
@override
@@ -1377,11 +1392,15 @@ class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackgrou
13771392

13781393
// |_SlideTarget|
13791394
@override
1380-
void didEnter({required bool fromPointerDown}) {
1395+
bool didEnter({required bool fromPointerDown, required bool innerEnabled}) {
1396+
// Action sheet doesn't support disabled buttons, therefore `innerEnabled`
1397+
// is always true.
1398+
assert(innerEnabled);
13811399
widget.onPressStateChange?.call(true);
13821400
if (!fromPointerDown) {
13831401
_emitVibration();
13841402
}
1403+
return innerEnabled;
13851404
}
13861405

13871406
// |_SlideTarget|
@@ -1418,7 +1437,7 @@ class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackgrou
14181437
borderRadius: borderRadius,
14191438
),
14201439
child: widget.child,
1421-
)
1440+
),
14221441
);
14231442
}
14241443
}
@@ -1843,7 +1862,7 @@ class _CupertinoAlertActionSection extends StatelessWidget {
18431862

18441863
// Renders the background of a button (both the pressed background and the idle
18451864
// background) and reports its state to the parent with `onPressStateChange`.
1846-
class _AlertDialogButtonBackground extends StatelessWidget {
1865+
class _AlertDialogButtonBackground extends StatefulWidget {
18471866
const _AlertDialogButtonBackground({
18481867
required this.idleColor,
18491868
required this.pressedColor,
@@ -1868,37 +1887,58 @@ class _AlertDialogButtonBackground extends StatelessWidget {
18681887
/// Typically a [Text] widget.
18691888
final Widget child;
18701889

1871-
void onTapDown(TapDownDetails details) {
1872-
onPressStateChange?.call(true);
1890+
@override
1891+
_AlertDialogButtonBackgroundState createState() => _AlertDialogButtonBackgroundState();
1892+
}
1893+
1894+
class _AlertDialogButtonBackgroundState extends State<_AlertDialogButtonBackground>
1895+
implements _SlideTarget {
1896+
void _emitVibration(){
1897+
switch (defaultTargetPlatform) {
1898+
case TargetPlatform.iOS:
1899+
case TargetPlatform.android:
1900+
HapticFeedback.selectionClick();
1901+
case TargetPlatform.fuchsia:
1902+
case TargetPlatform.linux:
1903+
case TargetPlatform.macOS:
1904+
case TargetPlatform.windows:
1905+
break;
1906+
}
1907+
}
1908+
1909+
// |_SlideTarget|
1910+
@override
1911+
bool didEnter({required bool fromPointerDown, required bool innerEnabled}) {
1912+
widget.onPressStateChange?.call(innerEnabled);
1913+
if (innerEnabled && !fromPointerDown) {
1914+
_emitVibration();
1915+
}
1916+
return innerEnabled;
18731917
}
18741918

1875-
void onTapUp(TapUpDetails details) {
1876-
onPressStateChange?.call(false);
1919+
// |_SlideTarget|
1920+
@override
1921+
void didLeave() {
1922+
widget.onPressStateChange?.call(false);
18771923
}
18781924

1879-
void onTapCancel() {
1880-
onPressStateChange?.call(false);
1925+
// |_SlideTarget|
1926+
@override
1927+
void didConfirm() {
1928+
widget.onPressStateChange?.call(false);
18811929
}
18821930

18831931
@override
18841932
Widget build(BuildContext context) {
1885-
final Color backgroundColor = pressed ? pressedColor : idleColor;
1886-
return MergeSemantics(
1887-
// TODO(mattcarroll): Button press dynamics need overhaul for iOS:
1888-
// https://github.com/flutter/flutter/issues/19786
1889-
child: GestureDetector(
1890-
excludeFromSemantics: true,
1891-
behavior: HitTestBehavior.opaque,
1892-
onTapDown: onTapDown,
1893-
onTapUp: onTapUp,
1894-
// TODO(mattcarroll): Cancel is currently triggered when user moves
1895-
// past slop instead of off button: https://github.com/flutter/flutter/issues/19783
1896-
onTapCancel: onTapCancel,
1933+
final Color backgroundColor = widget.pressed ? widget.pressedColor : widget.idleColor;
1934+
return MetaData(
1935+
metaData: this,
1936+
child: MergeSemantics(
18971937
child: Container(
18981938
decoration: BoxDecoration(
18991939
color: CupertinoDynamicColor.resolve(backgroundColor, context),
19001940
),
1901-
child: child,
1941+
child: widget.child,
19021942
),
19031943
),
19041944
);
@@ -1911,7 +1951,7 @@ class _AlertDialogButtonBackground extends StatelessWidget {
19111951
///
19121952
/// * [CupertinoAlertDialog], a dialog that informs the user about situations
19131953
/// that require acknowledgment.
1914-
class CupertinoDialogAction extends StatelessWidget {
1954+
class CupertinoDialogAction extends StatefulWidget {
19151955
/// Creates an action for an iOS-style dialog.
19161956
const CupertinoDialogAction({
19171957
super.key,
@@ -1958,10 +1998,31 @@ class CupertinoDialogAction extends StatelessWidget {
19581998
/// Typically a [Text] widget.
19591999
final Widget child;
19602000

1961-
/// Whether the button is enabled or disabled. Buttons are disabled by
1962-
/// default. To enable a button, set its [onPressed] property to a non-null
1963-
/// value.
1964-
bool get enabled => onPressed != null;
2001+
@override
2002+
State<CupertinoDialogAction> createState() => _CupertinoDialogActionState();
2003+
}
2004+
2005+
class _CupertinoDialogActionState extends State<CupertinoDialogAction>
2006+
implements _SlideTarget {
2007+
2008+
// The button is enabled when it has [onPressed].
2009+
bool get enabled => widget.onPressed != null;
2010+
2011+
// |_SlideTarget|
2012+
@override
2013+
bool didEnter({required bool fromPointerDown, required bool innerEnabled}) {
2014+
return enabled;
2015+
}
2016+
2017+
// |_SlideTarget|
2018+
@override
2019+
void didLeave() {}
2020+
2021+
// |_SlideTarget|
2022+
@override
2023+
void didConfirm() {
2024+
widget.onPressed?.call();
2025+
}
19652026

19662027
// Dialog action content shrinks to fit, up to a certain point, and if it still
19672028
// cannot fit at the minimum size, the text content is ellipsized.
@@ -1991,7 +2052,7 @@ class CupertinoDialogAction extends StatelessWidget {
19912052
),
19922053
child: Semantics(
19932054
button: true,
1994-
onTap: onPressed,
2055+
onTap: widget.onPressed,
19952056
child: DefaultTextStyle(
19962057
style: textStyle,
19972058
textAlign: TextAlign.center,
@@ -2022,12 +2083,12 @@ class CupertinoDialogAction extends StatelessWidget {
20222083
Widget build(BuildContext context) {
20232084
TextStyle style = _kCupertinoDialogActionStyle.copyWith(
20242085
color: CupertinoDynamicColor.resolve(
2025-
isDestructiveAction ? CupertinoColors.systemRed : CupertinoTheme.of(context).primaryColor,
2086+
widget.isDestructiveAction ? CupertinoColors.systemRed : CupertinoTheme.of(context).primaryColor,
20262087
context,
20272088
),
2028-
).merge(textStyle);
2089+
).merge(widget.textStyle);
20292090

2030-
if (isDefaultAction) {
2091+
if (widget.isDefaultAction) {
20312092
style = style.copyWith(fontWeight: FontWeight.w600);
20322093
}
20332094

@@ -2047,20 +2108,19 @@ class CupertinoDialogAction extends StatelessWidget {
20472108
final Widget sizedContent = _isInAccessibilityMode(context)
20482109
? _buildContentWithAccessibilitySizingPolicy(
20492110
textStyle: style,
2050-
content: child,
2111+
content: widget.child,
20512112
)
20522113
: _buildContentWithRegularSizingPolicy(
20532114
context: context,
20542115
textStyle: style,
2055-
content: child,
2116+
content: widget.child,
20562117
padding: padding,
20572118
);
20582119

20592120
return MouseRegion(
2060-
cursor: onPressed != null && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
2061-
child: GestureDetector(
2062-
excludeFromSemantics: true,
2063-
onTap: onPressed,
2121+
cursor: widget.onPressed != null && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
2122+
child: MetaData(
2123+
metaData: this,
20642124
behavior: HitTestBehavior.opaque,
20652125
child: ConstrainedBox(
20662126
constraints: const BoxConstraints(

packages/flutter/test/cupertino/action_sheet_test.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1035,7 +1035,9 @@ void main() {
10351035
await tester.pumpAndSettle();
10361036

10371037
// Find the location right within the upper edge of button 1.
1038-
final Offset start = tester.getTopLeft(find.text('Button 1')) + const Offset(30, -15);
1038+
final Offset start = tester.getTopLeft(
1039+
find.widgetWithText(CupertinoActionSheetAction, 'Button 1'),
1040+
) + const Offset(30, 5);
10391041
// Verify that the start location is within button 1.
10401042
await tester.tapAt(start);
10411043
expect(pressed, 1);

0 commit comments

Comments
 (0)