自定义组件
这种方式是通过拼装其它组件来组合成一个新的组件。例如我们之前介绍的Container
就是一个组合组件,它是由DecoratedBox
、ConstrainedBox
、Transform
、Padding
、Align
等组件组成。
在Flutter中,组合的思想非常重要,Flutter提供了非常多的基础组件,而我们的界面开发其实就是按照需要组合这些组件来实现各种不同的布局而已。
示例:自定义渐变按钮
Flutter Material组件库中的按钮默认不支持渐变背景,为了实现渐变背景按钮,我们自定义一个GradientButton
组件,它需要支持一下功能:
背景支持渐变色
手指按下时有涟漪效果
可以支持圆角
我们DecoratedBox
可以支持背景色渐变和圆角,InkWell
在手指按下有涟漪效果,所以我们可以通过组合DecoratedBox
和InkWell
来实现GradientButton
,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 import 'package:flutter/material.dart' ;class GradientButton extends StatelessWidget { GradientButton({ this .colors, this .width, this .height, this .onPressed, this .borderRadius, @required this .child, }); final List <Color> colors; final double width; final double height; final Widget child; final BorderRadius borderRadius; final GestureTapCallback onPressed; @override Widget build(BuildContext context) { ThemeData theme = Theme.of(context); List <Color> _colors = colors ?? [theme.primaryColor, theme.primaryColorDark ?? theme.primaryColor]; return DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient(colors: _colors), borderRadius: borderRadius, ), child: Material( type: MaterialType.transparency, child: InkWell( splashColor: _colors.last, highlightColor: Colors.transparent, borderRadius: borderRadius, onTap: onPressed, child: ConstrainedBox( constraints: BoxConstraints.tightFor(height: height, width: width), child: Center( child: Padding( padding: const EdgeInsets.all(8.0 ), child: DefaultTextStyle( style: TextStyle(fontWeight: FontWeight.bold), child: child, ), ), ), ), ), ), ); } }
可以看到GradientButton
是由DecoratedBox
、Padding
、Center
、InkWell
等组件组合而成。当然上面的代码只是一个示例,作为一个按钮它还并不完整,比如没有禁用状态,读者可以根据实际需要来完善。
使用GradientButton:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import 'package:flutter/material.dart' ;import '../widgets/index.dart' ;class GradientButtonRoute extends StatefulWidget { @override _GradientButtonRouteState createState() => _GradientButtonRouteState(); } class _GradientButtonRouteState extends State <GradientButtonRoute > { @override Widget build(BuildContext context) { return Container( child: Column( children: <Widget>[ GradientButton( colors: [Colors.orange, Colors.red], height: 50.0 , child: Text("Submit" ), onPressed: onTap, ), GradientButton( height: 50.0 , colors: [Colors.lightGreen, Colors.green[700 ]], child: Text("Submit" ), onPressed: onTap, ), GradientButton( height: 50.0 , colors: [Colors.lightBlue[300 ], Colors.blueAccent], child: Text("Submit" ), onPressed: onTap, ), ], ), ); } onTap() { print ("button click" ); } }
自绘
如果遇到无法通过现有的组件来实现需要的UI时,我们可以通过自绘组件的方式来实现,例如我们需要一个颜色渐变的圆形进度条,而Flutter提供的CircularProgressIndicator
并不支持在显示精确进度时对进度条应用渐变色(其valueColor
属性只支持执行旋转动画时变化Indicator的颜色),这时最好的方法就是通过自定义组件来绘制出我们期望的外观。我们可以通过Flutter中提供的CustomPaint
和Canvas
来实现UI自绘。
CustomPaint
CustomPaint
构造函数:
1 2 3 4 5 6 7 8 9 CustomPaint({ Key key, this .painter, this .foregroundPainter, this .size = Size.zero, this .isComplex = false , this .willChange = false , Widget child, })
painter
: 背景画笔,会显示在子节点后面;
foregroundPainter
: 前景画笔,会显示在子节点前面
size
:当child为null时,代表默认绘制区域大小,如果有child则忽略此参数,画布尺寸则为child尺寸。如果有child但是想指定画布为特定大小,可以使用SizeBox包裹CustomPaint实现。
isComplex
:是否复杂的绘制,如果是,Flutter会应用一些缓存策略来减少重复渲染的开销。
willChange
:和isComplex
配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变。
可以看到,绘制时我们需要提供前景或背景画笔,两者也可以同时提供。我们的画笔需要继承CustomPainter
类,我们在画笔类中实现真正的绘制逻辑。
如果CustomPaint
有子节点,为了避免子节点不必要的重绘并提高性能,通常情况下都会将子节点包裹在RepaintBoundary
组件中,这样会在绘制时就会创建一个新的绘制层(Layer),其子组件将在新的Layer上绘制,而父组件将在原来Layer上绘制,也就是说RepaintBoundary
子组件的绘制将独立于父组件的绘制,RepaintBoundary
会隔离其子节点和CustomPaint
本身的绘制边界。示例如下:
1 2 3 4 5 CustomPaint( size: Size(300 , 300 ), painter: MyPainter(), child: RepaintBoundary(child:...)), )
CustomPainter
CustomPainter
中提定义了一个虚函数paint
:
1 void paint(Canvas canvas, Size size);
paint
有两个参数:
Canvas
:一个画布,包括各种绘制方法,我们列出一下常用的方法:
|API名称 | 功能 | | ---------- | ------ | | drawLine | 画线 | | drawPoint | 画点 | | drawPath | 画路径 | | drawImage | 画图像 | | drawRect | 画矩形 | | drawCircle | 画圆 | | drawOval | 画椭圆 | | drawArc | 画圆弧 |
Size
:当前绘制区域大小。
画笔Paint
现在画布有了,我们最后还缺一个画笔,Flutter提供了Paint
类来实现画笔。在Paint
中,我们可以配置画笔的各种属性如粗细、颜色、样式等。如:
1 2 3 4 var paint = Paint() ..isAntiAlias = true ..style = PaintingStyle.fill ..color=Color(0x77cdb175 );
示例:五子棋/盘
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 import 'package:flutter/material.dart' ;import 'dart:math' ;class CustomPaintRoute extends StatelessWidget { @override Widget build(BuildContext context) { return Center( child: CustomPaint( size: Size(300 , 300 ), painter: MyPainter(), ), ); } } class MyPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { double eWidth = size.width / 15 ; double eHeight = size.height / 15 ; var paint = Paint() ..isAntiAlias = true ..style = PaintingStyle.fill ..color = Color(0x77cdb175 ); canvas.drawRect(Offset.zero & size, paint); paint ..style = PaintingStyle.stroke ..color = Colors.black87 ..strokeWidth = 1.0 ; for (int i = 0 ; i <= 15 ; ++i) { double dy = eHeight * i; canvas.drawLine(Offset(0 , dy), Offset(size.width, dy), paint); } for (int i = 0 ; i <= 15 ; ++i) { double dx = eWidth * i; canvas.drawLine(Offset(dx, 0 ), Offset(dx, size.height), paint); } paint ..style = PaintingStyle.fill ..color = Colors.black; canvas.drawCircle( Offset(size.width / 2 - eWidth / 2 , size.height / 2 - eHeight / 2 ), min(eWidth / 2 , eHeight / 2 ) - 2 , paint, ); paint.color = Colors.white; canvas.drawCircle( Offset(size.width / 2 + eWidth / 2 , size.height / 2 - eHeight / 2 ), min(eWidth / 2 , eHeight / 2 ) - 2 , paint, ); } @override bool shouldRepaint(CustomPainter oldDelegate) => true ; }
绘制是比较昂贵的操作,所以我们在实现自绘控件时应该考虑到性能开销,下面是两条关于性能优化的建议:
尽可能的利用好shouldRepaint
返回值;在UI树重新build时,控件在绘制前都会先调用该方法以确定是否有必要重绘;假如我们绘制的UI不依赖外部状态,那么就应该始终返回false
,因为外部状态改变导致重新build时不会影响我们的UI外观;如果绘制依赖外部状态,那么我们就应该在shouldRepaint
中判断依赖的状态是否改变,如果已改变则应返回true
来重绘,反之则应返回false
不需要重绘。
绘制尽可能多的分层;在上面五子棋的示例中,我们将棋盘和棋子的绘制放在了一起,这样会有一个问题:由于棋盘始终是不变的,用户每次落子时变的只是棋子,但是如果按照上面的代码来实现,每次绘制棋子时都要重新绘制一次棋盘,这是没必要的。优化的方法就是将棋盘单独抽为一个组件,并设置其shouldRepaint
回调值为false
,然后将棋盘组件作为背景。然后将棋子的绘制放到另一个组件中,这样每次落子时只需要绘制棋子。
实现RenderObject
Flutter提供的自身具有UI外观的组件,如文本Text
、Image
都是通过相应的RenderObject
渲染出来的,如Text是由RenderParagraph
渲染;而Image
是由RenderImage
渲染。RenderObject
是一个抽象类,它定义了一个抽象方法paint(...)
:
1 void paint(PaintingContext context, Offset offset)
PaintingContext
代表组件的绘制上下文,通过PaintingContext.canvas
可以获得Canvas
,而绘制逻辑主要是通过Canvas
API来实现。子类需要重写此方法以实现自身的绘制逻辑,如RenderParagraph
需要实现文本绘制逻辑,而RenderImage
需要实现图片绘制逻辑。
可以发现,RenderObject
中最终也是通过Canvas
API来绘制的,那么通过实现RenderObject
的方式和上面介绍的通过CustomPaint
和Canvas
自绘的方式有什么区别?其实答案很简单,CustomPaint
只是为了方便开发者封装的一个代理类,它直接继承自SingleChildRenderObjectWidget
,通过RenderCustomPaint
的paint
方法将Canvas
和画笔Painter
(需要开发者实现,后面章节介绍)连接起来实现了最终的绘制(绘制逻辑在Painter
中)。
事件
触摸事件
Flutter中可以使用Listener
来监听原始触摸事件
1 2 3 4 5 6 7 8 9 Listener({ Key key, this .onPointerDown, this .onPointerMove, this .onPointerUp, this .onPointerCancel, this .behavior = HitTestBehavior.deferToChild, Widget child })
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 PointerEvent _event; ... Listener( child: Container( alignment: Alignment.center, color: Colors.blue, width: 300.0 , height: 150.0 , child: Text(_event?.toString()??"" ,style: TextStyle(color: Colors.white)), ), onPointerDown: (PointerDownEvent event) => setState(()=>_event=event), onPointerMove: (PointerMoveEvent event) => setState(()=>_event=event), onPointerUp: (PointerUpEvent event) => setState(()=>_event=event), ),
PointerDownEvent
、PointerMoveEvent
、PointerUpEvent
都是PointerEvent
的一个子类,PointerEvent
类中包括当前指针的一些信息,如:
position
:它是鼠标相对于当对于全局坐标的偏移。
delta
:两次指针移动事件(PointerMoveEvent
)的距离。
pressure
:按压力度,如果手机屏幕支持压力传感器(如iPhone的3D Touch),此属性会更有意义,如果手机不支持,则始终为1。
orientation
:指针移动方向,是一个角度值。
点来介绍一下behavior
属性,它决定子组件如何响应命中测试,它的值类型为HitTestBehavior
,这是一个枚举类,有三个枚举值:
deferToChild
:子组件会一个接一个的进行命中测试,如果子组件中有测试通过的,则当前组件通过,这就意味着,如果指针事件作用于子组件上时,其父级组件也肯定可以收到该事件。
opaque
:在命中测试时,将当前组件当成不透明处理(即使本身是透明的),最终的效果相当于当前Widget的整个区域都是点击区域。
translucent
:当点击组件透明区域时,可以对自身边界内及底部可视区域都进行命中测试,这意味着点击顶部组件透明区域时,顶部组件和底部组件都可以接收到事件
忽略PointerEvent
我们可以使用IgnorePointer
和AbsorbPointer
,这两个组件都能阻止子树接收指针事件,不同之处在于AbsorbPointer
本身会参与命中测试,而IgnorePointer
本身不会参与,这就意味着AbsorbPointer
本身是可以接收指针事件的(但其子树不行),而IgnorePointer
不可以。一个简单的例子如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 Listener( child: AbsorbPointer( child: Listener( child: Container( color: Colors.red, width: 200.0 , height: 100.0 , ), onPointerDown: (event)=>print ("in" ), ), ), onPointerDown: (event)=>print ("up" ), )
点击Container
时,由于它在AbsorbPointer
的子树上,所以不会响应指针事件,所以日志不会输出"in",但AbsorbPointer
本身是可以接收指针事件的,所以会输出"up"。如果将AbsorbPointer
换成IgnorePointer
,那么两个都不会输出。
手势识别
Flutter中使用GestureDetector
和GestureRecognizer
处理手势。
GestureDetector
GestureDetector
是一个用于手势识别的功能性组件,我们通过它可以来识别各种手势。GestureDetector
实际上是指针事件的语义化封装
点击、双击、长按
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class GestureDetectorTestRoute extends StatefulWidget { @override _GestureDetectorTestRouteState createState() => new _GestureDetectorTestRouteState(); } class _GestureDetectorTestRouteState extends State <GestureDetectorTestRoute > { String _operation = "No Gesture detected!" ; @override Widget build(BuildContext context) { return Center( child: GestureDetector( child: Container( alignment: Alignment.center, color: Colors.blue, width: 200.0 , height: 100.0 , child: Text(_operation, style: TextStyle(color: Colors.white), ), ), onTap: () => updateText("Tap" ), onDoubleTap: () => updateText("DoubleTap" ), onLongPress: () => updateText("LongPress" ), ), ); } void updateText(String text) { setState(() { _operation = text; }); } }
拖动、滑动
一次完整的手势过程是指用户手指按下到抬起的整个过程,期间,用户按下手指后可能会移动,也可能不会移动。GestureDetector
对于拖动和滑动事件是没有区分的,他们本质上是一样的。GestureDetector
会将要监听的组件的原点(左上角)作为本次手势的原点,当用户在监听的组件上按下手指时,手势识别就会开始
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 class _Drag extends StatefulWidget { @override _DragState createState() => new _DragState(); } class _DragState extends State <_Drag > with SingleTickerProviderStateMixin { double _top = 0.0 ; double _left = 0.0 ; @override Widget build(BuildContext context) { return Stack( children: <Widget>[ Positioned( top: _top, left: _left, child: GestureDetector( child: CircleAvatar(child: Text("A" )), onPanDown: (DragDownDetails e) { print ("用户手指按下:${e.globalPosition} " ); }, onPanUpdate: (DragUpdateDetails e) { setState(() { _left += e.delta.dx; _top += e.delta.dy; }); }, onPanEnd: (DragEndDetails e){ print (e.velocity); }, ), ) ], ); } }
DragDownDetails.globalPosition
:当用户按下时,此属性为用户按下的位置相对于屏幕 (而非父组件)原点(左上角)的偏移。
DragUpdateDetails.delta
:当用户在屏幕上滑动时,会触发多次Update事件,delta
指一次Update事件的滑动的偏移量。
DragEndDetails.velocity
:该属性代表用户抬起手指时的滑动速度(包含x、y两个轴的),示例中并没有处理手指抬起时的速度,常见的效果是根据用户抬起手指时的速度做一个减速动画。
GestureRecognizer
GestureDetector
内部是使用一个或多个GestureRecognizer
来识别各种手势的,而GestureRecognizer
的作用就是通过Listener
来将原始指针事件转换为语义手势,GestureDetector
直接可以接收一个子widget。GestureRecognizer
是一个抽象类,一种手势的识别器对应一个GestureRecognizer
的子类,Flutter实现了丰富的手势识别器,我们可以直接使用。
示例:
假设我们要给一段富文本(RichText
)的不同部分分别添加点击事件处理器,但是TextSpan
并不是一个widget,这时我们不能用GestureDetector
,但TextSpan
有一个recognizer
属性,它可以接收一个GestureRecognizer
。
假设我们需要在点击时给文本变色:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import 'package:flutter/gestures.dart' ;class _GestureRecognizerTestRouteState extends State <_GestureRecognizerTestRoute > { TapGestureRecognizer _tapGestureRecognizer = new TapGestureRecognizer(); bool _toggle = false ; @override void dispose() { _tapGestureRecognizer.dispose(); super .dispose(); } @override Widget build(BuildContext context) { return Center( child: Text.rich( TextSpan( children: [ TextSpan(text: "你好世界" ), TextSpan( text: "点我变色" , style: TextStyle( fontSize: 30.0 , color: _toggle ? Colors.blue : Colors.red ), recognizer: _tapGestureRecognizer ..onTap = () { setState(() { _toggle = !_toggle; }); }, ), TextSpan(text: "你好世界" ), ] ) ), ); } }
手势竞争与冲突
竞争
如果在上例中我们同时监听水平和垂直方向的拖动事件,那么我们斜着拖动时哪个方向会生效?实际上取决于第一次移动时两个轴上的位移分量,哪个轴的大,哪个轴在本次滑动事件竞争中就胜出。实际上Flutter中的手势识别引入了一个Arena的概念,Arena直译为“竞技场”的意思,每一个手势识别器(GestureRecognizer
)都是一个“竞争者”(GestureArenaMember
),当发生滑动事件时,他们都要在“竞技场”去竞争本次事件的处理权,而最终只有一个“竞争者”会胜出(win)。例如,假设有一个ListView
,它的第一个子组件也是ListView
,如果现在滑动这个子ListView
,父ListView
会动吗?答案是否定的,这时只有子ListView
会动,因为这时子ListView
会胜出而获得滑动事件的处理权。
我们以拖动手势为例,同时识别水平和垂直方向的拖动手势,当用户按下手指时就会触发竞争(水平方向和垂直方向),一旦某个方向“获胜”,则直到当次拖动手势结束都会沿着该方向移动。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import 'package:flutter/material.dart' ;class BothDirectionTestRoute extends StatefulWidget { @override BothDirectionTestRouteState createState() => new BothDirectionTestRouteState(); } class BothDirectionTestRouteState extends State <BothDirectionTestRoute > { double _top = 0.0 ; double _left = 0.0 ; @override Widget build(BuildContext context) { return Stack( children: <Widget>[ Positioned( top: _top, left: _left, child: GestureDetector( child: CircleAvatar(child: Text("A" )), onVerticalDragUpdate: (DragUpdateDetails details) { setState(() { _top += details.delta.dy; }); }, onHorizontalDragUpdate: (DragUpdateDetails details) { setState(() { _left += details.delta.dx; }); }, ), ) ], ); } }
此示例运行后,每次拖动只会沿一个方向移动(水平或垂直),而竞争发生在手指按下后首次移动(move)时,此例中具体的“获胜”条件是:首次移动时的位移在水平和垂直方向上的分量大的一个获胜。
手势冲突
由于手势竞争最终只有一个胜出者,所以,当有多个手势识别器时,可能会产生冲突。假设有一个widget,它可以左右拖动,现在我们也想检测在它上面手指按下和抬起的事件,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 class GestureConflictTestRouteState extends State <GestureConflictTestRoute > { double _left = 0.0 ; @override Widget build(BuildContext context) { return Stack( children: <Widget>[ Positioned( left: _left, child: GestureDetector( child: CircleAvatar(child: Text("A" )), onHorizontalDragUpdate: (DragUpdateDetails details) { setState(() { _left += details.delta.dx; }); }, onHorizontalDragEnd: (details){ print ("onHorizontalDragEnd" ); }, onTapDown: (details){ print ("down" ); }, onTapUp: (details){ print ("up" ); }, ), ) ], ); } }
我们如果在外部再用onTapDown
、onTapUp
来监听的话
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 Positioned( top:80.0 , left: _leftB, child: Listener( onPointerDown: (details) { print ("down" ); }, onPointerUp: (details) { print ("up" ); }, child: GestureDetector( child: CircleAvatar(child: Text("B" )), onHorizontalDragUpdate: (DragUpdateDetails details) { setState(() { _leftB += details.delta.dx; }); }, onHorizontalDragEnd: (details) { print ("onHorizontalDragEnd" ); }, ), ), )
事件总线
在APP中,我们经常会需要一个广播机制,用以跨页面事件通知,比如一个需要登录的APP中,页面会关注用户登录或注销事件,来进行一些状态更新。这时候,一个事件总线便会非常有用,事件总线通常实现了订阅者模式,订阅者模式包含发布者和订阅者两种角色,可以通过事件总线来触发事件和监听事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 typedef void EventCallback(arg);class EventBus { EventBus._internal(); static EventBus _singleton = new EventBus._internal(); factory EventBus()=> _singleton; var _emap = new Map <Object , List <EventCallback>>(); void on(eventName, EventCallback f) { if (eventName == null || f == null ) return ; _emap[eventName] ??= new List <EventCallback>(); _emap[eventName].add(f); } void off(eventName, [EventCallback f]) { var list = _emap[eventName]; if (eventName == null || list == null ) return ; if (f == null ) { _emap[eventName] = null ; } else { list.remove(f); } } void emit(eventName, [arg]) { var list = _emap[eventName]; if (list == null ) return ; int len = list.length - 1 ; for (var i = len; i > -1 ; --i) { list[i](arg); } } } var bus = new EventBus();
使用示例:
1 2 3 4 5 6 7 8 9 10 11 ... bus.on("login" , (arg) { }); ... bus.emit("login" , userInfo);
Notification
通知(Notification)是Flutter中一个重要的机制,在widget树中,每一个节点都可以分发通知,通知会沿着当前节点向上传递,所有父节点都可以通过NotificationListener
来监听通知。Flutter中将这种由子向父的传递通知的机制称为通知冒泡 (Notification Bubbling)。通知冒泡和用户触摸事件冒泡是相似的,但有一点不同:通知冒泡可以中止,但用户触摸事件不行。
Flutter中很多地方使用了通知,如可滚动组件(Scrollable Widget)滑动时就会分发滚动通知 (ScrollNotification),而Scrollbar正是通过监听ScrollNotification来确定滚动条位置的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 NotificationListener( onNotification: (notification){ switch (notification.runtimeType){ case ScrollStartNotification: print ("开始滚动" ); break ; case ScrollUpdateNotification: print ("正在滚动" ); break ; case ScrollEndNotification: print ("滚动停止" ); break ; case OverscrollNotification: print ("滚动到边界" ); break ; } }, child: ListView.builder( itemCount: 100 , itemBuilder: (context, index) { return ListTile(title: Text("$index" ),); } ), );
ScrollStartNotification
、ScrollUpdateNotification
等都是继承自ScrollNotification
类,不同类型的通知子类会包含不同的信息,比如ScrollUpdateNotification
有一个scrollDelta
属性,它记录了移动的位移
上例中,我们通过NotificationListener
来监听子ListView
的滚动通知的,NotificationListener
定义如下:
1 2 3 4 5 6 7 8 class NotificationListener <T extends Notification > extends StatelessWidget { const NotificationListener({ Key key, @required this .child, this .onNotification, }) : super (key: key); ... }
NotificationListener
继承自StatelessWidget
类,所以它可以直接嵌套到Widget树中。
NotificationListener
可以指定一个模板参数,该模板参数类型必须是继承自Notification
;当显式指定模板参数时,NotificationListener
便只会接收该参数类型的通知。举个例子,如果我们将上例子代码改为:
1 2 3 4 5 6 7 8 9 10 11 12 13 NotificationListener<ScrollEndNotification>( onNotification: (notification){ print (notification); }, child: ListView.builder( itemCount: 100 , itemBuilder: (context, index) { return ListTile(title: Text("$index" ),); } ), );
上面代码运行后便只会在滚动结束时在控制台打印出通知的信息。
onNotification
回调为通知处理回调,其函数签名如下:
1 typedef NotificationListenerCallback<T extends Notification> = bool Function (T notification);
它的返回值类型为布尔值,当返回值为true
时,阻止冒泡,其父级Widget将再也收不到该通知;当返回值为false
时继续向上冒泡通知。
Flutter的UI框架实现中,除了在可滚动组件在滚动过程中会发出ScrollNotification
之外,还有一些其它的通知,如SizeChangedLayoutNotification
、KeepAliveNotification
、LayoutChangedNotification
等,Flutter正是通过这种通知机制来使父元素可以在一些特定时机来做一些事情。
自定义通知
除了Flutter内部通知,我们也可以自定义通知,下面我们看看如何实现自定义通知:
定义一个通知类,要继承自Notification类;
1 2 3 4 class MyNotification extends Notification { MyNotification(this .msg); final String msg; }
分发通知。
Notification
有一个dispatch(context)
方法,它是用于分发通知的,我们说过context
实际上就是操作Element
的一个接口,它与Element
树上的节点是对应的,通知会从context
对应的Element
节点向上冒泡。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 class NotificationRoute extends StatefulWidget { @override NotificationRouteState createState() { return new NotificationRouteState(); } } class NotificationRouteState extends State <NotificationRoute > { String _msg="" ; @override Widget build(BuildContext context) { return NotificationListener<MyNotification>( onNotification: (notification) { setState(() { _msg+=notification.msg+" " ; }); return true ; }, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ Builder( builder: (context) { return RaisedButton( onPressed: () => MyNotification("Hi" ).dispatch(context), child: Text("Send Notification" ), ); }, ), Text(_msg) ], ), ), ); } } class MyNotification extends Notification { MyNotification(this .msg); final String msg; }
阻止冒泡
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class NotificationRouteState extends State <NotificationRoute > { String _msg="" ; @override Widget build(BuildContext context) { return NotificationListener<MyNotification>( onNotification: (notification){ print (notification.msg); return false ; }, child: NotificationListener<MyNotification>( onNotification: (notification) { setState(() { _msg+=notification.msg+" " ; }); return false ; }, child: ... ), ); } }
上列中两个NotificationListener
进行了嵌套,子NotificationListener
的onNotification
回调返回了false
,表示不阻止冒泡,所以父NotificationListener
仍然会受到通知,所以控制台会打印出通知信息;如果将子NotificationListener
的onNotification
回调的返回值改为true
,则父NotificationListener
便不会再打印通知了,因为子NotificationListener
已经终止通知冒泡了。
通知冒泡
由于通知是通过Notification
的dispatch(context)
方法发出的,那我们先看看dispatch(context)
方法中做了什么,下面是相关源码:
1 2 3 void dispatch(BuildContext target) { target?.visitAncestorElements(visitAncestor); }
dispatch(context)
中调用了当前context的visitAncestorElements
方法,该方法会从当前Element开始向上遍历父级元素;visitAncestorElements
有一个遍历回调参数,在遍历过程中对遍历到的父级元素都会执行该回调。遍历的终止条件是:已经遍历到根Element或某个遍历回调返回false
。源码中传给visitAncestorElements
方法的遍历回调为visitAncestor
方法,我们看看visitAncestor
方法的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 bool visitAncestor(Element element) { if (element is StatelessElement) { final StatelessWidget widget = element.widget; if (widget is NotificationListener<Notification>) { if (widget._dispatch(this , element)) return false ; } } return true ; }
visitAncestor
会判断每一个遍历到的父级Widget是否是NotificationListener
,如果不是,则返回true
继续向上遍历,如果是,则调用NotificationListener
的_dispatch
方法,我们看看_dispatch
方法的源码:
1 2 3 4 5 6 7 8 9 10 bool _dispatch(Notification notification, Element element) { if (onNotification != null && notification is T) { final bool result = onNotification(notification); return result == true ; } return false ; }
我们可以看到NotificationListener
的onNotification
回调最终是在_dispatch
方法中执行的,然后会根据返回值来确定是否继续向上冒泡。上面的源码实现其实并不复杂,通过阅读这些源码,一些额外的点读者可以注意一下:
Context
上也提供了遍历Element树的方法。
我们可以通过Element.widget
得到element
节点对应的widget;我们已经反复讲过Widget和Element的对应关系,读者通过这些源码来加深理解。