7 手勢、自定義Widget

手勢

Flutter中的手勢系統(tǒng)有兩個獨立的層。第一層為原始指針(pointer)事件,它描述了屏幕上指針(例如,觸摸、鼠標(biāo)和觸控筆)的位置和移動。 第二層為手勢,描述由一個或多個指針移動組成的語義動作,如拖動、縮放、雙擊等。

原始指針事件

在移動端,各個平臺或UI系統(tǒng)的原始指針事件模型基本都是一致,即:一次完整的事件分為三個階段:手指按下、手指移動、和手指抬起,而高級的手勢(如點擊、雙擊、拖動等)都是基于這些原始事件的。

Flutter中可以使用Listener widget來監(jiān)聽原始觸摸事件:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      home: MainRoute(),
    );
  }
}

class MainRoute extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    // TODO: implement createState
    return _MainState();
  }
}

class _MainState extends State<MainRoute> {
  //定義一個狀態(tài),保存當(dāng)前指針位置
  PointerEvent _event;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("主頁"),
      ),
      body: Listener(
        child: Container(
          alignment: Alignment.center,
          width: 300.0,
          height: 150.0,
          child: Text(_event?.toString() ?? ""),
        ),
        onPointerDown: (PointerDownEvent event) =>
            setState(() => _event = event),
        onPointerMove: (PointerMoveEvent event) =>
            setState(() => _event = event),
        onPointerUp: (PointerUpEvent event) => setState(() => _event = event),
      ),
    );
  }
}

PointerDownEvent、PointerMoveEvent、PointerUpEvent都是PointerEvent的一個子類,PointerEvent類中包括當(dāng)前指針的一些信息。

  • position:它是鼠標(biāo)相對于當(dāng)對于全局坐標(biāo)的偏移。(左上角原點)
  • delta:兩次指針移動事件(PointerMoveEvent)的距離。
  • pressure:按壓力度,如果手機屏幕支持壓力傳感器(如iPhone的3D Touch),此屬性會更有意義,如果手機不支持,則始終為1。
  • orientation:指針移動方向,是一個角度值。

命中測試

當(dāng)指針按下時,F(xiàn)lutter會對應(yīng)用程序執(zhí)行命中測試(Hit Test),以確定指針與屏幕接觸的位置存在哪些widget, 指針按下事件(以及該指針的后續(xù)事件)然后被分發(fā)到由命中測試發(fā)現(xiàn)的最內(nèi)部的widget,然后從那里開始,事件會在widget樹中向上冒泡,這些事件會從最內(nèi)部的widget被分發(fā)到到widget根的路徑上的所有Widget。

behavior屬性決定子Widget如何響應(yīng)命中測試,它的值類型為HitTestBehavior,這是一個枚舉類,有三個枚舉值:

  • deferToChild:子widget會一個接一個的進行命中測試,如果子Widget中有測試通過的,則當(dāng)前Widget通過。

指針事件作用于子Widget上時,父Widget也肯定可以收到該事件。

  • opaque:不透明的。在命中測試時,將當(dāng)前Widget當(dāng)成不透明處理(即使本身是不可見、透明的),最終的效果相當(dāng)于當(dāng)前Widget的整個區(qū)域都是點擊區(qū)域。
  • translucent:半透明的。當(dāng)點擊Widget時,widget可以接收到事件(無論是否可見),子widget則需要點擊到可見區(qū)域才能接收。
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      home: MainRoute(),
    );
  }
}

class MainRoute extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _MainState();
  }
}

class _MainState extends State<MainRoute> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("主頁"),
      ),
      body: Listener(
        behavior: HitTestBehavior.translucent,
        child: Container(
          alignment: Alignment.center,
          ///300x150不可見,只有Text可見
          ///默認情況下,點擊Text區(qū)域才響應(yīng)事件。點擊空白區(qū)域無輸出;點擊Text才能響應(yīng)
          ///設(shè)置為 opaque 后 則在300x300內(nèi)都能響應(yīng),哪怕不可見。點擊空白區(qū)域就能響應(yīng)
          ///設(shè)置為 translucent 后 則在300x300能響應(yīng)。也是點擊空白區(qū)域就能響應(yīng)
//            color: Colors.blue,///不設(shè)置顏色就是不可見
          width: 300.0,
          height: 300.0,
          child: Text(
            "點擊",
          ),
        ),
        onPointerDown: (PointerDownEvent event) =>
            setState(() => debugPrint("響應(yīng)")),
      ),
    );
  }
}

opaquetranslucent的區(qū)別在于,后者是將透明區(qū)域視為半透明,這意味著能夠完成"穿透"效果。

