better auto scroll in tab list/grid/tree and quick tab switcher

This commit is contained in:
Fabian Freund
2026-05-04 17:08:20 +02:00
parent 63dcc8c8fa
commit 787c7b237d
5 changed files with 204 additions and 126 deletions
@@ -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,11 +543,79 @@ class QuickTabSwitcher extends HookConsumerWidget {
);
final chipScrollController = useScrollController();
final activeItemKey = useRef(GlobalKey());
final isUserScrolling = useRef(false);
final userScrollTimer = useRef<Timer?>(null);
return QuickTabSwitcherView(
useEffect(() {
return userScrollTimer.value?.cancel;
}, []);
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<UserScrollNotification>(
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 {
@@ -556,7 +625,11 @@ class QuickTabSwitcher extends HookConsumerWidget {
if (item.isHistory) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: item.url, tabMode: TabMode.regular, selectTab: true);
.addTab(
url: item.url,
tabMode: TabMode.regular,
selectTab: true,
);
} else {
await ref.read(tabRepositoryProvider.notifier).selectTab(item.id);
}
@@ -590,6 +663,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
},
);
},
),
);
}
}
@@ -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<QuickTabSwitcherItem> availableItems;
final QuickTabSwitcherItem? activeItem;
final ScrollController scrollController;
final GlobalKey? activeItemKey;
final bool showTitles;
final bool showIsolatedTabUi;
final Future<void> 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,
@@ -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;
final targetOffset =
(tabStart - viewportDimension / 2 + itemSize.height / 2).clamp(
0.0,
scrollController.position.maxScrollExtent,
);
if (!isVisible) {
if (disableAnimations) {
scrollController.jumpTo(tabStart);
scrollController.jumpTo(targetOffset);
} else {
unawaited(
scrollController.animateTo(
tabStart,
targetOffset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
didInitialScroll.value = true;
} else {
didInitialScroll.value = true;
}
} else {
didInitialScroll.value = true;
}
}
});
return null;
}, [activeTab]);
}, [activeTab, primaryRows.length]);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
@@ -325,27 +325,18 @@ 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(
final targetOffset =
(tabStart - viewportDimension / 2 + _itemHeight / 2).clamp(
0.0,
scrollController.position.maxScrollExtent,
);
@@ -361,19 +352,12 @@ class _TabListView extends HookConsumerWidget {
),
);
}
didInitialScroll.value = true;
} else {
didInitialScroll.value = true;
}
} else {
didInitialScroll.value = true;
}
}
});
return null;
}, [activeTab]);
}, [activeTab, primaryRows.length]);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
@@ -243,27 +243,45 @@ class ViewTabTreesWidget extends HookConsumerWidget {
);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!scrollController.hasClients || activeTab == null) {
return;
}
final index = filteredTabEntities.value.indexWhere(
(entity) => entity.tabId == activeTab,
);
if (index > -1) {
final offset = (index ~/ 2) * itemSize.height;
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 (offset != scrollController.offset) {
if (disableAnimations) {
scrollController.jumpTo(offset);
scrollController.jumpTo(targetOffset);
} else {
unawaited(
scrollController.animateTo(
offset,
targetOffset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
}
}
});
return null;
}, [filteredTabEntities, activeTab]);
@@ -63,6 +63,8 @@ class SelectableChips<T extends S, S, K> 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<T extends S, S, K> extends StatelessWidget {
this.onLongPress,
this.sortSelectedFirst = true,
this.scrollController,
this.activeItemKey,
this.cacheExtent = 0,
this.labelPadding,
super.key,
});
@@ -127,8 +131,7 @@ class SelectableChips<T extends S, S, K> 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<T extends S, S, K> 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;
},
);
},