自製 Flutter Tab Bar - 深入底層更新機制

更新於 2024/12/18閱讀時間約 18 分鐘


這個系列終於迎來最終回了,為了完成下圖這個特別的 Tab Bar 動畫效果,我們最初直接使用 CustomMultiChildLayout 來完成,但是也同時好奇為什麼 Row + Expanded + AnimatedSize 做不到一樣效果,所以在上一篇文章中分析了 Row 的佈局邏輯。

raw-image

但是在研究 Row 的過程中,卻發現事情跟想像中的不同。以 Row 的佈局邏輯來說,應該要能完美的配合 AnimatedSize 的動畫,讓 AnimatedSize 中子 Widget 在大小有所變化時,可以順暢地以動畫的方式呈現。

但是當實際使用了 Row + Expanded 包在 AnimatedSize 外面時,效果並不如預期。當狀態改變時,AnimatedSize 中的 Tab 卻是一瞬間縮小,而不是以動畫形式變小。

raw-image
Row(
children: [
for (int index = 0; index < tabLength; index++)
currentIndex == index
? AnimatedSize(
duration: const Duration(milliseconds: 300),
child: _SelectedTab(index: index),
)
: Expanded(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: _UnselectedTab(index: index),
),
),
)
],
)

那這是為什麼呢?明明 Row 沒有限制子 Widget 大小,為什麼選中的 Tab 沒有按照預期的用動畫變大變小呢?

答案其實是:因為被選中的 Tab 沒被 Expanded 包住

對 Flutter 不熟悉的觀眾可能會滿頭問號,為什麼沒有被 Expanded 包住會造成問題?而且如果要用 Expanded 包住被選中的 Tab,我們又要如何不限制選中的 Tab 大小?

讓我們來一一解答這些問題吧。

Element Tree 的更新邏輯

首先,對 Flutter 有研究的觀眾多少都會知道,Flutter 框架底層維護了三顆樹:Widget Tree、Element Tree 與 RenderObject Tree,他們分別掌管了不同職責。

其中 Element 負責管理 Widget 與 RenderObject,當狀態改變時,能有效的因應變化,只調整有改變的部分,避免頻繁的重建 Element,進而提高效能。

當畫面需要更新時,Element 會從需要更新的 Element 開始,一路往子 Element 更新。

當更新某個 Element 時,Element 中的 updateChild 方法就會被呼叫,用來處理當前 Element 的下一層節點結構變化。在 updateChild 方法的上方也有一大段註釋在解釋其運作邏輯,其中包含了一段表格,用來展示幾種更新的狀況。

|                 |   newWidget == null    |       newWidget != null          |
| :-------------: | :--------------------- | :------------------------------- |
| child == null | Returns null. | Returns new [Element]. |
| | | |
| child != null | Old child is removed, | Old child updated if possible, |
| | returns null. | returns child or new [Element]. |

表格中的 child 是指當前更新中的 Element 的子節點,而 newWidget 則是打算新增進來的 Widget。讓我們看個簡單的例子,假設我們在畫面中存在著一個 Text,這個 Text 會隨著不同的狀態而出現底色。

class _MyTextState extends State<MyText> {
bool isHighlight = false;

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => isHighlight = !isHighlight),
child: isHighlight
? Container(color: Colors.red, child: const Text("Hello World"))
: const Text("Hello World"),
);
}
}

若當前 isHighlight 為 false,畫面中的 Text("Hello World") 也沒有任何底色。接著下一秒程式呼叫了 setState 並將 isHighlight 更新為 true。GestureDetector 的子 Widget 也從 Text 改變成 Container。

此時 GestureDetector 的 Element 的 updateChild 方法就會被呼叫到,帶進來的 child 就是 Text,而 newWidget 則是 Container。(註:事實上,GestureDetector 並非 Text 的父母,而是祖父母,因為 GestureDetector 中還包了許多其他 Widget,這裡為了說明方便而簡化。)

Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
...
}

