From 32081aab69f82f88283cf43ce4cc2f9544c7c16c Mon Sep 17 00:00:00 2001 From: Tong Mu Date: Sat, 8 Jun 2024 10:35:21 -0700 Subject: [PATCH] [CupertinoActionSheet] Match colors to native (#149568) This PR matches the various colors of `CupertinoActionSheet` more closely with the native one. The following colors are changed. * Sheet background color * Pressed button color * Cancel button color * Pressed cancel button color * Divider color * Content text color The resulting colors match with native one with deviation of at most 1 (in terms of 0~255 RGB). The following are comparison (left to right: Native, Flutter after PR, Flutter current) image image _Note: The divider thickness is adjusted to `1/dpr` instead of 0.3 in both Flutter version to make them look more native, as will be proposed in https://github.com/flutter/flutter/pull/149636._ ### Derivation All the colors are derived through color picker and calculation. The algorithm is as followed: * Assume all colors are translucent grey colors, i.e. having the same value `x` for R, G, and B, with an alpha `a`. * Given the barrier color is `x_B1=0` when the background is black, and `x_B2=204` when the background is white. * Pick the target color `x_t1` when the background is black, and `x_t2` when the background is white * Solve the following equations for `x` and `a` ``` a * x + (1-a) * x_B1 = x_t1 a * x + (1-a) * x_B2 = x_t2 a = 1 - (x_t1 - x_t2) / (x_B1 - x_B2) x = (x_t1 - (1-a) * x_B1) / a ``` These equations use a linear model for color composition, which might not be exact, but is close enough for an accuracy of (1/255). The full table is as follows: image * The first two columns are colors picked from XCode. * The 3~4 columns are the colors picked from the current Flutter. Notice the deviation, which is sometimes drastic. * The 5~6 columns are the colors picked from Flutter after this PR. The deviation is at most 1. * The last few columns are calculation. * There are two rows whose calculation is based on adjusted numbers, since the original results are not accurate enough, possibly due to the linear composition. During the calculation, I assumed these colors vary between light and dark modes, but it turns out that both modes use the same set of colors. ### Screenshots --- .../flutter/lib/src/cupertino/dialog.dart | 42 +++---- .../test/cupertino/action_sheet_test.dart | 107 ++++++++++++++++++ 2 files changed, 128 insertions(+), 21 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index c482b5fee4999..990c3bffc18c6 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -104,35 +104,35 @@ const Color _kDialogColor = CupertinoDynamicColor.withBrightness( // Translucent light gray that is painted on top of the blurred backdrop as the // background color of a pressed button. // Eyeballed from iOS 13 beta simulator. -const Color _kPressedColor = CupertinoDynamicColor.withBrightness( +const Color _kDialogPressedColor = CupertinoDynamicColor.withBrightness( color: Color(0xFFE1E1E1), darkColor: Color(0xFF2E2E2E), ); -const Color _kActionSheetCancelPressedColor = CupertinoDynamicColor.withBrightness( - color: Color(0xFFECECEC), - darkColor: Color(0xFF49494B), -); +// Translucent light gray that is painted on top of the blurred backdrop as the +// background color of a pressed button. +// Eyeballed from iOS 17 simulator. +const Color _kActionSheetPressedColor = Color(0xCAE0E0E0); + +const Color _kActionSheetCancelColor = Color(0xFFFFFFFF); +const Color _kActionSheetCancelPressedColor = Color(0xFFECECEC); // Translucent, very light gray that is painted on top of the blurred backdrop // as the action sheet's background color. // TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/39272. Use // System Materials once we have them. -// Extracted from https://developer.apple.com/design/resources/. -const Color _kActionSheetBackgroundColor = CupertinoDynamicColor.withBrightness( - color: Color(0xC7F9F9F9), - darkColor: Color(0xC7252525), -); +// Eyeballed from iOS 17 simulator. +const Color _kActionSheetBackgroundColor = Color(0xC8FCFCFC); // The gray color used for text that appears in the title area. -// Extracted from https://developer.apple.com/design/resources/. -const Color _kActionSheetContentTextColor = Color(0xFF8F8F8F); +// Eyeballed from iOS 17 simulator. +const Color _kActionSheetContentTextColor = Color(0x851D1D1D); // 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. -// Eye-balled from iOS 13 beta simulator. -const Color _kActionSheetButtonDividerColor = _kActionSheetContentTextColor; +// Eyeballed from iOS 17 simulator. +const Color _kActionSheetButtonDividerColor = Color(0xD4C9C9C9); // 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 @@ -1115,19 +1115,19 @@ class _ActionSheetButtonBackgroundState extends State<_ActionSheetButtonBackgrou BorderRadius? borderRadius; if (!widget.isCancel) { backgroundColor = isBeingPressed - ? _kPressedColor - : CupertinoDynamicColor.resolve(_kActionSheetBackgroundColor, context); + ? _kActionSheetPressedColor + : _kActionSheetBackgroundColor; } else { backgroundColor = isBeingPressed - ? _kActionSheetCancelPressedColor - : CupertinoColors.secondarySystemGroupedBackground; + ? _kActionSheetCancelPressedColor + : _kActionSheetCancelColor; borderRadius = const BorderRadius.all(Radius.circular(_kCornerRadius)); } return MetaData( metaData: this, child: Container( decoration: BoxDecoration( - color: backgroundColor, + color: CupertinoDynamicColor.resolve(backgroundColor, context), borderRadius: borderRadius, ), child: widget.child, @@ -2269,7 +2269,7 @@ class _CupertinoDialogActionsRenderWidget extends MultiChildRenderObjectWidget { : _kCupertinoDialogWidth, dividerThickness: _dividerThickness, dialogColor: CupertinoDynamicColor.resolve(_kDialogColor, context), - dialogPressedColor: CupertinoDynamicColor.resolve(_kPressedColor, context), + dialogPressedColor: CupertinoDynamicColor.resolve(_kDialogPressedColor, context), dividerColor: CupertinoDynamicColor.resolve(CupertinoColors.separator, context), hasCancelButton: _hasCancelButton, ); @@ -2283,7 +2283,7 @@ class _CupertinoDialogActionsRenderWidget extends MultiChildRenderObjectWidget { : _kCupertinoDialogWidth ..dividerThickness = _dividerThickness ..dialogColor = CupertinoDynamicColor.resolve(_kDialogColor, context) - ..dialogPressedColor = CupertinoDynamicColor.resolve(_kPressedColor, context) + ..dialogPressedColor = CupertinoDynamicColor.resolve(_kDialogPressedColor, context) ..dividerColor = CupertinoDynamicColor.resolve(CupertinoColors.separator, context) ..hasCancelButton = _hasCancelButton; } diff --git a/packages/flutter/test/cupertino/action_sheet_test.dart b/packages/flutter/test/cupertino/action_sheet_test.dart index 84aa8e3239722..27283138e773f 100644 --- a/packages/flutter/test/cupertino/action_sheet_test.dart +++ b/packages/flutter/test/cupertino/action_sheet_test.dart @@ -18,6 +18,69 @@ import 'package:flutter_test/flutter_test.dart'; import '../widgets/semantics_tester.dart'; void main() { + testWidgets('Overall looks correctly under light theme', (WidgetTester tester) async { + await tester.pumpWidget( + TestScaffoldApp( + theme: const CupertinoThemeData(brightness: Brightness.light), + actionSheet: CupertinoActionSheet( + message: const Text('The title'), + actions: [ + CupertinoActionSheetAction(child: const Text('One'), onPressed: () {}), + CupertinoActionSheetAction(child: const Text('Two'), onPressed: () {}), + ], + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('One'))); + // This golden file also verifies the structure of an action sheet that + // has a message, no title, and no overscroll for any sections (in contrast + // to cupertinoActionSheet.dark-theme.png). + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoActionSheet.overall-light-theme.png'), + ); + + await gesture.up(); + }); + + testWidgets('Overall looks correctly under dark theme', (WidgetTester tester) async { + await tester.pumpWidget( + TestScaffoldApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + actionSheet: CupertinoActionSheet( + title: const Text('The title'), + message: const Text('The message'), + actions: List.generate(20, (int i) => + CupertinoActionSheetAction( + onPressed: () {}, + child: Text('Button $i'), + ), + ), + cancelButton: CupertinoActionSheetAction(child: const Text('Cancel'), onPressed: () {}), + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Button 0'))); + // This golden file also verifies the structure of an action sheet that + // has both a message and a title, and an overscrolled action section (in + // contrast to cupertinoActionSheet.light-theme.png). + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoActionSheet.overall-dark-theme.png'), + ); + + await gesture.up(); + }); + testWidgets('Verify that a tap on modal barrier dismisses an action sheet', (WidgetTester tester) async { await tester.pumpWidget( createAppWithButtonThatLaunchesActionSheet( @@ -1675,6 +1738,50 @@ Widget createAppWithButtonThatLaunchesActionSheet(Widget actionSheet) { ); } +// Shows an app that has a button with text "Go", and clicking this button +// displays the `actionSheet` and hides the button. +// +// The `theme` will be applied to the app and determines the background. +class TestScaffoldApp extends StatefulWidget { + const TestScaffoldApp({super.key, required this.theme, required this.actionSheet}); + final CupertinoThemeData theme; + final Widget actionSheet; + + @override + TestScaffoldAppState createState() => TestScaffoldAppState(); +} + +class TestScaffoldAppState extends State { + bool _pressedButton = false; + + @override + Widget build(BuildContext context) { + return CupertinoApp( + theme: widget.theme, + home: Builder(builder: (BuildContext context) => + CupertinoPageScaffold( + child: Center( + child: _pressedButton ? Container() : CupertinoButton( + onPressed: () { + setState(() { + _pressedButton = true; + }); + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return widget.actionSheet; + }, + ); + }, + child: const Text('Go'), + ), + ), + ), + ), + ); + } +} + Widget boilerplate(Widget child) { return Directionality( textDirection: TextDirection.ltr,