viewer: chrome reveal system — LeafViewerChrome state, LeafChrome layers, tap-to-toggle on zoomable pages, opt-in showPageCounter

This commit is contained in:
foxyz committed 2026-09-02 22:26:30 +03:00
1 parent 9bb0ff9187
commit 3cc02c9bbe
6 files changed
+342 -22

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'leaf_viewer_internal.dart';
/// Reveal state for everything that hides/shows on tap inside a [LeafViewer]:
/// video controls, post action rails, the viewer's own paging chrome.
///
/// One state, one timer. The auto-hide timer runs whenever chrome is visible
/// and not held — [hold] only *suppresses* hiding (e.g. a paused video keeps
/// controls up); it never shows anything and never cancels hiding while
/// playing. Media pages drive [hold]; passive layers just listen.
class LeafViewerChrome {
LeafViewerChrome({this.hideDelay = const Duration(seconds: 3), this.autoHide = true}) {
// Chrome starts visible — arm the hide timer right away, or entry state
// would never auto-hide until the first tap.
_restartTimer();
}
/// How long chrome stays up before auto-hiding while not held.
final Duration hideDelay;
/// Whether chrome auto-hides at all (off on desktop — hiding there is
/// tap-driven only).
final bool autoHide;
final ValueNotifier<bool> _visible = ValueNotifier<bool>(true);
bool _hold = false;
Timer? _timer;
ValueListenable<bool> get visibleListenable => _visible;
bool get visible => _visible.value;
void toggle() => _visible.value ? hide() : show();
void show() {
_visible.value = true;
_restartTimer();
}
void hide() {
_visible.value = false;
_cancelTimer();
}
/// While held, the auto-hide timer is suppressed. Releasing restarts it.
set hold(bool value) {
if (_hold == value) return;
_hold = value;
value ? _cancelTimer() : _restartTimer();
}
void _restartTimer() {
_cancelTimer();
if (!autoHide || _hold || !_visible.value) return;
_timer = Timer(hideDelay, () => _visible.value = false);
}
void _cancelTimer() {
_timer?.cancel();
_timer = null;
}
void dispose() {
_cancelTimer();
_visible.dispose();
}
}
/// A passive chrome layer: fades with the viewer's reveal state and stops
/// receiving pointers while hidden. Taps outside of actual controls pass
/// through to the media underneath.
///
/// Outside a chrome-enabled [LeafViewer] the layer renders permanently
/// visible.
class LeafChrome extends StatelessWidget {
const LeafChrome({super.key, required this.builder, this.duration = const Duration(milliseconds: 200)});
final WidgetBuilder builder;
final Duration duration;
@override
Widget build(BuildContext context) {
final chrome = LeafViewerScope.maybeOf(context)?.chrome;
if (chrome == null) return builder(context);
return ValueListenableBuilder<bool>(
valueListenable: chrome.visibleListenable,
builder: (context, visible, _) => IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(duration: duration, opacity: visible ? 1.0 : 0.0, child: builder(context)),
),
);
}
}
+74 -20
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
@@ -5,6 +6,7 @@ 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
@@ -46,6 +48,8 @@ class LeafViewer extends StatefulWidget {
this.controlsBuilder,
this.enableSwipeGestures,
this.enableKeyboard = true,
this.enableChrome = false,
this.showPageCounter = true,
});
final int pageCount;
@@ -75,11 +79,31 @@ class LeafViewer extends StatefulWidget {
/// 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;
}
/// 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();
}
@@ -108,6 +132,7 @@ class _LeafViewerState extends State<LeafViewer> with TickerProviderStateMixin i
int _currentIndex = 0;
bool _dismissing = false;
Size _viewerSize = Size.zero;
LeafViewerChrome? _chrome;
LeafZoomController? get _activeController => _controllers[_currentIndex];
@@ -151,6 +176,9 @@ class _LeafViewerState extends State<LeafViewer> with TickerProviderStateMixin i
..addListener(() {
if (mounted) setState(() {});
});
if (widget.enableChrome) {
_chrome = LeafViewerChrome(autoHide: !leafIsDesktopPlatform());
}
}
@override
@@ -158,6 +186,7 @@ class _LeafViewerState extends State<LeafViewer> with TickerProviderStateMixin i
_focusNode.dispose();
_dismissCtrl.dispose();
_indexNotifier.dispose();
_chrome?.dispose();
if (widget.pageController == null) _pageController.dispose();
super.dispose();
}
@@ -256,6 +285,21 @@ class _LeafViewerState extends State<LeafViewer> with TickerProviderStateMixin i
});
}
// ── 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
@@ -271,6 +315,8 @@ class _LeafViewerState extends State<LeafViewer> with TickerProviderStateMixin i
onKeyEvent: widget.enableKeyboard ? _onKeyEvent : null,
child: LeafViewerScope(
registry: this,
currentIndex: _indexNotifier,
chrome: _chrome,
child: LayoutBuilder(
builder: (context, constraints) {
_viewerSize = constraints.biggest;
@@ -319,18 +365,24 @@ class _LeafViewerState extends State<LeafViewer> with TickerProviderStateMixin i
),
),
if (widget.controlsBuilder != null)
ValueListenableBuilder<int>(
valueListenable: _indexNotifier,
builder: (context, index, _) => widget.controlsBuilder!(
context,
LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage),
_chromeRevealed(
ValueListenableBuilder<int>(
valueListenable: _indexNotifier,
builder: (context, index, _) => widget.controlsBuilder!(
context,
LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage),
),
),
)
else if (desktop)
ValueListenableBuilder<int>(
valueListenable: _indexNotifier,
builder: (context, index, _) =>
_LeafViewerDefaultControls(controls: LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage)),
_chromeRevealed(
ValueListenableBuilder<int>(
valueListenable: _indexNotifier,
builder: (context, index, _) => _LeafViewerDefaultControls(
controls: LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage),
showPageCounter: widget.showPageCounter,
),
),
),
],
);
@@ -342,9 +394,10 @@ class _LeafViewerState extends State<LeafViewer> with TickerProviderStateMixin i
}
class _LeafViewerDefaultControls extends StatelessWidget {
const _LeafViewerDefaultControls({required this.controls});
const _LeafViewerDefaultControls({required this.controls, this.showPageCounter = true});
final LeafViewerControls controls;
final bool showPageCounter;
@override
Widget build(BuildContext context) {
@@ -377,18 +430,19 @@ class _LeafViewerDefaultControls extends StatelessWidget {
right: 8,
child: _ControlButton(icon: Icons.close, onTap: () => Navigator.maybePop(context)),
),
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()]),
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()]),
),
),
),
),
],
);
}
+11 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'leaf_chrome.dart';
import 'leaf_zoom_controller.dart';
/// Whether the current platform is a desktop OS. Desktop viewers swap swipe
@@ -23,12 +24,20 @@ abstract class LeafViewerRegistry {
bool get swipeGesturesEnabled;
}
/// Internal: registry scope shared between [LeafViewer] and [LeafZoomable].
/// Internal: registry scope shared between [LeafViewer], [LeafZoomable] and
/// the video pages. Carries the viewer's outward state: the active page index
/// and, when enabled, the chrome reveal controller.
class LeafViewerScope extends InheritedWidget {
const LeafViewerScope({required this.registry, super.key, required super.child});
const LeafViewerScope({required this.registry, required this.currentIndex, this.chrome, super.key, required super.child});
final LeafViewerRegistry registry;
/// The currently settled page index (reactive; also drives the controls).
final ValueListenable<int> currentIndex;
/// Reveal state for chrome layers; null unless the viewer enabled chrome.
final LeafViewerChrome? chrome;
static LeafViewerScope? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<LeafViewerScope>();
}
+11
View File
@@ -227,6 +227,17 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
(instance) => instance.onDoubleTapDown = (details) => _controller.doubleTap(details.localPosition),
);
}
// Chrome-enabled viewers: single tap toggles chrome. Registered on
// THIS detector so the tap/double-tap arena coordination holds —
// a cross-detector tap would fire on the first tap of every
// double-tap-zoom.
final chrome = LeafViewerScope.maybeOf(context)?.chrome;
if (chrome != null) {
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(instance) => instance.onTap = chrome.toggle,
);
}
return Listener(
onPointerSignal: _onPointerSignal,
child: RawGestureDetector(