iT邦幫忙

2024 iThome 鐵人賽

DAY 11
0
Mobile Development

Flutter 開發實戰 - 30 天逃離新手村系列 第 11

Day 11 使用者輸入與手勢處理

  • 分享至 

  • xImage
  •  

輸入與手勢

通過 Widget 可以組織建立使用者介面,也可以支援使用者通過手勢和輸入資料來和介面互動。本文將探討用來處理使用者手勢以及使用者輸入資料的 相關 Widget。範圍將涵蓋:

  • 處理使用者手勢
  • 深入 StatefulWidget 的生命週期
  • 表單與輸入欄位
  • 自訂輸入欄位

使用者手勢的處理機制

一個行動應用程式如果不能互動,那麼可用性將非常有限。 為此,Flutter 支援處理各種可能的手勢,從簡單的點擊、拖拉、一或多個手指進行滑動的手勢(Pan Gesture)。

首先,Flutter 的手勢系統在螢幕事件中被分成兩層:

  • 指標層 Pointers Layer:這一層保存了關於指標的原始資訊如觸摸點擊,滑鼠或觸控筆。原始資料包含指標的位置和移動資訊。
  • 手勢層 Gestures Layer:這一層將多個指標行為嘗試賦予它們一些使用者動作的意義。這些包含意義的動作例如點擊、拖拉、縮放。對於應用程式來說這些行為會比較有用,通常這就是實作使用者操作的方式。

指標

Flutter 從底層指標層開始處理螢幕的輸入。一般來說,我們不需要使用這一層的事件,但如果要自訂一些客製化的輸入操作,那麼你可以使用這一層接收到的每一個指標事件並控制它們。舉例來說,如果你在開發遊戲,你可能需要對每一個指標回傳的精準訊息,而不是依靠更高階層的手勢事件。

從網頁開發的角度來看,這種底層指標處理方式類似於在 JavaScript 中使用原生的 DOM 事件,如 mousedownmousemovemouseup。在網頁開發中,我們通常會使用函式庫或框架(如 jQuery 、 React、D3)來處理這些事件或需求,這些函式庫為我們抽象了底層的複雜性。

在指標層,Flutter 會接收來自作業系統原始的輸入事件,這些事件可能是觸碰、滑鼠移動、數位筆的操作。這一層主要的任務是追蹤指標在螢幕生的位置和狀態例如按下、移動、抬起等等。

接著,Flutter 會根據下面的順序將每一個指標事件發送給 Widget 樹狀結構:

  • PointerDownEvent :是所有互動的起點,當手指或觸控筆接觸到螢幕上的某個位置。此時 Flutter 會搜尋 Widget 結構找到在該螢幕位置上的 Widget。這個動作叫做「 Hit Test 命中測試」。這個操作會從根節點開始遍歷 Widget Tree 直到找到位於該事件座標下最下層的 Widget。
  • 一旦找到目標 Widget ,Flutter 就會將每一個事件包含初始的 PointerDownEvent 事件,派發到和位置匹配最內層的 Widget ,然後事件會逐層向上傳遞,最終到根節點。這個事件的傳遞行為不能被中斷。這個過程類似於網頁的 Event Bubbling,雖然 Flutter 的事件不像網頁有 e.stopPropagation() 可以中斷傳遞,但有 HitTestBehavior IgnorePointer 等方式可以提供對應的處理。
  • 後續事件例如 PointerMoveEvent 表示指標位置改變,PointerUpEvent 表示指標不再接觸螢幕,或者 PointerCancelEvent 表示手指或觸控筆仍在設備上但不再和應用程式互動。
  • 一個操作互動會在 PointerUpEventPointerCancelEvent 其中一個事件結束。

Flutter 提供了 Listener Widget,它可以用來偵測上面提到的事件,你可以使用 Listener 包著 Widget 來處理這個 Widget 和子元素的事件。

手勢

理論上你可以使用 Listener 處理所有手勢,但實務上並非如此。我們可以在第二層處理,所謂手勢是從多個指標事件識別出來的,包含多點觸控。

在手勢層,Flutter 會使用手勢檢測和一個手勢競爭的模型去分析第一層取得的指標資訊來決定觸發的手勢事件。

