How to synchronize two or more PageView with different controllers

963

Solution 1

I find a hacky solution inspired by @yusufpats's solution. I describe more detail below:

Add Listener to each PageController

This part is a little tricky because when the controller listen to each other, the page will actually stuck and unscrollable (Why?). I add bool to check which one is scrolling and need to be listen.

bool _isPage1Scrolling;
bool _isPage2Scrolling;

void initState() {
  _isPage1Scrolling = false;
  _isPage2Scrolling = false;

  ...
  _controller1.addListener(() {
    if(_isPage1Scrolling){
      // Control _controller2
    }
  }

  _controller2.addListener(() {
    if(_isPage1Scrolling){
      // Control _controller2
    }
  }

Control other PageController by how?

This is the most hard part because if I use animateTo or jumpTo, the "Controlled controller" looks very strange and not looks fluently. It is by design when user call these 2 functions, the page will always turn to "BallisticScrollActivity" after reach the position for a very short period (bounce back to stable page position). I found some solutions from ScrollPosition inside the controller but seems only the last one can do well and no warning in result:

  1. jumpToWithoutSettling(value): Deprecated and may cause bug (which I am not understand yet)
  2. forcePixels(value): Protected function (Why I can still use it?)
  3. correctPixels(value): This will shift pixel without notifying. So I have to notify listener by my self.

I use offset as the first page's shift and calculate the other page's shift by viewportFraction

// Control _controller2

// _controller2.position.jumpToWithoutSettling(... 
// _controller2.position.forcePixels(...

_controller2.position.correctPixels(_controller1.offset * _controller2.viewportFraction / _controller1.viewportFraction);
_controller2.position.notifyListeners();
 

Finally listen to PageView itself

I use NotificationListener but not GestureDetector because the onTapDown & onTapUp are not fit-able for scrolling notification. Sometime there is no onTapDown event when I touch and scroll very fast.

I research the notification type and find something inside:

  1. It give a UserScrollNotification with direction when I drag it from the begining
  2. It give another UserScrollNotification with direction is idle when page become stable

There may be a better way to detect it. I just use a simple way I can think of.

...
child: NotificationListener(
  onNotification: (notification){
    if(notification is UserScrollNotification){
      if(notification.direction != ScrollDirection.idle){
        (_controller2.position as ScrollPositionWithSingleContext).goIdle();
        _isPage1Scrolling = true;
        _isPage2Scrolling = false;
      }
      else{
        _isPage1Scrolling = false;
      }
    }
    return false;
  },
  child: PageView.builder(
    ...

Maybe someone notice the following line:

(_controller2.position as ScrollPositionWithSingleContext).goIdle();

This line is for a Edge case that If I drag the firs PageView and then drag anther PageView before the first one return to a stable position. The first PageView's scroll position is still in a BallisticScrollActivity state and I need to force Idle it before I can control it.

Any suggestion is welcome!

Solution 2

So, you need to attach a listener on one of the PageView widget's PageController (_controller1), and then change the offset of the other PageView widget's PageController (_controller2).

You need to add this piece of code in the initState after initialising the controllers:

_controller1.addListener(() {
   _controller2.jumpTo(_controller1.offset);
});

Updated answer (with page selected synchronisation):

_controller1.addListener(() {
   _controller2.animateToPage(
     _controller1.page.toInt(),
     duration: Duration(milliseconds: 500),
     curve: Curves.ease,
   );
});

Updated answer (with detecting manual scroll):

import 'package:flutter/material.dart';

final Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: MyPageControllers(),
        ),
      ),
    );
  }
}

class MyPageControllers extends StatefulWidget {
  @override
  _MyPageControllersState createState() => _MyPageControllersState();
}

class _MyPageControllersState extends State<MyPageControllers> {
  PageController _controller1;
  PageController _controller2;
  int manualController = -1;

  Widget _itemBuilder(BuildContext context, int index) => Container(
        color: Colors.primaries[index % Colors.primaries.length],
        child: Center(
          child: Text(
            index.toString(),
            style: TextStyle(color: Colors.white, fontSize: 60),
          ),
        ),
      );

