Reland CupertinoPopupSurface (#159272)

https://github.com/flutter/flutter/pull/151430 had to be reverted
because of a black square appearing during animations. This PR fixes the
issue by switching from BlendMode.src -> BlendMode.srcOver. I visually
checked to make sure the issue was fixed on MacOS and iOS/Android
simulators.

Fixes https://github.com/flutter/flutter/issues/154887

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Tong Mu <dkwingsmt@users.noreply.github.com>
This commit is contained in:
davidhicks980 2024-11-26 20:42:37 -05:00 committed by GitHub
parent 4a4d7a7dac
commit a4a4e57bc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 561 additions and 21 deletions

View File

@ -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<CupertinoAlertDialog> {
}
}
/// 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,28 +554,160 @@ 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<double> 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 <double>[
// 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<double> _lightSaturationMatrix = <double>[
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<double> 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 <double>[
// 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<double> _darkSaturationMatrix = <double>[
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: const BorderRadius.all(Radius.circular(_kCornerRadius)),
borderRadius: _clipper,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: _kBlurAmount, sigmaY: _kBlurAmount),
filter: filter,
child: contents,
),
);
}
return ClipRRect(
borderRadius: _clipper,
child: contents,
);
}
}
typedef _HitTester = HitTestResult Function(Offset location);
@ -1138,7 +1291,10 @@ class _CupertinoActionSheetState extends State<CupertinoActionSheet> {
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,

View File

@ -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(<String>['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: <Widget>[
// 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: <Widget>[
CupertinoPopupSurface(
isSurfacePainted: false,
blurSigma: 0,
child: SizedBox(),
),
CupertinoPopupSurface(
child: SizedBox(),
)
]),
),
);
await expectLater(
find.byType(CupertinoApp),
matchesGoldenFile('cupertinoPopupSurface.composition.png'),
);
});
}