需要注意的是點擊 外部 文字,因為文字本身不是透明,不會進行穿透效果。

 @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text("主頁"),
        ),
        body: Stack(
          children: <Widget>[
            Listener(
              child: Container(
                width: 300.0,
                height: 300.0,
                color: Colors.blue,
                child: Center(child: Text("底部")),
              ),
              onPointerDown: (event) => print("down0"),
            ),
            Listener(
              child: Container(
                width: 100.0,
                height: 100.0,
                child: Center(child: Text("外部")),
              ),
              onPointerDown: (event) => print("down1"),
              behavior: HitTestBehavior.translucent, //穿透
            )
          ],
        ));
  }

手勢識別

Android中存在事件沖突,F(xiàn)lutter其實也存在,但是官方的GestureDetector來解決這個問題。通常我們?yōu)榱隧憫?yīng)用戶與設(shè)備屏幕交互就會使用這個手勢Widget:GestureDetector

包括之前使用的InkWell 內(nèi)部實現(xiàn)也是GestureDetector

手勢 說明
onTapDown 按下
onTapUp 抬起
onTapCancel 觸發(fā)了 onTapDown,但并沒有完成一個 onTap 動作
onTap 點擊動作
onDoubleTap 雙擊
onLongPress 長按
onScaleStart, onScaleUpdate, onScaleEnd 縮放
onVerticalDragDown, onVerticalDragStart, onVerticalDragUpdate, onVerticalDragEnd, onVerticalDragCancel 在豎直方向上移動
onHorizontalDragDown, onHorizontalDragStart, onHorizontalDragUpdate, onHorizontalDragEnd, onHorizontalDragCancel 在水平方向上移動
onPanDown, onPanStart, onPanUpdate, onPanEnd, onPanCancel 拖曳

手勢的識別比較復(fù)雜。分解動作:先點擊再進行后續(xù)的手勢操作(滑動、抬起)這時候點擊下去會回調(diào)所有的XXDown方法。因為此時系統(tǒng)并不知道你需要進行的后續(xù)手勢操作是什么。而如果是連貫的手勢動作就只會回調(diào)對應(yīng)的Down方法。

同時如果同時設(shè)置了拖拽手勢參數(shù)與固定方向方法(水平、垂直)參數(shù)時候,那只會回調(diào)固定方向的方法。即固定方向優(yōu)先級最高。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text("主頁"),
        ),
        body: _Drag(),
      ),
    );
  }
}

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")),
            //手指滑動時會觸發(fā)此回調(diào)
            onPanUpdate: (DragUpdateDetails e) {
              //用戶手指滑動時,更新偏移,重新構(gòu)建
              setState(() {
                _left += e.delta.dx;
                _top += e.delta.dy;
              });
            },
          ),
        )
      ],
    );
  }
}

手勢沖突

如果我們同時監(jiān)聽水平和垂直方向的拖動事件,那么我們斜著拖動時哪個方向會生效?實際上取決于第一次移動時兩個軸上的位移分量,哪個軸的大,哪個軸在本次滑動事件競爭中就勝出。例如,假設(shè)有一個ListView,它的第一個子Widget也是ListView,如果現(xiàn)在滑動這個子ListView,這時只有子Widget會動,因為這時子Widget會勝出而獲得滑動事件的處理權(quán)。

識別水平和垂直方向的拖動手勢,當(dāng)用戶按下手指時就會觸發(fā)競爭(水平方向和垂直方向),一旦某個方向“獲勝”,則直到當(dāng)次拖動手勢結(jié)束都會沿著該方向移動。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text("主頁"),
        ),
        body: Test(),
      ),
    );
  }
}

class Test extends StatefulWidget {
  @override
  TestState createState() => TestState();
}

class TestState extends State<Test> {
  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;
              });
            },
          ),
        )
      ],
    );
  }
}

自定義Widget

當(dāng)Flutter提供的現(xiàn)有Widget無法滿足我們的需求,或者我們?yōu)榱斯蚕泶a需要封裝一些通用Widget,這時我們就需要自定義Widget。自定義Widget主要有兩種方式:自繪與組合封裝。

自繪

對于一些復(fù)雜或不規(guī)則的UI,我們可能無法使用現(xiàn)有Widget組合的方式來實現(xiàn)。在Flutter中,提供了一個CustomPaint畫筆,它可以結(jié)合一個畫家CustomPainter來實現(xiàn)繪制自定義圖形。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text("主頁"),
        ),
        body: GradientCircularProgressRoute(),
      ),
    );
  }
}

class GradientCircularProgressRoute extends StatefulWidget {
  @override
  GradientCircularProgressRouteState createState() {
    return  GradientCircularProgressRouteState();
  }
}