  @override
  void initState() {
    super.initState();
    _controller1 = PageController(viewportFraction: 0.8);
    _controller2 = PageController(viewportFraction: 0.5);
    _controller1.addListener(() {
      if (manualController == 1) {
        _controller2.jumpTo(_controller1.offset);
      }
    });
    _controller2.addListener(() {
      if (manualController == 2) {
        _controller1.jumpTo(_controller2.offset);
      }
    });

//     _controller1.addListener(() {
//       _controller2.animateToPage(
//         _controller1.page.toInt(),
//         duration: Duration(milliseconds: 500),
//         curve: Curves.ease,
//       );
//     });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: GestureDetector(
            onTapDown: (tapDownDetails){
              manualController = 1;
              setState(() {});
            },
            onTapUp: (tapUpDetails){
              manualController = -1;
              setState(() {});
            },
            child: PageView.builder(
              controller: _controller1,
              itemBuilder: _itemBuilder,
            ),
          ),
        ),
        SizedBox(
          height: 40,
        ),
        Expanded(
          child: GestureDetector(
            onTapDown: (tapDownDetails){
              manualController = 2;
              setState(() {});
            },
            onTapUp: (tapUpDetails){
              manualController = -1;
              setState(() {});
            },
            child: PageView.builder(
              controller: _controller2,
              itemBuilder: _itemBuilder,
            ),
          ),
        ),
      ],
    );
  }
}


Share:
963
yellowgray
Author by

yellowgray

flutter lover

Updated on December 07, 2022

Comments

  • yellowgray
    yellowgray over 1 year

    I have 2 PageViews with different viewportFraction. Is there any way to scroll one of the PageViews and the other one is scrolled on the same page or offset?

    Also ask, is it possible to control the PageView to middle of the offset in code?

    class MyPageControllers extends StatefulWidget {
      @override
      _MyPageControllersState createState() => _MyPageControllersState();
    }
    
    class _MyPageControllersState extends State<MyPageControllers> {
    
      PageController _controller1;
      PageController _controller2;
    
      Widget _itemBuilder(BuildContext context, int index) => Container(
        color: Colors.primaries[index % Colors.primaries.length],
        child: Center(
          child: Text(
            index.toString(),
            style: TextStyle(color: Colors.white, fontSize: 60),
          ),
        ),
      );
    
      @override
      void initState() {
        super.initState();
        _controller1 = PageController(viewportFraction: 0.8);
        _controller2 = PageController(viewportFraction: 0.5);
      }
    
      @override
      Widget build(BuildContext context) {
        return Column(
          children: [
            Expanded(
              child: PageView.builder(
                controller: _controller1,
                itemBuilder: _itemBuilder,
              ),
            ),
            SizedBox(
              height: 40,
            ),
            Expanded(
              child: PageView.builder(
                controller: _controller2,
                itemBuilder: _itemBuilder,
              ),
            ),
          ],
        );
      }
    }
    

    enter image description here

  • yellowgray
    yellowgray over 3 years
    Thanks for the response. I already tried this. The controllers will actually stuck if they listen to each other at the same time. The offset from these 2 page are also not the same and the page will not result in the same page while scrolling.
  • yusufpats
    yusufpats over 3 years
    If you want the pages to be synchronised, you will have to attach a listener to the _controller1 to listen to page change event, then set the new page con _controller2
  • yellowgray
    yellowgray over 3 years
    Do you mean add same kind of listener to _controller2 as the same code you write on _controller1 ?
  • yusufpats
    yusufpats over 3 years
    Set the selected page of _controller2 based on current page of _controller1. See the updated answer above.
  • yellowgray
    yellowgray over 3 years
    Hi, your answer only control _controller2 with _controller1. I want to control them with both side. Also the animation of animateToPage makes it looks asynchronous.
  • yusufpats
    yusufpats over 3 years
    The first answer, if you need both interdependent: You will have to attach the listener on _controller2 as well and set value to _controller1, like shown in the answer. Second, Yes it will make it look out of synchrony, as the animateToPage gets called after the new page has been set on _controller1. If you need synchronising, you will need to use the offset method mentioned above, and adjust the _controller2 offset by interpolating the scroll based on widths of the items in the 2 PageViews.
  • yellowgray
    yellowgray over 3 years
    Thanks for your explanation. For the second part I will try to find a solution with offset. The problem is still on the first part. If I add _controller1.addListener((){_controller2 ... and _controller2.addListener((){_controller1 ... at the same time, the scrolling will actually stuck (Maybe it recursively infinitely listen to each other?).
  • yusufpats
    yusufpats over 3 years
    Thats sounds like an interesting problem, I have opened a new question for this here: stackoverflow.com/questions/64457895/… The way it worked for me is by wrapping the 2 pageviews in GestureDetector and keping a a track of which one is a manual scroll based on tapUp and tapDown. See the updated answer above.