按照上面表格的邏輯 child 不為 null,newWidget 也不為 null,就會走到 Old child updated if possible, returns child or new [Element] 這段分支。這段邏輯分支主要由三個 if 判斷實現,第一個 if 會比較 child.widget 與 newWidget 是否相同,這裡比較的是兩者是否是相同實例。通常如果使用 const 的 Widget 就會直接落入這個 if 條件中。

if (hasSameSuperclass && child.widget == newWidget) {
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
newChild = child;
}

若以上面的範例來說,child.widget 是 Text,而 newWidget 則是 Container。兩者類別都不同了,更別提是否是相同實例了。(註:以下 updateChild 方法邏輯經過簡化,原始碼可以參考這邊。)

接著看到下一段 if 判斷,判斷是否可以透過更新 Element 來避免建立新 Element。

if (hasSameSuperclass && child.widget == newWidget) {
// 省略...
} else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
child.update(newWidget);
newChild = child;
}

在這個 if 判斷中,最主要的判斷由 Widget.canUpdate 決定,來看一下 Widget.canUpdate 的邏輯。

abstract class Widget extends DiagnosticableTree {
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
}

帶入上面的範例後,我們會發現 oldWidget (Text) 的 runtimeType 與 newWidget (Container) 的 runtimeType 不同了,所以也無法直接更新 Element。

至此也只有最後一條路可以走,也就是建立新的 Element,這邊再往下走就會先停用當前的 Element,接著在 inflateWidget 方法中呼叫 Widget 身上的 createElement 方法來建立新的 Element。

if (hasSameSuperclass && child.widget == newWidget) {
// 省略...
} else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
// 省略...
} else {
deactivateChild(child);
newChild = inflateWidget(newWidget, newSlot);
}

至此我們應該可以回答:為什麼被選中的 Tab 沒被 Expanded 包住會造成 AnimatedSize 失效,其實原因就是因為 AnimatedSize 重建了。那 AnimatedSize 被重建為什麼會造成動畫失效呢?其實原因也很好想像 AnimatedSize 若想要有動畫效果,他就必須有改變前的狀態改變後的狀態,這樣才能夠知道要變大還是變小。

假設當前 AnimatedSize 的寬度為 10,setState 後變成 20,AnimatedSize 就會知道要從寬度要從 10 慢慢變化成 20。但是如果是重建 AnimatedSize 的情況,AnimatedSize 就不會有 10 的資訊,而是重新建了一個寬度為 20 的 AnimatedSize,自然也就不會有動畫。

往後有機會的話,我們再來深入 Animated 系列 Widget 是怎麼製作的,現在先讓我們看看要怎麼解決這個問題呢?這邊有兩個方法。

使用 Expanded 包住被選中的 Tab

可能有觀眾會好奇,用 Expanded 包住被選中的 Tab 的話,那我們還怎麼實現想要的效果呢?答案其實在上一篇文章中也有提到,其實就把 flex 設為 0 就好。在 flex 設為 0 的狀態下,Row 就會給該 Widget 任意大小的空間,讓 Widget 佔住他需要的大小。

同時也因為包了 Expanded,所以在 Element 更新子節點的邏輯中,就能在 Widget.canUpdate 的邏輯中被判斷為可更新,進而避免重建。

Row(
children: [
for (int index = 0; index < tabLength; index++)
currentIndex == index
? Expanded(
flex: 0,
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: _SelectedTab(index: index),
),
)
: Expanded(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: _UnselectedTab(index: index),
),
),
)
],
)

使用 GlobalObjectKey

除了使用 Expanded + flex 為 0 的設定之外,我們還能使用 GlobalKey 來避免 Widget 重建的問題。使用了 GlobalKey,在 Element 更新子節點時,就能透過 GlobalKey 來辨識 Element 是否已經建立過,進而重複使用先前已經建好的 Element。

Row(
children: [
for (int index = 0; index < tabLength; index++)
currentIndex == index
? AnimatedSize(
key: GlobalObjectKey(index),
duration: const Duration(milliseconds: 300),
child: _SelectedTab(index: index),
)
: Expanded(
child: AnimatedSize(
key: GlobalObjectKey(index),
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: _UnselectedTab(index: index),
),
),
)
],
)

