diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index c264683d08..8300e9bd37 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -79,7 +79,6 @@ const TextStyle _kActionSheetContentStyle = TextStyle( ); // Generic constants shared between Dialog and ActionSheet. -const double _kBlurAmount = 20.0; const double _kCornerRadius = 14.0; const double _kDividerThickness = 0.3; @@ -494,20 +493,34 @@ class _CupertinoAlertDialogState extends State { } } -/// Rounded rectangle surface that looks like an iOS popup surface, e.g., alert dialog -/// and action sheet. +/// An iOS-style component for creating modal overlays like dialogs and action +/// sheets. /// -/// A [CupertinoPopupSurface] can be configured to paint or not paint a white -/// color on top of its blurred area. Typical usage should paint white on top -/// of the blur. However, the white paint can be disabled for the purpose of -/// rendering divider gaps for a more complicated layout, e.g., [CupertinoAlertDialog]. -/// Additionally, the white paint can be disabled to render a blurred rounded -/// rectangle without any color (similar to iOS's volume control popup). +/// By default, [CupertinoPopupSurface] generates a rounded rectangle surface +/// that applies two effects to the background content: +/// +/// 1. Background filter: Saturates and then blurs content behind the surface. +/// 2. Overlay color: Covers the filtered background with a transparent +/// surface color. The color adapts to the CupertinoTheme's brightness: +/// light gray when the ambient [CupertinoTheme] brightness is +/// [Brightness.light], and dark gray when [Brightness.dark]. +/// +/// The blur strength can be changed by setting [blurSigma] to a positive value, +/// or removed by setting the [blurSigma] to 0. +/// +/// The saturation effect can be removed for debugging by setting +/// [debugIsVibrancePainted] to false. The saturation effect is not supported on +/// web with the skwasm renderer and will not be applied regardless of the value +/// of [debugIsVibrancePainted]. +/// +/// The surface color can be disabled by setting [isSurfacePainted] to false, +/// which is useful for more complicated layouts, such as rendering divider gaps +/// in [CupertinoAlertDialog] or rendering custom surface colors. /// /// {@tool dartpad} /// This sample shows how to use a [CupertinoPopupSurface]. The [CupertinoPopupSurface] -/// shows a model popup from the bottom of the screen. -/// Toggling the switch to configure its surface color. +/// shows a modal popup from the bottom of the screen. +/// Toggle the switch to configure its surface color. /// /// ** See code in examples/api/lib/cupertino/dialog/cupertino_popup_surface.0.dart ** /// {@end-tool} @@ -521,9 +534,17 @@ class CupertinoPopupSurface extends StatelessWidget { /// Creates an iOS-style rounded rectangle popup surface. const CupertinoPopupSurface({ super.key, + this.blurSigma = defaultBlurSigma, this.isSurfacePainted = true, - this.child, - }); + required this.child, + }) : assert(blurSigma >= 0, 'CupertinoPopupSurface requires a non-negative blur sigma.'); + + /// The strength of the gaussian blur applied to the area beneath this + /// surface. + /// + /// Defaults to [defaultBlurSigma]. Setting [blurSigma] to 0 will remove the + /// blur filter. + final double blurSigma; /// Whether or not to paint a translucent white on top of this surface's /// blurred background. [isSurfacePainted] should be true for a typical popup @@ -533,26 +554,158 @@ class CupertinoPopupSurface extends StatelessWidget { /// Some popups, like iOS's volume control popup, choose to render a blurred /// area without any white paint covering it. To achieve this effect, /// [isSurfacePainted] should be set to false. + /// + /// Defaults to true. final bool isSurfacePainted; /// The widget below this widget in the tree. - final Widget? child; + // Because [CupertinoPopupSurface] is composed of proxy boxes, which mimic + // the size of their child, a [child] is required to ensure that this surface + // has a size. + final Widget child; + + /// The default strength of the blur applied to widgets underlying a + /// [CupertinoPopupSurface]. + /// + /// Eyeballed from the iOS 17 simulator. + static const double defaultBlurSigma = 30.0; + + /// The default corner radius of a [CupertinoPopupSurface]. + static const BorderRadius _clipper = BorderRadius.all(Radius.circular(14)); + + // The [ColorFilter] matrix used to saturate widgets underlying a + // [CupertinoPopupSurface] when the ambient [CupertinoThemeData.brightness] is + // [Brightness.light]. + // + // To derive this matrix, the saturation matrix was taken from + // https://docs.rainmeter.net/tips/colormatrix-guide/ and was tweaked to + // resemble the iOS 17 simulator. + // + // The matrix can be derived from the following function: + // static List get _lightSaturationMatrix { + // const double lightLumR = 0.26; + // const double lightLumG = 0.4; + // const double lightLumB = 0.17; + // const double saturation = 2.0; + // const double sr = (1 - saturation) * lightLumR; + // const double sg = (1 - saturation) * lightLumG; + // const double sb = (1 - saturation) * lightLumB; + // return [ + // sr + saturation, sg, sb, 0.0, 0.0, + // sr, sg + saturation, sb, 0.0, 0.0, + // sr, sg, sb + saturation, 0.0, 0.0, + // 0.0, 0.0, 0.0, 1.0, 0.0, + // ]; + // } + static const List _lightSaturationMatrix = [ + 1.74, -0.40, -0.17, 0.00, 0.00, + -0.26, 1.60, -0.17, 0.00, 0.00, + -0.26, -0.40, 1.83, 0.00, 0.00, + 0.00, 0.00, 0.00, 1.00, 0.00 + ]; + + // The [ColorFilter] matrix used to saturate widgets underlying a + // [CupertinoPopupSurface] when the ambient [CupertinoThemeData.brightness] is + // [Brightness.dark]. + // + // To derive this matrix, the saturation matrix was taken from + // https://docs.rainmeter.net/tips/colormatrix-guide/ and was tweaked to + // resemble the iOS 17 simulator. + // + // The matrix can be derived from the following function: + // static List get _darkSaturationMatrix { + // const double additive = 0.3; + // const double darkLumR = 0.45; + // const double darkLumG = 0.8; + // const double darkLumB = 0.16; + // const double saturation = 1.7; + // const double sr = (1 - saturation) * darkLumR; + // const double sg = (1 - saturation) * darkLumG; + // const double sb = (1 - saturation) * darkLumB; + // return [ + // sr + saturation, sg, sb, 0.0, additive, + // sr, sg + saturation, sb, 0.0, additive, + // sr, sg, sb + saturation, 0.0, additive, + // 0.0, 0.0, 0.0, 1.0, 0.0, + // ]; + // } + static const List _darkSaturationMatrix = [ + 1.39, -0.56, -0.11, 0.00, 0.30, + -0.32, 1.14, -0.11, 0.00, 0.30, + -0.32, -0.56, 1.59, 0.00, 0.30, + 0.00, 0.00, 0.00, 1.00, 0.00 + ]; + + /// Whether or not the area beneath this surface should be saturated with a + /// [ColorFilter]. + /// + /// The appearance of the [ColorFilter] is determined by the [Brightness] + /// value obtained from the ambient [CupertinoTheme]. + /// + /// The vibrance is always painted if asserts are disabled. + /// + /// Defaults to true. + static bool debugIsVibrancePainted = true; + + ImageFilter? _buildFilter(Brightness? brightness) { + bool isVibrancePainted = true; + assert(() { + isVibrancePainted = debugIsVibrancePainted; + return true; + }()); + if ((kIsWeb && !isSkiaWeb) || !isVibrancePainted) { + if (blurSigma == 0) { + return null; + } + return ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ); + } + + final ColorFilter colorFilter = switch (brightness) { + Brightness.dark => const ColorFilter.matrix(_darkSaturationMatrix), + Brightness.light || null => const ColorFilter.matrix(_lightSaturationMatrix) + }; + + if (blurSigma == 0) { + return colorFilter; + } + + return ImageFilter.compose( + inner: colorFilter, + outer: ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + ); + } @override Widget build(BuildContext context) { - Widget? contents = child; + final ImageFilter? filter = _buildFilter(CupertinoTheme.maybeBrightnessOf(context)); + Widget contents = child; + if (isSurfacePainted) { contents = ColoredBox( color: CupertinoDynamicColor.resolve(_kDialogColor, context), child: contents, ); } + + if (filter != null) { + return ClipRRect( + borderRadius: _clipper, + child: BackdropFilter( + filter: filter, + child: contents, + ), + ); + } + return ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(_kCornerRadius)), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount), - child: contents, - ), + borderRadius: _clipper, + child: contents, ); } } @@ -1138,7 +1291,10 @@ class _CupertinoActionSheetState extends State { child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(12.0)), child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount), + filter: ImageFilter.blur( + sigmaX: CupertinoPopupSurface.defaultBlurSigma, + sigmaY: CupertinoPopupSurface.defaultBlurSigma, + ), child: _ActionSheetMainSheet( pressedIndex: _pressedIndex, onPressedUpdate: _onPressedUpdate, diff --git a/packages/flutter/test/cupertino/popup_surface_test.dart b/packages/flutter/test/cupertino/popup_surface_test.dart new file mode 100644 index 0000000000..1998978c85 --- /dev/null +++ b/packages/flutter/test/cupertino/popup_surface_test.dart @@ -0,0 +1,384 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(['reduced-test-set']) +library; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FilterTest extends StatelessWidget { + const _FilterTest(Widget child, {this.brightness = Brightness.light}) + : _child = child; + final Brightness brightness; + final Widget _child; + + @override + Widget build(BuildContext context) { + final Size size = MediaQuery.sizeOf(context); + final double tileHeight = size.height / 4; + final double tileWidth = size.width / 8; + return CupertinoApp( + home: Stack( + fit: StackFit.expand, + children: [ + // 512 color tiles + // 4 alpha levels (0.416, 0.25, 0.5, 0.75) + for (int a = 0; a < 4; a++) + for (int h = 0; h < 8; h++) // 8 hues + for (int s = 0; s < 4; s++) // 4 saturation levels + for (int b = 0; b < 4; b++) // 4 brightness levels + Positioned( + left: h * tileWidth + b * tileWidth / 4, + top: a * tileHeight + s * tileHeight / 4, + height: tileHeight, + width: tileWidth, + child: ColoredBox( + color: HSVColor.fromAHSV( + 0.5 + a / 8, + h * 45, + 0.5 + s / 8, + 0.5 + b / 8, + ).toColor(), + ), + ), + Padding( + padding: const EdgeInsets.all(32), + child: CupertinoTheme( + data: CupertinoThemeData(brightness: brightness), + child: _child, + ), + ), + ], + ), + ); + } +} + +void main() { + void disableVibranceForTest() { + CupertinoPopupSurface.debugIsVibrancePainted = false; + addTearDown(() { + CupertinoPopupSurface.debugIsVibrancePainted = true; + }); + } + + // Golden displays the color filter effect of the CupertinoPopupSurface + // when the ambient brightness is light. + testWidgets('Brightness.light color filter', (WidgetTester tester) async { + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + blurSigma: 0, + isSurfacePainted: false, + child: SizedBox(), + ), + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.color-filter.light.png'), + ); + }, + skip: kIsWasm, // https://github.com/flutter/flutter/issues/152026 + ); + + // Golden displays the color filter effect of the CupertinoPopupSurface + // when the ambient brightness is dark. + testWidgets('Brightness.dark color filter', (WidgetTester tester) async { + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + blurSigma: 0, + isSurfacePainted: false, + child: SizedBox(), + ), + brightness: Brightness.dark, + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.color-filter.dark.png'), + ); + }, + skip: kIsWasm, // https://github.com/flutter/flutter/issues/152026 + ); + + // Golden displays color tiles without CupertinoPopupSurface being + // displayed. + testWidgets('Setting debugIsVibrancePainted to false removes the color filter', (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + blurSigma: 0, + isSurfacePainted: false, + child: SizedBox(), + ), + ), + ); + + // The BackdropFilter widget should not be mounted when blurSigma is 0 and + // CupertinoPopupSurface.debugIsVibrancePainted is false. + expect( + find.descendant( + of: find.byType(CupertinoPopupSurface), + matching: find.byType(BackdropFilter), + ), + findsNothing, + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.color-filter.removed.png'), + ); + }); + + // Golden displays the surface color of the CupertinoPopupSurface + // in light mode. + testWidgets('Brightness.light surface color', (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + blurSigma: 0, + child: SizedBox(), + ), + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.surface-color.light.png'), + ); + }); + + // Golden displays the surface color of the CupertinoPopupSurface + // in dark mode. + testWidgets('Brightness.dark surface color', (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + blurSigma: 0, + child: SizedBox(), + ), + brightness: Brightness.dark, + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.surface-color.dark.png'), + ); + }, + ); + + // Golden displays a CupertinoPopupSurface with the color removed. The result + // should only display color tiles. + testWidgets('Setting isSurfacePainted to false removes the surface color', (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + blurSigma: 0, + isSurfacePainted: false, + child: SizedBox(), + ), + brightness: Brightness.dark, + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.surface-color.removed.png'), + ); + }, + ); + + // Goldens display a CupertinoPopupSurface with no vibrance or surface + // color, with blur sigmas of 5 and 30 (default). + testWidgets('Positive blurSigma applies blur', (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + isSurfacePainted: false, + blurSigma: 5, + child: SizedBox(), + ), + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.blur.5.png'), + ); + + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + isSurfacePainted: false, + child: SizedBox(), + ), + ), + ); + + await expectLater( + find.byType(CupertinoApp), + // 30 is the default blur sigma + matchesGoldenFile('cupertinoPopupSurface.blur.30.png'), + ); + }, + skip: kIsWasm, // https://github.com/flutter/flutter/issues/152026 + ); + + // Golden displays a CupertinoPopupSurface with a blur sigma of 0. Because + // the blur sigma is 0 and vibrance and surface are not painted, no popup + // surface is displayed. + testWidgets('Setting blurSigma to zero removes blur', (WidgetTester tester) async { + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + isSurfacePainted: false, + blurSigma: 0, + child: SizedBox(), + ), + ), + ); + + // The BackdropFilter widget should not be mounted when blurSigma is 0 and + // CupertinoPopupSurface.isVibrancePainted is false. + expect( + find.descendant( + of: find.byType(CupertinoPopupSurface), + matching: find.byType(BackdropFilter), + ), + findsNothing, + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.blur.0.png'), + ); + + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + isSurfacePainted: false, + blurSigma: 0, + child: SizedBox(), + ), + ), + ); + }); + + testWidgets('Setting a blurSigma to a negative number throws', (WidgetTester tester) async { + try { + disableVibranceForTest(); + await tester.pumpWidget( + _FilterTest( + CupertinoPopupSurface( + isSurfacePainted: false, + blurSigma: -1, + child: const SizedBox(), + ), + ), + ); + + fail('CupertinoPopupSurface did not throw when provided a negative blur sigma.'); + } on AssertionError catch (error) { + expect( + error.toString(), + contains('CupertinoPopupSurface requires a non-negative blur sigma.'), + ); + } + }); + + // Regression test for https://github.com/flutter/flutter/issues/154887. + testWidgets("Applying a FadeTransition to the CupertinoPopupSurface doesn't cause transparency", (WidgetTester tester) async { + final AnimationController controller = AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + addTearDown(controller.dispose); + controller.forward(); + + await tester.pumpWidget( + _FilterTest( + FadeTransition( + opacity: controller, + child: const CupertinoPopupSurface(child: SizedBox()), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 50)); + + // Golden should display a CupertinoPopupSurface with no transparency + // directly underneath the surface. A small amount of transparency should be + // present on the upper-left corner of the screen. + // + // If transparency (gray and white grid) is present underneath the surface, + // the blendmode is being incorrectly applied. + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.blendmode-fix.0.png'), + ); + + await tester.pumpAndSettle(); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + // Golden displays a CupertinoPopupSurface with all enabled features. + // + // CupertinoPopupSurface uses ImageFilter.compose, which applies an inner + // filter first, followed by an outer filter (e.g. result = + // outer(inner(source))). + // + // For CupertinoPopupSurface, this means that the pixels underlying the + // surface are first saturated with a ColorFilter, and the resulting saturated + // pixels are blurred with an ImageFilter.blur. This test verifies that this + // order does not change. + testWidgets('Saturation is applied before blur', (WidgetTester tester) async { + await tester.pumpWidget( + const _FilterTest( + CupertinoPopupSurface( + child: SizedBox(), + ), + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.composition.png'), + ); + + disableVibranceForTest(); + await tester.pumpWidget( + const _FilterTest( + Stack(fit: StackFit.expand, children: [ + CupertinoPopupSurface( + isSurfacePainted: false, + blurSigma: 0, + child: SizedBox(), + ), + CupertinoPopupSurface( + child: SizedBox(), + ) + ]), + ), + ); + + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('cupertinoPopupSurface.composition.png'), + ); + }); +}