Closed AlienKevin closed 10 months ago
Hello, your code is not complete. Can you provide a minimal complete code sample that can be run directly?
Have you tried following anchor_demo to achieve the effect you want?
Have you tried following anchor_demo to achieve the effect you want?
Thanks for pointing out this demo. I tried out the calcAnchorTabIndex
method but I think my use case is more complicated: I can't use an offset to decide which entry is in focus. The main challenge is that entries have different heights and some will never reach the top of the screen to trigger calcAnchorTabIndex
.
Another problem with using offset is that it doesn't necessarily align with users' intuitions of which entry is in focus. I think the user might think an entry is in focus when most portions of the entry is in the viewport, not necessarily when the entry is at the top of the screen or when it occupies most of the screen. The sample I provide below illustrates the issues of both my current attempt using screen proportions and the offset-based calcAnchorTabIndex
method.
Hello, your code is not complete. Can you provide a minimal complete code sample that can be run directly?
Here's a minimal sample with 3 entries. You can create a new flutter counter project and paste this code into main.dart
.
This example shows the problem of shorter entries not synced correctly with the TabBar
. In this case, entry 2 is much shorter than its neighbors so it's "skipped" in the TabBar
when scrolling down.
You can also search for calcAnchorTabIndex
, comment out the previous targetEntryIndex
calculation, and uncomment the commented portion. As you scroll down the list, you will see that Entry 2 will be at the middle of the screen but it doesn't come into focus until it's scrolled all the way to the top of the screen when you scrolled to the end. You can also notice that Entry 3 is never in focus in the TabBar
.
When the displayPercentage
of an entry is used, all of the issues above are resolved. I can't easily demonstrate this option though because I can't calculate the percentage for a collection of (potentially hidden) list items.
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:scrollview_observer/scrollview_observer.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class Entry {
final String name;
final List<String> defs;
const Entry({required this.name, required this.defs});
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: SafeArea(
child: const EntryWidget(
entryGroup: [
Entry(name: "Entry 1", defs: [
"Entry 1 Definition 1",
"Entry 1 Definition 2",
"Entry 1 Definition 3"
]),
Entry(
name: "Entry 2",
defs: ["Entry 2 Definition 1"]),
Entry(
name: "Entry 3",
defs: ["Entry 3 Definition 1", "Entry 3 Definition 2"]),
],
initialEntryIndex: 0,
initialDefIndex: 0,
),
),
);
}
}
class EntryWidget extends StatefulWidget {
final List<Entry> entryGroup;
final int initialEntryIndex;
final int? initialDefIndex;
const EntryWidget({
Key? key,
required this.entryGroup,
required this.initialEntryIndex,
required this.initialDefIndex,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _EntryWidgetState();
}
class _EntryWidgetState extends State<EntryWidget>
with SingleTickerProviderStateMixin {
int? entryIndex;
late TabController _tabController;
late ScrollController _scrollController;
late ListObserverController _observerController;
bool isScrollingToTarget = false;
late final List<(int, int)> defIndexRanges;
int getStartDefIndex(int entryIndex) => defIndexRanges[entryIndex].$1;
@override
void initState() {
super.initState();
getStartDefIndex(int entryIndex) => widget.entryGroup
.take(entryIndex)
.fold(0, (len, entry) => len + (entry.defs.length + 1));
defIndexRanges = widget.entryGroup
.mapIndexed((entryIndex, entry) => (
getStartDefIndex(entryIndex),
getStartDefIndex(entryIndex) + entry.defs.length + 1
))
.toList();
_tabController = TabController(
length: widget.entryGroup.length,
initialIndex: widget.initialEntryIndex,
vsync: this,
);
_scrollController = ScrollController();
final targetDefIndex = getStartDefIndex(widget.initialEntryIndex) +
(widget.initialDefIndex != null ? widget.initialDefIndex! + 1 : 0);
_observerController = ListObserverController(controller: _scrollController)
..initialIndexModel = ObserverIndexPositionModel(
index: targetDefIndex,
);
WidgetsBinding.instance.addPostFrameCallback((_) async {
setState(() {
isScrollingToTarget = true;
});
await _observerController.animateTo(
index: targetDefIndex,
duration: const Duration(milliseconds: 500),
curve: Curves.easeIn);
setState(() {
isScrollingToTarget = false;
});
// Trigger an observation manually after layout
if (mounted) {
_observerController.dispatchOnceObserve();
}
});
}
@override
Widget build(BuildContext context) {
return Column(children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10),
child: ListViewObserver(
controller: _observerController,
autoTriggerObserveTypes: const [
ObserverAutoTriggerObserveType.scrollUpdate,
],
triggerOnObserveType: ObserverTriggerOnObserveType.directly,
onObserve: (resultModel) {
if (!isScrollingToTarget &&
resultModel.displayingChildModelList.isNotEmpty) {
final targetEntryIndex = groupBy(
resultModel.displayingChildModelList,
(child) => defIndexRanges.indexWhere((entryRange) =>
child.index >= entryRange.$1 &&
child.index < entryRange.$2))
.map((defIndex, children) {
return MapEntry(
defIndex,
children
.map((child) => child.viewportMainAxisExtent)
.reduce((entryHeight, defHeight) =>
entryHeight + defHeight));
})
.entries
.fold(
(tallestEntryIndex: -1, tallestEntryHeight: -1.0),
(tallestEntry, entryHeight) =>
tallestEntry.tallestEntryHeight >= entryHeight.value
? tallestEntry
: (
tallestEntryIndex: entryHeight.key,
tallestEntryHeight: entryHeight.value
))
.tallestEntryIndex;
// Use calcAnchorTabIndex doesn't work for Entry 3 because it never reaches the top of the screen
// final targetEntryIndex = ObserverUtils.calcAnchorTabIndex(
// observeModel: resultModel,
// tabIndexs: defIndexRanges.map((entryRange) => entryRange.$1).toList(),
// currentTabIndex: _tabController.index,
// );
setState(() {
_tabController.animateTo(targetEntryIndex);
});
}
},
child: ListView(
controller: _scrollController,
children: widget.entryGroup.indexed
.map((item) => showDefs(item.$1))
.expand((x) => x)
.toList()),
),
),
),
Wrap(children: [
TabBar(
controller: _tabController,
onTap: (newIndex) async {
if (newIndex != entryIndex) {
setState(() {
entryIndex = newIndex;
isScrollingToTarget = true;
});
final targetDefIndex = getStartDefIndex(newIndex);
await _observerController.animateTo(
index: targetDefIndex,
duration: const Duration(milliseconds: 500),
curve: Curves.easeIn);
setState(() {
isScrollingToTarget = false;
});
}
},
tabs: widget.entryGroup
.asMap()
.entries
.map((entry) => Tab(text: entry.value.name))
.toList(),
),
]),
]);
}
List<Widget> showDefs(entryIndex) {
final entry = widget.entryGroup[entryIndex];
final itemCount = entry.defs.length + 1;
return [
Text(entry.name, style: const TextStyle(fontSize: 30)),
...entry.defs.indexed.map((item) {
final index = item.$1 + 1;
return showDef(entryIndex, index, itemCount);
})
];
}
showDef(int entryIndex, int index, int itemCount) {
final entry = widget.entryGroup[entryIndex];
final content = Padding(
padding: const EdgeInsets.all(10.0),
child: Text(
entry.defs[index - 1],
style: const TextStyle(fontSize: 50),
),
);
return index == itemCount - 1
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [content, const SizedBox(height: 10.0)])
: content;
}
}
The code has two dependencies:
scrollview_observer: ^1.19.0
collection: ^1.18.0
I think your implementation is too complicated. It is recommended to combine dynamicLeadingOffset
and trailingMarginToViewport
to achieve this effect.
ListViewObserveModel? currentResultModel;
ValueNotifier<double> currentLeadingOffset = ValueNotifier(0.0);
child: ListViewObserver(
dynamicLeadingOffset: () {
var resultModel = currentResultModel;
if (resultModel == null ||
resultModel.displayingChildModelList.isEmpty) {
currentLeadingOffset.value = 0;
return 0;
}
final firstChild = resultModel.firstChild;
if (firstChild == null) {
currentLeadingOffset.value = 0;
return 0;
}
final currentOffset = _scrollController.position.pixels;
if (firstChild.index == 0 &&
currentOffset <
firstChild.displayPercentage * firstChild.size.height) {
currentLeadingOffset.value = 0;
return 0;
}
currentLeadingOffset.value =
firstChild.viewportMainAxisExtent * 0.3;
return currentLeadingOffset.value;
},
onObserve: (resultModel) {
currentResultModel = resultModel;
if (!isScrollingToTarget &&
resultModel.displayingChildModelList.isNotEmpty) {
int targetEntryIndex = resultModel.firstChild?.index ?? 0;
final lastChild = resultModel.displayingChildModelList.last;
final lastChildIndex = lastChild.index;
if (lastChildIndex == widget.entryGroup.length - 1 &&
lastChild.trailingMarginToViewport > -30) {
targetEntryIndex = lastChildIndex;
}
_tabController.animateTo(targetEntryIndex);
}
}
Adding red solid line.
@override
Widget build(BuildContext context) {
Widget resultWidget = Column(children: [
...
]);
resultWidget = Stack(
children: [
resultWidget,
ValueListenableBuilder(
valueListenable: currentLeadingOffset,
builder: (context, value, child) {
return Positioned(
left: 0,
right: 0,
top: value,
child: Container(
height: 5,
color: Colors.red,
),
);
},
)
],
);
return resultWidget;
}
child: ListView(
controller: _scrollController,
// children: widget.entryGroup.indexed
// .map((item) => showDefs(item.$1))
// .expand((x) => x)
// .toList()),
children: widget.entryGroup.indexed
.map((item) => showDefsNew(item.$1))
.toList()),
...
Widget showDefsNew(entryIndex) {
final entry = widget.entryGroup[entryIndex];
final itemCount = entry.defs.length + 1;
return Column(
children: [
Text(entry.name, style: const TextStyle(fontSize: 30)),
...entry.defs.indexed.map((item) {
final index = item.$1 + 1;
return showDef(entryIndex, index, itemCount);
})
],
);
}
The red solid line in the video corresponds to dynamicLeadingOffset
Really appreciate that you took the time to write the code. However, I think using the Column
to group the definitions doesn't work when I need to navigate to one specific definition of an entry. That's why I had to flatten all the entries and do the calculation the hard way. I essentially need to have two granularity of navigation: one coarser granularity at the entry level and one finer granularity at the definition level. I can't go with the coarser granularity because there would be no obvious way to navigate to a definition when everything is grouped by entries, so I had to break everything down into definitions.
It would be nice if some form of logical grouping of the items can be achieved. So instead of directly querying displayingChildModelList
, I can get information about a logical group of items as specified by the defIndexRanges
in my example.
Oh, I tried using CustomScrollView
to implement it, but it becomes more complicated, so I still recommend using ListView
.
You can change the above code to flatten all the entries according to your ideas. Can combining dynamicLeadingOffset
and trailingMarginToViewport
meet the effect you want?
In addition, even if I don't filter out items that are not displayed, we can only get data for items that are being displayed and rendered in the cache, unless you set cacheExtent
to double.maxFinite
, but this also means that all items will be rendered all at once, which may cause lag.
In addition, even if I don't filter out items that are not displayed, we can only get data for items that are being displayed and rendered in the cache, unless you set cacheExtent to double.maxFinite, but this also means that all items will be rendered all at once, which may cause lag.
Since I need to navigate to arbitrary definitions when I first show the page, I'm currently rendering all entries at once using the ListView(children: [...])
without a builder. If I understand correctly, the item has to be loaded/rendered before you can scroll to it because otherwise you can't know its scroll position. And since there are a limited number of entries, I believe this is not a primary performance concern for me.
Can combining dynamicLeadingOffset and trailingMarginToViewport meet the effect you want?
I tried to use the dynamicLeadingOffset but the result is unfortunately not quite the effect I want. In the meantime, I can also try to reimplement part of the handleListObserve
in the onObserve
of my ListViewObserver
.
I think I found a way:
ListViewObserver(
controller: _observerController,
autoTriggerObserveTypes: const [
ObserverAutoTriggerObserveType.scrollUpdate,
],
triggerOnObserveType: ObserverTriggerOnObserveType.directly,
onObserve: (resultModel) {
if (!isScrollingToTarget &&
resultModel.displayingChildModelList.isNotEmpty) {
// get all the children, including the hidden ones
final RenderSliverMultiBoxAdaptor list = resultModel.sliverList;
List<
({
RenderIndexedSemantics child,
ListViewObserveDisplayingChildModel? displayingChild
})> items = [];
list.visitChildren((child) {
if (child is! RenderIndexedSemantics) {
return;
}
final displayingChild = resultModel.displayingChildModelList
.firstWhereOrNull((displayingChild) =>
displayingChild.renderObject == child);
items.add((child: child, displayingChild: displayingChild));
});
final targetEntryIndex = groupBy(
items,
(item) => defIndexRanges.indexWhere((entryRange) =>
item.child.index >= entryRange.$1 &&
item.child.index < entryRange.$2))
.map((defIndex, items) {
final entryDisplayingHeight = items
.map((item) => (item.displayingChild == null)
? 0
: item.displayingChild!.displayPercentage *
item.displayingChild!.mainAxisSize)
.reduce((entryHeight, defHeight) =>
entryHeight + defHeight);
final entryTotalHeight = items
.map((item) => item.child.size.height)
.reduce((entryHeight, defHeight) =>
entryHeight + defHeight);
return MapEntry(
defIndex, entryDisplayingHeight / entryTotalHeight);
})
.entries
.fold(
(tallestEntryIndex: -1, tallestEntryHeight: -1.0),
(tallestEntry, entryHeight) =>
tallestEntry.tallestEntryHeight > entryHeight.value
? tallestEntry
: (
tallestEntryIndex: entryHeight.key,
tallestEntryHeight: entryHeight.value
))
.tallestEntryIndex;
// Use calcAnchorTabIndex doesn't work for Entry 3 because it never reaches the top of the screen
// final targetEntryIndex = ObserverUtils.calcAnchorTabIndex(
// observeModel: resultModel,
// tabIndexs: defIndexRanges.map((entryRange) => entryRange.$1).toList(),
// currentTabIndex: _tabController.index,
// );
print("targetEntryIndex: $targetEntryIndex");
setState(() {
_tabController.animateTo(targetEntryIndex);
});
}
},
child: ListView(
controller: _scrollController,
children: widget.entryGroup.indexed
.map((item) => showDefs(item.$1))
.expand((x) => x)
.toList()),
),
Since I need to navigate to arbitrary definitions when I first show the page, I'm currently rendering all entries at once using the
ListView(children: [...])
without a builder. If I understand correctly, the item has to be loaded/rendered before you can scroll to it because otherwise you can't know its scroll position. And since there are a limited number of entries, I believe this is not a primary performance concern for me.
You can set the entryGroup
as follows and pay attention to the items
obtained through list.visitChildren
.
entryGroup: [
Entry(name: "Entry 1", defs: [
"Entry 1 Definition 1",
"Entry 1 Definition 2",
"Entry 1 Definition 3",
"Entry 1 Definition 1",
"Entry 1 Definition 2",
"Entry 1 Definition 3",
"Entry 1 Definition 1",
"Entry 1 Definition 2",
"Entry 1 Definition 3",
"Entry 1 Definition 1",
"Entry 1 Definition 2",
"Entry 1 Definition 3",
"Entry 1 Definition 1",
"Entry 1 Definition 2",
"Entry 1 Definition 3",
"Entry 1 Definition 1",
"Entry 1 Definition 2",
"Entry 1 Definition 3",
]),
Entry(name: "Entry 2", defs: ["Entry 2 Definition 1"]),
Entry(name: "Entry 3", defs: [
"Entry 3 Definition 1",
"Entry 3 Definition 2",
"Entry 3 Definition 1",
"Entry 3 Definition 2",
"Entry 3 Definition 1",
"Entry 3 Definition 2",
"Entry 3 Definition 1",
"Entry 3 Definition 2",
"Entry 3 Definition 1",
"Entry 3 Definition 2",
]),
],
You can set the entryGroup as follows and pay attention to the items obtained through list.visitChildren.
Ohhh, I see that only a small portion of the elements are included in items
. Now I understand the effect of the default cacheExtent
value. When I set it to double.maxFinite
(double.infinity
raises an error), I can see that all the 31 items are included.
I'm curious then how does this library scroll to an item that's outside the cacheExtent
? And when I'm using a ListView.builder
, the item might not even be rendered yet. So in that situation, how does this library locate the item?
When the target item is not rendered, the library will first turn the page until the target item is rendered, so in this case there will be unwanted scrolling which cannot be avoided at present.
I see, that's indeed quite hard to predict the location without turning the page. And I think lazy loading also messes with the scroll animations because you don't know when you will reach the target. The duration and curve of the animations can not be preserved without precise knowledge of the target offset ahead of time. So for now, I'll stick to maxing out the cacheExtent
and loading everything at once.
I think a lot of this complexity originates from the design of global properties like scrolling and routing in Flutter. Both lack declarative APIs and rely on (semi) global states to operate. To be fair, both are tough problems but I think the scrolling API needs a better design, similar to what they are doing with go_router.
And lastly, thanks a lot for all your help and for developing this library. I'd definitely be lost if I'm asked to implement the scrolling logic myself.
Platforms
Android, iOS
Description
Motivation
First, thanks for designing this awesome package! It's a lifesaver for my app š
I'm building a dictionary app where multiple entries are concatenated vertically and the user can scroll through the entries. They can also click on the
TabBar
of entry names at the bottom of the page to animateTo a particular entry quickly.Here's a screenshot of the app, where the orange boxes delimit the entries, the blue boxes delimit the definitions inside each entry, with the
TabBar
at the bottom.The complexity lies in the fact that each entry consists of several definitions and I need to also animateTo a particular definition while keeping the
TabBar
in sync. I used to achieve this by grouping the definitions of an entry into aColumn
and using this package to animateTo eachColumn
. To animateTo a particular definition of an entry, I used the scroll_to_index package andAutoTag
ged each definition. This dual model turned out to be quite complicated and sometimes the scrolling is off.Attempt
To solve the problem, I tried to flatten all the definitions into a large
ListView
. I then used this package exclusively for navigation, which eliminated the scrolling inaccuracies. However, I have a hard time keeping theTabBar
in sync with theListView
. Previously, I was using thedisplayPercentage
of each entry to decide which entry is in focus and update theTabBar
to also focus on that entry's name. Now with the flattenedListView
though, there's no easy way to calculate thisdisplayPercentage
for an entire entry because some definitions might be outside of the viewport. I've thought about over heuristics to decide which entry is in focus but none of them are as versatile asdisplayPercentage
.My Thoughts
I think a property like
allChildModelList
containing the information for all children in theListView
might come in handy for my use case. If I can get the sizes of all children, including the hidden ones, I can calculate thedisplayPercentage
of each entry quite straightforwardly. I looked inside the observer_core.dart and found that it seems to be applying aisDisplayingChildInSliver
filter to the full list of children of the sliver. I'm wondering if this package can expose an "unfiltered" list of children so I can access the sizes of hidden children as well?Lastly, I also thought about two nested layers of
ListView
s. I think it might not be a good idea because the animation will be split unnecessarily into two stages.My code
This is my current code. Parts irrelevant to this discussion are pruned. Since calculating the true
displayPercentage
is none-trivial, my current attempt only finds the entry with a maximum displayed height. This heuristic is prone to many edge cases, like entries which are shorter than both of their neighbors are usually not picked up by theTabBar
because they only occupy a very small portion of the screen at any given scroll position.Try do it
No response