502 lines
19 KiB
Dart
502 lines
19 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/physics.dart';
|
|
import 'package:flutter/rendering.dart' show RenderViewportBase;
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'leaf_viewer_internal.dart';
|
|
import 'leaf_chrome.dart';
|
|
import 'leaf_zoom_controller.dart';
|
|
|
|
/// Fullscreen pager with per-page zoom, boundary-aware edge release and a
|
|
/// viewer-level dismiss gesture.
|
|
///
|
|
/// Pages are built by [pageBuilder]. Wrap a page's content in [LeafZoomable]
|
|
/// to give it zoom/pan/double-tap; pages without one get paging + dismiss
|
|
/// only.
|
|
///
|
|
/// Gesture arbitration happens in the gesture arena (no raw pointer routing):
|
|
/// - [LeafZoomable]'s pan/zoom recognizer claims drags only when the child
|
|
/// can actually move (zoomed + a free axis); otherwise the PageView's drag
|
|
/// (horizontal) or the dismiss drag (perpendicular) wins — edge-release
|
|
/// - the mouse wheel zooms cursor-anchored on all platforms; a plain wheel
|
|
/// over a scrollable under the viewer scrolls that scrollable instead
|
|
/// - a quick tap outside the active image fires [onTapOutside]
|
|
/// - keyboard (when the viewer has focus): the arrows page prev/next,
|
|
/// Esc dismisses (via [onDismissed] or pop); disabled with
|
|
/// [enableKeyboard]
|
|
///
|
|
/// Desktop (Windows/Linux/macOS) differs from touch: no mouse drag paging, no
|
|
/// drag dismiss and no double-tap ([enableSwipeGestures] forces the touch
|
|
/// behaviour anywhere — the close button / wheel / keyboard replace them).
|
|
/// When [controlsBuilder] is null on desktop a default control set
|
|
/// (previous/next, close, page counter) is shown.
|
|
class LeafViewer extends StatefulWidget {
|
|
const LeafViewer({
|
|
super.key,
|
|
required this.pageCount,
|
|
required this.pageBuilder,
|
|
this.pageController,
|
|
this.initialPage = 0,
|
|
this.scrollDirection = Axis.horizontal,
|
|
this.onPageChanged,
|
|
this.dismissEnabled = true,
|
|
this.onDismissed,
|
|
this.onTapOutside,
|
|
this.backgroundBuilder,
|
|
this.controlsBuilder,
|
|
this.enableSwipeGestures,
|
|
this.enableKeyboard = true,
|
|
this.enableChrome = false,
|
|
this.showPageCounter = true,
|
|
});
|
|
|
|
final int pageCount;
|
|
final Widget Function(BuildContext context, int index) pageBuilder;
|
|
final PageController? pageController;
|
|
final int initialPage;
|
|
final Axis scrollDirection;
|
|
final ValueChanged<int>? onPageChanged;
|
|
final bool dismissEnabled;
|
|
final VoidCallback? onDismissed;
|
|
final VoidCallback? onTapOutside;
|
|
|
|
/// Builds the layer behind the pager. Receives the dismiss progress in
|
|
/// `[0, 1]` for backdrop fading.
|
|
final Widget Function(BuildContext context, double progress)? backgroundBuilder;
|
|
|
|
/// Builds the chrome overlay on top of the pager (media_kit `controlsBuilder`
|
|
/// style). When null, a default control set is shown on desktop and nothing
|
|
/// on touch platforms.
|
|
final Widget Function(BuildContext context, LeafViewerControls controls)? controlsBuilder;
|
|
|
|
/// Force the touch gesture set (swipe paging, drag dismiss, double-tap) on
|
|
/// any platform. Null = platform default (off on desktop, on elsewhere).
|
|
final bool? enableSwipeGestures;
|
|
|
|
/// Whether the viewer grabs keyboard focus and handles the arrows for
|
|
/// paging and Esc for dismissal (default true).
|
|
final bool enableKeyboard;
|
|
|
|
/// Opt-in chrome reveal: when true the viewer owns a [LeafViewerChrome]
|
|
/// (reachable via [chromeOf]) whose visibility tap-toggles on the media and
|
|
/// drives every [LeafChrome] layer plus the paging chrome below. Auto-hide
|
|
/// applies on touch platforms only; on desktop chrome hides on tap only.
|
|
final bool enableChrome;
|
|
|
|
/// Whether the default control set shows the "current / total" counter.
|
|
/// Meaningless with huge page counts — turn off there.
|
|
final bool showPageCounter;
|
|
|
|
/// The zoom controller of the currently active page, if any.
|
|
static LeafZoomController? controllerOf(BuildContext context) {
|
|
return LeafViewerScope.maybeOf(context)?.registry.activeController;
|
|
}
|
|
|
|
/// The index of the page this context belongs to, if inside a viewer.
|
|
static int? pageIndexOf(BuildContext context) {
|
|
return LeafPageIndexScope.maybeOf(context);
|
|
}
|
|
|
|
/// Reactive index of the currently settled page, if inside a viewer.
|
|
static ValueListenable<int>? currentIndexListOf(BuildContext context) {
|
|
return LeafViewerScope.maybeOf(context)?.currentIndex;
|
|
}
|
|
|
|
/// The viewer's chrome reveal controller, if inside a chrome-enabled viewer.
|
|
static LeafViewerChrome? chromeOf(BuildContext context) {
|
|
return LeafViewerScope.maybeOf(context)?.chrome;
|
|
}
|
|
|
|
@override
|
|
State<LeafViewer> createState() => _LeafViewerState();
|
|
}
|
|
|
|
/// State passed to [LeafViewer.controlsBuilder].
|
|
class LeafViewerControls {
|
|
const LeafViewerControls({required this.pageCount, required this.currentIndex, required this.goToPage});
|
|
|
|
final int pageCount;
|
|
final int currentIndex;
|
|
|
|
/// Animates to the given page index (clamped by [pageCount]).
|
|
final void Function(int index) goToPage;
|
|
|
|
bool get canGoPrevious => currentIndex > 0;
|
|
bool get canGoNext => currentIndex < pageCount - 1;
|
|
}
|
|
|
|
class _LeafViewerState extends State<LeafViewer> with TickerProviderStateMixin implements LeafViewerRegistry {
|
|
late final PageController _pageController;
|
|
late final AnimationController _dismissCtrl;
|
|
final Map<int, LeafZoomController> _controllers = {};
|
|
final ValueNotifier<int> _indexNotifier = ValueNotifier(0);
|
|
final FocusNode _focusNode = FocusNode();
|
|
|
|
int _currentIndex = 0;
|
|
bool _dismissing = false;
|
|
Size _viewerSize = Size.zero;
|
|
LeafViewerChrome? _chrome;
|
|
|
|
LeafZoomController? get _activeController => _controllers[_currentIndex];
|
|
|
|
bool get _swipeMode => widget.enableSwipeGestures ?? !leafIsDesktopPlatform();
|
|
|
|
@override
|
|
bool get swipeGesturesEnabled => _swipeMode;
|
|
|
|
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
|
|
final horizontal = widget.scrollDirection == Axis.horizontal;
|
|
final prev = horizontal ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowUp;
|
|
final next = horizontal ? LogicalKeyboardKey.arrowRight : LogicalKeyboardKey.arrowDown;
|
|
final key = event.logicalKey;
|
|
if (key == prev) {
|
|
_goToPage(_currentIndex - 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == next) {
|
|
_goToPage(_currentIndex + 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key == LogicalKeyboardKey.escape) {
|
|
if (widget.onDismissed != null) {
|
|
widget.onDismissed!();
|
|
} else {
|
|
Navigator.maybePop(context);
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_currentIndex = widget.pageController?.initialPage ?? widget.initialPage;
|
|
_indexNotifier.value = _currentIndex;
|
|
_pageController = widget.pageController ?? PageController(initialPage: _currentIndex);
|
|
_dismissCtrl = AnimationController.unbounded(vsync: this)
|
|
..addListener(() {
|
|
if (mounted) setState(() {});
|
|
});
|
|
if (widget.enableChrome) {
|
|
_chrome = LeafViewerChrome(autoHide: !leafIsDesktopPlatform());
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_focusNode.dispose();
|
|
_dismissCtrl.dispose();
|
|
_indexNotifier.dispose();
|
|
_chrome?.dispose();
|
|
if (widget.pageController == null) _pageController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// ── Controller registry ─────────────────────────────────────────────────
|
|
|
|
@override
|
|
void register(int index, LeafZoomController ctrl) {
|
|
_controllers[index] = ctrl;
|
|
}
|
|
|
|
@override
|
|
void unregister(int index, LeafZoomController ctrl) {
|
|
if (_controllers[index] == ctrl) _controllers.remove(index);
|
|
}
|
|
|
|
@override
|
|
LeafZoomController? get activeController => _controllers[_currentIndex];
|
|
|
|
void _onPageIndexChanged(int index) {
|
|
_currentIndex = index;
|
|
_indexNotifier.value = index;
|
|
}
|
|
|
|
void _goToPage(int index) {
|
|
if (index < 0 || index >= widget.pageCount) return;
|
|
_pageController.animateToPage(index, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
|
}
|
|
|
|
// ── Dismiss ─────────────────────────────────────────────────────────────
|
|
|
|
double get _dismissOffset => _dismissCtrl.value;
|
|
double get _dismissExtent => widget.scrollDirection == Axis.horizontal ? _viewerSize.height : _viewerSize.width;
|
|
double get _dismissProgress => _dismissExtent > 0 ? (_dismissOffset.abs() / (_dismissExtent * 0.5)).clamp(0.0, 1.0) : 0.0;
|
|
|
|
Offset get _dismissOffsetVector => widget.scrollDirection == Axis.horizontal ? Offset(0, _dismissOffset) : Offset(_dismissOffset, 0);
|
|
|
|
void _onDismissStart(DragStartDetails details) {
|
|
_activeController?.cancelGesture();
|
|
}
|
|
|
|
void _onDismissUpdate(DragUpdateDetails details) {
|
|
_dismissCtrl.value += widget.scrollDirection == Axis.horizontal ? details.delta.dy : details.delta.dx;
|
|
}
|
|
|
|
void _onDismissEnd(DragEndDetails details) {
|
|
_endDismissDrag(details.primaryVelocity ?? 0);
|
|
}
|
|
|
|
void _endDismissDrag(double velocity) {
|
|
if (_dismissing || !widget.dismissEnabled) return;
|
|
final offset = _dismissCtrl.value;
|
|
final extent = _dismissExtent;
|
|
if (velocity.abs() > 800 || offset.abs() > extent * 0.25) {
|
|
_dismissing = true;
|
|
final dir = velocity.abs() > 50 ? velocity.sign : (offset.sign == 0 ? 1.0 : offset.sign);
|
|
_dismissCtrl.animateTo(dir * extent * 1.5, duration: const Duration(milliseconds: 200), curve: Curves.easeIn).then((_) {
|
|
if (!mounted) return;
|
|
final onDismissed = widget.onDismissed;
|
|
if (onDismissed != null) {
|
|
onDismissed();
|
|
} else {
|
|
Navigator.maybePop(context);
|
|
}
|
|
});
|
|
} else {
|
|
_dismissCtrl.animateWith(SpringSimulation(const SpringDescription(mass: 1, stiffness: 600, damping: 50), offset, 0, velocity));
|
|
}
|
|
}
|
|
|
|
void _onTapUp(TapUpDetails details) {
|
|
final ctrl = _activeController;
|
|
if (ctrl != null && !ctrl.zoomed && !ctrl.imageRect.contains(details.localPosition)) {
|
|
widget.onTapOutside?.call();
|
|
}
|
|
}
|
|
|
|
void _onPointerSignal(PointerSignalEvent e) {
|
|
if (e is! PointerScrollEvent) return;
|
|
final ctrl = _activeController;
|
|
if (ctrl == null || _viewerSize == Size.zero) return;
|
|
// Plain wheel over a scrollable under the viewer scrolls that scrollable
|
|
// (it registers deeper, so the resolver picks it first); everywhere else,
|
|
// and on Ctrl+wheel or once zoomed, the wheel zooms cursor-anchored.
|
|
if (!HardwareKeyboard.instance.isControlPressed && !ctrl.zoomed) {
|
|
final hit = HitTestResult();
|
|
GestureBinding.instance.hitTestInView(hit, e.position, e.viewId);
|
|
final overScrollable = hit.path.any((entry) =>
|
|
entry.target is RenderViewportBase &&
|
|
(entry.target as RenderViewportBase).axis == Axis.vertical);
|
|
if (overScrollable) return;
|
|
}
|
|
GestureBinding.instance.pointerSignalResolver.register(e, (event) {
|
|
final scroll = event as PointerScrollEvent;
|
|
ctrl.wheelZoom(scroll.scrollDelta.dy, scroll.localPosition);
|
|
});
|
|
}
|
|
|
|
// ── Chrome reveal ────────────────────────────────────────────────────────
|
|
|
|
/// Wraps the viewer's paging chrome in the chrome reveal, when chrome is on.
|
|
Widget _chromeRevealed(Widget child) {
|
|
final chrome = _chrome;
|
|
if (chrome == null) return child;
|
|
return ValueListenableBuilder<bool>(
|
|
valueListenable: chrome.visibleListenable,
|
|
builder: (context, visible, _) => IgnorePointer(
|
|
ignoring: !visible,
|
|
child: AnimatedOpacity(duration: const Duration(milliseconds: 200), opacity: visible ? 1.0 : 0.0, child: child),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Build ───────────────────────────────────────────────────────────────
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final progress = _dismissProgress;
|
|
final behavior = ScrollConfiguration.of(context);
|
|
final horizontal = widget.scrollDirection == Axis.horizontal;
|
|
final desktop = leafIsDesktopPlatform();
|
|
final swipeMode = _swipeMode;
|
|
return Focus(
|
|
focusNode: _focusNode,
|
|
autofocus: widget.enableKeyboard,
|
|
onKeyEvent: widget.enableKeyboard ? _onKeyEvent : null,
|
|
child: LeafViewerScope(
|
|
registry: this,
|
|
currentIndex: _indexNotifier,
|
|
chrome: _chrome,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
_viewerSize = constraints.biggest;
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (widget.backgroundBuilder != null) widget.backgroundBuilder!(context, progress),
|
|
Listener(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPointerSignal: _onPointerSignal,
|
|
child: GestureDetector(
|
|
onVerticalDragStart: horizontal && widget.dismissEnabled && swipeMode ? _onDismissStart : null,
|
|
onVerticalDragUpdate: horizontal && widget.dismissEnabled && swipeMode ? _onDismissUpdate : null,
|
|
onVerticalDragEnd: horizontal && widget.dismissEnabled && swipeMode ? _onDismissEnd : null,
|
|
onHorizontalDragStart: !horizontal && widget.dismissEnabled && swipeMode ? _onDismissStart : null,
|
|
onHorizontalDragUpdate: !horizontal && widget.dismissEnabled && swipeMode ? _onDismissUpdate : null,
|
|
onHorizontalDragEnd: !horizontal && widget.dismissEnabled && swipeMode ? _onDismissEnd : null,
|
|
onTapUp: _onTapUp,
|
|
child: Transform.translate(
|
|
offset: _dismissOffsetVector,
|
|
child: Transform.scale(
|
|
scale: 1.0 - _dismissProgress * 0.15,
|
|
child: ScrollConfiguration(
|
|
behavior: behavior.copyWith(
|
|
dragDevices: swipeMode ? {...behavior.dragDevices, PointerDeviceKind.mouse} : behavior.dragDevices,
|
|
),
|
|
child: PageView.builder(
|
|
controller: _pageController,
|
|
physics: const PageScrollPhysics(),
|
|
scrollDirection: widget.scrollDirection,
|
|
// Keep adjacent pages alive so their zoom state
|
|
// survives swipes (and preloads neighbors).
|
|
allowImplicitScrolling: true,
|
|
itemCount: widget.pageCount,
|
|
onPageChanged: (index) {
|
|
widget.onPageChanged?.call(index);
|
|
_onPageIndexChanged(index);
|
|
},
|
|
itemBuilder: (context, index) {
|
|
return LeafPageIndexScope(index: index, child: widget.pageBuilder(context, index));
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (widget.controlsBuilder != null)
|
|
_chromeRevealed(
|
|
ValueListenableBuilder<int>(
|
|
valueListenable: _indexNotifier,
|
|
builder: (context, index, _) => widget.controlsBuilder!(
|
|
context,
|
|
LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage),
|
|
),
|
|
),
|
|
)
|
|
else if (desktop)
|
|
_chromeRevealed(
|
|
ValueListenableBuilder<int>(
|
|
valueListenable: _indexNotifier,
|
|
builder: (context, index, _) => _LeafViewerDefaultControls(
|
|
controls: LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage),
|
|
showPageCounter: widget.showPageCounter,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A [Hero] that only participates on the viewer's currently active page.
|
|
///
|
|
/// LeafViewer keeps neighbour pages alive, so an unconditional hero on every
|
|
/// page registers several per route — popping the viewer then flies the
|
|
/// active page AND its neighbours back to the grid. This gates the hero on
|
|
/// "this page is the settled one", so exactly one flight happens. The child
|
|
/// instance is passed through untouched, so providers stay stable across
|
|
/// page changes. Outside a viewer it behaves like a plain [Hero].
|
|
class LeafViewerHero extends StatelessWidget {
|
|
const LeafViewerHero({super.key, required this.tag, required this.child});
|
|
|
|
final Object tag;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final index = LeafViewer.pageIndexOf(context);
|
|
final currentIndex = LeafViewer.currentIndexListOf(context);
|
|
if (index == null || currentIndex == null) return Hero(tag: tag, child: child);
|
|
return ValueListenableBuilder<int>(
|
|
valueListenable: currentIndex,
|
|
child: child,
|
|
builder: (context, current, child) => current == index ? Hero(tag: tag, child: child!) : child!,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LeafViewerDefaultControls extends StatelessWidget {
|
|
const _LeafViewerDefaultControls({required this.controls, this.showPageCounter = true});
|
|
|
|
final LeafViewerControls controls;
|
|
final bool showPageCounter;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 8,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: _ControlButton(
|
|
icon: Icons.chevron_left,
|
|
onTap: controls.canGoPrevious ? () => controls.goToPage(controls.currentIndex - 1) : null,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 8,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: _ControlButton(
|
|
icon: Icons.chevron_right,
|
|
onTap: controls.canGoNext ? () => controls.goToPage(controls.currentIndex + 1) : null,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 8,
|
|
right: 8,
|
|
child: _ControlButton(icon: Icons.close, onTap: () => Navigator.maybePop(context)),
|
|
),
|
|
if (showPageCounter)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 16,
|
|
child: IgnorePointer(
|
|
child: Text(
|
|
'${controls.currentIndex + 1} / ${controls.pageCount}',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Colors.white70, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()]),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ControlButton extends StatelessWidget {
|
|
const _ControlButton({required this.icon, required this.onTap});
|
|
|
|
final IconData icon;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return IconButton(
|
|
onPressed: onTap,
|
|
icon: Icon(icon),
|
|
tooltip: null,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black.withValues(alpha: 0.45),
|
|
foregroundColor: Colors.white,
|
|
),
|
|
);
|
|
}
|
|
}
|