How to synchronize two or more PageView with different controllers

Viewed 1200

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

3 Answers

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!

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,
            ),
          ),
        ),
      ],
    );
  }
}


I will try to use a simple method, I'm trying to create two different listviews.

1. first, we need two controllers for each of our listview

final PageController controllerA = PageController();
final PageController controllerB = PageController();

2. Next, we create the widget

 Widget build(BuildContext context) {
  return Scaffold(
   body: SafeArea(
    child: Column(
     children: [
      Flexible(
       flex: 2,
       child: PageView.builder(
        controller: controllerA,
        onPageChanged: (value) {
         controllerB.jumpTo(controllerA.offset); // pay attention to this section
        },
        itemBuilder: (_, index) => Container(...),
       ),
      ),
     Flexible(
      flex: 1,
      child: PageView.builder(
       controller: controllerB,
       onPageChanged: (value) {
        controllerA.jumpTo(controllerB.offset); // pay attention to this section
       },
       itemBuilder: (_, index) => Container(...),
      ),
     )
    ],
   ),
  ),
 );
}

A brief description

In the PageView Widget section, we monitor changes to the onPageChange property. When one of the PageView Widgets changes the page, changes are also made to other PageView controllers according to the PageView controller that changes

Related