【Flutter】熊孩子拆元件系列之拆ListView(三)—— GlowingOverscrollIndicator

語言: CN / TW / HK

theme: condensed-night-purple

小知識,大挑戰!本文正在參與「程式設計師必備小知識」創作活動

本文已參與 「掘力星計劃」 ,贏取創作大禮包,挑戰創作激勵金。

前言

上一篇中對Widget樹種最頂層的兩個進行了分析;

下面看一下第三個,GlowingOverscrollIndicator 是個什麼東西,過度滾動效果是怎麼搞出來的;

目錄

https://juejin.cn/post/7016538861580845063

首先還是概念解析

首先還是註釋部分:

``` A visual indication that a scroll view has overscrolled.

A [GlowingOverscrollIndicator] listens for [ScrollNotification]s in order to control the overscroll indication. These notifications are typically generated by a [ScrollView], such as a [ListView] or a [GridView].

[GlowingOverscrollIndicator] generates [OverscrollIndicatorNotification] before showing an overscroll indication. To prevent the indicator from showing the indication, call [OverscrollIndicatorNotification.disallowGlow] on the notification.

Created automatically by [ScrollBehavior.buildOverscrollIndicator] on platforms (e.g., Android) that commonly use this type of overscroll indication.

In a [MaterialApp], the edge glow color is the overall theme's [ColorScheme.secondary] color.

## Customizing the Glow Position for Advanced Scroll Views

When building a [CustomScrollView] with a [GlowingOverscrollIndicator], the indicator will apply to the entire scrollable area, regardless of what slivers the CustomScrollView contains.

For example, if your CustomScrollView contains a SliverAppBar in the first position, the GlowingOverscrollIndicator will overlay the SliverAppBar. To manipulate the position of the GlowingOverscrollIndicator in this case, you can either make use of a [NotificationListener] and provide a [OverscrollIndicatorNotification.paintOffset] to the notification, or use a [NestedScrollView].

{@tool dartpad --template=stateless_widget_scaffold}

This example demonstrates how to use a [NotificationListener] to manipulate the placement of a [GlowingOverscrollIndicator] when building a [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll indicator.

Widget build(BuildContext context) { final double leadingPaintOffset = MediaQuery.of(context).padding.top + AppBar().preferredSize.height; return NotificationListener( onNotification: (OverscrollIndicatorNotification notification) { if (notification.leading) { notification.paintOffset = leadingPaintOffset; } return false; }, child: CustomScrollView( slivers: [ const SliverAppBar(title: Text('Custom PaintOffset')), SliverToBoxAdapter( child: Container( color: Colors.amberAccent, height: 100, child: const Center(child: Text('Glow all day!')), ), ), const SliverFillRemaining(child: FlutterLogo()), ], ), ); } {@end-tool}

{@tool dartpad --template=stateless_widget_scaffold}

This example demonstrates how to use a [NestedScrollView] to manipulate the placement of a [GlowingOverscrollIndicator] when building a [CustomScrollView]. Drag the scrollable to see the bounds of the overscroll indicator.

Widget build(BuildContext context) { return NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return const [ SliverAppBar(title: Text('Custom NestedScrollViews')), ]; }, body: CustomScrollView( slivers: [ SliverToBoxAdapter( child: Container( color: Colors.amberAccent, height: 100, child: const Center(child: Text('Glow all day!')), ), ), const SliverFillRemaining(child: FlutterLogo()), ], ), ); } {@end-tool}

從註釋上來看,GlowingOverscrollIndicator就是一個通過監聽OverscrollIndicatorNotification``` 事件,然後根據此事件作出相應反應,並繪製出來的 Widget ;

好像沒啥特殊的?

GlowingOverscrollIndicator 從何而來

回到ListView 的 build 部分;在其中,有這麼一句:

_configuration.buildOverscrollIndicator(context, result, details), 首先呢,這個_configuration 其實可以不用管,這個是來自app部分的,相當於theme配置之類的東西,用來規定Scrollable是如何表現的,但在OverScroollIndicator這塊並沒有很實際參與到,從OverScroollIndicator的角度可以無視掉;

經過追蹤,來到了buildViewportChrome方法,在這裡做了一次篩選:

Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) { switch (getPlatform(context)) { case TargetPlatform.iOS: case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: return child; case TargetPlatform.android: case TargetPlatform.fuchsia: return GlowingOverscrollIndicator( child: child, axisDirection: axisDirection, color: _kDefaultGlowColor, ); } } 可以看到 GlowingOverscrollIndicator 就這麼構造出來的,不過也因此得知,只有fuchsia、android,才會有這個 GlowingOverscrollIndicator ;

出於熊孩子的直覺,在這裡做下封裝修改,是否可以實現一個蕾絲自定義重新整理頭、重新整理腳之類的東西呢?

話說,fuchsia 已經開始適配了啊,不知道何時能看到fuchsia開始推廣呢

回正題,由來得知了,下面看一下具體內容

GlowingOverscrollIndicator 構造

還是慣例,來到build方法這裡:

@override Widget build(BuildContext context) { return NotificationListener<ScrollNotification>( onNotification: _handleScrollNotification, child: RepaintBoundary( child: CustomPaint( foregroundPainter: _GlowingOverscrollIndicatorPainter( leadingController: widget.showLeading ? _leadingController : null, trailingController: widget.showTrailing ? _trailingController : null, axisDirection: widget.axisDirection, repaint: _leadingAndTrailingListener, ), child: RepaintBoundary( child: widget.child, ), ), ), ); }

這build方法平平無奇啊,說白了,就是往ListView覆蓋了一層畫布,用來畫 OverScroll 效果而已;

