关于flutter NestedScrollView导致其body的tabbarview的多个list同步滚动的解决方案

讲道理我起的好长的名字啊,不过文如上题,搜索到这里的兄弟应该都知道我说的是啥情况,正好
~~
我这个方案可能有点笨拙TT,不过自测有效,有其它想法的老哥希望可以帮忙指点一下~
下面进入正题

在这之前我们先了解一下,一个ScrollView是怎么滚动的

点进源码里面看,可以发现他直接继承了StatelessWidget,那我们就直接看看build方法

@override
  Widget build(BuildContext context) {
    final List<Widget> slivers = buildSlivers(context);
    final AxisDirection axisDirection = getDirection(context);

    final ScrollController scrollController = primary
      ? PrimaryScrollController.of(context)
      : controller;
    final Scrollable scrollable = Scrollable(
      dragStartBehavior: dragStartBehavior,
      axisDirection: axisDirection,
      controller: scrollController,
      physics: physics,
      semanticChildCount: semanticChildCount,
      viewportBuilder: (BuildContext context, ViewportOffset offset) {
        return buildViewport(context, offset, axisDirection, slivers);
      },
    );
    return primary && scrollController != null
      ? PrimaryScrollController.none(child: scrollable)
      : scrollable;
  }

可以看到,这里直接返回一个scrollable或者一个子节点是scrollable的InheritedWidget
scrollable是一个StatefulWidget,那我们就看看它的state
首先scrollable持有一个scrollposition对象,是通过其scrollcontroller构建的

_position = controller?.createScrollPosition(_physics, this, oldPosition)
//scrollcontroller
ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition oldPosition,
  ) {
    return ScrollPositionWithSingleContext(
      physics: physics,
      context: context,//这里可以看到,scrollposition也是持有其scrollable的
      initialPixels: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }

在其state的setCanDrag方法中,对其拖动设置了一系列的监听

void _handleDragDown(DragDownDetails details) {
    assert(_drag == null);
    assert(_hold == null);
    _hold = position.hold(_disposeHold);
  }

  void _handleDragStart(DragStartDetails details) {
    // It's possible for _hold to become null between _handleDragDown and
    // _handleDragStart, for example if some user code calls jumpTo or otherwise
    // triggers a new activity to begin.
    assert(_drag == null);
    _drag = position.drag(details, _disposeDrag);
    assert(_drag != null);
    assert(_hold == null);
  }

  void _handleDragUpdate(DragUpdateDetails details) {
    // _drag might be null if the drag activity ended and called _disposeDrag.
    assert(_hold == null || _drag == null);
    _drag?.update(details);
  }

  void _handleDragEnd(DragEndDetails details) {
    // _drag might be null if the drag activity ended and called _disposeDrag.
    assert(_hold == null || _drag == null);
    _drag?.end(details);
    assert(_drag == null);
  }

  void _handleDragCancel() {
    // _hold might be null if the drag started.
    // _drag might be null if the drag activity ended and called _disposeDrag.
    assert(_hold == null || _drag == null);
    _hold?.cancel();
    _drag?.cancel();
    assert(_hold == null);
    assert(_drag == null);
  }

这里就可以看出来,当拖动触发时,就会通过当前scrollable的position生成一个Drag/Hold对象,并调用相应的方法 这个position有几个子类,我们先随便看一个实现

@override
  Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
    final ScrollDragController drag = ScrollDragController(
      delegate: this,
      details: details,
      onDragCanceled: dragCancelCallback,
      carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
      motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold,
    );
    beginActivity(DragScrollActivity(this, drag));
    assert(_currentDrag == null);
    _currentDrag = drag;
    return drag;
  }

可以看到生成了一个ScrollDragController对象,当手势拖动而调用这个对象的update方法时

@override
  void update(DragUpdateDetails details) {
    ···省略
    delegate.applyUserOffset(offset);
  }

可以看到直接调用其委托对象的applyUserOffset方法进行偏移,而这个委托对象根据刚才的drag方法可以得知正是我们scrollable中的position
最后,由position通知其scrollcontext,也就是之前的scrollable进行滑动

@override
  void applyUserOffset(double delta) {
    updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse);
    setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
  }
double setPixels(double newPixels) {
    assert(_pixels != null);
    assert(SchedulerBinding.instance.schedulerPhase.index <= SchedulerPhase.transientCallbacks.index);
    if (newPixels != pixels) {
      ···
      final double oldPixels = _pixels;
      _pixels = newPixels - overscroll;
      if (_pixels != oldPixels) {
        notifyListeners();
        didUpdateScrollPositionBy(_pixels - oldPixels);
      }
      if (overscroll != 0.0) {
        didOverscrollBy(overscroll);
        return overscroll;
      }
    }
    return 0.0;
  }
