美文网首页
flutter ScrollView滚动原理一:SingleCh

flutter ScrollView滚动原理一:SingleCh

作者: 某非著名程序员 | 来源:发表于2022-08-13 13:44 被阅读0次

    ScrollView滚动原理一:SingleChildScrollView滚动原理

    image.png

    ScrollView可以分为以下4步:

    1. SingleChildScrollView供外部使用的入口widget。
    2. Scrollable有position和监听手势两大块:当出现手指按下屏幕并拖拽时,更新position。
    3. position继承自ChangeNotifier。
    4. _RenderSingleChildViewport中监听position,当position随拖动手势发生改变时,_RenderSingleChildViewport监听到变化会paint重绘界面达到刷新效果。

    前面三步是flutter中滚动刷新经常使用到的方式,例如ListView、GridView等。不同点在于viewportBuilder中返回的widget。

    下面从ScrollView的4步介绍下SingleChildScrollView滚动原理。

    SingleChildScrollView手势与position

    1. SingleChildScrollView#build

    SingleChildScrollView继承自StatelessWidget,先看build方法:

    Widget build(BuildContext context) {
      //获取方向:正常默认垂直向下滚动
      final AxisDirection axisDirection = _getDirection(context);
      
      Widget? contents = child;
      //处理padding
      if (padding != null)
        contents = Padding(padding: padding!, child: contents);
        
      //获取controller,可用于监听滚动时的offset
      final ScrollController? scrollController = primary
        ? PrimaryScrollController.of(context)
        : controller;
      //创建Scrollable,position和手势拖拽监听都在其中
      Widget scrollable = Scrollable(
        dragStartBehavior: dragStartBehavior,
        axisDirection: axisDirection,
        controller: scrollController,
        physics: physics,
        restorationId: restorationId,
        viewportBuilder: (BuildContext context, ViewportOffset offset) {
          //SingleChildRenderObjectWidget,其中_RenderSingleChildViewport是真正的绘制类,会监听offset也就是position的变化,进行重绘
          return _SingleChildViewport(
            axisDirection: axisDirection,
            offset: offset,
            clipBehavior: clipBehavior,
            child: contents,
          );
        },
      );
    
      ...
    
      return primary && scrollController != null
        ? PrimaryScrollController.none(child: scrollable)
        : scrollable;
    }
    

    结合上面我们可以看下Scrollable是怎么处理手势且_SingleChildViewport是怎么计算布局且刷新界面的。

    2. ScrollableState#build

    @override
    Widget build(BuildContext context) {
      //_ScrollableScope继承自InheritedWidget,当position发生变化时_ScrollableScope.of(context)能刷新数据
      //TODO:作用未探究,有兴趣可以自己研究下,不影响分析
      Widget result = _ScrollableScope(
        scrollable: this,
        position: position,
        // TODO(ianh): Having all these global keys is sad.
        child: Listener(
          onPointerSignal: _receivedPointerSignal,
          child: RawGestureDetector(
            key: _gestureDetectorKey,
            gestures: _gestureRecognizers,//手势监听
            behavior: HitTestBehavior.opaque,
            excludeFromSemantics: widget.excludeFromSemantics,
            child: Semantics(
              explicitChildNodes: !widget.excludeFromSemantics,
              child: IgnorePointer(//不参与手势竞争,不响应手势
                key: _ignorePointerKey,
                ignoring: _shouldIgnorePointer,
                ignoringSemantics: false,
                child: widget.viewportBuilder(context, position),//调用外部方法
              ),
            ),
          ),
        ),
      );
      ...
    }
    

    这里两个重点:

    1. _gestureRecognizers手势监听,滑动时不断更新position
    2. 调用widget.viewportBuilder(context, position)

    手势怎么监听,viewportBuilder会发生什么?可以顺着这两点往下看。

    position

    先介绍position,手势和布局都是围绕着position进行的。

    1. ScrollableState#position

    下面看下position是什么?

    ScrollPosition get position => _position!;
    ScrollPosition? _position;
    
    @override
    void didChangeDependencies() {
      _updatePosition();
      super.didChangeDependencies();
    }
    
    void _updatePosition() {
      _configuration = widget.scrollBehavior ?? ScrollConfiguration.of(context);
      _physics = _configuration.getScrollPhysics(context);
      if (widget.physics != null) {
        _physics = widget.physics!.applyTo(_physics);
      } else if (widget.scrollBehavior != null) {
        _physics = widget.scrollBehavior!.getScrollPhysics(context).applyTo(_physics);
      }
      final ScrollPosition? oldPosition = _position;
      if (oldPosition != null) {
        _effectiveScrollController.detach(oldPosition);
        scheduleMicrotask(oldPosition.dispose);
      }
    
      _position = _effectiveScrollController.createScrollPosition(_physics!, this, oldPosition);//初始化_position
      _effectiveScrollController.attach(position);
    }
    

    _position初始化依赖_effectiveScrollController,而_effectiveScrollController是什么?

    ScrollableState#initState

    ScrollController get _effectiveScrollController => widget.controller ?? _fallbackScrollController!;
    
    @override
    void initState() {
      if (widget.controller == null)
        _fallbackScrollController = ScrollController();
      super.initState();
    }
    

    SingleChildScrollView初始化

    final ScrollController? controller;
    
    const SingleChildScrollView({
      Key? key,
      this.scrollDirection = Axis.vertical,
      this.reverse = false,
      this.padding,
      bool? primary,
      this.physics,
      this.controller,
      this.child,
      this.dragStartBehavior = DragStartBehavior.start,
      this.clipBehavior = Clip.hardEdge,
      this.restorationId,
      this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
    })
    

    _effectiveScrollController是SingleChildScrollView初始化的时候传入的;如果没有传入在initState也会构建一个ScrollController。用于监听滚动时位置变化,_effectiveScrollController就是ScrollController。

    _position是ScrollController中的createScrollPosition

    2. ScrollController#createScrollPosition

    ScrollPosition createScrollPosition(
      ScrollPhysics physics,
      ScrollContext context,
      ScrollPosition? oldPosition,
    ) {
      return ScrollPositionWithSingleContext(
        physics: physics,
        context: context,
        initialPixels: initialScrollOffset,
        keepScrollOffset: keepScrollOffset,
        oldPosition: oldPosition,
        debugLabel: debugLabel,
      );
    }
    

    _position的对象是ScrollPositionWithSingleContext。_position管理着手势,发生变化时通知监听者作用。

    关于ScrollPositionWithSingleContext,可以看下面UML图:


    image.png

    ScrollPositionWithSingleContext其父类ChangeNotifier是个通知类,可以通知观察者进行刷新;也管理拖拽手势ScrollDragController。

    手势处理

    1. RawGestureDetector手势

    检测给定手势的控件,对于普通的手势,通常使用GestureRecognizerRawGestureDetector主要用于开发自定义的手势。

    @override
    @protected
    void setCanDrag(bool canDrag) {
      if (canDrag == _lastCanDrag && (!canDrag || widget.axis == _lastAxisDirection))
        return;
      if (!canDrag) {
        _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
        _handleDragCancel();
      } else {
        switch (widget.axis) {
          case Axis.vertical:
            _gestureRecognizers = <Type, GestureRecognizerFactory>{
              VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
                () => VerticalDragGestureRecognizer(supportedDevices: _configuration.dragDevices),
                (VerticalDragGestureRecognizer instance) {
                  instance
                    ..onDown = _handleDragDown
                    ..onStart = _handleDragStart
                    ..onUpdate = _handleDragUpdate
                    ..onEnd = _handleDragEnd
                    ..onCancel = _handleDragCancel
                    ..minFlingDistance = _physics?.minFlingDistance
                    ..minFlingVelocity = _physics?.minFlingVelocity
                    ..maxFlingVelocity = _physics?.maxFlingVelocity
                    ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
                    ..dragStartBehavior = widget.dragStartBehavior;
                },
              ),
            };
            break;
          ...
        }
      }
      _lastCanDrag = canDrag;
      _lastAxisDirection = widget.axis;
      if (_gestureDetectorKey.currentState != null)
        _gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
    }
    
    void _handleDragDown(DragDownDetails details) {
      _hold = position.hold(_disposeHold);
    }
    
    void _handleDragStart(DragStartDetails details) {
      _drag = position.drag(details, _disposeDrag);
    }
    
    void _handleDragUpdate(DragUpdateDetails details) {
      _drag?.update(details);
    }
    
    void _handleDragEnd(DragEndDetails details) {
      _drag?.end(details);
    }
    

    _gestureRecognizers处理手势,在setCanDrag初始化手势方法,主要处理手势开始、按下、更新、结束及取消等。
    滑动过程中_handleDragUpdate会被持续调用,position发生改变

    思考:setCanDrag作用是什么,setCanDrag又在哪里调用?


    image.png

    这个问题在文章后面可以找到答案。

    2. ScrollPositionWithSingleContext#drag

    @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;
    }
    
    1. 在_handleDragStart调用的是position.drag(details, _disposeDrag),_drag是ScrollDragController,注意这里的delegate是this。
    2. 在_handleDragUpdate时发生了什么,是如何修改position值的呢?

    3. ScrollDragController#update

    @override
    void update(DragUpdateDetails details) {
      assert(details.primaryDelta != null);
      _lastDetails = details;
      double offset = details.primaryDelta!;
      if (offset != 0.0) {
        _lastNonStationaryTimestamp = details.sourceTimeStamp;
      }
      _maybeLoseMomentum(offset, details.sourceTimeStamp);
      offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
      if (offset == 0.0) {
        return;
      }
      if (_reversed) // e.g. an AxisDirection.up scrollable
        offset = -offset;
      delegate.applyUserOffset(offset);
    }
    

    计算出offset,通过delegate调用applyUserOffset更新offset,而delegate正是初始化时传递的ScrollPositionWithSingleContext

    4.ScrollPositionWithSingleContext#applyUserOffset

    @override
    void applyUserOffset(double delta) {
      updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse);
      setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
    }
    
    @override
    double setPixels(double newPixels) {
      assert(activity!.isScrolling);
      return super.setPixels(newPixels);
    }
    

    5. ScrollPosition#setPixels

    double setPixels(double newPixels) {
      if (newPixels != pixels) {
        final double overscroll = applyBoundaryConditions(newPixels);
        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;
    }
    

    setPixels修改_pixels值,并发出通知。

    渲染

    上面Scrollable中会监听手势,最终会修改_pixels值,并发出通知。下面看看是如何渲染的:

    SingleChildScrollView#build中的viewportBuilder返回_SingleChildViewport

    image.png

    1. _SingleChildViewport

    class _SingleChildViewport extends SingleChildRenderObjectWidget {
      const _SingleChildViewport({
        Key? key,
        this.axisDirection = AxisDirection.down,
        required this.offset,
        Widget? child,
        required this.clipBehavior,
      }) : assert(axisDirection != null),
           assert(clipBehavior != null),
           super(key: key, child: child);
    
      final AxisDirection axisDirection;
      final ViewportOffset offset;
      final Clip clipBehavior;
    
      @override
      _RenderSingleChildViewport createRenderObject(BuildContext context) {
        return _RenderSingleChildViewport(
          axisDirection: axisDirection,
          offset: offset,
          clipBehavior: clipBehavior,
        );
      }
    
      @override
      void updateRenderObject(BuildContext context, _RenderSingleChildViewport renderObject) {
        // Order dependency: The offset setter reads the axis direction.
        renderObject
          ..axisDirection = axisDirection
          ..offset = offset
          ..clipBehavior = clipBehavior;
      }
    }
    
    1. _SingleChildViewport继承自SingleChildRenderObjectWidget,对应的renderObject为_RenderSingleChildViewport
    2. offset来自ScrollableState的position, createRenderObject时会创建_RenderSingleChildViewport并把offset传入, updateRenderObject也会更新offset。

    1.1 _RenderSingleChildViewport#offset

    ViewportOffset get offset => _offset;
    ViewportOffset _offset;
    set offset(ViewportOffset value) {
      assert(value != null);
      if (value == _offset)
        return;
      if (attached)
        _offset.removeListener(_hasScrolled);
      _offset = value;
      if (attached)
        _offset.addListener(_hasScrolled);
      markNeedsLayout();
    }
    
    @override
    void attach(PipelineOwner owner) {
      super.attach(owner);
      _offset.addListener(_hasScrolled);
    }
    
    @override
    void detach() {
      _offset.removeListener(_hasScrolled);
      super.detach();
    }
    
    void _hasScrolled() {
      markNeedsPaint();
      markNeedsSemanticsUpdate();
    }
    

    在renderObject被attach时_offset添加观察者;被detach时会移除监听者。在set offset也会进行观察者处理。监听到变化后调用markNeedsPaint,给当前renderObject打上标记。

    2 _RenderSingleChildViewport#performLayout

    performLayout主要负责计算:计算size、_viewportExtent、_minScrollExtent、_maxScrollExtent等属性值。

    @override
    void performLayout() {
      final BoxConstraints constraints = this.constraints;
      if (child == null) {
        size = constraints.smallest;
      } else {
        child!.layout(_getInnerConstraints(constraints), parentUsesSize: true);
        size = constraints.constrain(child!.size);
      }
    
      offset.applyViewportDimension(_viewportExtent);
      offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent);
    }
    
    //获取滚动方向的高度
    double get _viewportExtent {
      assert(hasSize);
      switch (axis) {
        case Axis.horizontal:
          return size.width;
        case Axis.vertical:
          return size.height;
      }
    }
    
    1. child为空时,size为(0,0);当child不为空时通过child!.layout(constraints,true)计算child size
    2. 设置applyViewportDimension与applyContentDimensions,而这两个方法起了什么作用呢?

    从上文介绍知道offset是_position,而_position是ScrollPositionWithSingleContext类。

    2.1 ScrollPosition#applyViewportDimension

    applyViewportDimension是在ScrollPositionWithSingleContext父类ScrollPosition中实现的:

    @override
    bool applyViewportDimension(double viewportDimension) {
      if (_viewportDimension != viewportDimension) {
        _viewportDimension = viewportDimension;
        _didChangeViewportDimensionOrReceiveCorrection = true;
      }
      return true;
    }
    

    _didChangeViewportDimensionOrReceiveCorrection变量在applyContentDimensions使用

    2.2 ScrollPosition#applyViewportDimension

    @override
    bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
      if (!nearEqual(_minScrollExtent, minScrollExtent, Tolerance.defaultTolerance.distance) ||
          !nearEqual(_maxScrollExtent, maxScrollExtent, Tolerance.defaultTolerance.distance) ||
          _didChangeViewportDimensionOrReceiveCorrection ||
          _lastAxis != axis) {
        _minScrollExtent = minScrollExtent;
        _maxScrollExtent = maxScrollExtent;
        _lastAxis = axis;
        final ScrollMetrics? currentMetrics = haveDimensions ? copyWith() : null;
        _didChangeViewportDimensionOrReceiveCorrection = false;
        _pendingDimensions = true;
        if (haveDimensions && !correctForNewDimensions(_lastMetrics!, currentMetrics!)) {
          return false;
        }
        _haveDimensions = true;
      }
    
      if (_pendingDimensions) {
        applyNewDimensions();
        _pendingDimensions = false;
      }
    
      if (_isMetricsChanged()) {
        if (_lastMetrics != null && !_haveScheduledUpdateNotification) {
          scheduleMicrotask(didUpdateScrollMetrics);
          _haveScheduledUpdateNotification = true;
        }
        _lastMetrics = copyWith();
      }
      return true;
    }
    
    1. _didChangeViewportDimensionOrReceiveCorrection为true,会修正_minScrollExtent与_maxScrollExtent等变量。
    2. applyNewDimensions会调到ScrollPositionWithSingleContext中的方法

    2.3 ScrollPositionWithSingleContext#applyNewDimensions

    @override
    void applyNewDimensions() {
      super.applyNewDimensions();
      context.setCanDrag(physics.shouldAcceptUserOffset(this));
    }
    
    1. applyNewDimensions可以看出setCanDrag是什么时候调用的?来源于哪里。
    2. performLayout之后就会调用setCanDrag,初始化手势监听的方法。

    3 _RenderSingleChildViewport#paint

    @override
    void paint(PaintingContext context, Offset offset) {
      if (child != null) {
        final Offset paintOffset = _paintOffset;
    
        void paintContents(PaintingContext context, Offset offset) {
          context.paintChild(child!, offset + paintOffset);
        }
    
        if (_shouldClipAtPaintOffset(paintOffset) && clipBehavior != Clip.none) {
          _clipRectLayer.layer = context.pushClipRect(
            needsCompositing,
            offset,
            Offset.zero & size,
            paintContents,
            clipBehavior: clipBehavior,
            oldLayer: _clipRectLayer.layer,
          );
        } else {
          _clipRectLayer.layer = null;
          paintContents(context, offset);
        }
      }
    }
    

    paint方法负责绘制,看看其中的细节部分。

    3.1 _RenderSingleChildViewport#_paintOffset

    Offset get _paintOffset => _paintOffsetForPosition(offset.pixels);
    
    Offset _paintOffsetForPosition(double position) {
      assert(axisDirection != null);
      switch (axisDirection) {
        case AxisDirection.up:
          return Offset(0.0, position - child!.size.height + size.height);
        case AxisDirection.down:
          return Offset(0.0, -position);
        case AxisDirection.left:
          return Offset(position - child!.size.width + size.width, 0.0);
        case AxisDirection.right:
          return Offset(-position, 0.0);
      }
    }
    

    _paintOffsetForPosition变化主要是offset.pixels的变化,而手势处理最后修改的是ScrollPosition#setPixels。

    3.2 _RenderSingleChildViewport#_shouldClipAtPaintOffset

    bool _shouldClipAtPaintOffset(Offset paintOffset) {
      assert(child != null);
      return paintOffset.dx < 0 ||
        paintOffset.dy < 0 ||
        paintOffset.dx + child!.size.width > size.width ||
        paintOffset.dy + child!.size.height > size.height;
    }
    

    这里判断边界,如果paintOffset超出了本身的size,是需要做裁剪绘制的。

    小结

    1. ScrollableState中会监听处理手势,把对应的position通过widget.viewportBuilder(context, position)传递到SingleChildScrollView中的build方法中,并创建_SingleChildViewport。
    2. 在_SingleChildViewport中创建对应的_RenderSingleChildViewport,更新
    3. _RenderSingleChildViewport中先计算layout,监听到offset变化后进行绘制paint。整个渲染过程完成。

    思考

    1.SingleChildScrollView会有性能问题吗?
    通过_RenderSingleChildViewport的perforLayout可以看出,SingleScrollView会把child的size计算出来,也就是说child有多大,就会计算多大的size。在绘制的时候超出部分将会被裁剪。
    2. 为什么paint参数只传递了offset?没有size或者rect,PaintContext又是怎么绘制的。
    3. SingleChildScrollView滚动原理只是基础,但万变不离其宗,ListView是怎么滚动的,做了哪些优化呢?

    相关文章

      网友评论

          本文标题:flutter ScrollView滚动原理一:SingleCh

          本文链接:https://www.haomeiwen.com/subject/mjyfgrtx.html