import 'dart:math' as math; import 'package:flutter/material.dart'; /// Wraps the current page and turns the back/forward swipes into visible page /// transitions: dragging slides the current page away and reveals the /// neighboring page peeking in from the side (its title, when known). /// Releasing past the threshold or with a flick commits the navigation; /// otherwise everything springs back. Directions without a page resist the /// drag with a rubber band. class SwipeNavigator extends StatefulWidget { const SwipeNavigator({ required this.canGoBack, required this.canGoForward, required this.backTitle, required this.forwardTitle, required this.background, required this.onBack, required this.onForward, required this.child, super.key, }); final bool canGoBack; final bool canGoForward; final String? backTitle; final String? forwardTitle; final Color background; final VoidCallback onBack; final VoidCallback onForward; final Widget child; @override State createState() => _SwipeNavigatorState(); } class _SwipeNavigatorState extends State with SingleTickerProviderStateMixin { late final AnimationController _settle; @override void initState() { super.initState(); // Created eagerly: a lazy field would initialize during dispose() when no // swipe ever happened. _settle = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); } Animation? _settling; /// Raw finger travel; the displayed offset clamps and rubber-bands this. double _drag = 0; bool _dragging = false; double _width = 0; @override void dispose() { _settle.dispose(); super.dispose(); } bool get _settledAnimating => _settling != null && _settle.isAnimating; void _onDragStart(DragStartDetails details) { if (_settledAnimating) return; setState(() => _dragging = true); } void _onDragUpdate(DragUpdateDetails details) { if (!_dragging) return; setState(() => _drag += details.delta.dx); } void _onDragEnd(DragEndDetails details) { if (!_dragging) return; final dx = _displayed; final velocity = details.primaryVelocity ?? 0; final goBack = widget.canGoBack && (dx > _width * 0.3 || velocity > 500); final goForward = widget.canGoForward && (dx < -_width * 0.3 || velocity < -500); _animateTo( goBack ? _width : goForward ? -_width : 0, onSettled: () { if (goBack) widget.onBack(); if (goForward) widget.onForward(); }, ); } /// The page offset shown: full travel when a page waits in that direction, /// a rubber band when none does, capped near the screen width. double get _displayed { final drag = _drag; if (drag > 0 && !widget.canGoBack) { return math.min(drag * 0.25, 96.0); } if (drag < 0 && !widget.canGoForward) { return math.max(drag * 0.25, -96.0); } final max = _width * 0.85; return drag.clamp(-max, max).toDouble(); } void _animateTo(double target, {VoidCallback? onSettled}) { setState(() => _dragging = false); final from = _displayed; _settling = Tween(begin: from, end: target).animate( CurvedAnimation(parent: _settle, curve: Curves.easeOutCubic), )..addListener(() { if (_settle.isAnimating) { setState(() => _drag = _settling!.value); } }); _settle.forward(from: 0).whenComplete(() { setState(() => _drag = 0); _settling = null; onSettled?.call(); }); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { _width = constraints.maxWidth; final dx = _displayed; // Only directions with a page to show reveal a peek; the others // rubber-band the current page over the bare background. final showsBack = dx > 0 && widget.canGoBack; final showsForward = dx < 0 && widget.canGoForward; final peek = showsBack || showsForward ? _Peek( fromLeft: showsBack, offset: showsBack ? dx - _width : _width + dx, background: widget.background, title: showsBack ? widget.backTitle : widget.forwardTitle, ) : const SizedBox.shrink(); return GestureDetector( behavior: HitTestBehavior.deferToChild, onHorizontalDragStart: _onDragStart, onHorizontalDragUpdate: _onDragUpdate, onHorizontalDragEnd: _onDragEnd, child: Stack( children: [ Positioned.fill(child: peek), Transform.translate( offset: Offset(dx, 0), child: widget.child, ), ], ), ); }, ); } } /// The neighboring page sliding in under the current one. class _Peek extends StatelessWidget { const _Peek({ required this.fromLeft, required this.offset, required this.background, required this.title, }); final bool fromLeft; final double offset; final Color background; final String? title; @override Widget build(BuildContext context) { return Transform.translate( offset: Offset(offset, 0), child: ColoredBox( color: background, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Align( alignment: fromLeft ? Alignment.centerLeft : Alignment.centerRight, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: fromLeft ? CrossAxisAlignment.start : CrossAxisAlignment.end, children: [ Icon( fromLeft ? Icons.west : Icons.east, color: Theme.of(context).colorScheme.onSurface, ), if (title != null) ConstrainedBox( constraints: const BoxConstraints(maxWidth: 200), child: Text( title!, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium, ), ), ], ), ), ), ), ); } }