Skip to content

Instantly share code, notes, and snippets.

@mercen-lee
Created December 18, 2025 04:22
Show Gist options
  • Select an option

  • Save mercen-lee/675e5436790913e549c4cc78fac61017 to your computer and use it in GitHub Desktop.

Select an option

Save mercen-lee/675e5436790913e549c4cc78fac61017 to your computer and use it in GitHub Desktop.
iOS 26 style navigation animation implementation for Flutter
/*
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