flutter ScrollView滚动原理一:SingleCh

2022-08-13  本文已影响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是怎么滚动的,做了哪些优化呢?
上一篇下一篇

猜你喜欢

热点阅读