void didUpdateScrollPositionBy(double delta) {
    activity.dispatchScrollUpdateNotification(copyWith(), context.notificationContext, delta);
  }

具体的滑动流程这里就不细说了,我们只是要知道这个事件是怎么传递的就好了,有兴趣的老哥可以自行分析

然后我们要知道NestedScrollView是怎么进行滚动传递的,以及为啥会出现同步滑动的情况

NestedScrollView是一个statefulwidget,那我们就先看看它的build方法

@override
  Widget build(BuildContext context) {
    return _InheritedNestedScrollView(
      state: this,
      child: Builder(
        builder: (BuildContext context) {
          _lastHasScrolledBody = _coordinator.hasScrolledBody;
          return _NestedScrollViewCustomScrollView(
            dragStartBehavior: widget.dragStartBehavior,
            scrollDirection: widget.scrollDirection,
            reverse: widget.reverse,
            physics: widget.physics != null
                ? widget.physics.applyTo(const ClampingScrollPhysics())
                : const ClampingScrollPhysics(),
            controller: _coordinator._outerController,
            slivers: widget._buildSlivers(
              context,
              _coordinator._innerController,
              _lastHasScrolledBody,
            ),
            handle: _absorberHandle,
          );
        },
      ),
    );
  }
List<Widget> _buildSlivers(BuildContext context, ScrollController innerController, bool bodyIsScrolled) {
    final List<Widget> slivers = <Widget>[];
    slivers.addAll(headerSliverBuilder(context, bodyIsScrolled));
    slivers.add(SliverFillRemaining(
      child: PrimaryScrollController(
        controller: innerController,
        child: body,
      ),
    ));
    return slivers;
  }

先忽略其他奇奇怪怪的方法,我们发现在我们body的外面,包裹了一层PrimaryScrollController,同时它还持有innerController,这个innerController暂时先不管它是啥
还记不记得在最开始ScrollView的build方法中,生成Scrollable的时候,我们已经见过这个PrimaryScrollController了,再回顾一下

final ScrollController scrollController = primary
      ? PrimaryScrollController.of(context)
      : controller;

再看看PrimaryScrollController.of(context)

static ScrollController of(BuildContext context) {
    final PrimaryScrollController result = context.inheritFromWidgetOfExactType(PrimaryScrollController);
    return result?.controller;
  }

可以看到,在生成scrollable的时候,在primary = true的情况下是会向上查找的,看看有没有PrimaryScrollController,如果有的话,scrollable使用的controller实际就是nestedscrollview中的innerController了
而之前看过了,scrollable中的position就是scrollcontroller来生成的,那么在这种情况下:

  _innerController = _NestedScrollController(this, initialScrollOffset: 0.0, debugLabel: 'inner');
//_NestedScrollController
@override
  ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition oldPosition,
  ) {
    return _NestedScrollPosition(
      coordinator: coordinator,
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }

实际上是生成了_NestedScrollPosition并返回给了body中的scrollable
构造方法中有一个参数coordinator 暂时先不管
好了,下面我们在回头看刚才NestedScrollView的build方法,实际上是生成了一个_NestedScrollViewCustomScrollView,继承自大名鼎鼎的CustomScrollView,它当然也是scrollview啦,而我们传给它的controller也是一个_NestedScrollController,不过叫做_outerController,和body中的不是同一个罢了,那么自然这个父scrollview的position也是_NestedScrollPosition。
下面我们按照之前的逻辑,当拖动开始时,就会调用position.drag方法

  @override
  Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
    return coordinator.drag(details, dragCancelCallback);
  }

可以看到,实际上吧方法交给了我们之前多次见到的coordinator来完成,那我们就简单看一下吧

Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
    final ScrollDragController drag = ScrollDragController(
      delegate: this,//
      details: details,
      onDragCanceled: dragCancelCallback,
    );
    beginActivity(
      DragScrollActivity(_outerPosition, drag),
      (_NestedScrollPosition position) => DragScrollActivity(position, drag),
    );
    assert(_currentDrag == null);
    _currentDrag = drag;
    return drag;
  }

这里可以看到,他把返回的ScrollDragController的委托者设成了自己
那么自然在拖动的时候,调用的就是coordinator的applyUseroffset方法了 我们分析一下

