Skip to content

Instantly share code, notes, and snippets.

@Zekfad
Last active July 5, 2026 11:08
Show Gist options
  • Select an option

  • Save Zekfad/3cec56c904348cfb0b6be155dfffaa49 to your computer and use it in GitHub Desktop.

Select an option

Save Zekfad/3cec56c904348cfb0b6be155dfffaa49 to your computer and use it in GitHub Desktop.
Flutter Cupertino Page with fullscreen back gesture recognizer. Workaround for https://github.com/flutter/flutter/issues/180309
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
// DEMO CODE
import 'package:go_router/go_router.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({ super.key, });
@override
Widget build(BuildContext context) {
return CupertinoApp.router(
routerConfig: _router,
scrollBehavior: const _ExampleScrollBehavior(),
theme: .new(brightness: .light),
);
}
}
class _ExampleScrollBehavior extends CupertinoScrollBehavior {
const _ExampleScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices => {
.mouse,
...super.dragDevices,
};
@override
Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) =>
child;
}
/// The route configuration.
final GoRouter _router = .new(
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => ModernCupertinoPage(
child: CupertinoPageScaffold(
navigationBar: const CupertinoNavigationBar(
middle: Text('Home'),
),
child: Center(
child: Column(
mainAxisAlignment: .center,
children: [
Center(child: Text('Home')),
const SizedBox(height: 20.0),
Center(
child: CupertinoButton.filled(
onPressed: () => context.push('/nested'),
child: const Icon(CupertinoIcons.add),
),
),
],
),
),
),
),
),
GoRoute(
path: '/nested',
pageBuilder: (context, state) => ModernCupertinoPage(
child: CupertinoPageScaffold(
navigationBar: const CupertinoNavigationBar(
middle: Text('Nested'),
),
child: ListView(
children: [
Text(
'This example demonstrates that back gesture is correctly '
'competes with other gestures, such as horizontal scroll '
'or other recognizers (sliders and such).',
),
for (var i = 0; i < 10; i++)
CupertinoListTile(
title: Text('Tile $i'),
),
SizedBox(
height: 50,
child: ListView(
scrollDirection: .horizontal,
children: [
for (var i = 0; i < 10; i++)
SizedBox(
width: 100,
child: Center(
child: Text('Sample $i'),
),
),
SizedBox(
width: 200,
child: Builder(
builder: (context) {
var val = 50.0;
return StatefulBuilder(
builder: (context, setState) => CupertinoSlider(
value: val,
min: 0.0,
max: 100.0,
onChanged: (value) => setState(() => val = value),
),
);
},
),
),
for (var i = 0; i < 10; i++)
SizedBox(
width: 100,
child: Center(
child: Text('Sample $i'),
),
),
],
),
),
Builder(
builder: (context) {
var val = 50.0;
return StatefulBuilder(
builder: (context, setState) => CupertinoSlider(
value: val,
min: 0.0,
max: 100.0,
onChanged: (value) => setState(() => val = value),
),
);
},
),
SizedBox(
height: 500,
child: PageView(
children: [
Center(
child: Text('Page 1'),
),
Center(
child: Text('Page 2'),
),
Center(
child: Text('Page 3'),
),
],
),
),
for (var i = 0; i < 10; i++)
CupertinoListTile(
title: Text('Tile $i'),
),
],
),
),
),
),
],
);
// WORKROUND CODE
const double _kMinFlingVelocity = 1.0; // Screen widths per second.
// The duration for a page to animate when the user releases it mid-swipe.
const Duration _kDroppedSwipePageAnimationDuration = .new(milliseconds: 350);
class ModernCupertinoPage<T> extends CupertinoPage<T> {
const ModernCupertinoPage({
required super.child,
super.maintainState,
super.title,
super.fullscreenDialog,
super.allowSnapshotting,
super.canPop,
super.onPopInvoked,
super.key,
super.name,
super.arguments,
super.restorationId,
});
@override
Route<T> createRoute(BuildContext context) => _PageBasedModernCupertinoPageRoute<T>(
page: this,
allowSnapshotting: allowSnapshotting,
);
}
class _PageBasedModernCupertinoPageRoute<T> extends PageRoute<T>
with CupertinoRouteTransitionMixin<T> {
_PageBasedModernCupertinoPageRoute({
required ModernCupertinoPage<T> page,
super.allowSnapshotting = true,
}) : super(settings: page) {
assert(opaque, 'Page route must be opaque');
}
@override
DelegatedTransitionBuilder? get delegatedTransition =>
fullscreenDialog ? null : CupertinoPageTransition.delegatedTransition;
CupertinoPage<T> get _page => settings as CupertinoPage<T>;
@override
Widget buildContent(BuildContext context) => _page.child;
@override
String? get title => _page.title;
@override
bool get maintainState => _page.maintainState;
@override
bool get fullscreenDialog => _page.fullscreenDialog;
@override
String get debugLabel => '${super.debugLabel}(${_page.name})';
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
// Check if the route has an animation that's currently participating
// in a back swipe gesture.
//
// During back gesture drag use linear transition to match finger motion.
final linearTransition = popGestureInProgress;
if (fullscreenDialog) {
return CupertinoFullscreenDialogTransition(
primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: linearTransition,
child: child,
);
} else {
return CupertinoPageTransition(
primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: linearTransition,
child: _CupertinoBackGestureDetector<T>(
getPopGestureEnabled: () => popGestureEnabled,
onStartPopGesture: () => .new(
navigator: navigator!,
getIsCurrent: () => isCurrent,
getIsActive: () => isActive,
controller: controller!,
),
child: child,
),
);
}
}
}
/// Special type of [HorizontalDragGestureRecognizer] that wins gesture arena
/// only when swiping to the same direction as current text direction.
class _BackDragGestureRecognizer extends HorizontalDragGestureRecognizer {
_BackDragGestureRecognizer({
required this.getTextDirection,
super.debugOwner,
});
final ValueGetter<TextDirection> getTextDirection;
late OffsetPair _initialPosition;
@override
void addPointer(PointerDownEvent event) {
super.addPointer(event);
_initialPosition = lastPosition;
}
@override
bool hasSufficientGlobalDistanceToAccept(
PointerDeviceKind pointerDeviceKind,
double? deviceTouchSlop,
) {
final accept = super.hasSufficientGlobalDistanceToAccept(pointerDeviceKind, deviceTouchSlop);
if (!accept)
return false;
final sign = (lastPosition.local.dx - _initialPosition.local.dx).sign;
return switch (getTextDirection()) {
.ltr => sign > 0,
.rtl => sign < 0,
};
}
@override
String get debugDescription => 'scroll aware horizontal drag';
}
/// Modified private class from SDK: _CupertinoBackGestureDetector
/// Replaced gesture detector with a custom one.
/// Replaced listener with a custom one.
class _CupertinoBackGestureDetector<T> extends StatefulWidget {
const _CupertinoBackGestureDetector({
required this.getPopGestureEnabled,
required this.onStartPopGesture,
required this.child,
super.key,
});
final Widget child;
final ValueGetter<bool> getPopGestureEnabled;
final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture;
@override
_CupertinoBackGestureDetectorState<T> createState() => _CupertinoBackGestureDetectorState<T>();
}
class _CupertinoBackGestureDetectorState<T> extends State<_CupertinoBackGestureDetector<T>> {
_CupertinoBackGestureController<T>? _backGestureController;
late TextDirection _textDirection;
late HorizontalDragGestureRecognizer _recognizer;
@override
void initState() {
super.initState();
_recognizer = _BackDragGestureRecognizer(
getTextDirection: () => _textDirection,
debugOwner: this
)
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
assert(debugCheckHasDirectionality(context), 'context is missing text direction');
_textDirection = Directionality.of(context);
}
@override
void dispose() {
_recognizer.dispose();
// If disposed during a drag gesture, call navigator.didStopUserGesture.
if (_backGestureController != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_backGestureController?.navigator case final navigator? when navigator.mounted) {
navigator.didStopUserGesture();
}
_backGestureController = null;
});
}
super.dispose();
}
void _handleDragStart(DragStartDetails details) {
assert(mounted, 'state must be mounted');
assert(_backGestureController == null, 'controller reinitialization');
_backGestureController = widget.onStartPopGesture();
}
void _handleDragUpdate(DragUpdateDetails details) {
assert(mounted, 'state must be mounted');
assert(_backGestureController != null, 'controller is uninitialized');
_backGestureController!.dragUpdate(
_convertToLogical(details.primaryDelta! / context.size!.width),
);
}
void _handleDragEnd(DragEndDetails details) {
assert(mounted, 'state must be mounted');
assert(_backGestureController != null, 'controller is uninitialized');
_backGestureController!.dragEnd(
_convertToLogical(details.velocity.pixelsPerSecond.dx / context.size!.width),
);
_backGestureController = null;
}
void _handleDragCancel() {
assert(mounted, 'state must be mounted');
// This can be called even if start is not called, paired with the "down" event
// that we don't consider here.
_backGestureController?.dragEnd(0.0);
_backGestureController = null;
}
void _handlePointerDown(PointerDownEvent event) {
if (widget.getPopGestureEnabled()) {
_recognizer.addPointer(event);
}
}
double _convertToLogical(double value) => switch (_textDirection) {
.rtl => -value,
.ltr => value,
};
@override
Widget build(BuildContext context) => _ListenerWithHitTestUnderScrollView(
onPointerDown: _handlePointerDown,
behavior: .translucent,
child: widget.child,
);
}
/// Special type of [Listener] that modifies hit test path to inject itself
/// below horizontal viewport if there are any.
///
/// Hit test path is ordered to start from the deepest child.
///
/// Since events are dispatched in hit test order, injecting listener below
/// viewport allows it to compete with drag recognizer used for scroll.
class _ListenerWithHitTestUnderScrollView extends Listener {
const _ListenerWithHitTestUnderScrollView({
super.onPointerDown,
super.behavior,
super.child,
});
@override
_RenderListenerWithHitTestUnderScrollView createRenderObject(BuildContext context) => .new(
textDirection: Directionality.of(context),
onPointerDown: onPointerDown,
onPointerMove: onPointerMove,
onPointerUp: onPointerUp,
onPointerHover: onPointerHover,
onPointerCancel: onPointerCancel,
onPointerPanZoomStart: onPointerPanZoomStart,
onPointerPanZoomUpdate: onPointerPanZoomUpdate,
onPointerPanZoomEnd: onPointerPanZoomEnd,
onPointerSignal: onPointerSignal,
behavior: behavior,
);
@override
void updateRenderObject(BuildContext context, covariant _RenderListenerWithHitTestUnderScrollView renderObject) => renderObject
..textDirection = Directionality.of(context)
..onPointerDown = onPointerDown
..onPointerMove = onPointerMove
..onPointerUp = onPointerUp
..onPointerHover = onPointerHover
..onPointerCancel = onPointerCancel
..onPointerPanZoomStart = onPointerPanZoomStart
..onPointerPanZoomUpdate = onPointerPanZoomUpdate
..onPointerPanZoomEnd = onPointerPanZoomEnd
..onPointerSignal = onPointerSignal
..behavior = behavior;
}
class _RenderListenerWithHitTestUnderScrollView extends RenderPointerListener {
_RenderListenerWithHitTestUnderScrollView({
required this.textDirection,
super.onPointerDown,
super.onPointerMove,
super.onPointerUp,
super.onPointerHover,
super.onPointerCancel,
super.onPointerPanZoomStart,
super.onPointerPanZoomUpdate,
super.onPointerPanZoomEnd,
super.onPointerSignal,
super.behavior,
super.child,
});
TextDirection textDirection;
@override
bool hitTest(BoxHitTestResult result, { required Offset position, }) {
var hitTarget = false;
if (size.contains(position)) {
// collect true hit test path
// we cannot just override BoxHitTestResult#add because other subclasses
// use HitTestResult.wrap constructor which modifies private list directly.
final _container = BoxHitTestResult();
final thisTarget = BoxHitTestEntry(this, position);
hitTarget = hitTestChildren(_container, position: position) || hitTestSelf(position);
final includeSelf = hitTarget || behavior == .translucent;
// special subclass that directly copies to private list bypassing any
// safety checks
final _result = _CopyingBoxHitTestResult.wrap(result);
// when copying path before which entry to insert this recognizer
// this means that recognizer will be treated as a child of this element
// so it will receive events before this member
var insertBefore = -1;
if (includeSelf) {
// begin with parent and descend
for (final (i, entry) in _container.path.indexed.toList().reversed) {
final HitTestTarget target;
// See Scrollable build method
if (entry.target case RenderPointerListener(
child: RenderSemanticsAnnotations(
child: RenderIgnorePointer(
child: final RenderViewport trueTarget,
),
),
)) {
target = trueTarget;
} else {
target = entry.target;
}
if (target case RenderViewport(
:final axis,
:final axisDirection,
:final ScrollPosition offset,
) when axis == .horizontal) {
final checkBeginning = switch (textDirection) {
.ltr => axisDirection == .right,
.rtl => axisDirection == .left,
};
final inject = checkBeginning
? offset.pixels <= 0
: offset.pixels >= offset.maxScrollExtent;
if (inject) {
insertBefore = i;
} else {
// viewport is scrolled, we dont want to interfere with it
//
// if parent viewport is not suitable for injection
// do not consider going deeper, since it will win arena regardless
break;
}
}
}
}
for (final (i, entry) in _container.path.indexed) {
if (insertBefore == i)
_result.add(thisTarget);
_result.add(entry);
}
if (insertBefore == -1 && includeSelf) {
result.add(thisTarget);
}
}
return hitTarget;
}
}
/// Special type of [BoxHitTestResult] that bypasses any assertions and unsafely
/// modifies path.
///
/// Used to copy already resolved hit test entries to target `result`.
class _CopyingBoxHitTestResult extends BoxHitTestResult {
_CopyingBoxHitTestResult.wrap(super.result) : super.wrap();
@override
void add(HitTestEntry<HitTestTarget> entry) {
(path as List<HitTestEntry>).add(entry);
}
}
/// Private class from SDK:
/// _CupertinoBackGestureController
class _CupertinoBackGestureController<T> {
/// Creates a controller for an iOS-style back gesture.
_CupertinoBackGestureController({
required this.navigator,
required this.controller,
required this.getIsActive,
required this.getIsCurrent,
}) {
navigator.didStartUserGesture();
}
final AnimationController controller;
final NavigatorState navigator;
final ValueGetter<bool> getIsActive;
final ValueGetter<bool> getIsCurrent;
/// The drag gesture has changed by [delta]. The total range of the drag
/// should be 0.0 to 1.0.
void dragUpdate(double delta) {
controller.value -= delta;
}
/// The drag gesture has ended with a horizontal motion of [velocity] as a
/// fraction of screen width per second.
void dragEnd(double velocity) {
// Fling in the appropriate direction.
//
// This curve has been determined through rigorously eyeballing native iOS
// animations.
const Curve animationCurve = Curves.fastEaseInToSlowEaseOut;
final isCurrent = getIsCurrent();
final bool animateForward;
if (!isCurrent) {
// If the page has already been navigated away from, then the animation
// direction depends on whether or not it's still in the navigation stack,
// regardless of velocity or drag position. For example, if a route is
// being slowly dragged back by just a few pixels, but then a programmatic
// pop occurs, the route should still be animated off the screen.
// See https://github.com/flutter/flutter/issues/141268.
animateForward = getIsActive();
} else if (velocity.abs() >= _kMinFlingVelocity) {
// If the user releases the page before mid screen with sufficient velocity,
// or after mid screen, we should animate the page out. Otherwise, the page
// should be animated back in.
animateForward = velocity <= 0;
} else {
animateForward = controller.value > 0.5;
}
if (animateForward) {
unawaited(controller.animateTo(
1.0,
duration: _kDroppedSwipePageAnimationDuration,
curve: animationCurve,
));
} else {
if (isCurrent) {
// This route is destined to pop at this point. Reuse navigator's pop.
navigator.pop();
}
// The popping may have finished inline if already at the target destination.
if (controller.isAnimating) {
unawaited(controller.animateBack(
0.0,
duration: _kDroppedSwipePageAnimationDuration,
curve: animationCurve,
));
}
}
if (controller.isAnimating) {
// Keep the userGestureInProgress in true state so we don't change the
// curve of the page transition mid-flight since CupertinoPageTransition
// depends on userGestureInProgress.
late AnimationStatusListener animationStatusCallback;
animationStatusCallback = (status) {
navigator.didStopUserGesture();
controller.removeStatusListener(animationStatusCallback);
};
controller.addStatusListener(animationStatusCallback);
} else {
navigator.didStopUserGesture();
}
}
}

This is a temporary workaround for flutter/flutter#180309.

It depends on a private API for BoxHitTestResult (flutter/flutter#188982) and the exact build structure of Scrollable widget, which can change anytime in the future.

Back gesture recognizer is aware of writing direction and only competes for swipes to a related side.

Workaround modifies hit-test for back gesture recognition.

If there is a scroll view that is at the edge, the back gesture recognizer is inserted in a hit test path before it (so it is treated as the first child of this scroll view), thus taking priority when competing in a gesture arena. If there are no such scroll views, the recognizer works as a normal one, inserted at the end of the hit test path and takes the least priority in the arena.

DartPad: https://dartpad.dev/?id=3cec56c904348cfb0b6be155dfffaa49

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment