Created
December 18, 2025 04:22
-
-
Save mercen-lee/675e5436790913e549c4cc78fac61017 to your computer and use it in GitHub Desktop.
iOS 26 style navigation animation implementation for Flutter
This file contains hidden or 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
| /* | |
| How to use FullscreenSwipePage | |
| 1) Initialize in main() (required) | |
| Initialize iOS-related values once at app startup. | |
| ```dart | |
| void main() async { | |
| WidgetsFlutterBinding.ensureInitialized(); | |
| await FullscreenSwipePage.init(); | |
| runApp(...); | |
| } | |
| ``` | |
| 2) Use with Navigator (generic) | |
| `FullscreenSwipePage` is a `Page`, so you can use it with a `Navigator.pages` | |
| setup, or wrap it in a custom route wherever you build routes. | |
| Example A: Navigator 2.0 (`Navigator.pages`) | |
| ```dart | |
| Navigator( | |
| pages: [ | |
| const FullscreenSwipePage(child: HomeView()), | |
| if (showDetails) | |
| const FullscreenSwipePage(child: DetailsView()), | |
| ], | |
| onPopPage: (route, result) => route.didPop(result), | |
| ) | |
| ``` | |
| Example B: If you’re using `Navigator.push` | |
| You can push the route created by this `Page`: | |
| ```dart | |
| final route = const FullscreenSwipePage(child: DetailsView()).createRoute(context); | |
| Navigator.of(context).push(route); | |
| ``` | |
| 3) iOS (AppDelegate) integration (optional: corner rounding) | |
| `FullscreenSwipePage.init()` can request the display corner radius on iOS | |
| through a MethodChannel. | |
| - Channel name: `com.example/device_info` | |
| - Method name: `getDisplayCornerRadius` → returns a `double` | |
| In this project, [ios/Runner/AppDelegate.swift](ios/Runner/AppDelegate.swift) | |
| already contains a handler like below (add it if it’s missing): | |
| ```swift | |
| let controller : FlutterViewController = window?.rootViewController as! FlutterViewController | |
| let channel = FlutterMethodChannel( | |
| name: "com.example/device_info", | |
| binaryMessenger: controller.binaryMessenger | |
| ) | |
| channel.setMethodCallHandler({ call, result in | |
| if call.method == "getDisplayCornerRadius" { | |
| let radius = UIScreen.main.value(forKey: "_displayCornerRadius") as? CGFloat ?? 0 | |
| result(radius) | |
| } else { | |
| result(FlutterMethodNotImplemented) | |
| } | |
| }) | |
| ``` | |
| Notes: | |
| - The Swift code above reads `"_displayCornerRadius"` via KVC, which may be sensitive for iOS policy/review. | |
| - On the Dart side, the channel call is attempted only when `iosMajorVersion >= 26`. | |
| (If you always need corner rounding, you’d need to adjust that condition, but this file keeps the current behavior as-is.) | |
| */ | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/gestures.dart'; | |
| import 'package:flutter/services.dart'; | |
| import 'dart:io'; | |
| class FullscreenSwipePage<T> extends Page<T> { | |
| static double displayCornerRadius = 0; | |
| static int iosMajorVersion = 0; | |
| static Future<void> init() async { | |
| if (!Platform.isIOS) return; | |
| try { | |
| const channel = MethodChannel('com.example/device_info'); | |
| final version = Platform.operatingSystemVersion; | |
| final versionMatch = RegExp(r'(\d+)').firstMatch(version); | |
| if (versionMatch != null) { | |
| iosMajorVersion = int.tryParse(versionMatch.group(1)!) ?? 0; | |
| } | |
| if (iosMajorVersion >= 26) { | |
| final radius = await channel.invokeMethod<double>( | |
| 'getDisplayCornerRadius', | |
| ); | |
| if (radius != null) { | |
| displayCornerRadius = radius; | |
| } | |
| } | |
| } catch (_) {} | |
| } | |
| final Widget child; | |
| const FullscreenSwipePage({ | |
| required this.child, | |
| super.key, | |
| super.name, | |
| super.arguments, | |
| super.restorationId, | |
| }); | |
| @override | |
| Route<T> createRoute(BuildContext context) { | |
| return FullscreenSwipeRoute<T>(page: this); | |
| } | |
| } | |
| class FullscreenSwipeRoute<T> extends PageRoute<T> { | |
| final FullscreenSwipePage<T> page; | |
| bool _isScrolling = false; | |
| FullscreenSwipeRoute({required this.page}) : super(settings: page); | |
| @override | |
| Duration get transitionDuration => const Duration(milliseconds: 350); | |
| @override | |
| Duration get reverseTransitionDuration => const Duration(milliseconds: 350); | |
| @override | |
| bool get opaque => true; | |
| @override | |
| TickerFuture didPush() { | |
| super.didPush(); | |
| return controller!.animateTo( | |
| 1.0, | |
| duration: transitionDuration, | |
| curve: Curves.easeOutQuint, | |
| ); | |
| } | |
| @override | |
| bool didPop(T? result) { | |
| final bool res = super.didPop(result); | |
| if (res) { | |
| controller!.animateBack( | |
| 0.0, | |
| duration: reverseTransitionDuration, | |
| curve: Curves.easeOutQuint, | |
| ); | |
| } | |
| return res; | |
| } | |
| @override | |
| bool get barrierDismissible => false; | |
| @override | |
| Color? get barrierColor => null; | |
| @override | |
| String? get barrierLabel => null; | |
| @override | |
| bool get maintainState => true; | |
| @override | |
| Widget buildPage( | |
| BuildContext context, | |
| Animation<double> animation, | |
| Animation<double> secondaryAnimation, | |
| ) { | |
| return NotificationListener<ScrollNotification>( | |
| onNotification: (notification) { | |
| if (notification is ScrollStartNotification) { | |
| _isScrolling = true; | |
| } else if (notification is ScrollEndNotification) { | |
| _isScrolling = false; | |
| } | |
| return false; | |
| }, | |
| child: page.child, | |
| ); | |
| } | |
| @override | |
| Widget buildTransitions( | |
| BuildContext context, | |
| Animation<double> animation, | |
| Animation<double> secondaryAnimation, | |
| Widget child, | |
| ) { | |
| final slideAnimation = animation.drive( | |
| Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero), | |
| ); | |
| final previousScreenSlide = secondaryAnimation.drive( | |
| Tween<Offset>(begin: Offset.zero, end: const Offset(-0.3, 0)), | |
| ); | |
| return SlideTransition( | |
| position: previousScreenSlide, | |
| child: Stack( | |
| children: [ | |
| _SwipePopGestureDetector( | |
| enabledCallback: () => _isPopGestureEnabled(this), | |
| onStartPopGesture: () => _startPopGesture(this), | |
| backgroundColor: Theme.of(context).scaffoldBackgroundColor, | |
| child: AnimatedBuilder( | |
| animation: animation, | |
| builder: (context, _) { | |
| final isGestureActive = animation.value < 1.0; | |
| final shouldRoundCorners = | |
| isGestureActive && | |
| FullscreenSwipePage.displayCornerRadius > 0 && | |
| secondaryAnimation.isDismissed; | |
| return SlideTransition( | |
| position: slideAnimation, | |
| child: shouldRoundCorners | |
| ? ClipRRect( | |
| borderRadius: BorderRadius.only( | |
| topLeft: Radius.circular( | |
| FullscreenSwipePage.displayCornerRadius, | |
| ), | |
| bottomLeft: Radius.circular( | |
| FullscreenSwipePage.displayCornerRadius, | |
| ), | |
| ), | |
| child: ColoredBox( | |
| color: Theme.of(context).scaffoldBackgroundColor, | |
| child: child, | |
| ), | |
| ) | |
| : child, | |
| ); | |
| }, | |
| ), | |
| ), | |
| if (!secondaryAnimation.isDismissed) | |
| AnimatedBuilder( | |
| animation: secondaryAnimation, | |
| builder: (context, child) { | |
| return IgnorePointer( | |
| child: Container( | |
| color: Colors.black.withValues( | |
| alpha: 0.1 * secondaryAnimation.value, | |
| ), | |
| ), | |
| ); | |
| }, | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| static bool _isPopGestureEnabled<T>(PageRoute<T> route) { | |
| if (Platform.isAndroid) return false; | |
| if (route is FullscreenSwipeRoute<T> && route._isScrolling) return false; | |
| if (route.isFirst) return false; | |
| if (route.willHandlePopInternally) return false; | |
| if (route.popDisposition == RoutePopDisposition.doNotPop) return false; | |
| if (route.animation!.status != AnimationStatus.completed) return false; | |
| if (route.secondaryAnimation!.status != AnimationStatus.dismissed) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| static _SwipePopGestureController<T> _startPopGesture<T>(PageRoute<T> route) { | |
| return _SwipePopGestureController<T>( | |
| navigator: route.navigator!, | |
| controller: route.controller!, | |
| ); | |
| } | |
| } | |
| class _SwipePopGestureDetector extends StatefulWidget { | |
| const _SwipePopGestureDetector({ | |
| required this.child, | |
| required this.enabledCallback, | |
| required this.onStartPopGesture, | |
| required this.backgroundColor, | |
| }); | |
| final Widget child; | |
| final bool Function() enabledCallback; | |
| final _SwipePopGestureController Function() onStartPopGesture; | |
| final Color backgroundColor; | |
| @override | |
| State<_SwipePopGestureDetector> createState() => | |
| _SwipePopGestureDetectorState(); | |
| } | |
| class _SwipePopGestureDetectorState extends State<_SwipePopGestureDetector> | |
| with SingleTickerProviderStateMixin { | |
| _SwipePopGestureController? _controller; | |
| double _overscroll = 0.0; | |
| late AnimationController _overscrollController; | |
| late Animation<double> _overscrollAnimation; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _overscrollController = AnimationController( | |
| vsync: this, | |
| duration: const Duration(milliseconds: 350), | |
| ); | |
| _overscrollAnimation = _overscrollController.drive( | |
| Tween<double>(begin: 0, end: 0), | |
| ); | |
| } | |
| @override | |
| void dispose() { | |
| _overscrollController.dispose(); | |
| super.dispose(); | |
| } | |
| void _handleDragStart(DragStartDetails details) { | |
| if (widget.enabledCallback()) { | |
| _controller = widget.onStartPopGesture(); | |
| _controller!.onOverscroll = (value) { | |
| setState(() { | |
| _overscroll = value; | |
| }); | |
| }; | |
| } | |
| } | |
| void _handleDragUpdate(DragUpdateDetails details) { | |
| final controller = _controller; | |
| final delta = details.primaryDelta ?? details.delta.dx; | |
| final size = context.size; | |
| if (controller == null || size == null) return; | |
| controller.dragUpdate(delta, size.width); | |
| } | |
| void _handleDragEnd(DragEndDetails details) { | |
| final controller = _controller; | |
| final size = context.size; | |
| if (controller != null && size != null) { | |
| final velocity = details.velocity.pixelsPerSecond.dx / size.width; | |
| final willPop = controller.willPop(velocity); | |
| controller.dragEnd(velocity); | |
| if (_overscroll != 0) { | |
| if (willPop) { | |
| setState(() { | |
| _overscroll = 0; | |
| }); | |
| } else { | |
| _overscrollController.duration = const Duration(milliseconds: 350); | |
| _overscrollAnimation = Tween<double>(begin: _overscroll, end: 0) | |
| .animate( | |
| CurvedAnimation( | |
| parent: _overscrollController, | |
| curve: Curves.easeOutQuint, | |
| ), | |
| ); | |
| _overscrollController.reset(); | |
| _overscrollController.forward().then((_) { | |
| setState(() { | |
| _overscroll = 0; | |
| }); | |
| }); | |
| } | |
| } | |
| } | |
| _controller = null; | |
| } | |
| void _handleDragCancel() { | |
| _controller?.dragEnd(0); | |
| _controller = null; | |
| setState(() { | |
| _overscroll = 0; | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return RawGestureDetector( | |
| gestures: <Type, GestureRecognizerFactory>{ | |
| _AlwaysWinHorizontalDragGestureRecognizer: | |
| GestureRecognizerFactoryWithHandlers< | |
| _AlwaysWinHorizontalDragGestureRecognizer | |
| >( | |
| () => _AlwaysWinHorizontalDragGestureRecognizer( | |
| debugOwner: this, | |
| enabledCallback: widget.enabledCallback, | |
| ), | |
| (_AlwaysWinHorizontalDragGestureRecognizer instance) { | |
| instance | |
| ..onStart = _handleDragStart | |
| ..onUpdate = _handleDragUpdate | |
| ..onEnd = _handleDragEnd | |
| ..onCancel = _handleDragCancel | |
| ..dragStartBehavior = DragStartBehavior.start; | |
| }, | |
| ), | |
| }, | |
| behavior: HitTestBehavior.translucent, | |
| child: AnimatedBuilder( | |
| animation: _overscrollController, | |
| builder: (context, child) { | |
| final offset = _overscrollController.isAnimating | |
| ? _overscrollAnimation.value | |
| : _overscroll; | |
| final isOverflowing = offset > 0 || offset < 0; | |
| final cornerRadius = FullscreenSwipePage.displayCornerRadius; | |
| return Stack( | |
| clipBehavior: Clip.none, | |
| children: [ | |
| if (isOverflowing) ...[ | |
| Positioned.fill( | |
| child: Container(color: widget.backgroundColor), | |
| ), | |
| Positioned.fill( | |
| child: Container(color: Colors.black.withValues(alpha: 0.1)), | |
| ), | |
| ], | |
| Transform.translate( | |
| offset: Offset(offset, 0), | |
| child: isOverflowing && cornerRadius > 0 | |
| ? ClipRRect( | |
| borderRadius: BorderRadius.only( | |
| topRight: Radius.circular(cornerRadius), | |
| bottomRight: Radius.circular(cornerRadius), | |
| ), | |
| child: widget.child, | |
| ) | |
| : widget.child, | |
| ), | |
| ], | |
| ); | |
| }, | |
| ), | |
| ); | |
| } | |
| } | |
| class _AlwaysWinHorizontalDragGestureRecognizer | |
| extends HorizontalDragGestureRecognizer { | |
| _AlwaysWinHorizontalDragGestureRecognizer({ | |
| super.debugOwner, | |
| required this.enabledCallback, | |
| }); | |
| final bool Function() enabledCallback; | |
| @override | |
| void rejectGesture(int pointer) { | |
| super.rejectGesture(pointer); | |
| } | |
| } | |
| class _SwipePopGestureController<T> { | |
| _SwipePopGestureController({ | |
| required this.navigator, | |
| required this.controller, | |
| }) { | |
| navigator.didStartUserGesture(); | |
| } | |
| final NavigatorState navigator; | |
| final AnimationController controller; | |
| ValueChanged<double>? onOverscroll; | |
| double _overscroll = 0.0; | |
| bool _gestureStarted = false; | |
| void dragUpdate(double deltaPixels, double width) { | |
| final bool enableOverflow = | |
| !Platform.isIOS || FullscreenSwipePage.iosMajorVersion >= 26; | |
| if (!_gestureStarted && deltaPixels > 0) { | |
| _gestureStarted = true; | |
| } | |
| if (_overscroll != 0.0 && enableOverflow) { | |
| final resistance = 1.0 / (1.0 + (_overscroll.abs() / width) * 20); | |
| final prevOverscroll = _overscroll; | |
| _overscroll += deltaPixels * resistance; | |
| if ((prevOverscroll > 0 && _overscroll <= 0) || | |
| (prevOverscroll < 0 && _overscroll >= 0)) { | |
| final excessOverscroll = _overscroll; | |
| _overscroll = 0; | |
| final deltaProgress = excessOverscroll / width; | |
| final newProgress = controller.value - deltaProgress; | |
| controller.value = newProgress.clamp(0.0, 1.0); | |
| } | |
| } else { | |
| final deltaProgress = deltaPixels / width; | |
| final newProgress = controller.value - deltaProgress; | |
| if (enableOverflow && newProgress < 0.0 && _gestureStarted) { | |
| controller.value = 0.0; | |
| _overscroll = (-newProgress) * width; | |
| } else if (enableOverflow && newProgress > 1.0 && _gestureStarted) { | |
| controller.value = 1.0; | |
| _overscroll = -((newProgress - 1.0) * width); | |
| } else { | |
| controller.value = newProgress.clamp(0.0, 1.0); | |
| } | |
| } | |
| onOverscroll?.call(_overscroll); | |
| } | |
| bool willPop(double velocity) { | |
| if (velocity.abs() >= 1.0) { | |
| return velocity > 0; | |
| } else { | |
| return controller.value < 0.5; | |
| } | |
| } | |
| void dragEnd(double velocity) { | |
| const Curve animationCurve = Curves.easeOutQuint; | |
| final bool shouldPop; | |
| if (velocity.abs() >= 1.0) { | |
| shouldPop = velocity > 0; | |
| } else { | |
| shouldPop = controller.value < 0.5; | |
| } | |
| if (shouldPop) { | |
| controller | |
| .animateBack( | |
| 0.0, | |
| duration: const Duration(milliseconds: 350), | |
| curve: animationCurve, | |
| ) | |
| .then((_) { | |
| if (controller.value == 0.0) { | |
| navigator.pop(); | |
| } | |
| }); | |
| } else { | |
| controller.animateTo( | |
| 1.0, | |
| duration: const Duration(milliseconds: 350), | |
| curve: animationCurve, | |
| ); | |
| } | |
| navigator.didStopUserGesture(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment