flutter ScrollView滚动原理一:SingleCh
ScrollView滚动原理一:SingleChildScrollView滚动原理
image.pngScrollView可以分为以下4步:
- SingleChildScrollView供外部使用的入口widget。
- Scrollable有position和监听手势两大块:当出现手指按下屏幕并拖拽时,更新position。
- position继承自ChangeNotifier。
- _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),//调用外部方法
),
),
),
),
);
...
}
这里两个重点:
- _gestureRecognizers手势监听,滑动时不断更新position
- 调用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手势
检测给定手势的控件,对于普通的手势,通常使用GestureRecognizer,RawGestureDetector主要用于开发自定义的手势。
@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;
}
- 在_handleDragStart调用的是position.drag(details, _disposeDrag),_drag是ScrollDragController,注意这里的delegate是this。
- 在_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
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;
}
}
- _SingleChildViewport继承自SingleChildRenderObjectWidget,对应的renderObject为_RenderSingleChildViewport
- 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;
}
}
- child为空时,size为(0,0);当child不为空时通过child!.layout(constraints,true)计算child size
- 设置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;
}
- _didChangeViewportDimensionOrReceiveCorrection为true,会修正_minScrollExtent与_maxScrollExtent等变量。
- applyNewDimensions会调到ScrollPositionWithSingleContext中的方法
2.3 ScrollPositionWithSingleContext#applyNewDimensions
@override
void applyNewDimensions() {
super.applyNewDimensions();
context.setCanDrag(physics.shouldAcceptUserOffset(this));
}
- applyNewDimensions可以看出setCanDrag是什么时候调用的?来源于哪里。
- 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,是需要做裁剪绘制的。
小结
- ScrollableState中会监听处理手势,把对应的position通过widget.viewportBuilder(context, position)传递到SingleChildScrollView中的build方法中,并创建_SingleChildViewport。
- 在_SingleChildViewport中创建对应的_RenderSingleChildViewport,更新
- _RenderSingleChildViewport中先计算layout,监听到offset变化后进行绘制paint。整个渲染过程完成。
思考
1.SingleChildScrollView会有性能问题吗?
通过_RenderSingleChildViewport的perforLayout可以看出,SingleScrollView会把child的size计算出来,也就是说child有多大,就会计算多大的size。在绘制的时候超出部分将会被裁剪。
2. 为什么paint参数只传递了offset?没有size或者rect,PaintContext又是怎么绘制的。
3. SingleChildScrollView滚动原理只是基础,但万变不离其宗,ListView是怎么滚动的,做了哪些优化呢?