手勢的種類:

  • 點擊:在螢幕上單點點擊或觸控
  • 連點:在同一個位置快速點擊兩下
  • 長按:與點擊相似,但手指會保持接觸螢幕更長的時間
  • 拖拉:從某個位置接觸螢幕,然後水平或垂直移動,最終在另一個位置停止接觸螢幕
  • 平移:類似拖拉事件,但方向不限;平移手勢可包含水平和垂直的移動
  • 縮放:使用兩個手指觸摸並移動螢幕來實現縮放手勢。類似於放大/縮小操作。

類似 Listener ,Flutter 提供了 GestureDetector Widget 其包含了上面事件的 callback,你可以依據希望的互動使用。

GestureDetector

接著,讓我們建立一個使用 GestureDetector 的範例:

class DestinationLike extends StatefulWidget {
  DestinationLike ({ Key? key }): super(key: key);
  
  @override
  State<DestinationLike> createState() => _DestinationLikeState();
}

class _DestinationLikeState extends State<DestinationLike> {
  int _counter = 0;
  
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
    	onTap: () {
        setState(() {
          _counter++;
        });
      },
      
      child: Container(
      	color: Colors.grey,
        child: Text(
        	"Like count: $_counter",
        ),
      ),
    );
  }
}

在上面的範例我們有一個狀態 Widget 叫 DestinationLike 搭配其狀態類別 _DestinationLikeState,這個 Widget 會紀錄一個目的地被點了幾次 Like。這裡使用了狀態來記錄使用者的點擊操作。而狀態類別的部分和之前學習的一樣必須有一個 build 方法。最外層的 GestureDetectorchild 屬性,我們放入 ContainerText 當我們點擊這個範圍的時候它會偵測到點擊的操作。

關於 HitTestBehavior:

  1. deferToChild(延遲至子元素) :只有當子元素被點擊時,父元素才會響應。 例如,一個包含多個按鈕的容器。你希望只有點擊按鈕時才有反應,而不是點擊按鈕之間的空白處。

  2. opaque(不透明) :元素會攔截所有的點擊,不讓事件傳遞到下面的元素。 例如:一般的按鈕或卡片。當你點擊時,只有這個元素會響應,下面的元素不會被觸發,類似於網頁的 e.stopPropagation()

  3. translucent(半透明) :元素會響應點擊,同時也允許下面的元素接收到這個事件。例如,一個覆蓋在地圖上的資訊卡片。你可以和地圖互動,同時卡片也能檢測到這些互動。

GestureDetector(
  behavior: HitTestBehavior.deferToChild,
  child: Container(
    child: Row(children: [Button1(), Button2()]),
  ),
)

單點 - Tap

單點點擊手勢就是當我們觸碰螢幕上某個位置時,然後在同一點或非常接近的地方離開結束手勢。也就是上面的範例我們使用 GestureDetectoronTap 方法來接收對應操作行為。

GestureDetector(
	onTap: () {
    // ...
  }
)

onTap 屬性需要對應一個函式執行當操作發生時的行為。如果你需要更細節的操作還有其他屬性:

  • onTapDown :當點擊到螢幕時
  • onTapUp:點擊然後手指離開螢幕時的操作
  • onTapCancel :手勢系統認為有可能是單點操作(已經觸發了 onTapDown),但實際上並沒有完成單點操作。(例如,手指只是按住然後滑動了)。

使用 GestureDetector 來接收手勢概念非常簡單。其他還有:

連點 - Double Tap

連點的條件和單點非常類似,但增加了必須要快速連續點擊的條件。程式範例如下:

GestureDetector(
	onDoubleTap: () {
    setState(() {
      _counter++;
    });
  }
)

和上一個範例唯一的差異就是我們使用 onDoubleTap 參數,同樣使用直接代入函式作為參數然後函式將在每次在同一個螢幕位置連擊的時候被呼叫。

如果需要監聽更細緻的連擊手勢可以使用:

  • onDoubleTapDown:此事件在預判使用者進行連點操作第一次點擊的時候觸發。
  • onDoubleTapUp:在連點完成,第二次點擊離開螢幕時觸發。
  • onDoubleTapCancel:手指在螢幕上按了一下(觸發了onDoubleTapDown),也就是系統判斷有可能是連擊的開始,但最後確認不是連擊時觸發。

