From 787c7b237df0a0eec1eada6151da99e455b2e6e3 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 4 May 2026 17:08:20 +0200 Subject: [PATCH] better auto scroll in tab list/grid/tree and quick tab switcher --- .../browser_modules/bottom_app_bar.dart | 166 +++++++++++++----- .../widgets/tab_view/tab_grid_view.dart | 43 ++--- .../widgets/tab_view/tab_list_view.dart | 48 ++--- .../widgets/tab_view/tab_tree_view.dart | 58 +++--- .../widgets/selectable_chips.dart | 15 +- 5 files changed, 204 insertions(+), 126 deletions(-) diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index d0a5de58..0eba400f 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -22,6 +22,7 @@ import 'dart:async'; import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -542,54 +543,127 @@ class QuickTabSwitcher extends HookConsumerWidget { ); final chipScrollController = useScrollController(); + final activeItemKey = useRef(GlobalKey()); + final isUserScrolling = useRef(false); + final userScrollTimer = useRef(null); - return QuickTabSwitcherView( - availableItems: availableItems, - activeItem: activeItem.isActive ? activeItem : null, - scrollController: chipScrollController, - showTitles: showTitles, - showIsolatedTabUi: showIsolatedTabUi, - onSelected: (item) async { - if (!item.isHistory && item.isActive) { - return; - } - if (item.isHistory) { - await ref - .read(tabRepositoryProvider.notifier) - .addTab(url: item.url, tabMode: TabMode.regular, selectTab: true); - } else { - await ref.read(tabRepositoryProvider.notifier).selectTab(item.id); - } - }, - itemWrapBuilder: (child, item) { - if (item.isHistory) { - return child; - } + useEffect(() { + return userScrollTimer.value?.cancel; + }, []); - return TabMenu( - selectedTabId: item.id, - enableFindInPage: false, - enableFetchFeeds: false, - enableDesktopMode: false, - enableReaderMode: false, - enableReloadButton: false, - enableNavigationButtons: false, - enableAddToHomeScreen: false, - enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs, - builder: (context, controller, _) { - return InkWell( - onLongPress: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - child: child, + useEffect(() { + if (isUserScrolling.value) return null; + + WidgetsBinding.instance.addPostFrameCallback((_) { + final context = activeItemKey.value.currentContext; + if (context != null) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + } else if (chipScrollController.hasClients) { + final activeIndex = availableItems.indexWhere( + (item) => item.id == selectedTabId, + ); + if (activeIndex < 0) return; + + final totalItems = availableItems.length; + final maxExtent = chipScrollController.position.maxScrollExtent; + if (totalItems > 0 && maxExtent > 0) { + chipScrollController.jumpTo( + (activeIndex / totalItems * maxExtent).clamp(0.0, maxExtent), ); - }, - ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final retryContext = activeItemKey.value.currentContext; + if (retryContext != null) { + Scrollable.ensureVisible( + retryContext, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + } + }); + } + } + }); + + return null; + }, [selectedTabId]); + + return NotificationListener( + onNotification: (notification) { + if (notification.direction != ScrollDirection.idle) { + isUserScrolling.value = true; + userScrollTimer.value?.cancel(); + userScrollTimer.value = Timer(const Duration(milliseconds: 1500), () { + isUserScrolling.value = false; + }); + } else { + userScrollTimer.value?.cancel(); + userScrollTimer.value = Timer(const Duration(milliseconds: 1500), () { + isUserScrolling.value = false; + }); + } + + return false; }, + child: QuickTabSwitcherView( + availableItems: availableItems, + activeItem: activeItem.isActive ? activeItem : null, + scrollController: chipScrollController, + activeItemKey: activeItemKey.value, + showTitles: showTitles, + showIsolatedTabUi: showIsolatedTabUi, + onSelected: (item) async { + if (!item.isHistory && item.isActive) { + return; + } + if (item.isHistory) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: item.url, + tabMode: TabMode.regular, + selectTab: true, + ); + } else { + await ref.read(tabRepositoryProvider.notifier).selectTab(item.id); + } + }, + itemWrapBuilder: (child, item) { + if (item.isHistory) { + return child; + } + + return TabMenu( + selectedTabId: item.id, + enableFindInPage: false, + enableFetchFeeds: false, + enableDesktopMode: false, + enableReaderMode: false, + enableReloadButton: false, + enableNavigationButtons: false, + enableAddToHomeScreen: false, + enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs, + builder: (context, controller, _) { + return InkWell( + onLongPress: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: child, + ); + }, + ); + }, + ), ); } } @@ -600,6 +674,7 @@ class QuickTabSwitcherView extends StatelessWidget { required this.availableItems, required this.activeItem, required this.scrollController, + this.activeItemKey, required this.showTitles, required this.showIsolatedTabUi, required this.onSelected, @@ -609,6 +684,7 @@ class QuickTabSwitcherView extends StatelessWidget { final List availableItems; final QuickTabSwitcherItem? activeItem; final ScrollController scrollController; + final GlobalKey? activeItemKey; final bool showTitles; final bool showIsolatedTabUi; final Future Function(QuickTabSwitcherItem item) onSelected; @@ -634,6 +710,8 @@ class QuickTabSwitcherView extends StatelessWidget { sortSelectedFirst: false, maxCount: null, scrollController: scrollController, + activeItemKey: activeItemKey, + cacheExtent: 500, itemId: (item) => item.id, selectedItem: activeItem, selectedBorderColor: Theme.of(context).colorScheme.primary, diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart index eb8bd078..204e1760 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart @@ -286,51 +286,40 @@ class _TabGridView extends HookConsumerWidget { [screenWidth, crossAxisCount], ); - final didInitialScroll = useRef(false); - useEffect(() { - if (didInitialScroll.value) return null; - WidgetsBinding.instance.addPostFrameCallback((_) { if (scrollController.hasClients && activeTab != null) { final index = primaryRows.indexWhere((row) => row.tabId == activeTab); if (index > -1) { final row = index ~/ crossAxisCount; - final viewportStart = scrollController.offset; - final viewportEnd = - viewportStart + scrollController.position.viewportDimension; final tabStart = row * itemSize.height; - final tabEnd = tabStart + itemSize.height; + final viewportDimension = + scrollController.position.viewportDimension; - final isVisible = - tabStart >= viewportStart && tabEnd <= viewportEnd; - - if (!isVisible) { - if (disableAnimations) { - scrollController.jumpTo(tabStart); - } else { - unawaited( - scrollController.animateTo( - tabStart, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ), + final targetOffset = + (tabStart - viewportDimension / 2 + itemSize.height / 2).clamp( + 0.0, + scrollController.position.maxScrollExtent, ); - } - didInitialScroll.value = true; + if (disableAnimations) { + scrollController.jumpTo(targetOffset); } else { - didInitialScroll.value = true; + unawaited( + scrollController.animateTo( + targetOffset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); } - } else { - didInitialScroll.value = true; } } }); return null; - }, [activeTab]); + }, [activeTab, primaryRows.length]); return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart index d13513d4..6a95e6fe 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart @@ -325,55 +325,39 @@ class _TabListView extends HookConsumerWidget { final activeTab = ref.watch(selectedTabProvider); - final didInitialScroll = useRef(false); - useEffect(() { - if (didInitialScroll.value) return null; - WidgetsBinding.instance.addPostFrameCallback((_) { if (scrollController.hasClients && activeTab != null) { final index = primaryRows.indexWhere((row) => row.tabId == activeTab); if (index > -1) { - final viewportStart = scrollController.offset; - final viewportEnd = - viewportStart + scrollController.position.viewportDimension; + final viewportDimension = + scrollController.position.viewportDimension; final tabStart = index * _itemHeight; - final tabEnd = tabStart + _itemHeight; - final isVisible = - tabStart >= viewportStart && tabEnd <= viewportEnd; - - if (!isVisible) { - final targetOffset = (tabStart - _itemHeight).clamp( - 0.0, - scrollController.position.maxScrollExtent, - ); - - if (disableAnimations) { - scrollController.jumpTo(targetOffset); - } else { - unawaited( - scrollController.animateTo( - targetOffset, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ), + final targetOffset = + (tabStart - viewportDimension / 2 + _itemHeight / 2).clamp( + 0.0, + scrollController.position.maxScrollExtent, ); - } - didInitialScroll.value = true; + if (disableAnimations) { + scrollController.jumpTo(targetOffset); } else { - didInitialScroll.value = true; + unawaited( + scrollController.animateTo( + targetOffset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); } - } else { - didInitialScroll.value = true; } } }); return null; - }, [activeTab]); + }, [activeTab, primaryRows.length]); return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart index 469f18c4..930a7a29 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart @@ -243,27 +243,45 @@ class ViewTabTreesWidget extends HookConsumerWidget { ); useEffect(() { - final index = filteredTabEntities.value.indexWhere( - (entity) => entity.tabId == activeTab, - ); - - if (index > -1) { - final offset = (index ~/ 2) * itemSize.height; - - if (offset != scrollController.offset) { - if (disableAnimations) { - scrollController.jumpTo(offset); - } else { - unawaited( - scrollController.animateTo( - offset, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ), - ); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!scrollController.hasClients || activeTab == null) { + return; } - } + + final index = filteredTabEntities.value.indexWhere( + (entity) => entity.tabId == activeTab, + ); + + if (index < 0) return; + + final row = index ~/ 2; + final tabStart = row * itemSize.height; + final viewportDimension = + scrollController.position.viewportDimension; + + final targetOffset = + (tabStart - + viewportDimension / 2 + + itemSize.height / 2) + .clamp( + 0.0, + scrollController.position.maxScrollExtent, + ); + + if (targetOffset == scrollController.offset) return; + + if (disableAnimations) { + scrollController.jumpTo(targetOffset); + } else { + unawaited( + scrollController.animateTo( + targetOffset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); + } + }); return null; }, [filteredTabEntities, activeTab]); diff --git a/apps/weblibre/lib/presentation/widgets/selectable_chips.dart b/apps/weblibre/lib/presentation/widgets/selectable_chips.dart index 8900af68..3955c665 100644 --- a/apps/weblibre/lib/presentation/widgets/selectable_chips.dart +++ b/apps/weblibre/lib/presentation/widgets/selectable_chips.dart @@ -63,6 +63,8 @@ class SelectableChips extends StatelessWidget { final bool sortSelectedFirst; final ScrollController? scrollController; + final GlobalKey? activeItemKey; + final double? cacheExtent; final K Function(S item) itemId; final Widget Function(T item) itemLabel; @@ -98,6 +100,8 @@ class SelectableChips extends StatelessWidget { this.onLongPress, this.sortSelectedFirst = true, this.scrollController, + this.activeItemKey, + this.cacheExtent = 0, this.labelPadding, super.key, }); @@ -127,8 +131,7 @@ class SelectableChips extends StatelessWidget { builder: (context, controller) { return ListView.builder( controller: controller, - //Improve list performance by not rendering outside screen at all - cacheExtent: 0, + cacheExtent: cacheExtent, scrollDirection: Axis.horizontal, itemCount: prefixListItems.length + items.length, itemBuilder: (context, index) { @@ -180,7 +183,13 @@ class SelectableChips extends StatelessWidget { ), ); - return (itemWrap != null) ? itemWrap!(child, item) : child; + final wrappedChild = (itemWrap != null) + ? itemWrap!(child, item) + : child; + + return isSelected && activeItemKey != null + ? KeyedSubtree(key: activeItemKey, child: wrappedChild) + : wrappedChild; }, ); },