美文网首页跨平台
Flutter了解之手势

Flutter了解之手势

作者: 平安喜乐698 | 来源:发表于2020-10-27 09:12 被阅读0次
    目录
      1. 原始指针事件 (移动设备上通常为触摸事件)
      2. 手势识别(GestureDetector、GestureRecognizer)
      3. 事件总线
      4. Notification
    

    1. 原始指针事件 (移动设备上通常为触摸事件)

    描述了屏幕上指针(触摸、鼠标、触控笔)的位置和移动。

    在移动端,各个平台UI系统的原始指针事件模型基本一致,即:一次完整的事件分为三个阶段:手指按下、手指移动、和手指抬起。
    有4种类型的指针事件:PointerDownEvent、PointerMoveEvent、PointerUpEvent、PointerCancelEvent
    
    高级别的手势(单击、长按、滑动、扫动、缩放、旋转)都是基于这些原始事件。
    
    当指针按下时,Flutter会对应用程序执行命中测试(Hit Test),以确定指针与屏幕接触的位置存在哪些组件, 指针按下事件(以及该指针的后续事件)然后被分发到由命中测试发现的最内部的组件,然后从那里开始,事件会在组件树中向上冒泡,这些事件会从最内部的组件被分发到组件树根的路径上的所有组件。
    这和Web开发中浏览器的事件冒泡机制相似, 但是Flutter中没有机制取消或停止“冒泡”过程,而浏览器的冒泡是可以停止的。
    注意: 只有通过命中测试的组件才能触发事件。
    

    监听

    Flutter中可以使用Listener(功能性组件)来监听原始触摸事件

    Listener({
      Key key,
      // onPointerDown、onPointerMove、onPointerUp、onPointerCancel函数的参数类型分别为PointerDownEvent、PointerMoveEvent、PointerUpEvent、PointerCancelEvent,都继承自PointerEvent。
      this.onPointerDown, // 手指按下回调
      this.onPointerMove, // 手指移动回调
      this.onPointerUp,// 手指抬起回调
      this.onPointerCancel,// 触摸事件取消回调
      this.behavior = HitTestBehavior.deferToChild, // 在命中测试期间如何表现
      Widget child
    })
    
    
    当手指在被监听的组件内移动时会触发指针事件,这时PointerEvent类的实例中会包含当前指针的一些信息,如:
        1. position:指针相对于全局坐标的偏移。
        2. delta:两次指针移动事件(PointerMoveEvent)的距离。
        3. pressure:按压力度,如果手机屏幕支持压力传感器(如iPhone的3D Touch)时此属性会有意义,如果手机不支持则始终为1。
        4. orientation:指针移动方向,是一个角度值。
        5. behavior属性,它决定子组件如何响应命中测试,它的值类型为HitTestBehavior,这是一个枚举类,有三个枚举值:
          1. deferToChild:默认。子组件会一个接一个的进行命中测试,如果子组件中有测试通过的,则当前组件通过,这就意味着,如果指针事件作用于子组件上时,其父级组件也肯定可以收到该事件。
          2. opaque:在命中测试时,将当前组件当成不透明处理(即使本身是透明的),最终的效果相当于当前Widget的整个区域都是点击区域。注意:该属性并不能用于在组件树中拦截(忽略)事件,它只是决定命中测试时的组件大小。
          3. translucent:当点击组件透明区域时,可以对自身边界内及底部可视区域都进行命中测试,即点击顶部组件透明区域时,顶部组件和底部组件都可以接收到事件。
    

    例1

    // 定义一个状态,保存当前指针位置
    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),
    ),
    

    例2

    Listener(
        child: ConstrainedBox(
            constraints: BoxConstraints.tight(Size(300.0, 150.0)),
            child: Center(child: Text("Box A")),
        ),
        //behavior: HitTestBehavior.opaque,
        onPointerDown: (event) => print("down A")
    ),
    
    只有点击文本内容区域才会触发点击事件,因为 deferToChild 会去子组件判断是否命中测试,而该例中子组件就是 Text("Box A") 。 
    如果想让整个300×150的矩形区域都能点击可以将behavior设为HitTestBehavior.opaque。
    

    例3

    Stack(
      children: <Widget>[
        Listener(
          child: ConstrainedBox(
            constraints: BoxConstraints.tight(Size(300.0, 200.0)),
            child: DecoratedBox(
                decoration: BoxDecoration(color: Colors.blue)),
          ),
          onPointerDown: (event) => print("down0"),
        ),
        Listener(
          child: ConstrainedBox(
            constraints: BoxConstraints.tight(Size(200.0, 100.0)),
            child: Center(child: Text("左上角200*100范围内非文本区域点击")),
          ),
          onPointerDown: (event) => print("down1"),
          //behavior: HitTestBehavior.translucent, //放开此行注释后可以"点透"
        )
      ],
    )
    
    
    当注释掉最后一行代码后,在左上角200*100范围内非文本区域点击时(顶部组件透明区域),控制台只会打印“down0”,也就是说顶部组件没有接收到事件,而只有底部接收到了。
    当放开注释后,再点击时顶部和底部都会接收到事件,此时会打印:
    down1
    down0
    如果behavior值改为HitTestBehavior.opaque,则只会打印"down1"。
    

    忽略PointerEvent

    假如不想让某个子树响应PointerEvent的话,可以使用IgnorePointer和AbsorbPointer,这两个组件都能阻止子树接收指针事件。
    不同之处在于AbsorbPointer本身会参与命中测试,而IgnorePointer本身不会参与,这就意味着AbsorbPointer本身是可以接收指针事件的(但其子树不行),而IgnorePointer不可以。
    
    例
    
    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,那么两个都不会输出。
    

    2. 手势识别(GestureDetector、GestureRecognizer)

    手势: 描述由一个或多个指针移动组成的语义动作,如拖动、缩放、双击等。

        Tap
            onTapDown 手指按下
            onTapUp 手指离开
            onTap 单击后调用
            onTapCancel 单击取消后调用
        双击
            onDoubleTap 双击后调用
        长按
            onLongPress 指长按后调用
        垂直拖动
            onVerticalDragStart 纵向拖动开始
            onVerticalDragUpdate 纵向拖动移动
            onVerticalDragEnd 纵向拖动结束
        水平拖动
            onHorizontalDragStart 水平拖动开始
            onHorizontalDragUpdate 水平拖动移动
            onHorizontalDragEnd 水平拖动结束
    

    Material大多数widget已经对tap或手势做出了响应。 例如 IconButton和 FlatButton 响应单击,ListView响应滑动事件触发滚动。

    1. GestureDetector

    用于手势识别的功能性组件,通过它可以来识别各种手势。

    一次完整的手势过程是指用户手指按下到抬起的整个过程,期间,用户按下手指后可能会移动,也可能不会移动。
    
    GestureDetector对于扫动和滑动事件是没有区分的,他们本质上是一样的。
    GestureDetector会将要监听的组件的原点(左上角)作为本次手势的原点,当用户在监听的组件上按下手指时,手势识别就会开始。
    

    例(单击)

    class MyButton extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return new GestureDetector(
          onTap: () {
            print('MyButton was tapped!');
          },
          child: new Container(
            height: 36.0,
            padding: const EdgeInsets.all(8.0),
            margin: const EdgeInsets.symmetric(horizontal: 8.0),
            decoration: new BoxDecoration(
              borderRadius: new BorderRadius.circular(5.0),
              color: Colors.lightGreen[500],
            ),
            child: new Center(
              child: new Text('Engage'),
            ),
          ),
        );
      }
    }
    

    例(添加Material触摸水波效果 InkWell组件)

    import 'package:flutter/material.dart';
    
    void main() => runApp(new MyApp());
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        final title = 'InkWell Demo';
    
        return new MaterialApp(
          title: title,
          home: new MyHomePage(title: title),
        );
      }
    }
    
    class MyHomePage extends StatelessWidget {
      final String title;
    
      MyHomePage({Key key, this.title}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return new Scaffold(
          appBar: new AppBar(
            title: new Text(title),
          ),
          body: new Center(child: new MyButton()),
        );
      }
    }
    
    class MyButton extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        // The InkWell Wraps our custom flat button Widget
        return new InkWell(
          // When the user taps the button, show a snackbar
          onTap: () {
            Scaffold.of(context).showSnackBar(new SnackBar(
              content: new Text('Tap'),
            ));
          },
          child: new Container(
            padding: new EdgeInsets.all(12.0),
            child: new Text('Flat Button'),
          ),
        );
      }
    }
    

    例(滑动关闭 Dismissable组件)

    import 'package:flutter/foundation.dart';
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(new MyApp(
        items: new List<String>.generate(20, (i) => "Item ${i + 1}"),
      ));
    }
    
    class MyApp extends StatelessWidget {
      final List<String> items;
    
      MyApp({Key key, @required this.items}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        final title = 'Dismissing Items';
    
        return new MaterialApp(
          title: title,
          home: new Scaffold(
            appBar: new AppBar(
              title: new Text(title),
            ),
            body: new ListView.builder(
              itemCount: items.length,
              itemBuilder: (context, index) {
                final item = items[index];
    
                return new Dismissible(
                  // Each Dismissible must contain a Key. Keys allow Flutter to
                  // uniquely identify Widgets.
                  key: new Key(item),
                  // We also need to provide a function that will tell our app
                  // what to do after an item has been swiped away.
                  onDismissed: (direction) {
                    items.removeAt(index);
    
                    Scaffold.of(context).showSnackBar(
                        new SnackBar(content: new Text("$item dismissed")));
                  },
                  // Show a red background as the item is swiped away
                  background: new Container(color: Colors.red),
                  child: new ListTile(title: new Text('$item')),
                );
              },
            ),
          ),
        );
      }
    }
    
    

    例(单击、双击、长按)

    通过GestureDetector对Container进行手势识别,触发相应事件后,在Container上显示事件名,为了增大点击区域,将Container设置为200×100
    
    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;
        });
      }
    }
    
    当同时监听onTap和onDoubleTap事件时,当用户触发tap事件时,会有200毫秒左右的延时,这是因为当用户点击完之后很可能会再次点击以触发双击事件,所以GestureDetector会等一段时间来确定是否为双击事件。
    如果用户只监听了onTap(而且没有监听onDoubleTap)事件时,则没有延时。
    

    例(滑动)

    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(() {
                    // 当用户在屏幕上滑动时,会触发多次Update事件,delta指一次Update事件的滑动的偏移量。
                    _left += e.delta.dx;
                    _top += e.delta.dy;
                  });
                },
                onPanEnd: (DragEndDetails e){
                  // 打印滑动结束时在x、y轴上的速度。该属性代表用户抬起手指时的滑动速度(包含x、y两个轴的),示例中并没有处理手指抬起时的速度,常见的效果是根据用户抬起手指时的速度做一个减速动画。
                  print(e.velocity);
                },
              ),
            )
          ],
        );
      }
    }
    
    用户手指按下:Offset(26.3, 101.8)
    Velocity(235.5, 125.8)
    
    image

    例(扫动---单一方向)

    只需要沿一个方向来拖动,如一个垂直方向的列表,GestureDetector可以只识别特定方向的手势事件
    
    class _DragVertical extends StatefulWidget {
      @override
      _DragVerticalState createState() => new _DragVerticalState();
    }
    
    class _DragVerticalState extends State<_DragVertical> {
      double _top = 0.0;
    
      @override
      Widget build(BuildContext context) {
        return Stack(
          children: <Widget>[
            Positioned(
              top: _top,
              child: GestureDetector(
                child: CircleAvatar(child: Text("A")),
                // 垂直方向拖动事件
                onVerticalDragUpdate: (DragUpdateDetails details) {
                  setState(() {
                    _top += details.delta.dy;
                  });
                }
              ),
            )
          ],
        );
      }
    }
    

    例(缩放)

    GestureDetector可以监听缩放事件
    
    
    class _ScaleTestRouteState extends State<_ScaleTestRoute> {
      double _width = 200.0; // 通过修改图片宽度来达到缩放效果
    
      @override
      Widget build(BuildContext context) {
       return Center(
         child: GestureDetector(
            // 指定宽度,高度自适应
            child: Image.asset("./images/sea.png", width: _width),
            onScaleUpdate: (ScaleUpdateDetails details) {
              setState(() {
                // 缩放倍数在0.8到10倍之间
                _width=200*details.scale.clamp(.8, 10.0);
              });
            },
          ),
       );
      }
    }
    现在在图片上双指张开、收缩就可以放大、缩小图片
    
    1. GestureRecognizer

    GestureRecognizer是一个抽象类。
    一种手势的识别器对应一个GestureRecognizer的子类。

    GestureDetector内部是使用一个或多个GestureRecognizer来识别各种手势的,而GestureRecognizer的作用就是通过Listener来将原始指针事件转换为语义手势,GestureDetector直接可以接收一个子widget。
    

    假设要给一段富文本的不同部分分别添加点击事件处理器,但是TextSpan并不是一个widget,这时我们不能用GestureDetector,但TextSpan有一个recognizer属性,它可以接收一个GestureRecognizer。在点击时给文本变色
    
    import 'package:flutter/gestures.dart';
    class _GestureRecognizerTestRouteState
        extends State<_GestureRecognizerTestRoute> {
      TapGestureRecognizer _tapGestureRecognizer = new TapGestureRecognizer();
      bool _toggle = false; //变色开关
      @override
      void dispose() {
         // 用到GestureRecognizer的话一定要调用其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: "你好世界"),
                  ]
              )
          ),
        );
      }
    }
    注意:使用GestureRecognizer后一定要调用其dispose()方法来释放资源(主要是取消内部的计时器)。
    
    1. 手势竞争与冲突

    由于手势竞争最终只有一个胜出者,所以,当有多个手势识别器时,可能会产生冲突。

    Flutter中的手势识别引入了一个Arena的概念,Arena直译为“竞技场”的意思,每一个手势识别器(GestureRecognizer)都是一个“竞争者”(GestureArenaMember),当发生滑动事件时,他们都要在“竞技场”去竞争本次事件的处理权,而最终只有一个“竞争者”会胜出。
    
    例如,假设有一个ListView,它的第一个子组件也是ListView,如果现在滑动这个子ListView,父ListView会动吗?答案是否定的,这时只有子ListView会动,因为这时子ListView会胜出而获得滑动事件的处理权。
    

    以拖动手势为例,同时识别水平和垂直方向的拖动手势,当用户按下手指时就会触发竞争(水平方向和垂直方向),一旦某个方向“获胜”,则直到当次拖动手势结束都会沿着该方向移动。
    
    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,它可以左右拖动,现在也想检测在它上面手指按下和抬起的事件
    
    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")), //要拖动和点击的widget
                  onHorizontalDragUpdate: (DragUpdateDetails details) {
                    setState(() {
                      _left += details.delta.dx;
                    });
                  },
                  onHorizontalDragEnd: (details){
                    print("onHorizontalDragEnd");
                  },
                  onTapDown: (details){
                    print("down");
                  },
                  onTapUp: (details){
                    print("up");
                  },
              ),
            )
          ],
        );
      }
    }
    按住圆形“A”拖动然后抬起手指
    down
    onHorizontalDragEnd
    
    没有打印"up",这是因为在拖动时,刚开始按下手指时在没有移动时,拖动手势还没有完整的语义,此时TapDown手势胜出(win),此时打印"down",而拖动时,拖动手势会胜出,当手指抬起时,onHorizontalDragEnd 和 onTapUp发生了冲突,但是因为是在拖动的语义中,所以onHorizontalDragEnd胜出,所以就会打印 “onHorizontalDragEnd”。
    如果代码逻辑中,对于手指按下和抬起是强依赖的,比如在一个轮播图组件中,希望手指按下时,暂停轮播,而抬起时恢复轮播,但是由于轮播图组件中本身可能已经处理了拖动手势(支持手动滑动切换),甚至可能也支持了缩放手势,这时如果在外部再用onTapDown、onTapUp来监听的话是不行的。这时应该通过Listener监听原始指针事件就行:
    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");
          },
        ),
      ),
    )
    
    手势冲突只是手势级别的,而手势是对原始指针的语义化的识别,所以在遇到复杂的冲突场景时,都可以通过Listener直接识别原始指针事件来解决冲突。
    

    3. 事件总线

    在APP中经常会需要一个广播机制,用以跨页面通知。比如一个需要登录的APP中,页面会关注用户登录或注销事件,来进行一些状态更新。
    这时候,一个事件总线便会非常有用,事件总线通常实现了订阅者模式,订阅者模式包含发布者和订阅者两种角色,可以通过事件总线来触发事件和监听事件。
    对于一些简单的应用,事件总线是足以满足业务需求的,如果决定使用状态管理包的话,一定要想清楚APP是否真的有必要使用它,防止“化简为繁”、过度设计。

    事件总线通常用于组件之间状态共享,但关于组件之间状态共享也有一些专门的包如redux以及Provider。
    
    注意:Dart中实现单例模式的标准做法就是使用static变量+工厂构造函数的方式,这样就可以保证new EventBus()始终返回都是同一个实例。
    

    一个简单的全局事件总线,使用单例模式
    
    // 订阅者回调签名
    typedef void EventCallback(arg);
    
    class EventBus {
      // 私有构造函数
      EventBus._internal();
    
      // 保存单例
      static EventBus _singleton = new EventBus._internal();
    
      // 工厂构造函数
      factory EventBus()=> _singleton;
    
      // 保存事件订阅者队列,key:事件名(id),value: 对应事件的订阅者队列
      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);
        }
      }
    }
    
    //定义一个top-level(全局)变量,页面引入该文件后可以直接使用bus
    var bus = new EventBus();
    
    
    =================使用================
    
    页面A中
    // 监听登录事件
    bus.on("login", (arg) {
      // do something
    });
    
    登录页B中
    // 登录成功后触发登录事件,页面A中订阅者会被调用
    bus.emit("login", userInfo);
    

    4. Notification 通知

    在widget树中,每一个节点都可以分发通知,通知会沿着当前节点向上传递,所有父节点都可以通过NotificationListener来监听通知。

    Flutter中将这种由子向父的传递通知的机制称为通知冒泡(Notification Bubbling)。
    通知冒泡和用户触摸事件冒泡是相似的,但有一点不同:通知冒泡可以中止,但用户触摸事件不行。
    通知冒泡和Web开发中浏览器事件冒泡原理是相似的,都是事件从出发源逐层向上传递,可以在上层节点任意位置来监听通知/事件,也可以终止冒泡过程,终止冒泡后,通知将不会再向上传递。

    Flutter的UI框架实现中,除了在可滚动组件在滚动过程中会发出ScrollNotification之外,还有一些其它的通知,如SizeChangedLayoutNotification、KeepAliveNotification 、LayoutChangedNotification等,Flutter正是通过这种通知机制来使父元素可以在一些特定时机来做一些事情。

    NotificationListener定义:
    class NotificationListener<T extends Notification> extends StatelessWidget {
      const NotificationListener({
        Key key,
        @required this.child,
        this.onNotification,
      }) : super(key: key);
     ...
    }
    
    1. NotificationListener 继承自StatelessWidget类,所以它可以直接嵌套到Widget树中
    2. NotificationListener 可以指定一个模板参数,该模板参数类型必须是继承自Notification;当显式指定模板参数时,NotificationListener 便只会接收该参数类型的通知。
    3. onNotification回调为通知处理回调,其函数签名如下:
      typedef NotificationListenerCallback<T extends Notification> = bool Function(T notification);
      它的返回值类型为布尔值,当返回值为true时,阻止冒泡,其父级Widget将再也收不到该通知;当返回值为false 时继续向上冒泡通知。
    

    通过NotificationListener来监听子ListView的滚动通知
    
    NotificationListener(
      onNotification: (notification){
        switch (notification.runtimeType){
          // ScrollStartNotification、ScrollUpdateNotification等都是继承自ScrollNotification类,不同类型的通知子类会包含不同的信息,比如ScrollUpdateNotification有一个scrollDelta属性,它记录了移动的位移
          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"),);
          }
      ),
    );
    

    //指定监听通知的类型为滚动结束通知(ScrollEndNotification)
    NotificationListener<ScrollEndNotification>(
      onNotification: (notification){
        //只会在滚动结束时才会触发此回调
        print(notification);
      },
      child: ListView.builder(
          itemCount: 100,
          itemBuilder: (context, index) {
            return ListTile(title: Text("$index"),);
          }
      ),
    );
    
    只会在滚动结束时在控制台打印出通知的信息。
    

    自定义通知

    1. 定义一个通知类,要继承自Notification类;
    class MyNotification extends Notification {
      MyNotification(this.msg);
      final String msg;
    }
    
    2. 分发通知。
    Notification有一个dispatch(context)方法,它是用于分发通知的。context实际上就是操作Element的一个接口,它与Element树上的节点是对应的,通知会从context对应的Element节点向上冒泡。
    

    点一次按钮就会分发一个MyNotification类型的通知,在Widget根上监听通知,收到通知后将通知通过Text显示在屏幕上
    
    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>[
    // 不能正常工作的,因为这个context是根Context,而NotificationListener是监听的子树,所以通过Builder来构建RaisedButton,来获得按钮位置的context。
    //          RaisedButton(
    //           onPressed: () => MyNotification("Hi").dispatch(context),
    //           child: Text("Send Notification"),
    //          ),  
                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;
    }
    

    阻止冒泡

    将上面的例子改为:
    
    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)方法:
    void dispatch(BuildContext target) {
      target?.visitAncestorElements(visitAncestor);
    }
    dispatch(context)中调用了当前context的visitAncestorElements方法,该方法会从当前Element开始向上遍历父级元素;visitAncestorElements有一个遍历回调参数,在遍历过程中对遍历到的父级元素都会执行该回调。遍历的终止条件是:已经遍历到根Element或某个遍历回调返回false。
    
    visitAncestor方法:
    //遍历回调,会对每一个父级Element执行此回调
    bool visitAncestor(Element element) {
      //判断当前element对应的Widget是否是NotificationListener。
      //由于NotificationListener是继承自StatelessWidget,
      //故先判断是否是StatelessElement
      if (element is StatelessElement) {
        //是StatelessElement,则获取element对应的Widget,判断
        //是否是NotificationListener 。
        final StatelessWidget widget = element.widget;
        if (widget is NotificationListener<Notification>) {
          //是NotificationListener,则调用该NotificationListener的_dispatch方法
          if (widget._dispatch(this, element)) 
            return false;
        }
      }
      return true;
    }
    visitAncestor会判断每一个遍历到的父级Widget是否是NotificationListener,如果不是,则返回true继续向上遍历,如果是,则调用NotificationListener的_dispatch方法,
    
    _dispatch方法:
      bool _dispatch(Notification notification, Element element) {
        // 如果通知监听器不为空,并且当前通知类型是该NotificationListener监听的通知类型,则调用当前NotificationListener的onNotification
        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。
    

    相关文章

      网友评论

        本文标题:Flutter了解之手势

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