diff --git a/packages/cupertino_ui/lib/src/sheet.dart b/packages/cupertino_ui/lib/src/sheet.dart index 474e4e90deb5..e872af461c91 100644 --- a/packages/cupertino_ui/lib/src/sheet.dart +++ b/packages/cupertino_ui/lib/src/sheet.dart @@ -143,6 +143,11 @@ typedef _GetSheetDragged = bool Function(); /// When `showDragHandle` is set to `true`, then a drag handle will be placed at /// the top of the sheet. This flag will default to false. /// +/// The `hasPlatformViews` parameter should be set to `true` when the sheet or +/// the route underneath it contains a platform view that is composited by the +/// host platform. See [CupertinoSheetTransition.hasPlatformViews] for +/// platform-specific details. +/// /// iOS sheet widgets are generally designed to be tightly coupled to the context /// of the widget that opened the sheet. As such, it is not recommended to push /// a non-sheet route that covers the sheet without first popping the sheet. If @@ -194,6 +199,7 @@ Future showCupertinoSheet({ RouteSettings? settings, double? topGap, bool showDragHandle = false, + bool hasPlatformViews = false, }) { assert(topGap == null || (topGap >= 0.0 && topGap <= 0.9), 'topGap must be between 0.0 and 0.9'); assert(pageBuilder != null || builder != null || scrollableBuilder != null); @@ -211,6 +217,7 @@ Future showCupertinoSheet({ settings: settings, enableDrag: enableDrag, topGap: topGap, + hasPlatformViews: hasPlatformViews, ); return Navigator.of(context, rootNavigator: true).push(route); @@ -255,6 +262,7 @@ Future showCupertinoSheet({ settings: settings, enableDrag: enableDrag, topGap: topGap, + hasPlatformViews: hasPlatformViews, ); return Navigator.of(context, rootNavigator: true).push(route); } @@ -273,6 +281,7 @@ class CupertinoSheetTransition extends StatefulWidget { required this.secondaryRouteAnimation, required this.child, required this.linearTransition, + this.hasPlatformViews = false, this.topGap = _kTopGapRatio, }); @@ -304,21 +313,61 @@ class CupertinoSheetTransition extends StatefulWidget { /// {@endtemplate} final double topGap; + /// {@template cupertino_ui.CupertinoSheetTransition.hasPlatformViews} + /// Whether the sheet or the route underneath it contains a platform view + /// that is composited by the host platform. + /// + /// On iOS and macOS, set this to `true` when the sheet or the route underneath + /// it contains a platform view. On Android, this flag is generally not needed + /// unless the platform view uses a native-view composition mode such as hybrid + /// composition. + /// + /// When set to `false` (the default), the transition uses image-filtered + /// scaling for high-quality rendering. This can cause a native-composited + /// platform view to appear detached from the rest of the route. Set this to + /// `true` to keep it synchronized with the rest of the transition. + /// {@endtemplate} + final bool hasPlatformViews; + + static Widget _delegateTransitionWithPlatformViews( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + bool allowSnapshotting, + Widget? child, + ) => delegateTransition( + context, + animation, + secondaryAnimation, + allowSnapshotting, + child, + hasPlatformViews: true, + ); + /// The primary delegated transition. Will slide a non [CupertinoSheetRoute] page down. /// /// Provided to the previous route to coordinate transitions between routes. /// /// If a [CupertinoSheetRoute] already exists in the stack, then it will /// slide the previous sheet upwards instead. + /// + /// Set [hasPlatformViews] to `true` when the sheet or the route underneath it + /// contains a platform view that is composited by the host platform. See + /// [CupertinoSheetTransition.hasPlatformViews] for platform-specific details. static Widget delegateTransition( BuildContext context, Animation animation, Animation secondaryAnimation, bool allowSnapshotting, - Widget? child, - ) { + Widget? child, { + bool hasPlatformViews = false, + }) { if (CupertinoSheetRoute.hasParentSheet(context)) { - return _delegatedCoverSheetSecondaryTransition(secondaryAnimation, child); + return _delegatedCoverSheetSecondaryTransition( + secondaryAnimation, + child, + hasPlatformViews: hasPlatformViews, + ); } final bool linear = Navigator.of(context).userGestureInProgress; @@ -377,7 +426,12 @@ class CupertinoSheetTransition extends StatefulWidget { position: slideAnimation, child: ScaleTransition( scale: scaleAnimation, - filterQuality: FilterQuality.medium, + // During animation, FilterQuality.medium uses an ImageFilterLayer, which is + // usually worthwhile for animated transforms and avoids the observed subpixel + // drift. A null filter quality always uses a TransformLayer to keep platform + // views synchronized with the host. When not animating, filterQuality is ignored + // and both options have the same effect. + filterQuality: hasPlatformViews ? null : FilterQuality.medium, alignment: Alignment.topCenter, child: AnimatedBuilder( animation: radiusAnimation, @@ -399,8 +453,9 @@ class CupertinoSheetTransition extends StatefulWidget { static Widget _delegatedCoverSheetSecondaryTransition( Animation secondaryAnimation, - Widget? child, - ) { + Widget? child, { + bool hasPlatformViews = false, + }) { const Curve curve = Curves.linearToEaseOut; const Curve reverseCurve = Curves.easeInToLinear; final curvedAnimation = CurvedAnimation( @@ -418,7 +473,7 @@ class CupertinoSheetTransition extends StatefulWidget { transformHitTests: false, child: ScaleTransition( scale: scaleAnimation, - filterQuality: FilterQuality.medium, + filterQuality: hasPlatformViews ? null : FilterQuality.medium, alignment: Alignment.topCenter, child: ClipRSuperellipse( borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), @@ -537,7 +592,7 @@ class _CupertinoSheetTransitionState extends State transformHitTests: false, child: ScaleTransition( scale: _secondaryScaleAnimation, - filterQuality: FilterQuality.medium, + filterQuality: widget.hasPlatformViews ? null : FilterQuality.medium, alignment: Alignment.topCenter, child: child, ), @@ -667,6 +722,7 @@ class CupertinoSheetRoute extends PageRoute with _CupertinoSheetRouteTrans this.scrollableBuilder, this.enableDrag = true, this.showDragHandle = false, + this.hasPlatformViews = false, double? topGap, }) : assert( topGap == null || (topGap >= 0.0 && topGap <= 0.9), @@ -712,6 +768,10 @@ class CupertinoSheetRoute extends PageRoute with _CupertinoSheetRouteTrans @override final bool enableDrag; + /// {@macro cupertino_ui.CupertinoSheetTransition.hasPlatformViews} + @override + final bool hasPlatformViews; + // The gap between the top of the screen and the top of the sheet. final double? _topGap; @@ -850,7 +910,9 @@ mixin _CupertinoSheetRouteTransitionMixin on PageRoute { if (_hasCustomTopGap) { return null; } - return CupertinoSheetTransition.delegateTransition; + return hasPlatformViews + ? CupertinoSheetTransition._delegateTransitionWithPlatformViews + : CupertinoSheetTransition.delegateTransition; } /// Determines whether the content can be dragged. @@ -864,6 +926,9 @@ mixin _CupertinoSheetRouteTransitionMixin on PageRoute { /// {@macro cupertino_ui.CupertinoSheetTransition.topGap} double get topGap; + /// {@macro cupertino_ui.CupertinoSheetTransition.hasPlatformViews} + bool get hasPlatformViews; + /// Whether a custom top gap has been set. bool get _hasCustomTopGap; @@ -898,6 +963,7 @@ mixin _CupertinoSheetRouteTransitionMixin on PageRoute { Widget child, bool enableDrag, double topGap, + bool hasPlatformViews, ) { final bool linearTransition = route.popGestureInProgress; return CupertinoSheetTransition( @@ -905,6 +971,7 @@ mixin _CupertinoSheetRouteTransitionMixin on PageRoute { secondaryRouteAnimation: secondaryAnimation, linearTransition: linearTransition, topGap: topGap, + hasPlatformViews: hasPlatformViews, child: _CupertinoDragGestureDetector( enabledCallback: () => enableDrag, onStartPopGesture: () => _startPopGesture(route, topGap), @@ -941,6 +1008,7 @@ mixin _CupertinoSheetRouteTransitionMixin on PageRoute { child, enableDrag, topGap, + hasPlatformViews, ); } } diff --git a/packages/cupertino_ui/pending_changelogs/change_2026_08_24_platform_view_sheet.yaml b/packages/cupertino_ui/pending_changelogs/change_2026_08_24_platform_view_sheet.yaml new file mode 100644 index 000000000000..86e13a1728a2 --- /dev/null +++ b/packages/cupertino_ui/pending_changelogs/change_2026_08_24_platform_view_sheet.yaml @@ -0,0 +1,3 @@ +changelog: | + - Add `hasPlatformViews` to Cupertino sheet transitions to keep platform views synchronized with the underlying route. +version: minor diff --git a/packages/cupertino_ui/test/sheet_test.dart b/packages/cupertino_ui/test/sheet_test.dart index b944e2102af7..c7b203cd6429 100644 --- a/packages/cupertino_ui/test/sheet_test.dart +++ b/packages/cupertino_ui/test/sheet_test.dart @@ -5,6 +5,7 @@ import 'dart:async' show unawaited; import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart' show GlobalWidgetsLocalizations; import 'package:flutter_test/flutter_test.dart'; @@ -2298,6 +2299,98 @@ void main() { await tester.pumpAndSettle(); }); }); + + group('hasPlatformViews parameter tests', () { + final GlobalKey scaffoldKey = GlobalKey(); + + bool checkHasImageFilterLayer() { + // The transition widget is the ancestor of the scaffold. + final BuildContext context = scaffoldKey.currentContext!; + var found = false; + context.visitAncestorElements((element) { + final RenderObject? renderObject = element.findRenderObject(); + if (renderObject?.debugLayer is ImageFilterLayer) { + found = true; + return false; + } + return true; + }); + return found; + } + + Widget dragGestureApp(bool hasPlatformViews) { + return CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet( + context: scaffoldKey.currentContext!, + hasPlatformViews: hasPlatformViews, + scrollableBuilder: (BuildContext context, ScrollController controller) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 2'))); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + } + + Future pumpSheet(WidgetTester tester, bool hasPlatformViews) async { + await tester.pumpWidget(dragGestureApp(hasPlatformViews)); + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + expect(find.text('Page 2'), findsOneWidget); + } + + testWidgets('hasPlatformViews=false should add ImageFilterLayer during transition', ( + WidgetTester tester, + ) async { + await pumpSheet(tester, false); + + // ImageFilterLayer should not be found after the transition is completed + // because ScaleTransition will set the Transform.filterQuality to null. + expect(checkHasImageFilterLayer(), false); + + final TestGesture gesture = await tester.startGesture(const Offset(100, 300)); + await gesture.moveBy(const Offset(0, 100)); + await tester.pump(); + + // ImageFilterLayer should be found when the sheet is dragged. + expect(checkHasImageFilterLayer(), true); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('hasPlatformViews=true should not add ImageFilterLayer during transition', ( + WidgetTester tester, + ) async { + await pumpSheet(tester, true); + + expect(checkHasImageFilterLayer(), false); + + final TestGesture gesture = await tester.startGesture(const Offset(100, 300)); + await gesture.moveBy(const Offset(0, 100)); + await tester.pump(); + + // ImageFilterLayer should not be found even when the sheet is dragged. + expect(checkHasImageFilterLayer(), false); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + testWidgets('didUpdateWidget in sheet transition does not try and use multiple tickers', ( WidgetTester tester, ) async {