@override
  void applyUserOffset(double delta) {
    updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse);
    assert(delta != 0.0);
    if (_innerPositions.isEmpty) {//
      _outerPosition.applyFullDragUpdate(delta);
    } else if (delta < 0.0) {
      final double innerDelta = _outerPosition.applyClampedDragUpdate(delta);
      if (innerDelta != 0.0) {
        for (_NestedScrollPosition position in _innerPositions)
          position.applyFullDragUpdate(innerDelta);
      }
    } else {
      // dragging "down" - delta is positive
      // prioritize the inner views, so that the inner content will move before the app bar grows
      double outerDelta = 0.0; // it will go positive if it changes
      final List<double> overscrolls = <double>[];
      final List<_NestedScrollPosition> innerPositions = _innerPositions.toList();
      for (_NestedScrollPosition position in innerPositions) {
        final double overscroll = position.applyClampedDragUpdate(delta);
        outerDelta = math.max(outerDelta, overscroll);
        overscrolls.add(overscroll);
      }
      if (outerDelta != 0.0)
        outerDelta -= _outerPosition.applyClampedDragUpdate(outerDelta);
      // now deal with any overscroll
      for (int i = 0; i < innerPositions.length; ++i) {
        final double remainingDelta = overscrolls[i] - outerDelta;
        if (remainingDelta > 0.0)
          innerPositions[i].applyFullDragUpdate(remainingDelta);
      }
    }
  }

可以看到,在需要子列表滚动时,是对innerPositions中的所有position调用滑动方法的
而这innerPositions中的position是怎么来的呢?跟踪一下可以发现是在调用NestedScrollController的attach时添加进来的,如下

  /// Register the given position with this controller.
  ///
  /// After this function returns, the [animateTo] and [jumpTo] methods on this
  /// controller will manipulate the given position.
  void attach(ScrollPosition position) {
    assert(!_positions.contains(position));
    _positions.add(position);
    position.addListener(notifyListeners);
  }

  /// Unregister the given position with this controller.
  ///
  /// After this function returns, the [animateTo] and [jumpTo] methods on this
  /// controller will not manipulate the given position.
  void detach(ScrollPosition position) {
    assert(_positions.contains(position));
    position.removeListener(notifyListeners);
    _positions.remove(position);
  }

因为之前我们看到过,子scrollable中的controller就是这个NestedScrollController,所以在updateopsition时会把旧的detach调,把新生成的position attach进来

 void _updatePosition() {
    _configuration = ScrollConfiguration.of(context);
    _physics = _configuration.getScrollPhysics(context);
    if (widget.physics != null)
      _physics = widget.physics.applyTo(_physics);
    final ScrollController controller = widget.controller;
    final ScrollPosition oldPosition = position;
    if (oldPosition != null) {
      controller?.detach(oldPosition);//这就是最外层的NestedScrollController
      // It's important that we not dispose the old position until after the
      // viewport has had a chance to unregister its listeners from the old
      // position. So, schedule a microtask to do it.
      scheduleMicrotask(oldPosition.dispose);
    }

    _position = controller?.createScrollPosition(_physics, this, oldPosition)
      ?? ScrollPositionWithSingleContext(physics: _physics, context: this, oldPosition: oldPosition);
    assert(position != null);
    controller?.attach(position);
  }

另外,在dispose中也会detach

@override
  void dispose() {
    widget.controller?.detach(position);
    position.dispose();
    super.dispose();
  }

由此我们就知道啦,因为开启了缓存后就不会调用划出屏幕的页面的dispose,自然所有子scrollable的position都存在nestedScrollController里面了,当发生滑动时,遍历调用positions数组,就导致屏幕外的列表也跟着滑动了~

最后呼应一下主题,谈一谈我的解决办法

既然开启了缓存,手动dispose肯定是没啥意义的,实际上我们只要在页面切换过后把未显示的position 给detach掉就好了。
然鹅,因为flutter不支持反射,子布局传递的position我们拿不到,nestedScrollController我们也不能直接拿到=。=
不过有一个对象我们之前见到过,scrollable就是通过他获取controller的,而position则是传给了获取到的controller 就是PrimaryScrollController了,所以我打算在中间第三者插足,对传递Position的PrimaryScrollController进行Hook

import 'package:flutter/cupertino.dart';

class PrimaryScrollContainer extends StatefulWidget {
  final Widget child;

  PrimaryScrollContainer(
    GlobalKey<PrimaryScrollContainerState> key,
    this.child,
  ) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return PrimaryScrollContainerState();
  }
}

class PrimaryScrollContainerState extends State<PrimaryScrollContainer> {
  ScrollControllerWrapper _scrollController;

  get scrollController {
    final PrimaryScrollController primaryScrollController =
        context.inheritFromWidgetOfExactType(PrimaryScrollController);
    if (primaryScrollController != null)
      _scrollController.inner = primaryScrollController.controller;
    return _scrollController;
  }

  @override
  void initState() {
    print('initstate');
    _scrollController = ScrollControllerWrapper();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return PrimaryScrollControllerWrapper(
      child: widget.child,
      scrollController: scrollController,
    );
  }

  void onPageChange(bool show) {
    _scrollController.onAttachChange(show);
  }
}

