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 itself ([backView]/[forwardView]) sliding in from the /// side, falling back to a title panel while the neighbor is still being /// parsed. 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.background, required this.onBack, required this.onForward, required this.child, this.backView, this.forwardView, this.backTitle, this.forwardTitle, this.contentWidth, super.key, }); final bool canGoBack; final bool canGoForward; /// Pre-rendered neighboring pages, shown while swiping. final Widget? backView; final Widget? forwardView; /// Fallback titles for the neighbor panels while the pages are parsed. final String? backTitle; final String? forwardTitle; /// Width of the content column; swipes are accepted anywhere in the empty /// margin beside it, plus a base zone at each screen edge. Null keeps only /// the base edge zone. final double? contentWidth; 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; /// Base zone at each screen edge that always swipes; horizontal drags /// elsewhere belong to text selection. static const _edgeZone = 40.0; /// The swipe zone: the whole margin beside the content column, with the /// base zone as the floor. double _stripWidth(double width) => widget.contentWidth == null ? _edgeZone : math.max(_edgeZone, (width - widget.contentWidth!) / 2); 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 Widget? peek = showsBack || showsForward ? Transform.translate( offset: Offset(showsBack ? dx - _width : _width + dx, 0), child: IgnorePointer( // The neighbor is a look ahead, not an interactive page; // committing the navigation brings the real one. child: (showsBack ? widget.backView : widget.forwardView) ?? _Peek( fromLeft: showsBack, background: widget.background, title: showsBack ? widget.backTitle : widget.forwardTitle, ), ), ) : null; return Stack( children: [ if (peek != null) Positioned.fill(child: peek), Transform.translate( offset: Offset(dx, 0), child: widget.child, ), // Horizontal drags in the strips navigate; elsewhere they select // text. The strips are translucent, so taps, vertical scrolling // and mid-page selection still pass through everywhere. _edgeStrip(fromLeft: true, width: _stripWidth(_width)), _edgeStrip(fromLeft: false, width: _stripWidth(_width)), ], ); }, ); } Widget _edgeStrip({required bool fromLeft, required double width}) => Positioned( left: fromLeft ? 0 : null, right: fromLeft ? null : 0, top: 0, bottom: 0, width: width, child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragStart: _onDragStart, onHorizontalDragUpdate: _onDragUpdate, onHorizontalDragEnd: _onDragEnd, ), ); } /// The neighboring page's fallback panel while it is still being parsed. class _Peek extends StatelessWidget { const _Peek({ required this.fromLeft, required this.background, required this.title, }); final bool fromLeft; final Color background; final String? title; @override Widget build(BuildContext context) { return 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, ), ), ], ), ), ), ); } }