2

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
  • 4,006
  • 6
  • 28

3 Answers3

5

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!

yellowgray
  • 4,006
  • 6
  • 28
3

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


yusufpats
  • 778
  • 4
  • 12
  • 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. – yellowgray Oct 20 '20 at 12:55
  • 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` – yusufpats Oct 20 '20 at 13:07
  • Do you mean add same kind of listener to `_controller2` as the same code you write on `_controller1` ? – yellowgray Oct 20 '20 at 15:37
  • Set the selected page of `_controller2` based on current page of `_controller1`. See the updated answer above. – yusufpats Oct 21 '20 at 03:59
  • 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. – yellowgray Oct 21 '20 at 05:43
  • 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. – yusufpats Oct 21 '20 at 05:50
  • 1
    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?). – yellowgray Oct 21 '20 at 06:00
  • Thats sounds like an interesting problem, I have opened a new question for this here: https://stackoverflow.com/questions/64457895/detect-if-scrollable-widget-is-scrolled-manually-or-programatically 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. – yusufpats Oct 21 '20 at 06:54
0

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