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; +}