當 Widget 使用了 GlobalKey 之後,雖然在 updateChild 方法中依舊會走到 inflateWidget 嘗試重建 Element,但是在 inflateWidget 方法中,其實還有一場敗部復活賽。Element 會從被停用的子節點中,找尋是否有與新子節點一樣的 GlobalKey 的節點,如果有的話,就把它拿回來重複利用。

小結

從使用 CustomMultiChildLayout 自製 Widget,再到探索 Row 的佈局邏輯,輾轉之後,還帶著觀眾一起看了部分 Element 更新機制。雖然我們可能不太有機會修改到底層框架的機制,但是認識這些機制有助於我們在開發的時候提高程式運行效能,也能在碰到問題的時候,有更多資訊可以提供解決思路。


分享各種軟體開發技巧與心得
留言0
查看全部
avatar-img
發表第一個留言支持創作者!
本文深入分析了 Row 的佈局邏輯及其與 Flexible 和 Expanded 的互動,帶領讀者深入了解 Row 的運作機制。
本文探討如何在 Flutter 中自訂 Tab Bar 特效,提升使用者介面互動性。從基本的 Row 佈局開始,我們逐步實現選中 Tab 動態變化的需求。最後,使用 CustomMultiChildLayout 與 AnimatedSize 實現一個符合設計需求的 Tab Bar,提升整體使用體驗。
本文探討如何利用 ListView 實現自動對齊的效果。深入說明如何透過覆寫 ScrollPhysics 中的相關方法來達成精確的滾動模擬,讓使用者在滑動列表時獲得更佳的體驗。讀者也能學習到如何調整滑動細節,提供開發上的新思路和技巧。
本文探討如何有效解決 Flutter 中 PageView 動畫與複雜畫面造成的卡頓問題。透過使用 Provider 優化效能,減少不必要的 Widget 重建,達成更流暢的使用體驗。本文提供範例程式碼及效能分析,讓開發者能夠理解並應用於實際產品中,從而改善應用的效能。
本文介紹如何解決 Flutter 應用程式中 PageView 的卡頓問題。透過使用 DevTools 的 Profile 模式及啟用 Track Widget Builds 功能,分析了 UI phase 的效能瓶頸,識別出 PlayerInfoGameLogView 重新建構的高成本。
本文介紹如何在 Flutter 應用中實現 Light 模式與 Dark 模式的切換,並通過使用內建的 Theme 和狀態管理套件來增強使用者體驗。我們探討瞭如何自定義 ThemeExtension 和使用 lerp 方法實現平滑的顏色轉換,並展示了獨特的切換動畫效果,讓應用更具吸引力。
本文深入分析了 Row 的佈局邏輯及其與 Flexible 和 Expanded 的互動,帶領讀者深入了解 Row 的運作機制。
本文探討如何在 Flutter 中自訂 Tab Bar 特效,提升使用者介面互動性。從基本的 Row 佈局開始,我們逐步實現選中 Tab 動態變化的需求。最後,使用 CustomMultiChildLayout 與 AnimatedSize 實現一個符合設計需求的 Tab Bar,提升整體使用體驗。
本文探討如何利用 ListView 實現自動對齊的效果。深入說明如何透過覆寫 ScrollPhysics 中的相關方法來達成精確的滾動模擬,讓使用者在滑動列表時獲得更佳的體驗。讀者也能學習到如何調整滑動細節,提供開發上的新思路和技巧。
本文探討如何有效解決 Flutter 中 PageView 動畫與複雜畫面造成的卡頓問題。透過使用 Provider 優化效能,減少不必要的 Widget 重建,達成更流暢的使用體驗。本文提供範例程式碼及效能分析,讓開發者能夠理解並應用於實際產品中,從而改善應用的效能。
本文介紹如何解決 Flutter 應用程式中 PageView 的卡頓問題。透過使用 DevTools 的 Profile 模式及啟用 Track Widget Builds 功能,分析了 UI phase 的效能瓶頸,識別出 PlayerInfoGameLogView 重新建構的高成本。
本文介紹如何在 Flutter 應用中實現 Light 模式與 Dark 模式的切換,並通過使用內建的 Theme 和狀態管理套件來增強使用者體驗。我們探討瞭如何自定義 ThemeExtension 和使用 lerp 方法實現平滑的顏色轉換,並展示了獨特的切換動畫效果,讓應用更具吸引力。
你可能也想看
Google News 追蹤
Thumbnail
本文探討了複利效應的重要性,並藉由巴菲特的投資理念,說明如何選擇穩定產生正報酬的資產及長期持有的核心理念。透過定期定額的投資方式,不僅能減少情緒影響,還能持續參與全球股市的發展。此外,文中介紹了使用國泰 Cube App 的便利性及低手續費,幫助投資者簡化投資流程,達成長期穩定增長的財務目標。
Thumbnail
浮動(float)是早期用來創建佈局的技術。元素可以向左或向右浮動,旁邊的元素會環繞浮動元素。浮動元素通常用於圖文混排或簡單的兩欄佈局。
Thumbnail
切換頁面卡卡有很多種原因,這裡舉的例子只針對元件太大的情境。 除了想辦法拆分外,還有一個方法就是利用 Vue 的 Async Component。
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
# 簡介 身為一位專注於 Vue.js 的前端開發者,這是我第一次嘗試構建 Flutter 網頁應用。讓我們開始吧! ## 第一次嘗試 ### 第一步:創建一個 Flutter 應用 首先,通過運行以下命令來創建一個新的 Flutter 項目: ```sh flutter
Thumbnail
在開發應用程式時,經常會遇到需要調整圖片大小以節省空間或加快加載速度的情況。本教學將介紹如何使用 C# 語言來壓縮圖片並調整其大小,以便在應用程式中使用。
Thumbnail
這是一個介紹React Text Wrap Balancer套件的文章,主要內容包括套件的使用方式,常見的實作方式和一些注意事項。文章內容較長,內容大概是在介紹套件的使用方法、使用技巧和注意事項。
Thumbnail
題目敘述 題目會給我們一組定義好的界面和需求,要求我們設計一個資料結構,可以滿足平均O(1)的插入元素、刪除元素、隨機取得元素的操作。 RandomizedSet() 類別建構子 bool insert(int val) 插入元素的function界面 bool remove(int val
Thumbnail
本文探討了複利效應的重要性,並藉由巴菲特的投資理念,說明如何選擇穩定產生正報酬的資產及長期持有的核心理念。透過定期定額的投資方式,不僅能減少情緒影響,還能持續參與全球股市的發展。此外,文中介紹了使用國泰 Cube App 的便利性及低手續費,幫助投資者簡化投資流程,達成長期穩定增長的財務目標。
Thumbnail
浮動(float)是早期用來創建佈局的技術。元素可以向左或向右浮動,旁邊的元素會環繞浮動元素。浮動元素通常用於圖文混排或簡單的兩欄佈局。
Thumbnail
切換頁面卡卡有很多種原因,這裡舉的例子只針對元件太大的情境。 除了想辦法拆分外,還有一個方法就是利用 Vue 的 Async Component。
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
# 簡介 身為一位專注於 Vue.js 的前端開發者,這是我第一次嘗試構建 Flutter 網頁應用。讓我們開始吧! ## 第一次嘗試 ### 第一步:創建一個 Flutter 應用 首先,通過運行以下命令來創建一個新的 Flutter 項目: ```sh flutter
Thumbnail
在開發應用程式時,經常會遇到需要調整圖片大小以節省空間或加快加載速度的情況。本教學將介紹如何使用 C# 語言來壓縮圖片並調整其大小,以便在應用程式中使用。
Thumbnail
這是一個介紹React Text Wrap Balancer套件的文章,主要內容包括套件的使用方式,常見的實作方式和一些注意事項。文章內容較長,內容大概是在介紹套件的使用方法、使用技巧和注意事項。
Thumbnail
題目敘述 題目會給我們一組定義好的界面和需求,要求我們設計一個資料結構,可以滿足平均O(1)的插入元素、刪除元素、隨機取得元素的操作。 RandomizedSet() 類別建構子 bool insert(int val) 插入元素的function界面 bool remove(int val