Flutter 必知必會系列 —— Navigator 的開始 Overlay

語言: CN / TW / HK

theme: simplicity-green

這是我參與2022首次更文挑戰的第17天,活動詳情檢視:2022首次更文挑戰

Overlay 的場景是在螢幕上展示懸浮窗,比如 Flutter 版本的 Toast,任意位置的 PopWindow 等等。其實,Overlay 也與 Flutter 的路由管理有著密不可分的聯絡,Navigator 就是使用 Overlay 實現了頁面疊加的效果。作為 Navigator 系列的開始,我們就先介紹 Overlay

企業微信截圖_1479b9b9-56ed-4e6a-b68f-a026f85430d9.png

Overlay 是什麼

我們先看文件是怎麼介紹的。

``` A stack of entries that can be managed independently. Overlay 是一個 維護著entries 的 Stack,並且每一個 entry 是自管理的

Overlays let independent child widgets "float" visual elements on top of other widgets by inserting them into the overlay's stack. The overlay lets each of these widgets manage their participation in the overlay using OverlayEntry objects. Overlay 讓它棧中的 Widget 懸浮在螢幕的其他元件之上。並且 OverlayEntry 可以實現懸浮元件 的自管理,比如插入、移出等等

Although you can create an Overlay directly, it's most common to use the overlay created by the [Navigator] in a [WidgetsApp] or a [MaterialApp]. The navigator uses its overlay to manage the visual appearance of its routes. 雖然開發者可以直接建立一個 Overlay 元件,但是大多數的時候開發者可以直接使用 Navigator 建立的 Overlay。Navigator 就是使用它所建立的 Overlay 來管理路由頁面

The [Overlay] widget uses a custom stack implementation, which is very similar to the [Stack] widget. The main use case of [Overlay] is related to navigation and being able to insert widgets on top of the pages in an app. To simply display a stack of widgets, consider using [Stack] instead. Overlay 和 Stack 非常相似,Overlay 的應用場景主要是:路由管理和懸浮視窗。其他的場景可以 直接使用 Stack 元件

``` 總結下來就是:

Overlay 中維護了一個 OverlayEntry 棧,並且每一個 OverlayEntry 是自管理的

Navigator 已經建立了一個 Overlay 元件,開發者可以直接使用,並且通過 Overlay 實現了頁面管理

Overlay 的應用場景有兩個:實現懸浮視窗的功能,實現頁面疊加

上面的文件介紹了 Overlay 的基本作用,我們再看 Overlay 的程式碼

````dart

///...省略assert class Overlay extends StatefulWidget {

const Overlay({ Key? key, this.initialEntries = const [], this.clipBehavior = Clip.hardEdge, }) : super(key: key);

final List initialEntries;

final Clip clipBehavior;

static OverlayState? of( BuildContext context, { bool rootOverlay = false, Widget? debugRequiredFor, }) { final OverlayState? result = rootOverlay ? context.findRootAncestorStateOfType() : context.findAncestorStateOfType(); return result; }

@override OverlayState createState() => OverlayState(); } ````

Overlay 是一個 StatefulWidget 元件,我們可以按著 StatefulWidget 的方式去理解它,它的顯示邏輯和功能都封裝在了 OverlayState 中。

不同的是它提供了一個靜態的 of 方法,那麼我們就可以使用 of 拿到 OverlayState,執行OverlayState 的方法了。

這裡簡單介紹一個查詢的機制,BuildContext 是抽象的介面,代表了 Widget 樹的位置,ElementBuildContext 真正的實現。

``dart /// Returns the nearest ancestor widget of the given typeT`, which must be the /// type of a concrete [Widget] subclass.

