Search code examples
flutterflutter-dependenciesflutter-animation

Synchronizing ListWheelScrollView in Flutter


When I scroll through one List Wheel Scroll View, the other list either lags or does not scroll smoothly.
https://pub.dev/packages/linked_scroll_controller allows to sync lists but does not support FixedExtendScrollPhysics.

Output : -

https://media.giphy.com/media/tpUIY3KgavxZoOVmvP/giphy.gif

https://pub.dev/packages/linked_scroll_controller works perfectly if we are using ScrollPhysics but throws an error when used with a widget that uses FixedExtendScrollPhysics. I want both the list to move Synchronizing that is if I move green list I want red list to move simultaneously and vice versa

Code :

import 'dart:math';
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'List',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      debugShowCheckedModeBanner: false,
      home: const List(),
    );
  }
}

class List extends StatefulWidget {
  const List({Key? key}) : super(key: key);
  @override
  _ListState createState() => _ListState();
}

class _ListState extends State<List> {
  final scrollController = FixedExtentScrollController();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text("List"),
          backgroundColor: Colors.green,
        ),
        body: Row(
          children: [
            SizedBox(
              height: 600,
              width: 300,
              child: ListWheelScrollView(
                  itemExtent: 100,
                  physics: const FixedExtentScrollPhysics(),
                  onSelectedItemChanged: (value) {
                    setState(() {
                      scrollController.animateToItem(value,
                          duration: const Duration(milliseconds: 200),
                          curve: Curves.easeInOut);
                    });
                  },
                  children: [
                    for (int i = 0; i < 5; i++) ...[
                      Container(
                        color: Colors.green,
                        height: 50,
                        width: 50,
                      )
                    ]
                  ]),
            ),
            SizedBox(
              height: 600,
              width: 300,
              child: ListWheelScrollView(
                  controller: scrollController,
                  physics: const FixedExtentScrollPhysics(),
                  itemExtent: 100,
                  children: [
                    for (int i = 0; i < 5; i++) ...[
                      Container(
                        color: Colors.red,
                        height: 50,
                        width: 50,
                      )
                    ]
                  ]),
            )
          ],
        ));
  }
}

Solution

  • Really interesting question. The problem was syncing both the scrollviews. I made few changes to your code to achieve the desired result.

    The basic idea is to remove listener to the other scroll before forcing pixels. After the scroll, add the same listener. But because it happens instantaneously and actual scroll happens sometimes in future, they don't overlap perfectly.

    So I had to introduce CancelableCompleter from the async library to make sure add operation does not happen if another scroll event had happened.

    With forcePixels, the scrolling to other wheel is not deferred hence CancelableCompleter is not required.

    // ignore_for_file: invalid_use_of_protected_member
    
    import 'package:flutter/material.dart';
    
    void main() => runApp(const MyApp());
    
    class MyApp extends StatelessWidget {
      const MyApp({Key? key}) : super(key: key);
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'List',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          debugShowCheckedModeBanner: false,
          home: const List(),
        );
      }
    }
    
    class List extends StatefulWidget {
      const List({Key? key}) : super(key: key);
      @override
      _ListState createState() => _ListState();
    }
    
    class _ListState extends State<List> {
      final _firstScrollController = FixedExtentScrollController();
      final _secondScrollController = FixedExtentScrollController();
    
      @override
      void initState() {
        super.initState();
        _firstScrollController.addListener(_firstScrollListener);
        _secondScrollController.addListener(_secondScrollListener);
      }
    
      @override
      void dispose() {
        _firstScrollController
          ..removeListener(_firstScrollListener)
          ..dispose();
        _secondScrollController
          ..removeListener(_secondScrollListener)
          ..dispose();
        super.dispose();
      }
    
      void _firstScrollListener() {
        _secondScrollController.removeListener(_secondScrollListener);
        _secondScrollController.position.forcePixels(_firstScrollController.offset);
        _secondScrollController.addListener(_secondScrollListener);
      }
    
      void _secondScrollListener() {
        _firstScrollController.removeListener(_firstScrollListener);
        _firstScrollController.position.forcePixels(_secondScrollController.offset);
        _firstScrollController.addListener(_firstScrollListener);
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
              title: const Text("List"),
              backgroundColor: Colors.green,
            ),
            body: Row(
              children: [
                SizedBox(
                  height: 600,
                  width: 300,
                  child: ListWheelScrollView(
                      itemExtent: 100,
                      controller: _firstScrollController,
                      physics: const FixedExtentScrollPhysics(),
                      onSelectedItemChanged: (value) {
                        print('first wheel : item selected: $value');
                      },
                      children: [
                        for (int i = 0; i < 25; i++) ...[
                          Container(
                            color: Colors.green,
                            height: 50,
                            width: 50,
                          )
                        ]
                      ]),
                ),
                SizedBox(
                  height: 600,
                  width: 300,
                  child: ListWheelScrollView(
                      controller: _secondScrollController,
                      physics: const FixedExtentScrollPhysics(),
                      itemExtent: 100,
                      onSelectedItemChanged: (value) {
                        print('second wheel : item selected: $value');
                      },
                      children: [
                        for (int i = 0; i < 25; i++) ...[
                          Container(
                            color: Colors.red,
                            height: 50,
                            width: 50,
                          )
                        ]
                      ]),
                )
              ],
            ));
      }
    }
    
    

    I am using protective member function forcePixels as Flutter has not provided any way to set pixels without animation without creating subclass of ScrollPosition. If you are fine with this linter warning, it is all good. If not, we will have to extend ListWheelScrollView to use ScrollPosition where we could make changes as per need.