注意:一些粒度或局部的手勢事件可能會在一個完整手勢被確定之前就觸發了,因此可能和正確手勢不一致。舉例來說 onTapDown 可能在 onDoubleTap 確認之前就觸發了。但最終卻不會觸發 onTap 這是因為手勢判定為 onDoubleTap 而單點事件在「手勢競爭模型」中輸了。因此在使用這些事件或者多個事件的時候要特別注意。

長按 - Press & Hold

長按螢幕類似於單點,但和螢幕接觸事件更長,並且在手指離開螢幕之前沒有移動位置。

下面範例程式非常類似上面的範例:

GestureDetector(
	onLongPress: () {
    setState(() {
      _counter++;
    });
  },
)

同樣的差異是這次換成使用 onLongPress ,此函式會在執行長按的時候觸發執行。

若希望監聽相關更細粒度的操作手勢可以使用:

  • onLongPressStart:當手指和螢幕接觸且被識別為長按時觸發
  • onLongPressEndonLongPressUp :當長按被觸發,然後離開螢幕時觸發。兩者都會觸發,先觸發 onLongPressEnd 接著 onLongPressUponLongPressEnd 事件會提供了關於手勢結束的詳細資訊,而 onLongPressUp 更多被視為手勢完全結束,不包含其他資訊。
  • onLongPressMoveUpdate:當觸發 onLongPressStart 然後位置發生移動時觸發。

在實務上很有可能在同一個 GestureDetector 使用很多不同的手勢,GestureDetector 會解析決定觸發對應的事件。由於 Flutter 有自己的解析識別機制(手勢競爭模型),在實務上如果多個事件同時使用時應謹慎測試其行為。

拖移、滑動、縮放 - Drag, Pan, Scale

拖移、滑動、縮放手勢非常類似。我們需要決定何者比較適合所需的情景,而且它們無法一起在同一個 GestureDetector 使用。

其中拖移手勢被區分為垂直和水平兩種手勢,甚至連 callback 都不一樣。

水平拖移 - Horizontal Drag

水平拖移如同名稱,即手指放置在螢幕上然後水平方向拖移,然後放開。和單點點擊或長按不同,監聽拖移的主要是為了能立刻對拖移的動作做出反應,比如移動一個 Widget。因此水平拖移手勢的 callback 控制的粒度程度都會更細。

讓我們來看看一個水平拖移的簡單範例:

GestureDetector(
	onHorizontalDragStart: (DragStartDetails details) {
    setState(() {
      _move = Offset.zero;
      _dragging = true;
    });
  },
  onHorizontalDragUpdate: (DragUpdateDetails details) {
    setState(() {
      _move += details.delta;
    });
  },
  onHorizontalDragEnd: (DragEndDetails details) {
    setState(() {
      _dragging = false;
    });
  }
)

如上所見,比起單點點擊和長按操作,這次我們需要處理更多行為。

針對水平拖移我們需要三個參數:

  • onHorizontalDragStart:當手指接觸到螢幕並且被識別可能是水平拖移時觸發,callback 會收到 DragStartDetails 引數,其包含全域和區域座標資訊 - gloalPositionlocalPosition
  • onHorizontalDragUpdate:當觸發 onHorizontalDragStart 之後開始水平拖移時觸發。callback 會收到 DragUpdateDetails 引數,其包含移動座標的資訊 - delta 和全域、區域座標資訊
  • onHorizontalDragEnd:當手指開始水平拖移並且完成離開螢幕時觸發。 callback 會收到 DragEndDetails 引數,其包含停止接觸時的速度,這些訊息對於處理快速滑動的手勢非常實用。

上面例子我們通過這些事件來處理兩個狀態進而對應管理顯示的東西。

_dragging 設定為 true 表示正在拖移,false 表示完成拖移。_move 用於記錄累積的偏移量,因為這是水平拖移因此 delta 只會在水平方向移動時改變。

delta 是每次拖移的變化量,也就是這是相對於上一次位置的偏移量。比如其實點 x 為 0,第一次觸發事件時 delta 為 2 表示從起始點移動了 2 ,當前總移動量為 2。下一次觸發時假設 delta 為 1,表示位置又移動了 1,因此總共移動了3。通過累加就是整個拖移的距離。

