better auto scroll in tab list/grid/tree and quick tab switcher
This commit is contained in:
+122
-44
@@ -22,6 +22,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
@@ -542,54 +543,127 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final chipScrollController = useScrollController();
|
final chipScrollController = useScrollController();
|
||||||
|
final activeItemKey = useRef(GlobalKey());
|
||||||
|
final isUserScrolling = useRef(false);
|
||||||
|
final userScrollTimer = useRef<Timer?>(null);
|
||||||
|
|
||||||
return QuickTabSwitcherView(
|
useEffect(() {
|
||||||
availableItems: availableItems,
|
return userScrollTimer.value?.cancel;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TabMenu(
|
useEffect(() {
|
||||||
selectedTabId: item.id,
|
if (isUserScrolling.value) return null;
|
||||||
enableFindInPage: false,
|
|
||||||
enableFetchFeeds: false,
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
enableDesktopMode: false,
|
final context = activeItemKey.value.currentContext;
|
||||||
enableReaderMode: false,
|
if (context != null) {
|
||||||
enableReloadButton: false,
|
Scrollable.ensureVisible(
|
||||||
enableNavigationButtons: false,
|
context,
|
||||||
enableAddToHomeScreen: false,
|
alignment: 0.5,
|
||||||
enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs,
|
duration: const Duration(milliseconds: 200),
|
||||||
builder: (context, controller, _) {
|
curve: Curves.easeInOut,
|
||||||
return InkWell(
|
);
|
||||||
onLongPress: () {
|
} else if (chipScrollController.hasClients) {
|
||||||
if (controller.isOpen) {
|
final activeIndex = availableItems.indexWhere(
|
||||||
controller.close();
|
(item) => item.id == selectedTabId,
|
||||||
} else {
|
);
|
||||||
controller.open();
|
if (activeIndex < 0) return;
|
||||||
}
|
|
||||||
},
|
final totalItems = availableItems.length;
|
||||||
child: child,
|
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 {
|
||||||
|
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.availableItems,
|
||||||
required this.activeItem,
|
required this.activeItem,
|
||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
|
this.activeItemKey,
|
||||||
required this.showTitles,
|
required this.showTitles,
|
||||||
required this.showIsolatedTabUi,
|
required this.showIsolatedTabUi,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
@@ -609,6 +684,7 @@ class QuickTabSwitcherView extends StatelessWidget {
|
|||||||
final List<QuickTabSwitcherItem> availableItems;
|
final List<QuickTabSwitcherItem> availableItems;
|
||||||
final QuickTabSwitcherItem? activeItem;
|
final QuickTabSwitcherItem? activeItem;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
final GlobalKey? activeItemKey;
|
||||||
final bool showTitles;
|
final bool showTitles;
|
||||||
final bool showIsolatedTabUi;
|
final bool showIsolatedTabUi;
|
||||||
final Future<void> Function(QuickTabSwitcherItem item) onSelected;
|
final Future<void> Function(QuickTabSwitcherItem item) onSelected;
|
||||||
@@ -634,6 +710,8 @@ class QuickTabSwitcherView extends StatelessWidget {
|
|||||||
sortSelectedFirst: false,
|
sortSelectedFirst: false,
|
||||||
maxCount: null,
|
maxCount: null,
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
|
activeItemKey: activeItemKey,
|
||||||
|
cacheExtent: 500,
|
||||||
itemId: (item) => item.id,
|
itemId: (item) => item.id,
|
||||||
selectedItem: activeItem,
|
selectedItem: activeItem,
|
||||||
selectedBorderColor: Theme.of(context).colorScheme.primary,
|
selectedBorderColor: Theme.of(context).colorScheme.primary,
|
||||||
|
|||||||
+16
-27
@@ -286,51 +286,40 @@ class _TabGridView extends HookConsumerWidget {
|
|||||||
[screenWidth, crossAxisCount],
|
[screenWidth, crossAxisCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
final didInitialScroll = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (didInitialScroll.value) return null;
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (scrollController.hasClients && activeTab != null) {
|
if (scrollController.hasClients && activeTab != null) {
|
||||||
final index = primaryRows.indexWhere((row) => row.tabId == activeTab);
|
final index = primaryRows.indexWhere((row) => row.tabId == activeTab);
|
||||||
|
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
final row = index ~/ crossAxisCount;
|
final row = index ~/ crossAxisCount;
|
||||||
final viewportStart = scrollController.offset;
|
|
||||||
final viewportEnd =
|
|
||||||
viewportStart + scrollController.position.viewportDimension;
|
|
||||||
final tabStart = row * itemSize.height;
|
final tabStart = row * itemSize.height;
|
||||||
final tabEnd = tabStart + itemSize.height;
|
final viewportDimension =
|
||||||
|
scrollController.position.viewportDimension;
|
||||||
|
|
||||||
final isVisible =
|
final targetOffset =
|
||||||
tabStart >= viewportStart && tabEnd <= viewportEnd;
|
(tabStart - viewportDimension / 2 + itemSize.height / 2).clamp(
|
||||||
|
0.0,
|
||||||
if (!isVisible) {
|
scrollController.position.maxScrollExtent,
|
||||||
if (disableAnimations) {
|
|
||||||
scrollController.jumpTo(tabStart);
|
|
||||||
} else {
|
|
||||||
unawaited(
|
|
||||||
scrollController.animateTo(
|
|
||||||
tabStart,
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
didInitialScroll.value = true;
|
if (disableAnimations) {
|
||||||
|
scrollController.jumpTo(targetOffset);
|
||||||
} else {
|
} else {
|
||||||
didInitialScroll.value = true;
|
unawaited(
|
||||||
|
scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
didInitialScroll.value = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [activeTab]);
|
}, [activeTab, primaryRows.length]);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
|||||||
+16
-32
@@ -325,55 +325,39 @@ class _TabListView extends HookConsumerWidget {
|
|||||||
|
|
||||||
final activeTab = ref.watch(selectedTabProvider);
|
final activeTab = ref.watch(selectedTabProvider);
|
||||||
|
|
||||||
final didInitialScroll = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (didInitialScroll.value) return null;
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (scrollController.hasClients && activeTab != null) {
|
if (scrollController.hasClients && activeTab != null) {
|
||||||
final index = primaryRows.indexWhere((row) => row.tabId == activeTab);
|
final index = primaryRows.indexWhere((row) => row.tabId == activeTab);
|
||||||
|
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
final viewportStart = scrollController.offset;
|
final viewportDimension =
|
||||||
final viewportEnd =
|
scrollController.position.viewportDimension;
|
||||||
viewportStart + scrollController.position.viewportDimension;
|
|
||||||
final tabStart = index * _itemHeight;
|
final tabStart = index * _itemHeight;
|
||||||
final tabEnd = tabStart + _itemHeight;
|
|
||||||
|
|
||||||
final isVisible =
|
final targetOffset =
|
||||||
tabStart >= viewportStart && tabEnd <= viewportEnd;
|
(tabStart - viewportDimension / 2 + _itemHeight / 2).clamp(
|
||||||
|
0.0,
|
||||||
if (!isVisible) {
|
scrollController.position.maxScrollExtent,
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
didInitialScroll.value = true;
|
if (disableAnimations) {
|
||||||
|
scrollController.jumpTo(targetOffset);
|
||||||
} else {
|
} else {
|
||||||
didInitialScroll.value = true;
|
unawaited(
|
||||||
|
scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
didInitialScroll.value = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [activeTab]);
|
}, [activeTab, primaryRows.length]);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
|||||||
+38
-20
@@ -243,27 +243,45 @@ class ViewTabTreesWidget extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
final index = filteredTabEntities.value.indexWhere(
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
(entity) => entity.tabId == activeTab,
|
if (!scrollController.hasClients || activeTab == null) {
|
||||||
);
|
return;
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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;
|
return null;
|
||||||
}, [filteredTabEntities, activeTab]);
|
}, [filteredTabEntities, activeTab]);
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
|
|||||||
final bool sortSelectedFirst;
|
final bool sortSelectedFirst;
|
||||||
|
|
||||||
final ScrollController? scrollController;
|
final ScrollController? scrollController;
|
||||||
|
final GlobalKey? activeItemKey;
|
||||||
|
final double? cacheExtent;
|
||||||
|
|
||||||
final K Function(S item) itemId;
|
final K Function(S item) itemId;
|
||||||
final Widget Function(T item) itemLabel;
|
final Widget Function(T item) itemLabel;
|
||||||
@@ -98,6 +100,8 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
|
|||||||
this.onLongPress,
|
this.onLongPress,
|
||||||
this.sortSelectedFirst = true,
|
this.sortSelectedFirst = true,
|
||||||
this.scrollController,
|
this.scrollController,
|
||||||
|
this.activeItemKey,
|
||||||
|
this.cacheExtent = 0,
|
||||||
this.labelPadding,
|
this.labelPadding,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
@@ -127,8 +131,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
|
|||||||
builder: (context, controller) {
|
builder: (context, controller) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
//Improve list performance by not rendering outside screen at all
|
cacheExtent: cacheExtent,
|
||||||
cacheExtent: 0,
|
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: prefixListItems.length + items.length,
|
itemCount: prefixListItems.length + items.length,
|
||||||
itemBuilder: (context, index) {
|
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;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user