class GradientCircularProgressRouteState
    extends State<GradientCircularProgressRoute>  {
  @override
  Widget build(BuildContext context) {
    //返回畫筆
    return CustomPaint(
      painter: MyPainter(50.0),
    );
  }
}

class MyPainter extends CustomPainter {
  MyPainter(this.radius);

  double radius;

  @override
  void paint(Canvas canvas, Size size) {
    ///根據(jù)半徑計算大小
    size = Size.fromRadius(radius);
    var paint = Paint() //創(chuàng)建一個畫筆并配置其屬性
      ..isAntiAlias = true //是否抗鋸齒
      ..style = PaintingStyle.fill //畫筆樣式:填充
      ..color = Colors.blue //畫筆顏色
      ..strokeWidth = 3.0; //畫筆的寬度

    ///畫一個實心圓
    Rect rect =
    Rect.fromCircle(center: size.center(Offset.zero), radius: radius);
    canvas.drawCircle(rect.center, radius, paint);
  }


  /// 返回true來重繪,反之則應(yīng)返回false不需要重繪。
  @override
  bool shouldRepaint(MyPainter oldDelegate) {
    if(oldDelegate.radius != radius){
      return true;
    }
    return false;
  }
}

組合

這種方式是通過拼裝其它低級別的Widget來組合成一個高級別的Widget,例如Container就是一個組合Widget,它是由DecoratedBox、ConstrainedBox、Transform、Padding、Align等組成。

Notification機制

內(nèi)容引用自:
[Notification]: https://book.flutterchina.club/chapter8/notification.html

Notification是Flutter中一個重要的機制,在Widget樹中,每一個節(jié)點都可以分發(fā)通知,通知會沿著當(dāng)前節(jié)點(context)向上傳遞,所有父節(jié)點都可以通過NotificationListener來監(jiān)聽通知,F(xiàn)lutter中稱這種通知由子向父的傳遞為“通知冒泡”(Notification Bubbling),這個和用戶觸摸事件冒泡是相似的,但有一點不同:通知冒泡可以中止,但用戶觸摸事件不行。

Flutter中很多地方使用了通知,如可滾動(Scrollable) Widget中滑動時就會分發(fā)ScrollNotification,而Scrollbar正是通過監(jiān)聽ScrollNotification來確定滾動條位置的。除了ScrollNotification,F(xiàn)lutter中還有SizeChangedLayoutNotification、KeepAliveNotification 、LayoutChangedNotification等。下面是一個監(jiān)聽Scrollable Widget滾動通知的例子:

NotificationListener(
  onNotification: (notification){
    //print(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屬性,它記錄了移動的位移,其它通知屬性讀者可以自己查看SDK文檔。

自定義通知

除了Flutter內(nèi)部通知,我們也可以自定義通知,下面我們看看如何實現(xiàn)自定義通知:

  1. 定義一個通知類,要繼承自Notification類;

    class MyNotification extends Notification {
      MyNotification(this.msg);
      final String msg;
    }
    
  2. 分發(fā)通知。

    Notification有一個dispatch(context)方法,它是用于分發(fā)通知的,我們說過context實際上就是操作Element的一個接口,它與Element樹上的節(jié)點是對應(yīng)的,通知會從context對應(yīng)的Element節(jié)點向上冒泡。

下面我們看一個完整的例子:

class NotificationRoute extends StatefulWidget {
  @override
  NotificationRouteState createState() {
    return new NotificationRouteState();
  }
}

class NotificationRouteState extends State<NotificationRoute> {
  String _msg="";
  @override
  Widget build(BuildContext context) {
    //監(jiān)聽通知  
    return NotificationListener<MyNotification>(
      onNotification: (notification) {
        setState(() {
          _msg+=notification.msg+"  ";
        });
      },
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
//          RaisedButton(
//           onPressed: () => MyNotification("Hi").dispatch(context),
//           child: Text("Send Notification"),
//          ),  
            Builder(
              builder: (context) {
                return RaisedButton(
                  //按鈕點擊時分發(fā)通知  
                  onPressed: () => MyNotification("Hi").dispatch(context),
                  child: Text("Send Notification"),
                );
              },
            ),
            Text(_msg)
          ],
        ),
      ),
    );
  }
}

class MyNotification extends Notification {
  MyNotification(this.msg);
  final String msg;
}

上面代碼中,我們每點一次按鈕就會分發(fā)一個MyNotification類型的通知,我們在Widget根上監(jiān)聽通知,收到通知后我們將通知通過Text顯示在屏幕上。

注意:代碼中注釋的部分是不能正常工作的,因為這個context是根Context,而NotificationListener是監(jiān)聽的子樹,所以我們通過Builder來構(gòu)建RaisedButton,來獲得按鈕位置的context。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容