fix: optimized swiping and address bar toggle on tap
This commit is contained in:
parent
ac998b394d
commit
0e2027c9e2
5 changed files with 466 additions and 78 deletions
|
|
@ -2,6 +2,8 @@ import 'dart:io' show Process;
|
|||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, TargetPlatform;
|
||||
import 'package:flutter/gestures.dart'
|
||||
show kLongPressTimeout, kTouchSlop, PointerDownEvent, PointerUpEvent;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
|
|
@ -47,6 +49,16 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
bool _showBrokenText = false;
|
||||
final _checkedSubscriptions = <String>{};
|
||||
|
||||
// The finger behind a possible tap on the page, tracked outside the
|
||||
// gesture arena because taps over selectable text lose it.
|
||||
Offset? _tapDownPosition;
|
||||
Duration _tapDownTime = Duration.zero;
|
||||
|
||||
/// Whether the tap in flight acted on the page: opened a link, submitted
|
||||
/// a form or pressed a control in one of the chrome's own pages. Such a
|
||||
/// tap leaves the bar where it was.
|
||||
bool _tapActed = false;
|
||||
|
||||
/// Touch devices navigate with gestures; the others are pointer hosts
|
||||
/// whose readers reach the chrome with the mouse and the keyboard.
|
||||
static bool get _isTouch => switch (defaultTargetPlatform) {
|
||||
|
|
@ -85,73 +97,135 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
void _onPageTapDown(PointerDownEvent event) {
|
||||
_tapDownPosition = event.position;
|
||||
_tapDownTime = event.timeStamp;
|
||||
_tapActed = false;
|
||||
}
|
||||
|
||||
/// A short tap on the page shows a hidden address bar and hides a shown
|
||||
/// one. A page that fits the viewport never scrolls, so a hidden bar has
|
||||
/// nothing else to bring it back.
|
||||
void _onPageTapUp(PointerUpEvent event) {
|
||||
if (_tapDownPosition == null || !_isTouch) return;
|
||||
final travel = (event.position - _tapDownPosition!).distance;
|
||||
_tapDownPosition = null;
|
||||
// A finger that travelled was scrolling or swiping; one that held that
|
||||
// long began a selection. Both leave the bar where it was.
|
||||
if (travel > kTouchSlop) return;
|
||||
if (event.timeStamp - _tapDownTime >= kLongPressTimeout) return;
|
||||
|
||||
// A tap that acts on the page — a link, a form — runs as the gesture
|
||||
// arena settles after this event, so the decision waits for the frame
|
||||
// that carries it.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
// A tap that opened something, or that put the focus in a field of
|
||||
// the page, is not a tap on the reading surface.
|
||||
if (_tapActed) return;
|
||||
if (_focusInPageField()) return;
|
||||
setState(() => _chromeVisible = !_chromeVisible);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a page field, such as a search box, holds the focus: a tap
|
||||
/// that gave it the focus is a tap into a form, not on the page.
|
||||
bool _focusInPageField() => FocusManager.instance.primaryFocus?.context
|
||||
?.findAncestorWidgetOfExactType<TextField>() !=
|
||||
null;
|
||||
|
||||
/// Runs [action] for a control in one of the chrome's own error pages;
|
||||
/// pressing it acts on the page, so the bar keeps its place.
|
||||
VoidCallback _press(VoidCallback action) => () {
|
||||
_tapActed = true;
|
||||
action();
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final deps = AppScope.of(context).deps;
|
||||
|
||||
final reader = Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
AnimatedSlide(
|
||||
offset: _chromeVisible ? Offset.zero : const Offset(0, -1),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
child: AnimatedSize(
|
||||
// A phone draws under the status bar, so the chrome starts below it;
|
||||
// the reader content above and beside it already reaches the edges.
|
||||
body: SafeArea(
|
||||
top: true,
|
||||
bottom: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedSlide(
|
||||
offset: _chromeVisible ? Offset.zero : const Offset(0, -1),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _chromeVisible
|
||||
? AddressBar(focusNode: _addressFocus)
|
||||
: const SizedBox(width: double.infinity),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _chromeVisible
|
||||
? AddressBar(focusNode: _addressFocus)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final browser = deps.browser;
|
||||
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,
|
||||
url: page.url,
|
||||
).background;
|
||||
Expanded(
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final browser = deps.browser;
|
||||
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,
|
||||
url: page.url,
|
||||
).background;
|
||||
|
||||
// The 40em column plus its padding: swipes are accepted
|
||||
// anywhere in the margin beside it.
|
||||
final contentWidth =
|
||||
42 * 17.0 * deps.settings.fontScale.value;
|
||||
// The 40em column plus its padding: swipes are accepted
|
||||
// anywhere in the margin beside it.
|
||||
final contentWidth =
|
||||
42 * 17.0 * deps.settings.fontScale.value;
|
||||
|
||||
return SwipeNavigator(
|
||||
canGoBack: browser.canGoBack,
|
||||
canGoForward: browser.canGoForward,
|
||||
backView: _neighborView(browser.backPage.value),
|
||||
forwardView: _neighborView(browser.forwardPage.value),
|
||||
backTitle: browser.backPage.value?.title,
|
||||
forwardTitle: browser.forwardPage.value?.title,
|
||||
background: peekBackground,
|
||||
contentWidth: page == null ? null : contentWidth,
|
||||
onBack: browser.back,
|
||||
onForward: browser.forward,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _body(context, page)),
|
||||
if (browser.loading.value)
|
||||
const Positioned(
|
||||
top: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
return SwipeNavigator(
|
||||
canGoBack: browser.canGoBack,
|
||||
canGoForward: browser.canGoForward,
|
||||
backView: _neighborView(browser.backPage.value),
|
||||
forwardView: _neighborView(browser.forwardPage.value),
|
||||
backTitle: browser.backPage.value?.title,
|
||||
forwardTitle: browser.forwardPage.value?.title,
|
||||
background: peekBackground,
|
||||
contentWidth: page == null ? null : contentWidth,
|
||||
// A touch host has no margin beside the column, so there
|
||||
// the page itself carries the back and forward swipes.
|
||||
swipesAnywhere: _isTouch,
|
||||
onBack: browser.back,
|
||||
onForward: browser.forward,
|
||||
// Opaque, so a tap on a blank stretch of the page counts
|
||||
// too; the widgets inside keep their own taps.
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: _onPageTapDown,
|
||||
onPointerUp: _onPageTapUp,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _body(context, page)),
|
||||
if (browser.loading.value)
|
||||
const Positioned(
|
||||
top: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -252,9 +326,14 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
fontFamily: font,
|
||||
fontScale: fontScale,
|
||||
visitedUrls: visited,
|
||||
onLinkTap: (url) => deps.browser.open(url),
|
||||
onFormSubmit: (form, values) =>
|
||||
deps.browser.submitForm(form.action, form.method, values),
|
||||
onLinkTap: (url) {
|
||||
_tapActed = true;
|
||||
deps.browser.open(url);
|
||||
},
|
||||
onFormSubmit: (form, values) {
|
||||
_tapActed = true;
|
||||
deps.browser.submitForm(form.action, form.method, values);
|
||||
},
|
||||
onFormChange: (form, values) =>
|
||||
deps.browser.narrowWhileTyping(form.action, values),
|
||||
fetchImageBytes: deps.browser.fetchImageBytes,
|
||||
|
|
@ -322,7 +401,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => deps.browser.reload(),
|
||||
onPressed: _press(() => deps.browser.reload()),
|
||||
child: Text(strings.t('error-try-again')),
|
||||
),
|
||||
],
|
||||
|
|
@ -355,7 +434,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
const SizedBox(height: 16),
|
||||
if (held.isEmpty)
|
||||
FilledButton(
|
||||
onPressed: () => deps.browser.open(MewsUrls.certificates),
|
||||
onPressed: _press(() => deps.browser.open(MewsUrls.certificates)),
|
||||
child: Text(strings.t('certificates-add')),
|
||||
)
|
||||
else
|
||||
|
|
@ -363,13 +442,13 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => deps.browser.chooseCertificate(certificate.id),
|
||||
onPressed: _press(() => deps.browser.chooseCertificate(certificate.id)),
|
||||
child: Text(certificate.name),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => deps.browser.reload(),
|
||||
onPressed: _press(() => deps.browser.reload()),
|
||||
child: Text(strings.t('error-try-again')),
|
||||
),
|
||||
],
|
||||
|
|
@ -395,12 +474,12 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
Text(strings.t('error-certificate-changed'), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => deps.browser.trustPresentedCertificate(),
|
||||
onPressed: _press(() => deps.browser.trustPresentedCertificate()),
|
||||
child: Text(strings.t('error-certificate-trust')),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => deps.browser.reload(),
|
||||
onPressed: _press(() => deps.browser.reload()),
|
||||
child: Text(strings.t('error-try-again')),
|
||||
),
|
||||
],
|
||||
|
|
@ -457,13 +536,13 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
Text(strings.t('error-load-failed'), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => deps.browser.reload(),
|
||||
onPressed: _press(() => deps.browser.reload()),
|
||||
child: Text(strings.t('error-try-again')),
|
||||
),
|
||||
if (canOpenOutside) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => _openOutside(failed),
|
||||
onPressed: _press(() => _openOutside(failed)),
|
||||
child: Text(strings.t('error-open-in-browser')),
|
||||
),
|
||||
],
|
||||
|
|
@ -485,7 +564,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
Text(strings.t('error-broken-page'), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => setState(() => _showBrokenText = true),
|
||||
onPressed: _press(() => setState(() => _showBrokenText = true)),
|
||||
child: Text(strings.t('error-broken-page-show-text')),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/gestures.dart'
|
||||
show kLongPressTimeout, kTouchSlop, PointerDeviceKind;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Wraps the current page and turns the back/forward swipes into visible page
|
||||
|
|
@ -22,6 +24,7 @@ class SwipeNavigator extends StatefulWidget {
|
|||
this.backTitle,
|
||||
this.forwardTitle,
|
||||
this.contentWidth,
|
||||
this.swipesAnywhere = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -41,6 +44,15 @@ class SwipeNavigator extends StatefulWidget {
|
|||
/// the base edge zone.
|
||||
final double? contentWidth;
|
||||
|
||||
/// Whether the page itself answers horizontal drags, not only the strips.
|
||||
/// A touch host has no margin beside the column to swipe in, so there the
|
||||
/// whole surface navigates: the finger is tracked outside the gesture
|
||||
/// arena, because a selectable page's own recognizer wins every
|
||||
/// horizontal drag over its text there. A swipe crosses the slop sideways
|
||||
/// before the long press that starts a selection completes, so the two
|
||||
/// never compete for the same finger.
|
||||
final bool swipesAnywhere;
|
||||
|
||||
final Color background;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onForward;
|
||||
|
|
@ -71,6 +83,14 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
|
|||
bool _dragging = false;
|
||||
double _width = 0;
|
||||
|
||||
// The raw touch swipe's finger, tracked outside the gesture arena.
|
||||
int? _trackedPointer;
|
||||
Offset _downPosition = Offset.zero;
|
||||
Duration _downTime = Duration.zero;
|
||||
Offset _lastPosition = Offset.zero;
|
||||
Duration _lastMoveTime = Duration.zero;
|
||||
double _swipeVelocity = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settle.dispose();
|
||||
|
|
@ -99,12 +119,15 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
|
|||
setState(() => _drag += details.delta.dx);
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails details) {
|
||||
void _onDragEnd(DragEndDetails details) =>
|
||||
_commitSwipe(details.primaryVelocity ?? 0);
|
||||
|
||||
void _commitSwipe(double velocity) {
|
||||
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);
|
||||
final goForward =
|
||||
widget.canGoForward && (dx < -_width * 0.3 || velocity < -500);
|
||||
|
||||
_animateTo(
|
||||
goBack
|
||||
|
|
@ -119,6 +142,71 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
|
|||
);
|
||||
}
|
||||
|
||||
// Raw touch tracking ------------------------------------------------------
|
||||
|
||||
void _onPagePointerDown(PointerDownEvent event) {
|
||||
if (_trackedPointer != null || _dragging || _settledAnimating) return;
|
||||
// A mouse drives a pointer host's strips, not this listener.
|
||||
if (event.kind == PointerDeviceKind.mouse) return;
|
||||
_trackedPointer = event.pointer;
|
||||
_downPosition = event.position;
|
||||
_downTime = event.timeStamp;
|
||||
_lastPosition = event.position;
|
||||
_lastMoveTime = event.timeStamp;
|
||||
_swipeVelocity = 0;
|
||||
}
|
||||
|
||||
void _onPagePointerMove(PointerMoveEvent event) {
|
||||
if (_trackedPointer != event.pointer) return;
|
||||
final total = event.position - _downPosition;
|
||||
|
||||
if (!_dragging) {
|
||||
if (total.distance <= kTouchSlop) {
|
||||
_lastPosition = event.position;
|
||||
_lastMoveTime = event.timeStamp;
|
||||
return;
|
||||
}
|
||||
// The gate between a swipe and a selection: a finger that crosses the
|
||||
// slop sideways, before the long press that starts a selection
|
||||
// completes, is swiping. One that held still that long, or traveled
|
||||
// along the page first, belongs to text selection or to scrolling.
|
||||
final stillInSwipeWindow =
|
||||
event.timeStamp - _downTime < kLongPressTimeout;
|
||||
final sideways = total.dx.abs() > total.dy.abs();
|
||||
if (!stillInSwipeWindow || !sideways) {
|
||||
_trackedPointer = null;
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_dragging = true;
|
||||
_drag = total.dx;
|
||||
});
|
||||
_lastPosition = event.position;
|
||||
_lastMoveTime = event.timeStamp;
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _drag += event.position.dx - _lastPosition.dx);
|
||||
final elapsed = event.timeStamp - _lastMoveTime;
|
||||
if (elapsed > Duration.zero) {
|
||||
_swipeVelocity =
|
||||
(event.position.dx - _lastPosition.dx) * 1e6 / elapsed.inMicroseconds;
|
||||
}
|
||||
_lastPosition = event.position;
|
||||
_lastMoveTime = event.timeStamp;
|
||||
}
|
||||
|
||||
void _finishPagePointer(PointerEvent event, {required bool commit}) {
|
||||
if (_trackedPointer != event.pointer) return;
|
||||
_trackedPointer = null;
|
||||
if (!_dragging) return;
|
||||
if (commit) {
|
||||
_commitSwipe(_swipeVelocity);
|
||||
} else {
|
||||
_animateTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -182,13 +270,36 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
|
|||
if (peek != null) Positioned.fill(child: peek),
|
||||
Transform.translate(
|
||||
offset: Offset(dx, 0),
|
||||
child: widget.child,
|
||||
// Opaque, so a drag over a blank stretch of an error page
|
||||
// navigates too; the widgets inside still take their own taps,
|
||||
// scrolls and long presses first.
|
||||
// The raw listener owns the touch swipe: a selectable page's
|
||||
// own recognizer wins every horizontal drag over its text in
|
||||
// the arena, so the finger is tracked outside it. Opaque, so
|
||||
// a drag over a blank stretch of an error page navigates too;
|
||||
// the widgets inside keep their own taps, scrolls and long
|
||||
// presses.
|
||||
child: widget.swipesAnywhere
|
||||
? Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: _onPagePointerDown,
|
||||
onPointerMove: _onPagePointerMove,
|
||||
onPointerUp: (event) => _finishPagePointer(event, commit: true),
|
||||
onPointerCancel: (event) =>
|
||||
_finishPagePointer(event, commit: false),
|
||||
child: widget.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)),
|
||||
// and mid-page selection still pass through everywhere. A touch
|
||||
// host navigates with the raw listener instead; strips there
|
||||
// would feed the same drag twice.
|
||||
if (!widget.swipesAnywhere) ...[
|
||||
_edgeStrip(fromLeft: true, width: _stripWidth(_width)),
|
||||
_edgeStrip(fromLeft: false, width: _stripWidth(_width)),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -485,6 +485,61 @@ void main() {
|
|||
expect(find.byType(AddressBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a short tap on the page brings a hidden address bar back',
|
||||
(tester) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
// A page long enough to scroll.
|
||||
final tall = pageSource.replaceFirst(
|
||||
'</body>',
|
||||
'<p>Padding paragraph to make the page scroll.</p>' * 80 + '</body>');
|
||||
final page = parseSource(tall, 'https://example.com/index.html');
|
||||
deps.browser.currentPage.value = page;
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Scrolling down hides the address bar.
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AddressBar), findsNothing);
|
||||
|
||||
// A short tap on the page text returns it, the way a reader reaches
|
||||
// for it on a page that has nothing left to scroll.
|
||||
await tester.tapAt(tester.getCenter(find.byType(ListView)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AddressBar), findsOneWidget);
|
||||
|
||||
// A short tap hides it again.
|
||||
await tester.tapAt(tester.getCenter(find.byType(ListView)));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AddressBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a tap that opens a link keeps the address bar where it was',
|
||||
(tester) async {
|
||||
final sources = {
|
||||
'https://example.com/index.html': _page(
|
||||
'Index', '<h1>Index</h1><p><a href="next.html">The next page</a></p>'),
|
||||
'https://example.com/next.html':
|
||||
_page('Next', '<h1>Next</h1><p>The second page.</p>'),
|
||||
};
|
||||
final stubDeps = AppDependencies(
|
||||
database: MewsDatabase.test(NativeDatabase.memory()),
|
||||
fetch: FakeFetch(sources),
|
||||
strings: strings,
|
||||
);
|
||||
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await stubDeps.browser.open('https://example.com/index.html');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The tap follows a link, so it does not spend itself on the chrome.
|
||||
await tapRun(tester, 'The next page');
|
||||
|
||||
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/next.html');
|
||||
expect(find.byType(AddressBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('swiping back shows the previous page during the drag',
|
||||
(tester) async {
|
||||
final sources = {
|
||||
|
|
@ -528,6 +583,102 @@ void main() {
|
|||
expect(find.text('https://example.com/a.html'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a swipe across the body navigates on a touch host',
|
||||
(tester) async {
|
||||
final sources = {
|
||||
'https://example.com/a.html':
|
||||
_page('Page A', '<h1>Page A</h1><p>The first pages content.</p>'),
|
||||
'https://example.com/b.html':
|
||||
_page('Page B', '<h1>Page B</h1><p>The second pages content.</p>'),
|
||||
};
|
||||
final stubDeps = AppDependencies(
|
||||
database: MewsDatabase.test(NativeDatabase.memory()),
|
||||
fetch: FakeFetch(sources),
|
||||
strings: strings,
|
||||
);
|
||||
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await stubDeps.browser.open('https://example.com/a.html');
|
||||
await stubDeps.browser.open('https://example.com/b.html');
|
||||
await _pumpUntil(tester, () => stubDeps.browser.backPage.value != null);
|
||||
|
||||
// The drag starts over the body text, not in an edge margin; a swipe
|
||||
// crosses the slop before the long press that starts a selection.
|
||||
final center = tester.getCenter(find.byType(SwipeNavigator));
|
||||
final gesture = await tester.startGesture(center);
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await gesture.moveBy(const Offset(70, 0));
|
||||
await tester.pump();
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
|
||||
});
|
||||
|
||||
testWidgets('a long press on body text keeps its drag for selection',
|
||||
(tester) async {
|
||||
final sources = {
|
||||
'https://example.com/a.html':
|
||||
_page('Page A', '<h1>Page A</h1><p>The first pages content.</p>'),
|
||||
'https://example.com/b.html':
|
||||
_page('Page B', '<h1>Page B</h1><p>The second pages content.</p>'),
|
||||
};
|
||||
final stubDeps = AppDependencies(
|
||||
database: MewsDatabase.test(NativeDatabase.memory()),
|
||||
fetch: FakeFetch(sources),
|
||||
strings: strings,
|
||||
);
|
||||
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await stubDeps.browser.open('https://example.com/a.html');
|
||||
await stubDeps.browser.open('https://example.com/b.html');
|
||||
await _pumpUntil(tester, () => stubDeps.browser.backPage.value != null);
|
||||
|
||||
// Hold on the paragraph long enough for the selection to begin, then
|
||||
// drag sideways: the selection keeps the finger, the page stays put.
|
||||
final text = tester.getCenter(find.textContaining('The second pages content'));
|
||||
final gesture = await tester.startGesture(text);
|
||||
await tester.pump(const Duration(milliseconds: 550));
|
||||
await gesture.moveBy(
|
||||
const Offset(300, 0),
|
||||
timeStamp: const Duration(milliseconds: 600),
|
||||
);
|
||||
await tester.pump();
|
||||
// No neighbor peeks in; the swipe never started.
|
||||
expect(find.text('Page A'), findsNothing);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/b.html');
|
||||
});
|
||||
|
||||
testWidgets('the chrome starts below the status bar on a phone',
|
||||
(tester) async {
|
||||
// View padding arrives in physical pixels; the framework scales it by
|
||||
// the device pixel ratio to the logical inset the reader sits below.
|
||||
final previous = tester.view.padding;
|
||||
tester.view.padding = FakeViewPadding(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 44 * tester.view.devicePixelRatio,
|
||||
bottom: 0,
|
||||
);
|
||||
addTearDown(() => tester.view.padding = previous);
|
||||
|
||||
final stubDeps = AppDependencies(
|
||||
database: MewsDatabase.test(NativeDatabase.memory()),
|
||||
fetch: FakeFetch(const {}),
|
||||
strings: strings,
|
||||
);
|
||||
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getTopLeft(find.byType(AddressBar)).dy, 44);
|
||||
});
|
||||
|
||||
testWidgets('falls back to the wap subdomain before showing an error',
|
||||
(tester) async {
|
||||
final stubDeps = AppDependencies(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/gestures.dart' show kLongPressTimeout;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
|
|
@ -21,9 +22,11 @@ SwipeNavigator _navigator({
|
|||
bool canGoBack = true,
|
||||
bool canGoForward = true,
|
||||
double? contentWidth,
|
||||
bool swipesAnywhere = false,
|
||||
}) =>
|
||||
SwipeNavigator(
|
||||
contentWidth: contentWidth,
|
||||
swipesAnywhere: swipesAnywhere,
|
||||
canGoBack: canGoBack,
|
||||
canGoForward: canGoForward,
|
||||
backTitle: 'Previous page',
|
||||
|
|
@ -124,6 +127,50 @@ void main() {
|
|||
expect(back, 1);
|
||||
});
|
||||
|
||||
testWidgets('with swipes anywhere, a drag over the page navigates',
|
||||
(tester) async {
|
||||
var back = 0;
|
||||
await tester.pumpWidget(_wrap(_navigator(
|
||||
onBack: () => back++,
|
||||
onForward: () {},
|
||||
swipesAnywhere: true,
|
||||
)));
|
||||
|
||||
// The middle of the page carries the swipe, where a margin would be.
|
||||
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('Previous page'), findsOneWidget);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(back, 1);
|
||||
});
|
||||
|
||||
testWidgets('a finger held long enough to select keeps its drag',
|
||||
(tester) async {
|
||||
var back = 0;
|
||||
await tester.pumpWidget(_wrap(_navigator(
|
||||
onBack: () => back++,
|
||||
onForward: () {},
|
||||
swipesAnywhere: true,
|
||||
)));
|
||||
|
||||
// Sideways travel after the long-press window belongs to a selection,
|
||||
// not to the walk.
|
||||
final center = tester.getCenter(find.byType(SwipeNavigator));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await gesture.moveBy(
|
||||
const Offset(300, 0),
|
||||
timeStamp: kLongPressTimeout + const Duration(milliseconds: 1),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('Previous page'), findsNothing);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(back, 0);
|
||||
});
|
||||
|
||||
testWidgets('drags over the content column still belong to selection',
|
||||
(tester) async {
|
||||
var back = 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue