diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart b/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart
index 11ef3bf0..2be1e48f 100644
--- a/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart
@@ -27,6 +27,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
+import 'package:weblibre/presentation/hooks/scroll_visibility.dart';
class TabViewScreen extends HookConsumerWidget {
const TabViewScreen();
@@ -38,6 +39,9 @@ class TabViewScreen extends HookConsumerWidget {
final scrollController = useScrollController(keys: [tabsReorderable]);
+ // Track FAB visibility based on scroll direction
+ final isFabVisible = useScrollVisibility(scrollController);
+
return Dialog.fullscreen(
child: Scaffold(
body: SafeArea(
@@ -69,21 +73,30 @@ class TabViewScreen extends HookConsumerWidget {
),
},
),
- floatingActionButton: FloatingActionButton(
- onPressed: () async {
- final settings = ref.read(generalSettingsWithDefaultsProvider);
+ floatingActionButton: AnimatedSlide(
+ duration: const Duration(milliseconds: 200),
+ offset: isFabVisible.value ? Offset.zero : const Offset(0, 2),
+ curve: Curves.easeInOut,
+ child: AnimatedOpacity(
+ duration: const Duration(milliseconds: 200),
+ opacity: isFabVisible.value ? 1.0 : 0.0,
+ child: FloatingActionButton(
+ onPressed: () async {
+ final settings = ref.read(generalSettingsWithDefaultsProvider);
- await SearchRoute(
- tabType:
- ref.read(selectedTabTypeProvider) ??
- settings.defaultCreateTabType,
- ).push(context);
+ await SearchRoute(
+ tabType:
+ ref.read(selectedTabTypeProvider) ??
+ settings.defaultCreateTabType,
+ ).push(context);
- if (context.mounted) {
- const BrowserRoute().go(context);
- }
- },
- child: const Icon(Icons.add),
+ if (context.mounted) {
+ const BrowserRoute().go(context);
+ }
+ },
+ child: const Icon(Icons.add),
+ ),
+ ),
),
),
);
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart
index e79aec99..39588c2e 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart
@@ -27,6 +27,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
+import 'package:weblibre/presentation/hooks/scroll_visibility.dart';
class _TabDraggable extends HookConsumerWidget {
final TabEntity entity;
@@ -400,6 +401,9 @@ class ViewTabGridWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ // Track FAB visibility based on scroll direction
+ final isFabVisible = useScrollVisibility(scrollController);
+
return Stack(
alignment: Alignment.bottomRight,
children: [
@@ -431,24 +435,33 @@ class ViewTabGridWidget extends HookConsumerWidget {
),
),
if (showNewTabFab)
- Padding(
- padding: const EdgeInsets.only(
- top: TabViewHeader.headerSize + 4,
- right: 4,
- ),
- child: FloatingActionButton.small(
- onPressed: () async {
- final settings = ref.read(generalSettingsWithDefaultsProvider);
+ AnimatedSlide(
+ duration: const Duration(milliseconds: 200),
+ offset: isFabVisible.value ? Offset.zero : const Offset(0, 2),
+ curve: Curves.easeInOut,
+ child: AnimatedOpacity(
+ duration: const Duration(milliseconds: 200),
+ opacity: isFabVisible.value ? 1.0 : 0.0,
+ child: Padding(
+ padding: const EdgeInsets.only(
+ top: TabViewHeader.headerSize + 4,
+ right: 4,
+ ),
+ child: FloatingActionButton.small(
+ onPressed: () async {
+ final settings = ref.read(generalSettingsWithDefaultsProvider);
- await SearchRoute(
- tabType:
- ref.read(selectedTabTypeProvider) ??
- settings.defaultCreateTabType,
- ).push(context);
+ await SearchRoute(
+ tabType:
+ ref.read(selectedTabTypeProvider) ??
+ settings.defaultCreateTabType,
+ ).push(context);
- onClose();
- },
- child: const Icon(Icons.add),
+ onClose();
+ },
+ child: const Icon(Icons.add),
+ ),
+ ),
),
),
],
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart
index ff87e5d4..63d41fd5 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart
@@ -25,6 +25,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
+import 'package:weblibre/presentation/hooks/scroll_visibility.dart';
class _TabDraggable extends HookConsumerWidget {
final TabEntity entity;
@@ -369,6 +370,9 @@ class ViewTabListWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ // Track FAB visibility based on scroll direction
+ final isFabVisible = useScrollVisibility(scrollController);
+
return Stack(
alignment: Alignment.bottomRight,
children: [
@@ -400,24 +404,33 @@ class ViewTabListWidget extends HookConsumerWidget {
),
),
if (showNewTabFab)
- Padding(
- padding: const EdgeInsets.only(
- top: TabViewHeader.headerSize + 4,
- right: 4,
- ),
- child: FloatingActionButton.small(
- onPressed: () async {
- final settings = ref.read(generalSettingsWithDefaultsProvider);
+ AnimatedSlide(
+ duration: const Duration(milliseconds: 200),
+ offset: isFabVisible.value ? Offset.zero : const Offset(0, 2),
+ curve: Curves.easeInOut,
+ child: AnimatedOpacity(
+ duration: const Duration(milliseconds: 200),
+ opacity: isFabVisible.value ? 1.0 : 0.0,
+ child: Padding(
+ padding: const EdgeInsets.only(
+ top: TabViewHeader.headerSize + 4,
+ right: 4,
+ ),
+ child: FloatingActionButton.small(
+ onPressed: () async {
+ final settings = ref.read(generalSettingsWithDefaultsProvider);
- await SearchRoute(
- tabType:
- ref.read(selectedTabTypeProvider) ??
- settings.defaultCreateTabType,
- ).push(context);
+ await SearchRoute(
+ tabType:
+ ref.read(selectedTabTypeProvider) ??
+ settings.defaultCreateTabType,
+ ).push(context);
- onClose();
- },
- child: const Icon(Icons.add),
+ onClose();
+ },
+ child: const Icon(Icons.add),
+ ),
+ ),
),
),
],
diff --git a/app/lib/presentation/hooks/scroll_visibility.dart b/app/lib/presentation/hooks/scroll_visibility.dart
new file mode 100644
index 00000000..93e0d46d
--- /dev/null
+++ b/app/lib/presentation/hooks/scroll_visibility.dart
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2024-2025 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+
+/// A hook that manages visibility state based on scroll direction.
+///
+/// Returns a [ValueNotifier] that automatically updates based on scroll behavior:
+/// - Hides when scrolling down beyond [hideThreshold]
+/// - Shows when scrolling up beyond [showThreshold] or at the top of the scroll view
+/// - Ignores scroll events during [initializationDelay] to prevent hiding during jumpTo/animateTo
+///
+/// Example:
+/// ```dart
+/// final scrollController = useScrollController();
+/// final isVisible = useScrollVisibility(scrollController);
+///
+/// return AnimatedOpacity(
+/// opacity: isVisible.value ? 1.0 : 0.0,
+/// child: FloatingActionButton(...),
+/// );
+/// ```
+ValueNotifier useScrollVisibility(
+ ScrollController scrollController, {
+ double hideThreshold = 5.0,
+ double showThreshold = 5.0,
+ Duration initializationDelay = const Duration(milliseconds: 1000),
+}) {
+ final isVisible = useState(true);
+ final lastScrollOffset = useRef(0.0);
+ final isInitialized = useRef(false);
+
+ useEffect(() {
+ // Start timer to enable scroll listener after initialization delay
+ final timer = Timer(initializationDelay, () {
+ isInitialized.value = true;
+ });
+
+ void scrollListener() {
+ // Ignore scroll events until initialization is complete
+ if (!isInitialized.value) return;
+
+ final currentOffset = scrollController.offset;
+ final difference = currentOffset - lastScrollOffset.value;
+
+ // Hide when scrolling down beyond threshold
+ if (difference > hideThreshold && isVisible.value) {
+ isVisible.value = false;
+ }
+ // Show when scrolling up beyond threshold or at top
+ else if ((difference < -showThreshold || currentOffset <= 0) &&
+ !isVisible.value) {
+ isVisible.value = true;
+ }
+
+ lastScrollOffset.value = currentOffset;
+ }
+
+ scrollController.addListener(scrollListener);
+ return () {
+ timer.cancel();
+ scrollController.removeListener(scrollListener);
+ };
+ }, [scrollController, hideThreshold, showThreshold, initializationDelay]);
+
+ return isVisible;
+}