feat: show back and forward swipes as visible page transitions
This commit is contained in:
parent
65e693b9c6
commit
204aee899a
4 changed files with 387 additions and 11 deletions
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:actors/actors.dart';
|
import 'package:actors/actors.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:signals/signals.dart';
|
import 'package:signals/signals.dart';
|
||||||
|
|
@ -45,6 +47,10 @@ class BrowserController {
|
||||||
/// Pages opened this session, so links can show the visited color.
|
/// Pages opened this session, so links can show the visited color.
|
||||||
final visitedUrls = signal(<String>{});
|
final visitedUrls = signal(<String>{});
|
||||||
|
|
||||||
|
/// Titles of the stack's neighboring pages, shown while swiping.
|
||||||
|
final backTitle = signal<String?>(null);
|
||||||
|
final forwardTitle = signal<String?>(null);
|
||||||
|
|
||||||
bool get canGoBack => _stackIndex > 0;
|
bool get canGoBack => _stackIndex > 0;
|
||||||
bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex;
|
bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex;
|
||||||
|
|
||||||
|
|
@ -63,18 +69,21 @@ class BrowserController {
|
||||||
_stack.removeRange(_stackIndex + 1, _stack.length);
|
_stack.removeRange(_stackIndex + 1, _stack.length);
|
||||||
_stack.add(url);
|
_stack.add(url);
|
||||||
_stackIndex++;
|
_stackIndex++;
|
||||||
|
_updatePeeks();
|
||||||
await _loadCurrent();
|
await _loadCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> back() async {
|
Future<void> back() async {
|
||||||
if (!canGoBack) return;
|
if (!canGoBack) return;
|
||||||
_stackIndex--;
|
_stackIndex--;
|
||||||
|
_updatePeeks();
|
||||||
await _loadCurrent();
|
await _loadCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> forward() async {
|
Future<void> forward() async {
|
||||||
if (!canGoForward) return;
|
if (!canGoForward) return;
|
||||||
_stackIndex++;
|
_stackIndex++;
|
||||||
|
_updatePeeks();
|
||||||
await _loadCurrent();
|
await _loadCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +99,7 @@ class BrowserController {
|
||||||
_stack.removeRange(_stackIndex + 1, _stack.length);
|
_stack.removeRange(_stackIndex + 1, _stack.length);
|
||||||
_stack.add(page.url);
|
_stack.add(page.url);
|
||||||
_stackIndex++;
|
_stackIndex++;
|
||||||
|
_updatePeeks();
|
||||||
_show(page);
|
_show(page);
|
||||||
case _:
|
case _:
|
||||||
error.value = const LoadFailed();
|
error.value = const LoadFailed();
|
||||||
|
|
@ -102,9 +112,23 @@ class BrowserController {
|
||||||
if (url == null) return;
|
if (url == null) return;
|
||||||
_stack.add(url);
|
_stack.add(url);
|
||||||
_stackIndex = 0;
|
_stackIndex = 0;
|
||||||
|
_updatePeeks();
|
||||||
await _loadCurrent();
|
await _loadCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refreshes the neighboring-page titles from the cache; a page without a
|
||||||
|
/// cached title falls back to its URL.
|
||||||
|
void _updatePeeks() {
|
||||||
|
Future<String?> peek(int index) async {
|
||||||
|
if (index < 0 || index >= _stack.length) return null;
|
||||||
|
final url = _stack[index];
|
||||||
|
return (await _db.cachedPage(url))?.title ?? url;
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(peek(_stackIndex - 1).then((t) => backTitle.value = t));
|
||||||
|
unawaited(peek(_stackIndex + 1).then((t) => forwardTitle.value = t));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadCurrent() async {
|
Future<void> _loadCurrent() async {
|
||||||
if (_stackIndex < 0) return;
|
if (_stackIndex < 0) return;
|
||||||
final url = _stack[_stackIndex];
|
final url = _stack[_stackIndex];
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'fob.dart';
|
||||||
import 'mews_page_view.dart';
|
import 'mews_page_view.dart';
|
||||||
import 'palettes.dart';
|
import 'palettes.dart';
|
||||||
import 'settings_sheet.dart';
|
import 'settings_sheet.dart';
|
||||||
|
import 'swipe_navigator.dart';
|
||||||
|
|
||||||
/// The whole v1 UI: an address bar that hides on scroll, the page reader with
|
/// The whole v1 UI: an address bar that hides on scroll, the page reader with
|
||||||
/// swipe back/forward, a settings FOB that appears on scroll, and error
|
/// swipe back/forward, a settings FOB that appears on scroll, and error
|
||||||
|
|
@ -86,18 +87,24 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
||||||
child: SignalBuilder(builder: (context) {
|
child: SignalBuilder(builder: (context) {
|
||||||
final browser = deps.browser;
|
final browser = deps.browser;
|
||||||
final page = browser.currentPage.value;
|
final page = browser.currentPage.value;
|
||||||
|
// The swipe peek uses the current page's palette when there is
|
||||||
|
// one, so the neighbor slides in on matching paper.
|
||||||
|
final peekBackground = page == null
|
||||||
|
? Theme.of(context).scaffoldBackgroundColor
|
||||||
|
: paletteFor(
|
||||||
|
pageVariant: page.variant,
|
||||||
|
readerVariant: deps.settings.variant.value,
|
||||||
|
dark: Theme.of(context).brightness == Brightness.dark,
|
||||||
|
).background;
|
||||||
|
|
||||||
return GestureDetector(
|
return SwipeNavigator(
|
||||||
behavior: HitTestBehavior.deferToChild,
|
canGoBack: browser.canGoBack,
|
||||||
onHorizontalDragEnd: (details) {
|
canGoForward: browser.canGoForward,
|
||||||
final velocity = details.primaryVelocity ?? 0;
|
backTitle: browser.backTitle.value,
|
||||||
// Swipe right goes back, swipe left goes forward.
|
forwardTitle: browser.forwardTitle.value,
|
||||||
if (velocity > 300) {
|
background: peekBackground,
|
||||||
browser.back();
|
onBack: browser.back,
|
||||||
} else if (velocity < -300) {
|
onForward: browser.forward,
|
||||||
browser.forward();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(child: _body(context, page)),
|
Positioned.fill(child: _body(context, page)),
|
||||||
|
|
|
||||||
218
app/lib/presentation/reader/swipe_navigator.dart
Normal file
218
app/lib/presentation/reader/swipe_navigator.dart
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
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<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;
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/test/swipe_navigator_test.dart
Normal file
127
app/test/swipe_navigator_test.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:mews/presentation/reader/swipe_navigator.dart';
|
||||||
|
|
||||||
|
Widget _wrap(SwipeNavigator navigator) => MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
child: navigator,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
SwipeNavigator _navigator({
|
||||||
|
required VoidCallback onBack,
|
||||||
|
required VoidCallback onForward,
|
||||||
|
bool canGoBack = true,
|
||||||
|
bool canGoForward = true,
|
||||||
|
}) =>
|
||||||
|
SwipeNavigator(
|
||||||
|
canGoBack: canGoBack,
|
||||||
|
canGoForward: canGoForward,
|
||||||
|
backTitle: 'Previous page',
|
||||||
|
forwardTitle: 'Next page',
|
||||||
|
background: const Color(0xfffaf8f3),
|
||||||
|
onBack: onBack,
|
||||||
|
onForward: onForward,
|
||||||
|
child: const ColoredBox(
|
||||||
|
color: Color(0xff18191b),
|
||||||
|
child: SizedBox.expand(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('dragging right reveals the previous page and commits',
|
||||||
|
(tester) async {
|
||||||
|
var back = 0;
|
||||||
|
var forward = 0;
|
||||||
|
await tester.pumpWidget(_wrap(_navigator(
|
||||||
|
onBack: () => back++,
|
||||||
|
onForward: () => forward++,
|
||||||
|
)));
|
||||||
|
|
||||||
|
final center = tester.getCenter(find.byType(SwipeNavigator));
|
||||||
|
final gesture = await tester.startGesture(center);
|
||||||
|
await gesture.moveBy(const Offset(300, 0));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// The neighbor is peeking in with its title while the page slides away.
|
||||||
|
expect(find.text('Previous page'), findsOneWidget);
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(back, 1);
|
||||||
|
expect(forward, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dragging left commits forward navigation', (tester) async {
|
||||||
|
var back = 0;
|
||||||
|
var forward = 0;
|
||||||
|
await tester.pumpWidget(_wrap(_navigator(
|
||||||
|
onBack: () => back++,
|
||||||
|
onForward: () => forward++,
|
||||||
|
)));
|
||||||
|
|
||||||
|
final center = tester.getCenter(find.byType(SwipeNavigator));
|
||||||
|
final gesture = await tester.startGesture(center);
|
||||||
|
await gesture.moveBy(const Offset(-300, 0));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Next page'), findsOneWidget);
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(forward, 1);
|
||||||
|
expect(back, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a short drag springs back without navigating', (tester) async {
|
||||||
|
var back = 0;
|
||||||
|
await tester.pumpWidget(_wrap(_navigator(
|
||||||
|
onBack: () => back++,
|
||||||
|
onForward: () {},
|
||||||
|
)));
|
||||||
|
|
||||||
|
final center = tester.getCenter(find.byType(SwipeNavigator));
|
||||||
|
final gesture = await tester.startGesture(center);
|
||||||
|
await gesture.moveBy(const Offset(80, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(back, 0);
|
||||||
|
// The neighbor disappeared again.
|
||||||
|
expect(find.text('Previous page'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('directions without pages rubber-band and never commit',
|
||||||
|
(tester) async {
|
||||||
|
var back = 0;
|
||||||
|
var forward = 0;
|
||||||
|
await tester.pumpWidget(_wrap(_navigator(
|
||||||
|
onBack: () => back++,
|
||||||
|
onForward: () => forward++,
|
||||||
|
canGoBack: false,
|
||||||
|
canGoForward: false,
|
||||||
|
)));
|
||||||
|
|
||||||
|
final center = tester.getCenter(find.byType(SwipeNavigator));
|
||||||
|
final gesture = await tester.startGesture(center);
|
||||||
|
await gesture.moveBy(const Offset(400, 0));
|
||||||
|
await tester.pump();
|
||||||
|
// No neighbor title shows when there is nothing to go back to.
|
||||||
|
expect(find.text('Previous page'), findsNothing);
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(back, 0);
|
||||||
|
expect(forward, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue