Home > Software engineering >  How to manage multiple ScrollView widgets using one useScrollController() hook?
How to manage multiple ScrollView widgets using one useScrollController() hook?

Time:02-03

Flutter documentation for ScrollController has this paragraph:

Scroll controllers are typically stored as member variables in State objects and are reused in each State.build. A single scroll controller can be used to control multiple scrollable widgets, but some operations, such as reading the scroll offset, require the controller to be used with a single scrollable widget.

Does this mean that we cannot pass the same ScrollController to different ScrollView widgets to read ScrollController.offset?

What I'm trying to accomplish is this:

  1. There are two screens. Each screen has a ListView.builder() widget.
  2. Through parameters I pass from screen 1 to screen 2 an object ScrollController and apply it to ListView.
  3. I use scrolling and the offset value changes, but as soon as I move/return to another screen, the offset is knocked down to 0.0 and I see the beginning of the list.
  4. The same ScrollController object is used all the time (hashcode is the same)

How can we use one ScrollController object for different ScrollView widgets, so that the offset is not knocked down when moving from screen to screen?

This problem can be solved a bit if, when switching to another screen, we create a new ScrollController object with initialScrollOffset = oldScrollController.offset and pass it to ScrollView.

Update: I don't seem to understand how to use flutter_hooks. I created a simple example showing that if we use separate widgets and specify ScrollController as a parameter, the scroll is reset to position 0.0.

Reference for an example: https://dartpad.dev/?id=d31f4714ce95869716c18b911fee80c1

How do we overcome this?

CodePudding user response:

For now, the best solution I can offer is to pass final ValueNotifier<double> offsetState; instead of final ScrollController controller; as a widget parameter.

Then, in each widget we create a ScrollController. By listening to it via the useListenableSelector hook we change the offsetState.

To avoid unnecessary rebuilding, we use the useValueNotifier hook.

A complete example looks like this:

void main() => runApp(
      const MaterialApp(
        debugShowCheckedModeBanner: false,
        home: MyApp(),
      ),
    );

class MyApp extends HookWidget {
  const MyApp();

  @override
  Widget build(BuildContext context) {
    print('#build $MyApp');

    final isPrimaries = useState(true);

    final offsetState = useValueNotifier(0.0);

    return Scaffold(
      appBar: AppBar(
        title: Text(isPrimaries.value
            ? 'Colors.primaries List'
            : 'Colors.accents List'),
        actions: [
          IconButton(
            onPressed: () => isPrimaries.value = !isPrimaries.value,
            icon: const Icon(Icons.ac_unit_sharp),
          )
        ],
      ),
      body: isPrimaries.value
          ? ListPrimaries(offsetState: offsetState)
          : ListAccents(offsetState: offsetState),
    );
  }
}

class ListAccents extends HookConsumerWidget {
  const ListAccents({
    Key? key,
    required this.offsetState,
  }) : super(key: key);

  final ValueNotifier<double> offsetState;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('#build $ListAccents');

    final controller =
        useScrollController(initialScrollOffset: offsetState.value);

    useListenableSelector(controller, () {
      print(controller.positions);
      if (controller.hasClients) {
        offsetState.value = controller.offset;
      }
      return null;
    });

    return ListView(
      primary: false,
      controller: controller,
      children: Colors.accents
          .map((color) => Container(color: color, height: 100))
          .toList(),
    );
  }
}

class ListPrimaries extends HookConsumerWidget {
  const ListPrimaries({
    Key? key,
    required this.offsetState,
  }) : super(key: key);

  final ValueNotifier<double> offsetState;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('#build $ListPrimaries');

    final controller =
        useScrollController(initialScrollOffset: offsetState.value);

    useListenableSelector(controller, () {
      print(controller.positions);
      if (controller.hasClients) {
        offsetState.value = controller.offset;
      }
      return null;
    });

    return ListView(
      primary: false,
      controller: controller,
      children: Colors.primaries
          .map((color) => Container(color: color, height: 100))
          .toList(),
    );
  }
}

Another idea was to use useEffect hook and give it a function to save the last value at the moment of dispose():

useEffect(() {
  return () {
    offsetState.value = controller.offset;
  };
}, const []);

But the problem is that at this point, we no longer have clients.


Bonus:

If our task is to synchronize the scroll of the ListView, another useListenableSelector hook added to each of the widgets solves this problem. Remind that we cannot use the same `ScrollController' for two or more lists at the same time.

useListenableSelector(offsetState, () {
  if (controller.hasClients) {
    // if the contents of the ListView are of different lengths, then do nothing
    if (controller.position.maxScrollExtent < offsetState.value) {
      return;
    }
    controller.jumpTo(offsetState.value);
  }
});
  • Related