@override T? findAncestorWidgetOfExactType() { Element? ancestor = _parent; while (ancestor != null && ancestor.widget.runtimeType != T)//第一處 ancestor = ancestor._parent; return ancestor?.widget as T?; } `` 我們看到,查詢的過程就是一個while 迴圈,因為是層序的向上查詢,所以演算法的角度是O(N)` 的

OverlayState 提供了插入,重組等方法,後面在詳細看

現在我們知道了 Overlay 是什麼,下一步嘗試使用 Overlay

Overlay 使用

Overlay 的使用大家可以參考這一篇文章 👉 Flutter 使用 Overlay 實現全域性彈窗

總結下來 Overlay 的使用步驟如下:

第一步:向上查詢到 OverlayState

OverlayState state = Overlay.of(context);

第二步:構造 OverlyEntry

OverlayEntry overlayEntry = OverlayEntry(  builder: (context){    return _buildWidget(); } );

第三步:構造懸浮的 Widget

Widget _buildWidget(){  return Text("懸浮文字"); }

上面就是使用的具體步驟。額外的注意點有以下幾點。

懸浮在指定的位置

上面我們已經知道了 Overlay 就是 Stack 元件,那麼我們的懸浮內容,就可以用Positioned 包裹,給它指定的 top、left 即可。

比如 下面的程式碼,

Positioned( top: 100, right: 100, child: Text("懸浮文字"), ); 上面的文字元件,就可以顯示在(100,100)的位置。如下圖:

但是有的時候我們想根據某個元件來確定顯示的位置,這種情況下我們就需要拿到該元件的位置了。 但是 Widget 是不包含位置資訊,位置資訊包含在 Render 之中。這裡提供兩個思路來獲取位置資訊。

GlobalKey 獲取位置資訊

dart Widget _buildWidget() { RenderBox renderBox =     globalKey.currentContext.findRenderObject() as RenderBox; Offset position = renderBox.localToGlobal(Offset.zero); ​ return Positioned(   left: position.dx,   top: position.dy,   child: Text("懸浮文字"), ); }

這個方法的前提是需要將 GlobalKey 作為 Key 屬性賦值給想要錨定的元件。這樣就可以通過GlobalKey 拿到錨定元件的 RenderObject 了。\ 有了 RenderObject 我們就可以拿到尺寸、位置等資訊。

自定義 RenderObject 獲取位置資訊

Flutter 有一種冒泡機制的 Notification,我們可以將佈局冒泡出去。(其實不僅僅是位置,還可以是尺寸等資訊),具體操縱是這樣的。

第一步:定義攜帶位置資訊的 Notification

class PositionedNotification extends Notification {  Offset offset;//位置資訊 }

第二步:定義監聽位置資訊的 Widget

```dart class PositionedNotificationNotifier extends SingleChildRenderObjectWidget {//第一處 const PositionedNotificationNotifier({ Key key, Widget child, }) : super(key: key, child: child);

@override _PositionedCallback createRenderObject(BuildContext context) { return _PositionedCallback(onLayoutChangedCallback: (offset) { (PositionedNotification()..offset = offset).dispatch(context);//第二處 }); } }

class _PositionedCallback extends RenderProxyBox { _PositionedCallback({ RenderBox child, @required this.onLayoutChangedCallback, }) : assert(onLayoutChangedCallback != null), super(child);

final Function(Offset) onLayoutChangedCallback; Offset tmp;

@override void performLayout() { super.performLayout();

SchedulerBinding.instance.addPostFrameCallback((time) { //第三處
  tmp = localToGlobal(Offset.zero);
  onLayoutChangedCallback(tmp);//第二處
});

} }

```

首先看第一處,如果我們想要自定義帶有 RenderObject 資訊的元件的話,就可以繼承自SingleChildRenderObjectWidgetMultiChildRenderObjectWidget,而不用直接繼承自 RenderObject。

再看第二處, 我們在 performLayout 中,將位置資訊回撥給 onLayoutChangedCallback 中,並通過通知的 dispatch 機制,傳送出去。

這裡注意看第三處。使用了 SchedulerBinding 的幀回撥,等首幀繪製完成之後,採取獲取尺寸資訊。否則的話 還沒有繪製完就去拿資訊,就會報錯,因為這個時候整個佈局過程還沒完成。

如果不想要這麼做的話,我們可以將這個動作放在paint方法中。因為paint的時候佈局流程已經結束了。如下:

dart @override void paint(PaintingContext context, Offset offset) {  super.paint(context, offset);  tmp = localToGlobal(Offset.zero);  onLayoutChangedCallback(tmp); }

第三步:使用冒泡

NotificationListener<PositionedNotification>(  onNotification: (positionedNotification) {    print(positionedNotification.offset);    return true; },  child: PositionedNotificationNotifier(child: Text("sss")), )

這就是兩種獲取位置資訊的方法:一種是通過 GlobalKey,一種是通過自定義 RenderObject,當然自定義 RenderObject 不僅僅冒泡一種方式,還可以通過回撥的方式,殊途同歸,都是參與到佈局過程中,將資訊回調出來。

關於佈局等三棵樹的其他資訊,可以看這裡 👉 Flutter 必知必會系列 —— 三棵樹最終章

懸浮窗可隨意拖動

上面我們通過 Position 實現了顯示指定位置的功能,現在再加一點點自由拖動。有兩種實現的途徑:第一種就是自己新增手勢,第二種就是使用系統的元件。我們以第一種手勢為例。

示例程式碼如下:

class __XXXState extends State<XXXWidget> {  double left;  double top; ​  @override  void initState() {    super.initState();    left = widget.initX ?? 0;    top = widget.initY ?? 0; } ​  @override  Widget build(BuildContext context) {    return Positioned(      top: top,      left: left,      child: GestureDetector(//第一處          onPanUpdate: (detail) {            setState(() {//第二處              top += detail.delta.dy;              left += detail.delta.dx;           });         },          child: widget.floatWidget),   ); } }

第一處:在原有元件的基礎上,增加了手勢識別元件 GestureDetecto

第二處:跟隨手勢,實時 setState 新的座標。delta 就是 xy 的偏移量。

這就是基本的實現過程,當然我們也可以在裡面加入類似邊緣吸附的效果。

系統元件中實現拖拽的有:DraggableDragTarget,這一對的具體實現過程可以參考 👉 Flutter 拖拽控制元件Draggable看這一篇就夠了

現在我們基本就可以靈活使用 Overlay 實現任意型別的懸浮窗了。下面我們看為啥它可以做到這一點。

Overly 是怎麼做到的

Overly 的顯示和管理和 OverlayEntry 密不可分,我們先從 OverlayEntry 開始。

OverlayEntry 是核心

A place in an [Overlay] that can contain a widget. Overlay 中的一個小單元,可以包含一個 Widget ​ Overlay entries are inserted into an [Overlay] using the [OverlayState.insert] or [OverlayState.insertAll] functions. To find the closest enclosing overlay for a given [BuildContext], use the [Overlay.of] function. 我們可以使用 [OverlayState.insert] 或者 [OverlayState.insertAll] 方法將 OverlayEntry 插入到 Overlay 中。並且可以使用Overlay.of向上找 到最近的OverlayState ​ An overlay entry can be in at most one overlay at a time. To remove an entry from its overlay, call the [remove] function on the overlay entry. OverlayEntry 在移出之前只能在 Overlay 中插入一次。可以呼叫 remove 方 法移除這個 entry ​ Because an [Overlay] uses a [Stack] layout, overlay entries can use [Positioned] and [AnimatedPositioned] to position themselves within the overlay. 由於 Overlay 內部使用的是 Stack 元件,所以 overlayEntry 包含的元件可 以是 [Positioned] 和 [AnimatedPositioned] 總結一下:

OverlayEntry 中承載這 Overlay 要顯示的 Widget,並且是自管理的,可以插入和刪除

Overlay 是 Stack 元件的包裹,所以 OverlayEntry 的 Widget 可以使用 Position 來顯示在指定的位置

opaquemaintainState 屬性影響著三棵樹的形成

下面我們看 OverlayEntry 是怎麼包裹我們給它的元件的。

```dart class OverlayEntry extends ChangeNotifier {

OverlayEntry({ required this.builder, //第一處 bool opaque = false, bool maintainState = false, }) : _opaque = opaque, _maintainState = maintainState;

final WidgetBuilder builder; //..省略程式碼 } ``` OverlayEntry 的作用是要承載我們想要顯示的 Widget。但是我們看到它並沒有持有我們的 Widget 引用,而是持有一個 builder,而我們知道 builder 是一個方法引數,並沒有實體,所以在顯示的時候,肯定會呼叫它,我們看它是那裡呼叫的。

OverlayStatebuild 方法中,為每一個 OverlayEntry 構建了一個 _OverlayEntryWidget 元件。

企業微信截圖_c131dc44-905c-4095-b5cf-1eab30f82aaf.png _OverlayEntryWidget 也是 StatefulWidget 元件,它的邏輯封裝在 _OverlayEntryWidgetState 中。

```dart class _OverlayEntryWidgetState extends State<_OverlayEntryWidget> { @override void initState() { super.initState(); widget.entry._updateMounted(true); }

@override void dispose() { widget.entry._updateMounted(false); super.dispose(); }

@override Widget build(BuildContext context) { return TickerMode( enabled: widget.tickerEnabled, child: widget.entry.builder(context),//第一處 ); }

void _markNeedsBuild() { setState(() { / the state that changed is in the builder / }); } } ``` 我們看第一處,這裡呼叫了我們上面說的 builder 。所以,總結下來就是:每一個 OverlayEntry 都有一個 _OverlayEntryWidget 元件,螢幕上實際掛載的是 _OverlayEntryWidget 元件。

Entry包裝.gif

官方一值說自管理,自管理是什麼呢?就是重新整理和刪除!!

具體的邏輯是這樣的~

```dart class OverlayEntry extends ChangeNotifier {

//... 省略程式碼

OverlayState? _overlay;//第一處 final GlobalKey<_OverlayEntryWidgetState> _key = GlobalKey<_OverlayEntryWidgetState>(); //第一處

void remove() { //第二處 final OverlayState overlay = _overlay!; _overlay = null; if (!overlay.mounted) return;

overlay._entries.remove(this);
if (SchedulerBinding.instance!.schedulerPhase == SchedulerPhase.persistentCallbacks) {
  SchedulerBinding.instance!.addPostFrameCallback((Duration duration) {
    overlay._markDirty();
  });
} else {
  overlay._markDirty();
}

}

void markNeedsBuild() {//第三處 _key.currentState?._markNeedsBuild(); } } ``` 首先看第一處:_overlay_key 是非常巧妙的。對開發者來說,關閉就是 Overly 元件不顯示某一個元件,開發者不引用 Overly,那髒活肯定得有人幹,就是 OverlayEntry 來乾的。

_overlay 就是顯示懸浮的 OverlayOverlayState 引用,在插入的時候會為這個值賦值。

企業微信截圖_89bbaa17-dd8e-4df9-a8d4-22ce43d3de81.png

_key就是包裹的 _OverlayEntryWidget 的 key,而且還是 GlobalKey,這樣就可以拿到包裹的元件,呼叫包裹元件的方法。

企業微信截圖_f922fb0f-8a64-465f-8306-786a4400414d.png

在 Overlay 的 build 方法中,為每個 OverlayEntry 生成 _OverlayEntryWidget 的時候,會用這個 key元件的 key 賦值。

所以 OverlayEntry 中持有了 Overlay 的實現邏輯 和 包裹元件的實現邏輯,在這個基礎上實現,刪除和重新整理就很順理成章了。

首先看刪除,刪除的邏輯在上面的第二處

```dart void remove() { final OverlayState overlay = _overlay!; _overlay = null; if (!overlay.mounted) return;

overlay._entries.remove(this); if (SchedulerBinding.instance!.schedulerPhase == SchedulerPhase.persistentCallbacks) { SchedulerBinding.instance!.addPostFrameCallback((Duration duration) { overlay._markDirty(); }); } else { overlay._markDirty(); } } `` 移除就比較簡單了,overlay` 的 _entries 就是 OverlayEntry 集合,從集合中把自己刪除,然後呼叫 overlay 的 _markDirty 方法,實質是 setState 方法重新整理就可以了。這樣懸浮窗就關閉了。

再看重新整理,重新整理就是 markNeedsBuild 方法

dart void markNeedsBuild() { _key.currentState?._markNeedsBuild(); } _key.currentState 就是包裹的 _OverlayEntryWidget 的 State 物件,它的 _markNeedsBuild 就是 setState。

dart void _markNeedsBuild() { setState(() { /* the state that changed is in the builder */ }); } 這樣我們給 OverlayEntry 的 builder 方法就會重新呼叫,從而實現重新整理效果。 總結下來就是:

Entry關係圖.png

build 方法才可以顯示

上面我們講了 OverlayStatefulWidget,顯示邏輯封裝在 OverlayState 中,和其他的 StatefulWidget 一樣,它的顯示也在 build 中。

在分析 build 之前,我們先說一下概念:Overlay 其實並不會保持所有的 Entry,而是僅僅保持需要的 Entry

Overly 僅僅顯示 不被上層遮擋(opaque = false)的

Overly 僅僅會重新整理 存在記憶體中(maintainState = true)的

我們看是如何做到這一點的。

@override Widget build(BuildContext context) { final List<Widget> children = <Widget>[]; bool onstage = true; int onstageCount = 0;//第一處 for (int i = _entries.length - 1; i >= 0; i -= 1) { final OverlayEntry entry = _entries[i]; if (onstage) { onstageCount += 1; children.add(_OverlayEntryWidget( key: entry._key, entry: entry, ));//第二處 if (entry.opaque) onstage = false;//第三處 } else if (entry.maintainState) { children.add(_OverlayEntryWidget( key: entry._key, entry: entry, tickerEnabled: false, ));//第二處 //第四處 } } return _Theatre( //第五處 skipCount: children.length - onstageCount, clipBehavior: widget.clipBehavior, children: children.reversed.toList(growable: false), ); } _Theatre 舞臺是 MultiChildRenderObjectWidget元件

在舞臺上的(顯示出來的)就是 onstage 的。不在舞臺上的(不顯示但是還活著的)就是 maintainState 的。

這裡有opaque屬性,opaque的意思是 完全不透明,在這裡就是完全覆蓋住了螢幕。

也就說,如果某一個人完全佔據了舞臺,那麼其他的人 就上不去舞臺了。

再看 這個人還能不能活,也就是 maintainState 屬性是不是 true。如果ture的話,那就是不在舞臺上,後面可能會上到舞臺上。

把這些在舞臺上人,放到 Stack 中,所以就可以顯示啦。

我們看 build 的程式碼。

第一處的程式碼:先宣告初始化的標記位onstage 預設是 true 的,也就是第一個是可以上舞臺的。onstageCount 預設是 0 ,表示當前舞臺上沒有人。

第二處的程式碼:對在舞臺上的Entry進行包裝,上面我們已經分析過了。

第三處的程式碼:看從第幾個人開始,不用等上舞臺了。前一個 Entry 的opaque 屬性是否是 true。如果前一個人完全佔據了舞臺,那麼就走到了第四處。

第四處的程式碼:如果一個 entry,死皮賴臉不顯示也要等待,那麼也會新增到陣列中,但是計數器不會 +1。除此之外,我們看 TickerMode 的 enabled 屬性是 false,就是說裡面的動畫也不會執行。

看到這裡我們會發現:children 陣列的數量小於等於我們設定的 Entry 陣列的數量。 因為有一波 entry 可能不是死皮賴臉的,它的 maintainState 是 false,就會被捨棄掉。

第五處的程式碼:把陣列交給舞臺_Theatre_Theatre 幫開發者處理了顯示邏輯。

所以,結論就是 Overlay 其實並不會保持所有的 Entry,而是僅僅保持需要的 Entry 。只要一個 Entry 不透明,後續不想存活的 Entry,都不會掛載到樹上。並且顯示的邏輯是 _Theatre 實現的,我們暫且先知道 _Theatre 就是一個定製化的 Stack,鑑於篇幅下一篇專門介紹 _Theatre。

簡單的插入和刪除

Overlay 提供了兩種插入的方式,插入單個 OverlayEntry 和 插入一組 OverlayEntry

Overlay.of(context)?.insert(entry); //插入單個

Overlay.of(context)?.insertAll(entries); //插入一組

上面我們知道 Overlay.of(context) 實際就是 OverlayState ,所以就走到它裡面的插入方法。

插入單個

dart void insert(OverlayEntry entry, { OverlayEntry? below, OverlayEntry? above }) { entry._overlay = this; //第一處 setState(() { _entries.insert(_insertionIndex(below, above), entry);//第二處 }); }

entry 是想要插入的 OverlayEntry\ below 如果設定了,那麼 OverlayEntry 會插在 below 下面\ above 如果設定了,那麼 OverlayEntry 會插在 above 上面

第一處就是我們上面提到的,為 OverlayEntry 繫結 OverlayState

第二處就是在陣列中插入 OverlayEntry 並重新執行 build 方法(看上面的build 方法介紹哦)。這裡有一點注意一下,_insertionIndex 會找到合適的位置,如果below 和 above 都不設定的話就是在陣列的頂部插入,也就是顯示的視窗的最前面~~~

插入多個

dart void insertAll(Iterable<OverlayEntry> entries, { OverlayEntry? below, OverlayEntry? above }) { if (entries.isEmpty) return; for (final OverlayEntry entry in entries) { entry._overlay = this; } setState(() { _entries.insertAll(_insertionIndex(below, above), entries); }); } 和插入單個邏輯基本是一樣的,就是為陣列的每一個元素的都綁定了 OverlayState,也是 setState 重新整理,也是為找到合適的位置。

刪除我們上面介紹了,就是反向找到 OverlayEntry 繫結的 OverlayState,然後從陣列中移出自己,也是 setState (overlay._markDirty())。

```dart void remove() { final OverlayState overlay = _overlay!; _overlay = null; if (!overlay.mounted) return;

overlay._entries.remove(this); if (SchedulerBinding.instance!.schedulerPhase == SchedulerPhase.persistentCallbacks) { SchedulerBinding.instance!.addPostFrameCallback((Duration duration) { overlay._markDirty(); }); } else { overlay._markDirty(); } } 唯一不同的是,多了一個幀排程。這裡簡單介紹一下,我們後面介紹初始化的時候,在詳細介紹幀排程。整個幀分為一下幾個階段: enum SchedulerPhase { /// 空閒 idle,

/// 動畫 transientCallbacks,

/// 微任務 midFrameMicrotasks,

/// 佈局繪製 persistentCallbacks,

/// 下一幀 postFrameCallbacks, } ``` 在刪除的時候,如果是在佈局繪製階段之前,那麼就在當前幀把移除的任務完成,否則就安排到下一幀執行

所以總結下來就是,不管插入還是刪除,都是變化了 OverlayState 維護的 OverlayEntry 陣列,然後 setState,執行自己的 build 方法,在 build 方法中變化需要顯示的 OverlayEntry 陣列,交給 _Theatre 元件顯示。

總結

至此,我們就認識了 Overlay ,並且可以使用 Overlay 實現指定位置、隨意拖動的懸浮窗。再進一步,知道了 Overlay 就是 StatefulWidget,它的任務是管理 OverlayEntry 陣列,每一個 OverlayEntry 是自管理的可以刪除自己、重新整理自己。真正負責佈局承載的是 _Theatre 元件,下一篇我們就結合中的 OverlayEntry 的 opaquemaintainState 來專門揭祕它。