improve toolbar logic

This commit is contained in:
Fabian Freund
2026-03-02 16:30:56 +01:00
parent 8898f8a317
commit 3b61c2242d
4 changed files with 254 additions and 196 deletions
@@ -75,6 +75,8 @@ class ToolbarVisibilityController extends _$ToolbarVisibilityController {
/// Hide toolbar via scroll. All guards checked internally.
void requestHide() {
if (state != ToolbarVisibility.visible) return;
final settings = ref.read(generalSettingsWithDefaultsProvider);
if (!settings.autoHideTabBar) return;
@@ -84,15 +86,13 @@ class ToolbarVisibilityController extends _$ToolbarVisibilityController {
final viewportService = ref.read(viewportServiceProvider);
if (!viewportService.isBrowserHandlingScrollEnabled) return;
if (state == ToolbarVisibility.dismissed) return;
state = ToolbarVisibility.hidden;
}
/// Show toolbar (scroll-up, tab change, loading start, etc).
/// Won't show if manually dismissed — use forceShow() for that.
void show() {
if (state == ToolbarVisibility.dismissed) return;
if (state != ToolbarVisibility.hidden) return;
state = ToolbarVisibility.visible;
}
@@ -61,21 +61,10 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart
import 'package:weblibre/utils/move_to_background.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
/// Callback for toolbar animation progress updates.
/// [progress] is 0.0 when hidden, 1.0 when fully visible.
/// [heightPx] is the toolbar height in pixels.
typedef ToolbarAnimationCallback =
void Function(double progress, double heightPx);
/// Animated toolbar that slides in/out without changing layout constraints.
/// Uses SlideTransition to animate visual transform while maintaining
/// constant intrinsic size for layout purposes.
class _AnimatedToolbar extends HookWidget {
final bool visible;
final TabBarPosition position;
final Widget child;
final double toolbarHeight;
final ToolbarAnimationCallback? onAnimationProgress;
static const _kAnimationDuration = Duration(milliseconds: 250);
@@ -83,9 +72,6 @@ class _AnimatedToolbar extends HookWidget {
required this.visible,
required this.position,
required this.child,
required this.toolbarHeight,
// ignore: unused_element_parameter
this.onAnimationProgress,
});
@override
@@ -95,19 +81,6 @@ class _AnimatedToolbar extends HookWidget {
initialValue: visible ? 1.0 : 0.0,
);
// Listen to animation changes and report progress
useEffect(() {
if (onAnimationProgress == null) return null;
void listener() {
onAnimationProgress?.call(controller.value, toolbarHeight);
}
controller.addListener(listener);
listener();
return () => controller.removeListener(listener);
}, [onAnimationProgress, toolbarHeight]);
useEffect(() {
if (visible) {
unawaited(controller.forward());
@@ -352,21 +325,10 @@ class BrowserScreen extends HookConsumerWidget {
final displayedSheet = ref.watch(bottomSheetControllerProvider);
final sheetDisplayed = displayedSheet != null;
// Compute visibility states for toolbars
// Track selected tab. Toolbar visibility is watched in scoped Consumer
// widgets below to avoid rebuilding the entire BrowserScreen on every
// hide/show cycle during scrolling.
final selectedTabId = ref.watch(selectedTabProvider);
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
// Toolbar is visible when: sheet is shown OR (not fullscreen AND controller says visible)
// The controller handles loading-start show internally via ref.listen on isLoading
final effectiveAppBarVisible = toolbarState == ToolbarVisibility.visible;
final toolbarManuallyDismissed =
toolbarState == ToolbarVisibility.dismissed;
final topToolbarVisible =
sheetDisplayed || (!tabInFullScreen && effectiveAppBarVisible);
final bottomToolbarVisible =
sheetDisplayed || (!tabInFullScreen && effectiveAppBarVisible);
// Calculate relative safe area for sheet max size
final relativeSafeArea = MediaQuery.of(context).relativeSafeArea();
@@ -384,13 +346,7 @@ class BrowserScreen extends HookConsumerWidget {
final bottomAppBarTotalHeight =
bottomAppBarContentSize.height + bottomSafeArea;
// When auto-hide is disabled, constrain browser to not extend behind toolbar
// (unless toolbar is manually dismissed via swipe gesture)
final browserBottomOffset = (!autoHideTabBar && bottomToolbarVisible)
? bottomAppBarTotalHeight
: 0.0;
// Calculate top toolbar size for browser offset
// Calculate top toolbar size for browser offset and progress indicator
final topSafeArea = MediaQuery.of(context).padding.top;
final topAppBarContentSize = BrowserTopAppBar(
showMainToolbar: tabBarPosition == TabBarPosition.top,
@@ -399,14 +355,6 @@ class BrowserScreen extends HookConsumerWidget {
).preferredSize;
final topAppBarTotalHeight = topAppBarContentSize.height + topSafeArea;
// For top bar: always constrain browser below toolbar when visible
// (unlike bottom bar which only constrains when auto-hide is disabled)
// This ensures top-of-page content is always accessible
final browserTopOffset =
(tabBarPosition == TabBarPosition.top && topToolbarVisible)
? topAppBarTotalHeight
: 0.0;
// Get pixel ratio for converting logical pixels to physical pixels
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
@@ -425,11 +373,30 @@ class BrowserScreen extends HookConsumerWidget {
: 0.0;
final findInPageHeightPx = (findInPageHeight * pixelRatio).round();
final stableToolbarHeight = autoHideTabBar && !toolbarManuallyDismissed
// Track dismissed state without triggering full rebuild on every
// hide/show. Updated via ref.listen below and synced on tab switch.
final toolbarDismissed = useState(false);
// Sync dismissed state when selected tab changes
useEffect(() {
toolbarDismissed.value =
ref.read(toolbarVisibilityControllerProvider(selectedTabId)) ==
ToolbarVisibility.dismissed;
return null;
}, [selectedTabId]);
final stableToolbarHeight =
autoHideTabBar && !toolbarDismissed.value && !tabInFullScreen
? bottomAppBarTotalHeight
: 0.0;
final stableToolbarHeightPx = (stableToolbarHeight * pixelRatio).round();
// Compute toolbar visibility for keyboard inset math.
// Uses ref.watch via the toolbarDismissed useState to avoid
// subscribing to every hide/show toggle.
final toolbarVisibleForLayout =
sheetDisplayed || (!tabInFullScreen && !toolbarDismissed.value);
final keyboardHeightPx = useState<int?>(null);
useOnStreamChange(
@@ -446,7 +413,7 @@ class BrowserScreen extends HookConsumerWidget {
final keyboardVisible = keyboardHeightPx.value != null;
final bottomLayoutReservedPx = (!autoHideTabBar && bottomToolbarVisible)
final bottomLayoutReservedPx = (!autoHideTabBar && toolbarVisibleForLayout)
? (bottomAppBarTotalHeight * pixelRatio).round()
: 0;
final keyboardViewportHeightPx = keyboardVisible
@@ -469,63 +436,70 @@ class BrowserScreen extends HookConsumerWidget {
return null;
}, [effectiveToolbarHeightPx]);
// Track last clipping value to avoid redundant platform channel calls
// Track last clipping value to avoid redundant platform channel calls.
// Clipping is set once when visibility changes (before animation starts),
// rather than on every animation frame, to avoid platform channel spam.
final lastClippingPx = useRef(0);
final desiredToolbarClippingPx = useRef(0);
// Keep clipping state in sync with keyboard and auto-hide states.
// While keyboard is visible, do not apply toolbar clipping.
useEffect(
() {
if (toolbarManuallyDismissed) {
desiredToolbarClippingPx.value = 0;
}
void updateClipping() {
final toolbarState = ref.read(
toolbarVisibilityControllerProvider(selectedTabId),
);
final effectiveVisible = toolbarState == ToolbarVisibility.visible;
final dismissed = toolbarState == ToolbarVisibility.dismissed;
final targetClippingPx =
autoHideTabBar &&
!toolbarManuallyDismissed &&
!keyboardVisible &&
!tabIsLoading
? desiredToolbarClippingPx.value
: 0;
if (targetClippingPx != lastClippingPx.value) {
lastClippingPx.value = targetClippingPx;
unawaited(viewportService.setVerticalClipping(targetClippingPx));
}
return null;
},
[autoHideTabBar, toolbarManuallyDismissed, keyboardVisible, tabIsLoading],
);
final animationProgressCallback = useCallback((
double progress,
double heightPx,
) {
if (toolbarManuallyDismissed) {
desiredToolbarClippingPx.value = 0;
if (lastClippingPx.value != 0) {
lastClippingPx.value = 0;
unawaited(viewportService.setVerticalClipping(0));
}
return;
}
// Clip content from bottom as toolbar hides.
// Negative clipping = clip from bottom.
final clippingPx = -((1.0 - progress) * heightPx * pixelRatio).round();
desiredToolbarClippingPx.value = clippingPx;
final targetClippingPx = (keyboardVisible || tabIsLoading)
? 0
: clippingPx;
// When hidden: clip from bottom by toolbar height so GeckoView
// can compute its layout while Flutter animates.
// When visible/dismissed or overridden by keyboard/loading: no clipping.
final targetClippingPx =
autoHideTabBar &&
!dismissed &&
!effectiveVisible &&
!keyboardVisible &&
!tabIsLoading &&
!tabInFullScreen
? -(bottomAppBarTotalHeight * pixelRatio).round()
: 0;
if (targetClippingPx != lastClippingPx.value) {
lastClippingPx.value = targetClippingPx;
unawaited(viewportService.setVerticalClipping(targetClippingPx));
}
}, [toolbarManuallyDismissed, keyboardVisible, pixelRatio, tabIsLoading]);
}
// Update clipping when toolbar visibility changes (does NOT trigger
// full rebuild). Also tracks dismissed state via useState so that
// stableToolbarHeight / effectiveToolbarHeightPx stay correct.
ref.listen(toolbarVisibilityControllerProvider(selectedTabId), (
previous,
next,
) {
updateClipping();
// Update dismissed state — triggers a targeted rebuild only when
// the dismissed flag actually changes (not on every hide/show).
final isDismissed = next == ToolbarVisibility.dismissed;
if (toolbarDismissed.value != isDismissed) {
toolbarDismissed.value = isDismissed;
}
});
// Update clipping when other relevant state changes
useEffect(
() {
updateClipping();
return null;
},
[
autoHideTabBar,
keyboardVisible,
tabIsLoading,
tabInFullScreen,
bottomAppBarTotalHeight,
pixelRatio,
selectedTabId,
],
);
// Theme with dynamic snackbar margin to position above bottom toolbar
final themeData = Theme.of(context).copyWith(
@@ -545,7 +519,8 @@ class BrowserScreen extends HookConsumerWidget {
left: 16,
right: 16,
bottom:
(bottomToolbarVisible ? bottomAppBarContentSize.height + 8 : 16) +
bottomAppBarContentSize.height +
8 +
(findInPageVisible ? findInPageHeight : 0),
),
),
@@ -565,18 +540,45 @@ class BrowserScreen extends HookConsumerWidget {
// Layer 0: Browser content
// Position changes instantly (no animation) to avoid jarring native view resize
// The toolbar itself animates, providing visual continuity
Positioned(
left: 0,
right: 0,
top: browserTopOffset,
bottom: browserBottomOffset,
child: _Browser(
overlayController: overlayController,
tabInFullScreen: tabInFullScreen,
pointerMoveEventSink: pointerMoveEventsController.sink,
sheetDisplayed: sheetDisplayed,
hasTopBarOffset: browserTopOffset > 0,
),
Consumer(
builder: (context, ref, child) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final toolbarVisible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
// When auto-hide is disabled, constrain browser above toolbar
// (unless toolbar is manually dismissed via swipe gesture)
final bottomOffset = (!autoHideTabBar && toolbarVisible)
? bottomAppBarTotalHeight
: 0.0;
// For top bar: constrain browser below toolbar when visible
// to ensure top-of-page content is always accessible
final topOffset =
(tabBarPosition == TabBarPosition.top && toolbarVisible)
? topAppBarTotalHeight
: 0.0;
return Positioned(
left: 0,
right: 0,
top: topOffset,
bottom: bottomOffset,
child: _Browser(
overlayController: overlayController,
tabInFullScreen: tabInFullScreen,
pointerMoveEventSink: autoHideTabBar
? pointerMoveEventsController.sink
: null,
sheetDisplayed: sheetDisplayed,
hasTopBarOffset: topOffset > 0,
),
);
},
),
// Layer 1: Sheet (when displayed) - positioned above toolbar
@@ -598,24 +600,31 @@ class BrowserScreen extends HookConsumerWidget {
left: 0,
right: 0,
bottom: 0,
child: _AnimatedToolbar(
position: TabBarPosition.bottom,
visible: bottomToolbarVisible,
toolbarHeight: bottomAppBarTotalHeight,
onAnimationProgress:
autoHideTabBar && !toolbarManuallyDismissed
? animationProgressCallback
: null,
child: _TabBar(
tabBarPosition: TabBarPosition.bottom,
showMainToolbar: tabBarPosition == TabBarPosition.bottom,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
// Only subscribe to scroll events when this is the active position
pointerMoveEvents: tabBarPosition == TabBarPosition.bottom
? pointerMoveEventsController.stream
: null,
),
child: Consumer(
builder: (context, ref, _) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final visible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
return _AnimatedToolbar(
position: TabBarPosition.bottom,
visible: visible,
child: _TabBar(
tabBarPosition: TabBarPosition.bottom,
showMainToolbar:
tabBarPosition == TabBarPosition.bottom,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
pointerMoveEvents:
tabBarPosition == TabBarPosition.bottom
? pointerMoveEventsController.stream
: null,
),
);
},
),
),
@@ -625,44 +634,76 @@ class BrowserScreen extends HookConsumerWidget {
left: 0,
right: 0,
top: 0,
child: _AnimatedToolbar(
position: TabBarPosition.top,
visible: topToolbarVisible,
toolbarHeight: topAppBarTotalHeight,
child: _TabBar(
tabBarPosition: TabBarPosition.top,
showMainToolbar: true,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
pointerMoveEvents: pointerMoveEventsController.stream,
),
child: Consumer(
builder: (context, ref, _) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final visible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
return _AnimatedToolbar(
position: TabBarPosition.top,
visible: visible,
child: _TabBar(
tabBarPosition: TabBarPosition.top,
showMainToolbar: true,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
pointerMoveEvents: pointerMoveEventsController.stream,
),
);
},
),
),
// Layer 4: FAB (draggable via long press)
DraggableFab(
bottomToolbarVisible: bottomToolbarVisible,
bottomAppBarHeight: bottomAppBarTotalHeight,
bottomSafeArea: bottomSafeArea,
Consumer(
builder: (context, ref, child) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final visible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
return DraggableFab(
bottomToolbarVisible: visible,
bottomAppBarHeight: bottomAppBarTotalHeight,
bottomSafeArea: bottomSafeArea,
child: child!,
);
},
child: const BrowserFab(),
),
// Layer 5: Page load progress indicator (animates with toolbar visibility)
AnimatedPositioned(
duration: _AnimatedToolbar._kAnimationDuration,
curve: Curves.easeInOutQuart,
left: 0,
right: 0,
top: tabBarPosition == TabBarPosition.top && topToolbarVisible
? topAppBarTotalHeight
: null,
bottom:
tabBarPosition == TabBarPosition.bottom &&
bottomToolbarVisible
? bottomAppBarTotalHeight
: tabBarPosition == TabBarPosition.bottom
? 0
: null,
Consumer(
builder: (context, ref, child) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final visible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
return AnimatedPositioned(
duration: _AnimatedToolbar._kAnimationDuration,
curve: Curves.easeInOutQuart,
left: 0,
right: 0,
top: tabBarPosition == TabBarPosition.top && visible
? topAppBarTotalHeight
: null,
bottom: tabBarPosition == TabBarPosition.bottom && visible
? bottomAppBarTotalHeight
: tabBarPosition == TabBarPosition.bottom
? 0
: null,
child: child!,
);
},
child: Consumer(
builder: (context, ref, child) {
final value = ref.watch(
@@ -685,17 +726,27 @@ class BrowserScreen extends HookConsumerWidget {
),
// Layer 6: Find in Page widget (above toolbar or keyboard, whichever is higher)
AnimatedPositioned(
duration: _AnimatedToolbar._kAnimationDuration,
curve: Curves.easeInOutQuart,
left: 0,
right: 0,
bottom: math.max(
bottomToolbarVisible
? bottomAppBarTotalHeight
: bottomSafeArea,
MediaQuery.viewInsetsOf(context).bottom,
),
Consumer(
builder: (context, ref, child) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final visible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
return AnimatedPositioned(
duration: _AnimatedToolbar._kAnimationDuration,
curve: Curves.easeInOutQuart,
left: 0,
right: 0,
bottom: math.max(
visible ? bottomAppBarTotalHeight : bottomSafeArea,
MediaQuery.viewInsetsOf(context).bottom,
),
child: child!,
);
},
child: Consumer(
builder: (context, ref, child) {
final tabId = ref.watch(selectedTabProvider);
@@ -777,7 +828,7 @@ class _Browser extends HookConsumerWidget {
Duration get _backButtonPressTimeout => const Duration(seconds: 2);
final OverlayPortalController overlayController;
final StreamSink<Offset> pointerMoveEventSink;
final StreamSink<Offset>? pointerMoveEventSink;
final bool tabInFullScreen;
final bool sheetDisplayed;
final bool hasTopBarOffset;
@@ -90,6 +90,10 @@ class _BrowserViewState extends ConsumerState<BrowserView>
DateTime? _suggestionCountTime;
static const _pointerThrottleInterval = Duration(milliseconds: 32);
DateTime _lastPointerEvent = DateTime(0);
Offset _accumulatedDelta = Offset.zero;
Future<void> _timerTick(Timer timer) async {
await ref
.read(selectedTabSessionProvider)
@@ -280,10 +284,25 @@ class _BrowserViewState extends ConsumerState<BrowserView>
return Listener(
behavior: HitTestBehavior.translucent,
onPointerUp: (widget.pointerMoveEventSink != null)
? (_) {
if (_accumulatedDelta != Offset.zero) {
widget.pointerMoveEventSink!.add(_accumulatedDelta);
_accumulatedDelta = Offset.zero;
}
}
: null,
onPointerMove: (widget.pointerMoveEventSink != null)
? (event) {
if (event.down) {
widget.pointerMoveEventSink!.add(event.localDelta);
_accumulatedDelta += event.localDelta;
final now = DateTime.now();
if (now.difference(_lastPointerEvent) >=
_pointerThrottleInterval) {
_lastPointerEvent = now;
widget.pointerMoveEventSink!.add(_accumulatedDelta);
_accumulatedDelta = Offset.zero;
}
}
}
: null,
@@ -8,7 +8,6 @@ package eu.weblibre.flutter_mozilla_components.api
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportApi
import mozilla.components.support.base.log.logger.Logger
/**
* Implementation of GeckoViewportApi that controls GeckoView's viewport behavior
@@ -22,12 +21,6 @@ import mozilla.components.support.base.log.logger.Logger
* is called, the value is stored and applied when the EngineView becomes available.
*/
class GeckoViewportApiImpl : GeckoViewportApi {
companion object {
private const val TAG = "GeckoViewportApi"
}
private val logger = Logger(TAG)
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
@@ -46,13 +39,11 @@ class GeckoViewportApiImpl : GeckoViewportApi {
val engineView = components.mainBrowserEngineView
if (engineView == null) {
logger.debug("$TAG: setDynamicToolbarMaxHeight($height) - mainBrowserEngineView not ready, storing as pending")
pendingToolbarHeight = height
return
}
pendingToolbarHeight = null
logger.debug("$TAG: setDynamicToolbarMaxHeight($height)")
engineView.setDynamicToolbarMaxHeight(height)
}
@@ -64,7 +55,6 @@ class GeckoViewportApiImpl : GeckoViewportApi {
val pending = pendingToolbarHeight ?: return
val engineView = components.mainBrowserEngineView ?: return
pendingToolbarHeight = null
logger.debug("$TAG: Applying pending toolbar height: $pending")
engineView.setDynamicToolbarMaxHeight(pending)
}
@@ -78,11 +68,9 @@ class GeckoViewportApiImpl : GeckoViewportApi {
val engineView = components.mainBrowserEngineView
if (engineView == null) {
logger.warn("$TAG: setVerticalClipping called but mainBrowserEngineView is null")
return
}
logger.debug("$TAG: setVerticalClipping($clipping)")
engineView.setVerticalClipping(clipping)
}
}