globalPosition 和 localPosition

在 Flutter 中,GestureDetector 可以偵測手勢。它提供了很多相關資訊,其中 globalPositionlocalPosition 是兩個非常重要的屬性。

  • globalPosition 手勢接觸點在整個螢幕(全域座標)中的位置,也就是不管在應用程式的那個位置都是基於整個螢幕左上角來計算座標
  • localPosition 手勢接觸點座標是相對於目前互動的 Widget 比如所一個按鈕,則為和該按鈕左上角之間的偏移量。 localPosition 時基於當前組件來計算座標

垂直拖移 - Vertical Drag

垂直拖移如同其名,即手指放置到螢幕上,然後垂直移動,然後放開。垂直拖移就是垂直方向的水平拖移。唯一的差異就是 callback 屬性,它們是 onVerticalDragStartonVerticalDragUpdateonVerticalDragEnd 。就程式碼來說差異只有 delta 的不同。水平只會有水平偏移量,垂直也是只有垂直偏移量。

滑動 - Pan

滑動類似於水平或垂直拖移,當手指接觸螢幕,但移動的方向不限於單純垂直或水平,而是混合。

主要的差異和之前一樣還是 callback 屬性的不同 - onPanStartonPanUpdateonPanEnd。滑動的話,兩個方向的偏移量都會計算也因此 DragUpdateDetails 的 delta 兩個方向都會有偏移量。

縮放 - Scale

縮放手勢其實就是多點觸控的滑動。本質上是由兩個或多個手指同時進行的滑動 Pan 所組成。當手指靠近或遠離時,Flutter 會計算手指間距的變化,並將這種變化作為縮放比例進行處理。範例如下:

GestureDetector(
	onScaleStart: (ScaleStartDetails details) {
    _scale = 1.0;
    _resizing = true;
  },
  onScaleUpdate: (ScaleUpdateDetails details) {
    setState(() {
      _scale = details.scale;
    });
  },
  onScaleEnd: (ScaleEndDetails details) {
    setState(() {
      _resizing = false;
    });
  }
  
)

和拖移類似我們使用了相關事件管理了兩個狀態:

  • _resizing :當設為 true 的時候表示縮放開始,而 false 表示結束
  • _scale:此狀態儲存了縮放的比例值,可以用來變更組件的大小

如你所見,縮放的 callback 和拖移非常類似也是多個事件分別接收相關參數 ScaleStartDetailsScaleUpdateDetailsScaleEndDetails,其中的參數可以協助我們進行縮放。

Material Design 的手勢

雖然 GestureDetector 是一個非常好用的組件,但大部分的情況,我們不會使用,這是因為內建組件已經有對應的手勢了。大概只有在比較進階的互動模式你會需要使用 GestureDetector 例如遊戲,不過理解 Flutter 支援的底層功能也非常有幫助。

Material Design 和 Cupertino 組件內部也使用 GestureDector 將許多手勢抽象化變成簡單的建構子參數。

例如 Material Design 的 ElevatedButton 內嵌一個特殊的組件叫做 InkWell 除了賦予存取單點手勢事件外,也同時建立水波紋效果。然後其 onPressed 屬性可以設定對應點擊的操作,實現按鈕的對應行為。這種設計我們可以輕鬆的將點擊手勢和對應行為操作加到組件上。

ElevatedButton(
	onPressed: () {
    print("Destination like");
    setState(() {
      _counter++;
    });
  },
  child: Text("Like destination"),
)

child 屬性是一個 Text 組件顯示在按鈕中,並且點擊按鈕會執行我們傳入的函式。其他之前學習的按鈕例如 TextButtonIconButton 都使用類似的屬性參數搭配一個 callback 函式。

現在我們可以修改 DestinationWidget 使其顯示讚的數量,但先緩緩我們需要學習更多關於狀態組件的知識。

進階學習:

總結

畢竟,在一個行動應用程式來說,操作至關重要。這裡我們希望全面的介紹了手勢相關知識,順帶提及一些進階的關鍵字幫助我們在後續的學習查詢資料。


上一篇
Day 10 認識內建組件 Widgets
下一篇
Day 12 深入狀態組件的生命週期
系列文
Flutter 開發實戰 - 30 天逃離新手村27
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言