Last active
February 20, 2024 13:09
-
-
Save toda-bps/f0d99dc5e87be019dbd71ebdfb7da435 to your computer and use it in GitHub Desktop.
CustomCupertinoPageTransitionsBuilder
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'dart:ui' show lerpDouble; | |
import 'package:flutter/cupertino.dart'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/gestures.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:go_router/go_router.dart'; | |
class CustomCupertinoPageTransitionsBuilder extends PageTransitionsBuilder { | |
const CustomCupertinoPageTransitionsBuilder(); | |
@override | |
Widget buildTransitions<T>( | |
PageRoute<T> route, | |
BuildContext context, | |
Animation<double> animation, | |
Animation<double> secondaryAnimation, | |
Widget child, | |
) { | |
return _CustomCupertinoRouteTransitionMixin.buildPageTransitions<T>( | |
route, | |
context, | |
animation, | |
secondaryAnimation, | |
child, | |
); | |
} | |
} | |
///////////////////////////////////////////////////////////////////////////// | |
/// | |
/// Customized CupertinoRouteTransitionMixin | |
/// | |
/// based on: | |
/// https://github.com/flutter/flutter/blob/35c52e7bee/packages/flutter/lib/src/cupertino/route.dart | |
/// | |
const Color _kCupertinoPageTransitionBarrierColor = Color(0x18000000); | |
mixin _CustomCupertinoRouteTransitionMixin<T> on PageRoute<T> { | |
@protected | |
Widget buildContent(BuildContext context); | |
String? get title; | |
ValueNotifier<String?>? _previousTitle; | |
ValueListenable<String?> get previousTitle { | |
assert( | |
_previousTitle != null, | |
'Cannot read the previousTitle for a route ' | |
'that has not yet been installed', | |
); | |
return _previousTitle!; | |
} | |
@override | |
void dispose() { | |
_previousTitle?.dispose(); | |
super.dispose(); | |
} | |
@override | |
void didChangePrevious(Route<dynamic>? previousRoute) { | |
final previousTitleString = previousRoute is CupertinoRouteTransitionMixin | |
? previousRoute.title | |
: null; | |
if (_previousTitle == null) { | |
_previousTitle = ValueNotifier<String?>(previousTitleString); | |
} else { | |
_previousTitle!.value = previousTitleString; | |
} | |
super.didChangePrevious(previousRoute); | |
} | |
@override | |
Duration get transitionDuration => const Duration(milliseconds: 500); | |
@override | |
Color? get barrierColor => | |
fullscreenDialog ? null : _kCupertinoPageTransitionBarrierColor; | |
@override | |
String? get barrierLabel => null; | |
@override | |
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) { | |
return nextRoute is CupertinoRouteTransitionMixin && | |
!nextRoute.fullscreenDialog; | |
} | |
static bool isPopGestureInProgress(PageRoute<dynamic> route) { | |
return route.navigator!.userGestureInProgress; | |
} | |
bool get popGestureInProgress => isPopGestureInProgress(this); | |
bool get popGestureEnabled => _isPopGestureEnabled(this); | |
static bool _isPopGestureEnabled<T>(PageRoute<T> route) { | |
if (route.isFirst) { | |
return false; | |
} | |
if (route.willHandlePopInternally) { | |
return false; | |
} | |
// ignore: deprecated_member_use | |
if (route.hasScopedWillPopCallback || | |
route.popDisposition == RoutePopDisposition.doNotPop) { | |
return false; | |
} | |
if (route.fullscreenDialog) { | |
return false; | |
} | |
if (route.animation!.status != AnimationStatus.completed) { | |
return false; | |
} | |
if (route.secondaryAnimation!.status != AnimationStatus.dismissed) { | |
return false; | |
} | |
if (isPopGestureInProgress(route)) { | |
return false; | |
} | |
return true; | |
} | |
@override | |
Widget buildPage( | |
BuildContext context, | |
Animation<double> animation, | |
Animation<double> secondaryAnimation, | |
) { | |
final child = buildContent(context); | |
return Semantics( | |
scopesRoute: true, | |
explicitChildNodes: true, | |
child: child, | |
); | |
} | |
// Customize: | |
// Whether pop animation is currently animating | |
static bool _popAnimationInProgress = false; | |
static _CupertinoBackGestureController<T> _startPopGesture<T>( | |
PageRoute<T> route, | |
) { | |
assert(_isPopGestureEnabled(route)); | |
// Customize: | |
// Track pop animation status to determine whether to use linearTransition | |
// in buildPageTransitions() | |
_popAnimationInProgress = true; | |
final controller = route.controller!; | |
late final AnimationStatusListener animationStatusCallback; | |
animationStatusCallback = (status) { | |
if (status == AnimationStatus.dismissed) { | |
_popAnimationInProgress = false; | |
controller.removeStatusListener(animationStatusCallback); | |
} | |
}; | |
controller.addStatusListener(animationStatusCallback); | |
return _CupertinoBackGestureController<T>( | |
navigator: route.navigator!, | |
controller: controller, | |
); | |
} | |
static Widget buildPageTransitions<T>( | |
PageRoute<T> route, | |
BuildContext context, | |
Animation<double> animation, | |
Animation<double> secondaryAnimation, | |
Widget child, | |
) { | |
// Customize: | |
// Consider _popAnimationInProgress | |
final linearTransition = | |
isPopGestureInProgress(route) || _popAnimationInProgress; | |
if (route.fullscreenDialog) { | |
return CupertinoFullscreenDialogTransition( | |
primaryRouteAnimation: animation, | |
secondaryRouteAnimation: secondaryAnimation, | |
linearTransition: linearTransition, | |
child: child, | |
); | |
} else { | |
return CupertinoPageTransition( | |
primaryRouteAnimation: animation, | |
secondaryRouteAnimation: secondaryAnimation, | |
linearTransition: linearTransition, | |
child: _CupertinoBackGestureDetector<T>( | |
enabledCallback: () => _isPopGestureEnabled<T>(route), | |
onStartPopGesture: () => _startPopGesture<T>(route), | |
child: child, | |
), | |
); | |
} | |
} | |
@override | |
Widget buildTransitions( | |
BuildContext context, | |
Animation<double> animation, | |
Animation<double> secondaryAnimation, | |
Widget child, | |
) { | |
return buildPageTransitions<T>( | |
this, | |
context, | |
animation, | |
secondaryAnimation, | |
child, | |
); | |
} | |
} | |
class _CupertinoBackGestureDetector<T> extends StatefulWidget { | |
const _CupertinoBackGestureDetector({ | |
required this.enabledCallback, | |
required this.onStartPopGesture, | |
required this.child, | |
super.key, | |
}); | |
final Widget child; | |
final ValueGetter<bool> enabledCallback; | |
final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture; | |
@override | |
_CupertinoBackGestureDetectorState<T> createState() => | |
_CupertinoBackGestureDetectorState<T>(); | |
} | |
const double _kBackGestureWidth = 20; | |
const double _kMinFlingVelocity = 1; | |
const int _kMaxDroppedSwipePageForwardAnimationTime = 800; | |
const int _kMaxPageBackAnimationTime = 300; | |
class _CupertinoBackGestureDetectorState<T> | |
extends State<_CupertinoBackGestureDetector<T>> { | |
_CupertinoBackGestureController<T>? _backGestureController; | |
late HorizontalDragGestureRecognizer _recognizer; | |
@override | |
void initState() { | |
super.initState(); | |
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this) | |
..onStart = _handleDragStart | |
..onUpdate = _handleDragUpdate | |
..onEnd = _handleDragEnd | |
..onCancel = _handleDragCancel; | |
} | |
@override | |
void dispose() { | |
_recognizer.dispose(); | |
if (_backGestureController != null) { | |
WidgetsBinding.instance.addPostFrameCallback((_) { | |
if (_backGestureController?.navigator.mounted ?? false) { | |
_backGestureController?.navigator.didStopUserGesture(); | |
} | |
_backGestureController = null; | |
}); | |
} | |
super.dispose(); | |
} | |
void _handleDragStart(DragStartDetails details) { | |
assert(mounted); | |
assert(_backGestureController == null); | |
_backGestureController = widget.onStartPopGesture(); | |
} | |
void _handleDragUpdate(DragUpdateDetails details) { | |
assert(mounted); | |
assert(_backGestureController != null); | |
_backGestureController!.dragUpdate( | |
_convertToLogical(details.primaryDelta! / context.size!.width), | |
); | |
} | |
void _handleDragEnd(DragEndDetails details) { | |
assert(mounted); | |
assert(_backGestureController != null); | |
_backGestureController!.dragEnd( | |
_convertToLogical( | |
details.velocity.pixelsPerSecond.dx / context.size!.width, | |
), | |
); | |
_backGestureController = null; | |
} | |
void _handleDragCancel() { | |
assert(mounted); | |
_backGestureController?.dragEnd(0); | |
_backGestureController = null; | |
} | |
void _handlePointerDown(PointerDownEvent event) { | |
if (widget.enabledCallback()) { | |
_recognizer.addPointer(event); | |
} | |
} | |
double _convertToLogical(double value) { | |
switch (Directionality.of(context)) { | |
case TextDirection.rtl: | |
return -value; | |
case TextDirection.ltr: | |
return value; | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
assert(debugCheckHasDirectionality(context)); | |
var dragAreaWidth = Directionality.of(context) == TextDirection.ltr | |
? MediaQuery.paddingOf(context).left | |
: MediaQuery.paddingOf(context).right; | |
dragAreaWidth = max(dragAreaWidth, _kBackGestureWidth); | |
return Stack( | |
fit: StackFit.passthrough, | |
children: <Widget>[ | |
widget.child, | |
PositionedDirectional( | |
start: 0, | |
width: dragAreaWidth, | |
top: 0, | |
bottom: 0, | |
child: Listener( | |
onPointerDown: _handlePointerDown, | |
behavior: HitTestBehavior.translucent, | |
), | |
), | |
], | |
); | |
} | |
} | |
class _CupertinoBackGestureController<T> { | |
_CupertinoBackGestureController({ | |
required this.navigator, | |
required this.controller, | |
}) { | |
navigator.didStartUserGesture(); | |
} | |
final AnimationController controller; | |
final NavigatorState navigator; | |
void dragUpdate(double delta) { | |
controller.value -= delta; | |
} | |
void dragEnd(double velocity) { | |
const Curve animationCurve = Curves.fastLinearToSlowEaseIn; | |
final bool animateForward; | |
if (velocity.abs() >= _kMinFlingVelocity) { | |
animateForward = velocity <= 0; | |
} else { | |
animateForward = controller.value > 0.5; | |
} | |
if (animateForward) { | |
final int droppedPageForwardAnimationTime = min( | |
lerpDouble( | |
_kMaxDroppedSwipePageForwardAnimationTime, | |
0, | |
controller.value, | |
)! | |
.floor(), | |
_kMaxPageBackAnimationTime, | |
); | |
controller.animateTo( | |
1, | |
duration: Duration(milliseconds: droppedPageForwardAnimationTime), | |
curve: animationCurve, | |
); | |
} else { | |
navigator.pop(); | |
if (controller.isAnimating) { | |
final droppedPageBackAnimationTime = lerpDouble( | |
0, | |
_kMaxDroppedSwipePageForwardAnimationTime, | |
controller.value, | |
)! | |
.floor(); | |
controller.animateBack( | |
0, | |
duration: Duration(milliseconds: droppedPageBackAnimationTime), | |
curve: animationCurve, | |
); | |
} | |
} | |
// Customize: | |
// Always call didStopUserGesture() immediately | |
// to enable gestures on the underlying page. | |
navigator.didStopUserGesture(); | |
} | |
} | |
///////////////////////////////////////////////////////////////////////////// | |
/// | |
/// Sample App | |
/// | |
void main() => runApp(const _App()); | |
@immutable | |
class _SamplePage extends StatelessWidget { | |
const _SamplePage({ | |
required this.path, | |
required this.next, | |
}); | |
final String? path; | |
final String next; | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: Text(path ?? ''), | |
), | |
body: ListView.builder( | |
itemBuilder: (context, index) { | |
return ListTile( | |
title: Text('row $index'), | |
onTap: () { | |
GoRouter.of(context).go(next); | |
}, | |
); | |
}, | |
), | |
); | |
} | |
} | |
@immutable | |
class _LastPage extends StatelessWidget { | |
const _LastPage({required this.path}); | |
final String? path; | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: Text(path ?? ''), | |
), | |
); | |
} | |
} | |
final _routerConfig = GoRouter( | |
routes: [ | |
GoRoute( | |
path: '/', | |
builder: (context, state) => _SamplePage( | |
path: state.path, | |
next: '/second', | |
), | |
routes: [ | |
GoRoute( | |
path: 'second', | |
builder: (context, state) => _SamplePage( | |
path: state.path, | |
next: '/second/last', | |
), | |
routes: [ | |
GoRoute( | |
path: 'last', | |
builder: (context, state) => _LastPage(path: state.path), | |
), | |
], | |
), | |
], | |
), | |
], | |
); | |
@immutable | |
class _CustomPageTransitionsTheme extends PageTransitionsTheme { | |
const _CustomPageTransitionsTheme(); | |
static const _builder = CustomCupertinoPageTransitionsBuilder(); | |
@override | |
Widget buildTransitions<T>( | |
PageRoute<T> route, | |
BuildContext context, | |
Animation<double> animation, | |
Animation<double> secondaryAnimation, | |
Widget child, | |
) { | |
return _builder.buildTransitions<T>( | |
route, | |
context, | |
animation, | |
secondaryAnimation, | |
child, | |
); | |
} | |
} | |
@immutable | |
class _CustomScrollBehavior extends MaterialScrollBehavior { | |
const _CustomScrollBehavior(); | |
@override | |
Set<PointerDeviceKind> get dragDevices => PointerDeviceKind.values.toSet(); | |
} | |
@immutable | |
class _App extends StatelessWidget { | |
const _App(); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp.router( | |
routerConfig: _routerConfig, | |
theme: ThemeData( | |
pageTransitionsTheme: const _CustomPageTransitionsTheme(), | |
), | |
scrollBehavior: const _CustomScrollBehavior(), | |
); | |
} | |
} |
Why do you need a _CustomScrollBehavior in your example?
ScrollBehavior is now set to provide the same scrolling experience on web, mobile, and desktop.
This is just a sample and is not essential for a workaround.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hi @toda-bps, thanks for your hard work trying to fix flutter/flutter#48225!
I want to try your workaround but I have one question:
Why do you need a _CustomScrollBehavior in your example?
Thank you :)