那看下具體怎麼實習繪製的,按照註釋說明,實現方式是通過監聽 OverscrollIndicatorNotification 事件來實現的,那麼自然就要看下 NotificationListener 的 onNotification 方法嘍:

這個方法,看上去挺長的,但拆開來看,好像也沒啥;

1、首先結合邊界情況,計算了一下偏移量(https://github.com/flutter/flutter/issues/64149) :

_leadingController!._paintOffsetScrollPixels = -math.min(notification.metrics.pixels - notification.metrics.minScrollExtent, _leadingController!._paintOffset); _trailingController!._paintOffsetScrollPixels = -math.min(notification.metrics.maxScrollExtent - notification.metrics.pixels, _trailingController!._paintOffset); 2、看一下往哪偏的,應該下一步會對繪製器的哪個controller來做操作: if (notification.overscroll < 0.0) { controller = _leadingController; } else if (notification.overscroll > 0.0) { controller = _trailingController; } else { assert(false); } 3、不讓過度的繪製效果一直顯示個沒完,在這裡做個截斷: if (_lastNotificationType != OverscrollNotification) { final OverscrollIndicatorNotification confirmationNotification = OverscrollIndicatorNotification(leading: isLeading); confirmationNotification.dispatch(context); _accepted[isLeading] = confirmationNotification._accepted; if (_accepted[isLeading]!) { controller!._paintOffset = confirmationNotification.paintOffset; } } 4、如果沒被截斷,那麼根據 overScroll 的情況做出相應繪製:

比如說,如果是那種有慣性的,那麼呼叫 absorbImpact ,繪製那種有慣性的繪製效果; if (_accepted[isLeading]!) { if (notification.velocity != 0.0) { assert(notification.dragDetails == null); controller!.absorbImpact(notification.velocity.abs()); } else { assert(notification.overscroll != 0.0); if (notification.dragDetails != null) { assert(notification.dragDetails!.globalPosition != null); final RenderBox renderer = notification.context!.findRenderObject()! as RenderBox; assert(renderer != null); assert(renderer.hasSize); final Size size = renderer.size; final Offset position = renderer.globalToLocal(notification.dragDetails!.globalPosition); switch (notification.metrics.axis) { case Axis.horizontal: controller!.pull(notification.overscroll.abs(), size.width, position.dy.clamp(0.0, size.height), size.height); break; case Axis.vertical: controller!.pull(notification.overscroll.abs(), size.height, position.dx.clamp(0.0, size.width), size.width); break; } } } } 5.如果不拉了或者別的結束情況,繪製結束,記錄當前操作,用於對比

} else if (notification is ScrollEndNotification || notification is ScrollUpdateNotification) { if ((notification as dynamic).dragDetails != null) { _leadingController!.scrollEnd(); _trailingController!.scrollEnd(); } } _lastNotificationType = notification.runtimeType;

至此,onNotification 部分的方法大體分析完成;

上面提到的controller ,本質上是 ChangeNotify ,在build的時候傳給了 CustomPaint;重繪通知這塊,也通過一個Listener.merge 組合監聽那倆controller來處理,也就是build方法出現的那個_leadingAndTrailingListener;

之後就是平平無奇的根據 ChangeNotify 的變化通知,來做相應處理:

``` @override void paint(Canvas canvas, Size size) { _paintSide(canvas, size, leadingController, axisDirection, GrowthDirection.reverse); _paintSide(canvas, size, trailingController, axisDirection, GrowthDirection.forward); }

@override bool shouldRepaint(_GlowingOverscrollIndicatorPainter oldDelegate) { return oldDelegate.leadingController != leadingController || oldDelegate.trailingController != trailingController; } ``` _paintSide 方法:

void _paintSide(Canvas canvas, Size size, _GlowController? controller, AxisDirection axisDirection, GrowthDirection growthDirection) { if (controller == null) return; switch (applyGrowthDirectionToAxisDirection(axisDirection, growthDirection)) { case AxisDirection.up: controller.paint(canvas, size); break; case AxisDirection.down: canvas.save(); canvas.translate(0.0, size.height); canvas.scale(1.0, -1.0); controller.paint(canvas, size); canvas.restore(); break; case AxisDirection.left: canvas.save(); canvas.rotate(piOver2); canvas.scale(1.0, -1.0); controller.paint(canvas, Size(size.height, size.width)); canvas.restore(); break; case AxisDirection.right: canvas.save(); canvas.translate(size.width, 0.0); canvas.rotate(piOver2); controller.paint(canvas, Size(size.height, size.width)); canvas.restore(); break; } } controller的paint方法:

void paint(Canvas canvas, Size size) { if (_glowOpacity.value == 0.0) return; final double baseGlowScale = size.width > size.height ? size.height / size.width : 1.0; final double radius = size.width * 3.0 / 2.0; final double height = math.min(size.height, size.width * _widthToHeightFactor); final double scaleY = _glowSize.value * baseGlowScale; final Rect rect = Rect.fromLTWH(0.0, 0.0, size.width, height); final Offset center = Offset((size.width / 2.0) * (0.5 + _displacement), height - radius); final Paint paint = Paint()..color = color.withOpacity(_glowOpacity.value); canvas.save(); canvas.translate(0.0, _paintOffset + _paintOffsetScrollPixels); canvas.scale(1.0, scaleY); canvas.clipRect(rect); canvas.drawCircle(center, radius, paint); canvas.restore(); }

文章太長不看的懶人總結篇

OverScrollableIndicator 本質上就是一個組合型Widget,當收到 OverscrollIndicatorNotification 事件的時候,計算處理overScroll效果,並通知CustomPainter來做出相應繪製操作,就這麼簡單;

「其他文章」