feat: show real neighboring pages during back and forward swipes

This commit is contained in:
randogoth 2026-10-11 11:05:03 +03:00
parent 4e07c9a305
commit c104c2026b
7 changed files with 283 additions and 71 deletions

View file

@ -4,27 +4,37 @@ 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.
/// 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.backTitle,
required this.forwardTitle,
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;
@ -135,14 +145,21 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
// 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,
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,
),
),
)
: const SizedBox.shrink();
: null;
return GestureDetector(
behavior: HitTestBehavior.deferToChild,
@ -151,7 +168,7 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
onHorizontalDragEnd: _onDragEnd,
child: Stack(
children: [
Positioned.fill(child: peek),
if (peek != null) Positioned.fill(child: peek),
Transform.translate(
offset: Offset(dx, 0),
child: widget.child,
@ -164,52 +181,47 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
}
}
/// The neighboring page sliding in under the current one.
/// The neighboring page's fallback panel while it is still being parsed.
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,
),
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,
),
],
),
),
],
),
),
),