2026-10-11 10:38:50 +03:00
|
|
|
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
|
2026-10-11 11:05:03 +03:00
|
|
|
/// 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.
|
2026-10-11 10:38:50 +03:00
|
|
|
class SwipeNavigator extends StatefulWidget {
|
|
|
|
|
const SwipeNavigator({
|
|
|
|
|
required this.canGoBack,
|
|
|
|
|
required this.canGoForward,
|
|
|
|
|
required this.background,
|
|
|
|
|
required this.onBack,
|
|
|
|
|
required this.onForward,
|
|
|
|
|
required this.child,
|
2026-10-11 11:05:03 +03:00
|
|
|
this.backView,
|
|
|
|
|
this.forwardView,
|
|
|
|
|
this.backTitle,
|
|
|
|
|
this.forwardTitle,
|
2026-10-11 11:53:27 +03:00
|
|
|
this.contentWidth,
|
2026-10-11 10:38:50 +03:00
|
|
|
super.key,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final bool canGoBack;
|
|
|
|
|
final bool canGoForward;
|
2026-10-11 11:05:03 +03:00
|
|
|
|
|
|
|
|
/// Pre-rendered neighboring pages, shown while swiping.
|
|
|
|
|
final Widget? backView;
|
|
|
|
|
final Widget? forwardView;
|
|
|
|
|
|
|
|
|
|
/// Fallback titles for the neighbor panels while the pages are parsed.
|
2026-10-11 10:38:50 +03:00
|
|
|
final String? backTitle;
|
|
|
|
|
final String? forwardTitle;
|
2026-10-11 11:05:03 +03:00
|
|
|
|
2026-10-11 11:53:27 +03:00
|
|
|
/// 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;
|
|
|
|
|
|
2026-10-11 10:38:50 +03:00
|
|
|
final Color background;
|
|
|
|
|
final VoidCallback onBack;
|
|
|
|
|
final VoidCallback onForward;
|
|
|
|
|
final Widget child;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<SwipeNavigator> createState() => _SwipeNavigatorState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _SwipeNavigatorState extends State<SwipeNavigator>
|
|
|
|
|
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<double>? _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;
|
|
|
|
|
|
2026-10-11 11:53:27 +03:00
|
|
|
/// Base zone at each screen edge that always swipes; horizontal drags
|
|
|
|
|
/// elsewhere belong to text selection.
|
2026-10-11 11:48:24 +03:00
|
|
|
static const _edgeZone = 40.0;
|
|
|
|
|
|
2026-10-11 11:53:27 +03:00
|
|
|
/// 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);
|
|
|
|
|
|
2026-10-11 10:38:50 +03:00
|
|
|
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<double>(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;
|
2026-10-11 11:05:03 +03:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 10:38:50 +03:00
|
|
|
)
|
2026-10-11 11:05:03 +03:00
|
|
|
: null;
|
2026-10-11 10:38:50 +03:00
|
|
|
|
2026-10-11 11:48:24 +03:00
|
|
|
return Stack(
|
|
|
|
|
children: [
|
|
|
|
|
if (peek != null) Positioned.fill(child: peek),
|
|
|
|
|
Transform.translate(
|
|
|
|
|
offset: Offset(dx, 0),
|
|
|
|
|
child: widget.child,
|
|
|
|
|
),
|
2026-10-11 11:53:27 +03:00
|
|
|
// 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)),
|
2026-10-11 11:48:24 +03:00
|
|
|
],
|
2026-10-11 10:38:50 +03:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-11 11:48:24 +03:00
|
|
|
|
2026-10-11 11:53:27 +03:00
|
|
|
Widget _edgeStrip({required bool fromLeft, required double width}) => Positioned(
|
2026-10-11 11:48:24 +03:00
|
|
|
left: fromLeft ? 0 : null,
|
|
|
|
|
right: fromLeft ? null : 0,
|
|
|
|
|
top: 0,
|
|
|
|
|
bottom: 0,
|
2026-10-11 11:53:27 +03:00
|
|
|
width: width,
|
2026-10-11 11:48:24 +03:00
|
|
|
child: GestureDetector(
|
|
|
|
|
behavior: HitTestBehavior.translucent,
|
|
|
|
|
onHorizontalDragStart: _onDragStart,
|
|
|
|
|
onHorizontalDragUpdate: _onDragUpdate,
|
|
|
|
|
onHorizontalDragEnd: _onDragEnd,
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-10-11 10:38:50 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 11:05:03 +03:00
|
|
|
/// The neighboring page's fallback panel while it is still being parsed.
|
2026-10-11 10:38:50 +03:00
|
|
|
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) {
|
2026-10-11 11:05:03 +03:00
|
|
|
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,
|
2026-10-11 10:38:50 +03:00
|
|
|
),
|
2026-10-11 11:05:03 +03:00
|
|
|
),
|
|
|
|
|
],
|
2026-10-11 10:38:50 +03:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|