the-mews/app/lib/presentation/reader/swipe_navigator.dart

247 lines
7.5 KiB
Dart

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,
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;
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;
/// How close to a screen edge a drag must begin to navigate; horizontal
/// drags elsewhere belong to text selection.
static const _edgeZone = 40.0;
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;
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,
),
// Only horizontal drags that begin at a screen edge navigate;
// the strips are translucent, so taps, vertical scrolling and
// text selection still pass through everywhere.
_edgeStrip(fromLeft: true),
_edgeStrip(fromLeft: false),
],
);
},
);
}
Widget _edgeStrip({required bool fromLeft}) => Positioned(
left: fromLeft ? 0 : null,
right: fromLeft ? null : 0,
top: 0,
bottom: 0,
width: _edgeZone,
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,
),
),
],
),
),
),
);
}
}