原始指標事件


在手機、平板上,主要是透過觸控螢幕進行操作,在 Flutter 中,除了一些按鈕等元件,大部份元件本身不能傾聽操作相關事件,然而,實際上也可以為這類元件加上一些事件傾聽。

Flutter 系統將相關事件分為兩個層次:原始指標事件(Raw pointer events)與手勢(Gestures)。

這邊談一下原始指標事件,指標描述的是手指或觸控筆在螢幕上的位置,想傾聽指標的相關事件,可以透過 Listerner 類別,就撰寫本文的時間點,可以傾聽的事件有:

  • onPointerCancel
  • onPointerDown
  • onPointerMove
  • onPointerSignal
  • onPointerUp

這就是原始指標事件之所以「原始」的原因,因為不包含一些較高階的手勢操作,像是拖曳、雙連觸(Double tap)等較高級的事件;手機、平板可以接上滑鼠,早期的 Listerner 可以傾聽滑鼠事件,不過現在相關的事件已經被廢棄,職責分給了 MouseRegion

Listerner 本身是個 Widget,有個 child 可以指定子元件,因此最簡單的傾聽範例就是:

import 'package:flutter/material.dart';

void main() => runApp(
  MaterialApp(
    home: Scaffold(
      appBar: AppBar(title: Text('Openhome.cc')),
      body: Center(
        child: Listener(
          child: Container(
            width: 300,
            height: 200,
            color: Colors.blue,
          ),
          onPointerDown: (event) => print('Down: ${event.position.dx}, ${event.position.dy}'),
          onPointerMove: (event) => print('Move ${event.position.dx}, ${event.position.dy}'),
          onPointerUp: (event) => print('Up ${event.position.dx}, ${event.position.dy}'),
        )
      )
    ),
  )
);

這個範例很簡單,就不貼操作示範的圖了。看來是包在想傾聽的子元件上,就可以傾聽該元件的原始事件,只不過,實際的 UI 不會這麼單純,來進一步看看,若在 Container 中設個子元件並加上另一個 Listener 會如何?

import 'package:flutter/material.dart';

void main() => runApp(
  MaterialApp(
    home: Scaffold(
      appBar: AppBar(title: Text('Openhome.cc')),
      body: Center(
        child: Listener(
          child: Container(
            width: 300,
            height: 200,
            color: Colors.blue,

            child: Center(
              // 加上個 Listener,傾聽其子元件
              child: Listener(
                child: Text('按我'),
                onPointerDown: (_) => print('Inner Down'),
              ),
            ),

          ),
          onPointerDown: (_) => print('Outer Down'),
        )
      )
    ),
  )
);

來看一下執行結果,分點在文字與其外圍點點看:

原始指標事件

在點選文字時,Container 父元件 Listener 也被觸發了,這令人聯想到 DOM 事件浮昇,不過在 Flutter 中正確的說法是,「子元件命中測試(hit test)」通過,因而父裔元件的「Listener 的命中測試」就通過,不過這只是 Listener 預設的命中測試行為,這個行為可以藉由 behavior 來控制,預設是 HitTestBehavior.deferToChild,另外還可以指定為 HitTestBehavior.opaqueHitTestBehavior.translucent

命中測試的實現,是在 RenderProxyBoxWithHitTestBehavior 的原始碼中:

  @override
  bool hitTest(BoxHitTestResult result, { Offset position }) {
    bool hitTarget = false;
    if (size.contains(position)) {
      hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
      if (hitTarget || behavior == HitTestBehavior.translucent)
        result.add(BoxHitTestEntry(this, position));
    }
    return hitTarget;
  }

  @override
  bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;

hitTest 用來進行命中測試,如果指標位置(position)在 Listener 範圍(size)之內(也就是 size.contains(position) 該行),hitTestChildren 測試子元件是否命中,也就是指標是否在子元件繪製範圍內,子元件必須是可見的,透明的子元件不會命中。

因此上一個範例中,若將 Text('按我') 改為 Container(width: 50, height: 30),怎麼按中間位置,都不會顯示「Inner Down」的訊息,只會顯示「Outer Down」的訊息。

從原始碼中可以看到,如果 hitTestChildren 結果是 false,然而 behaviorHitTestBehavior.opaque 的話,目前 Listener 命中測試也會通過,什麼時候會想要這種行為呢?來看看以下的情況:

import 'package:flutter/material.dart';