class PrimaryScrollControllerWrapper extends InheritedWidget
    implements PrimaryScrollController {
  final ScrollController scrollController;

  const PrimaryScrollControllerWrapper({
    Key key,
    @required Widget child,
    @required this.scrollController,
  }) : super(key: key, child: child);

  get runtimeType => PrimaryScrollController;

  get controller => scrollController;

  @override
  bool updateShouldNotify(PrimaryScrollControllerWrapper oldWidget) =>
      controller != oldWidget.controller;
}

//代理
class ScrollControllerWrapper implements ScrollController {
  static int a = 1;

  ScrollController inner;

  int code = a++;

  ScrollPosition interceptedAttachPosition; //拦截的position
  ScrollPosition lastPosition;

  bool showing = true;

  @override
  void addListener(listener) => inner.addListener(listener);

  @override
  Future<void> animateTo(double offset, {Duration duration, Curve curve}) =>
      inner.animateTo(offset, duration: duration, curve: curve);

  @override
  void attach(ScrollPosition position) {
    print('{$code}:attach start {$showing}');
    if(position == interceptedAttachPosition)
      print("attach by inner");
    position.hasListeners;
    print('{$code}:attach end {$showing}');
    if (inner.positions.contains(position)) return;
    if (showing) {
      inner.attach(position);
      lastPosition = position;
    } else {
      interceptedAttachPosition = position;
    }
  }

  @override
  void detach(ScrollPosition position, {bool fake = false}) {
    assert((){
      print('{$code}:detach start {$showing}');
      return true;
    }.call());
    if(fake)
      print("detach is innner");
    if (inner.positions.contains(position)) {
      inner.detach(position);
    }
    if (position == interceptedAttachPosition && !fake) {
      print('{$code}:set null {$showing}');
      interceptedAttachPosition = null;
    }
    if (position == lastPosition && !fake) {
      print('{$code}:set null {$showing}');
      lastPosition = null;
    }
    if (fake) {
      interceptedAttachPosition = position;
    }
    assert((){
      print('{$code}:detach end {$showing}');
      return true;
    }.call());
  }

  void onAttachChange(bool b) {
    print('{$code}:change{$b}');
    showing = b;
    if (!showing) {
      if(lastPosition!=null)
        detach(lastPosition, fake: true);
    } else {
      if (interceptedAttachPosition != null)
        attach(interceptedAttachPosition);
    }
  }

  @override
  ScrollPosition createScrollPosition(ScrollPhysics physics,
          ScrollContext context, ScrollPosition oldPosition) =>
      inner.createScrollPosition(physics, context, oldPosition);

  @override
  void debugFillDescription(List<String> description) =>
      inner.debugFillDescription(description);

  @override
  String get debugLabel => inner.debugLabel;

  @override
  void dispose() => inner.dispose();

  @override
  bool get hasClients => inner.hasClients;

  @override
  bool get hasListeners => inner.hasListeners;

  @override
  double get initialScrollOffset => inner.initialScrollOffset;

  @override
  void jumpTo(double value) => inner.jumpTo(value);

  @override
  bool get keepScrollOffset => inner.keepScrollOffset;

  @override
  void notifyListeners() => inner.notifyListeners();

  @override
  double get offset => inner.offset;

  @override
  ScrollPosition get position => inner.position;

  @override
  Iterable<ScrollPosition> get positions => inner.positions;

  @override
  void removeListener(listener) => inner.removeListener(listener);

  @override
  int get hashCode => inner.hashCode;

  @override
  bool operator ==(other) {
    return hashCode == (other.hashCode);
  }
}

在使用的时候把子列表添加进去,并设置对应的GlobalKey。

body: TabBarView(
                children: <Widget>[
                  PrimaryScrollContainer(
                    scrollChildKeys[0],//GlobalKey
                    NewArticle(),//内容列表
                  ),
                  PrimaryScrollContainer(
                    scrollChildKeys[1],
                    NewProject(),
                  ),
                  PrimaryScrollContainer(
                    scrollChildKeys[2],
                    TagSystemPage(),
                  ),
                ],
                controller: _tabController,
              ),

然后监听Tab切换

_tabController = TabController(length: 3, vsync: this);
    _tabController.addListener(() {
      for (int i = 0; i < scrollChildKeys.length; i++) {
        GlobalKey<PrimaryScrollContainerState> key = scrollChildKeys[i];
        if (key.currentState != null) {
          key.currentState.onPageChange(_tabController.index == i);//控制是否当前显示
        }
      }
    });

以上是我的方案,有问题的话还希望老哥帮忙指正,也希望有其他思路的老哥指点一下~~
上一下Github项目地址 用Flutter写的WanAndroid 其中用到了这个方案

https://github.com/Doooyao/wanandroid-flutter

摸摸大

= =
<3

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容