fluttercandies / flutter_scrollview_observer

A widget for observing data related to the child widgets being displayed in a ScrollView. Maintainer: @LinXunFeng
https://pub.dev/packages/scrollview_observer
MIT License
438 stars 47 forks source link

[How to use] Get sizes of children outside viewport #69

Closed AlienKevin closed 10 months ago

AlienKevin commented 10 months ago

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.

screenshot

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 a Column and using this package to animateTo each Column. To animateTo a particular definition of an entry, I used the scroll_to_index package and AutoTagged 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 the TabBar in sync with the ListView. Previously, I was using the displayPercentage of each entry to decide which entry is in focus and update the TabBar to also focus on that entry's name. Now with the flattened ListView though, there's no easy way to calculate this displayPercentage 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 as displayPercentage.

My Thoughts

I think a property like allChildModelList containing the information for all children in the ListView might come in handy for my use case. If I can get the sizes of all children, including the hidden ones, I can calculate the displayPercentage of each entry quite straightforwardly. I looked inside the observer_core.dart and found that it seems to be applying a isDisplayingChildInSliver 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 ListViews. 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 the TabBar because they only occupy a very small portion of the screen at any given scroll position.

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) {
    double rubyFontSize = Theme.of(context).textTheme.headlineSmall!.fontSize!;
    TextStyle lineTextStyle = Theme.of(context).textTheme.bodyMedium!;
    final localizationContext = AppLocalizations.of(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;
                setState(() {
                  _tabController.animateTo(targetEntryIndex);
                });
              }
            },
            child: ListView(
                controller: _scrollController,
                children: widget.entryGroup.indexed
                    .map((item) => showDefs(entryIndex: item.$1))
                    .expand((x) => x)
                    .toList()),
          ),
        ),
      ),
      Row(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.name))
                  .toList(),
            ),
          ])]);
  }

  List<Widget> showDefs(entryIndex) {
    final entry = widget.entryGroup[entryIndex];
    final itemCount = entry.defs.length + 1;
    return [
      Text("Entry Title"),
      ...entry.defs.indexed.map((item) {
        final index = item.$1 + 1;
        return showDef(entryIndex, index, itemCount, lineTextStyle, linkColor,
            rubyFontSize);
      })
    ];
  }
}

Try do it

No response

LinXunFeng commented 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?

AlienKevin commented 10 months ago

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
LinXunFeng commented 10 months ago

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

https://github.com/fluttercandies/flutter_scrollview_observer/assets/19367531/eeb29aad-4dea-4959-bc01-13a5556f451c

AlienKevin commented 10 months ago

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.

LinXunFeng commented 10 months ago

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.

AlienKevin commented 10 months ago

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.

AlienKevin commented 10 months ago

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()),
          ),
LinXunFeng commented 10 months ago

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",
  ]),
],
AlienKevin commented 10 months ago

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?

LinXunFeng commented 10 months ago

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.

AlienKevin commented 10 months ago

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.