void main() => runApp(
  MaterialApp(
    home: Scaffold(
      appBar: AppBar(title: Text('Openhome.cc')),
      body: Center(
        child: Container(
          width: 300,
          height: 200,
          color: Colors.blue,

          child: Listener(
            // behavior: HitTestBehavior.opaque,
            child: Center(
              child: Text('按我'),
            ),
            onPointerDown: (_) => print('Down'),
          ),

        )
      )
    ),
  )
);

在這個範例中,Listener 的範圍與 Container 相同,如果你在 Text 繪製範圍內按下,子元件命中測試通過,因而父裔的 Listener 的命中測試通過,如果不是點在 Text 上,子元件命中測試不通過,就不會顯示「Down」的訊息。

若是將註解符號去除,若不是點在 Text 上,子元件命中測試不通過,然而因為 behaviorHitTestBehavior.opaque,從方才的 hitTest 來看,Listener 可以通過命中測試,這時就會顯示「Down」的訊息。

簡單來說,opaque 代表著不透明,HitTestBehavior.opaque 也就是將 Listener 當成是個不透明元件,在指標落於 Listener 範圍時,就視為命中測試,因此底下的範例中,Listener 雖然沒有子元件,只要在藍色方塊中按下,都會顯示「Down」的訊息。

import 'package:flutter/material.dart';

void main() => runApp(
  MaterialApp(
    home: Scaffold(
      appBar: AppBar(title: Text('Openhome.cc')),
      body: Center(
        child: Container(
          width: 300,
          height: 200,
          color: Colors.blue,

          child: Listener(
            behavior: HitTestBehavior.opaque,
            onPointerDown: (_) => print('Down'),
          ),

        )
      )
    ),
  )
);

在方才的 hitTest 中可以看到,如果 hitTargettrueresult 會收集命中測試的物件,被收集的物件會被發送事件。

如果 hitTargetfalse,然而 behaviorHitTestBehavior.translucent,也會收集物件,這時意謂著就算 Listener 命中測試失敗,只要 behaviorHitTestBehavior.translucent,一樣可以收到事件。

來看看底下的範例:

import 'package:flutter/material.dart';

void main() => runApp(
  MaterialApp(
    home: Scaffold(
      appBar: AppBar(title: Text('Openhome.cc')),
      body: Center(
        child: Container(
          width: 300,
          height: 200,
          color: Colors.blue,

          child: Listener(
            behavior: HitTestBehavior.translucent,
            child: Center(
              child: Text('按我'),
            ),
            onPointerDown: (_) => print('Down'),
          ),

        )
      )
    ),
  )
);

嗯?跟更之前的範例相同?只不過 HitTestBehavior.opaque 改為 HitTestBehavior.translucent?就執行效果來看,兩者是相同的,然而意義不同,因為 HitTestBehavior.translucent 無論如何都會收到事件通知(就算命中測試失敗),這就像是把 Listener 當成一個透明膜,只要指標在 Listener 範圍中,就可以傾聽事件(無論命中測試成功或失敗)。

來看另一個堆疊元件的例子:

import 'package:flutter/material.dart';

void main() => runApp(
  MaterialApp(
    home: Scaffold(
      appBar: AppBar(title: Text('Openhome.cc')),
      body: Center(
        child: Stack(
          children: [
            Listener(
              child: Container(
                width: 300,
                height: 200,
                color: Colors.blue,
              ),
              onPointerDown: (event) => print("堆疊底部"),
            ),
            Listener(
              onPointerDown: (event) => print("有一層膜"),
              // behavior: HitTestBehavior.translucent,
            )
          ],
        )
      )
    ),
  )
);

就這個例子來說,按藍色範圍,只會顯示「堆疊底部」,然而,若去掉註解符號,堆疊頂端罩的膜就能收到事件,按藍色範圍,就會顯示「有一層膜」與「堆疊底部」的訊息。

有的時候,會想要讓子元件無法參與指標事件,這時可以透過 IgnorePointerAbsorbPointer,兩者的差別在於,前者不能參與命中測試而後者可以,例如:

import 'package:flutter/material.dart';

void main() => runApp(
  MaterialApp(
    home: Scaffold(
      appBar: AppBar(title: Text('Openhome.cc')),
      body: Center(
          child: Listener(
            child: AbsorbPointer(
              child: Listener(
                child: Container(
                  width: 300,
                  height: 200,
                  color: Colors.blue,
                ),
                onPointerDown: (_) => print('Container'),
              )
            ),
            onPointerDown: (_) => print('AbsorbPointer'),
        )
      )
    ),
  )
);

上例中,點選藍色範圍,只會出現「AbsorbPointer」訊息,因為 AbsorbPointer 不會有指標事件,如果將 AbsorbPointer 改為 IgnorePointer,就怎麼點都不會有訊息了。