自製 Flutter Tab Bar - 使用 CustomMultiChildLayout

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


raw-image

Photo by Firmbee.com on Unsplash

在產品開發上我們常常使用 Tab Bar 來切換不同分類的內容,使用 Tab Bar 可以讓使用者快速找到想要的內容,提升效率。一般來說,我們可以使用 Flutter 內建 TabBar 來完成,即便我們希望的樣式與預設的不同,也能透過參數調整或額外加工來調整成想要的結果。

raw-image

但是若碰上內建 TabBar 無法符合設計需求,我們通常就上 pub.dev 搜尋,看看有沒有人已經提供相同功能的套件。再找不到呢,我們也就只能自己做了,而這次碰到 Tab Bar 設計就剛好是最後一種狀況,這也給了筆者一個嘗試的機會。

raw-image

了解 Tab Bar 行為

首先先來簡單分析一下這個特別的 Tab Bar 的行為:

  1. 被選到 Tab 佔據他所需要的寬度,剩下的寬度由那些未被選到的 Tab 平均分配
  2. 被選到的 Tab 擁有不同的文字
  3. 當使用者點選其他 Tab 時,透過淡入淡與放大縮小來變化 Tab 樣式

raw-image

分析不只讓我們更清楚要完成什麼需求,將需求拆成一個一個的小需求,我們就能解決多個簡單的小問題,最後集合解決原本的大問題。這也能讓我們優先處理最有價值的部分,用最快的時間產出最有價值的部分,這也是開發人員必備的 Divide and Conquer 技巧。

從最重要的功能開始

如果我們先不考慮動畫,我們可很容易地完成兩項要求。首先利用 Row 來放置每個一個 Tab,接著用 Expanded 包住其他沒被選到的 Tab,使得這些沒被選到的 Tab 以平均分配的形式來排列。(為了盡量讓程式碼簡短一些些,筆者拿掉了一些例如圓角或粗體等不重要的細節)

class MyTabBar extends StatefulWidget {
const MyTabBar({super.key});

@override
State<MyTabBar> createState() => _MyTabBarState();
}

class _MyTabBarState extends State<MyTabBar> {
int currentIndex = 0;

@override
Widget build(BuildContext context) {
const tabLength = 4;
return Container(
padding: const EdgeInsets.all(4),
color: const Color(0xFFE6E6E6),
child: Row(
children: [
for (int index = 0; index < tabLength; index++)
currentIndex == index
? Container(
padding: const EdgeInsets.all(8),
color: const Color(0xFF510E59),
child: Text(
"Selected Tab $index",
style: const TextStyle(color: Colors.white),
),
)
: Expanded(
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: Container(
padding: const EdgeInsets.all(8),
color: const Color(0xFFE6E6E6),
child: Text("Tab $index", textAlign: TextAlign.center),
),
),
)
],
),
);
}
}

這個版本相當簡單,當使用者選到某個 Tab 之後,被選到的 Tab 一瞬間就換了樣式。[Dartpad的範例]

raw-image

以迭代的方式完成功能,避免一口氣花很多時間完成最終版本。這有幾個好處,以 Tab Bar 的例子來說,我們用熟練的工具快速完成了一個非動畫版本的 Tab Bar,這時候其實最重要的功能已經完成,即便最後時間來不及直接上線的話,也不會造成功能有使用上的問題。

在開發這個功能的過程中,筆者也是先完成到這邊,接著轉頭去完成其他部分的工作。等到其他更重要的工作完成的差不多之後,才又回頭來思考如何實現 Tab Bar 動畫,接著讓我們來看看怎麼完成吧。

嘗試加上動畫

當我們想加動畫時,我們除了使用 AnimationController 自定義之外,還有其他更簡單的方式。Flutter 內建提供許多好用的動畫 Widget,例如:AnimatedSwitcherAnimatedContainer ……等。筆者最一開始也是打算在 Row 的基礎上加上 AnimatedSize 來完成動畫的部分,但是天不從人願,代誌不是憨人想得那麼簡單,加上 AnimatedSize 沒有任何效果。

Row(
children: [
for (int index = 0; index < tabLength; index++)
currentIndex == index
? AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Container(
padding: const EdgeInsets.all(8),
color: const Color(0xFF510E59),
child: Text(
"Selected Tab $index",
style: const TextStyle(color: Colors.white),
),
),
)
: Expanded(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: Container(
padding: const EdgeInsets.all(8),
color: const Color(0xFFE6E6E6),
child: Text(
"Tab $index",
textAlign: TextAlign.center,
),
),
),
),
)
],
)

但是我們若是再嘗試一下,加上 AnimatedSize 但拿掉 Expanded 的話,會發現其實 AnimatedSize 是有效果的,顯然是 Row 的某些機制造成了問題,關於為什麼沒有效果以後我們會做一期專門的影片逕行講解。但是我們也不能接受這個版本,因為我們需要沒被選取的 Tab 平均分配寬度。

Row(
children: [
for (int index = 0; index < tabLength; index++)
currentIndex == index
? AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Container(
padding: const EdgeInsets.all(8),
color: const Color(0xFF510E59),
child: Text(
"Selected Tab $index",
style: const TextStyle(color: Colors.white),
),
),
)
: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: Container(
padding: const EdgeInsets.all(8),
color: const Color(0xFFE6E6E6),
child: Text(
"Tab $index",
textAlign: TextAlign.center,
),
),
),
)
],
)

raw-image

[Dartpad範例]

事情到了這邊,顯然我們無法使用 Row 完成這個 Tab Bar 設計了,那我們還有什麼辦法呢?有的,我們可以用 CustomMultiChildLayout 自製一個簡單的 Row,一個為這個特殊的 Tab Bar 佈局而生的 Row。

使用 CustomMultiChildLayout + AnimatedSize

使用 CustomMultiChildLayout 方法並不複雜,CustomMultiChildLayout 有個 children 參數可以傳入複數個 Widget,這邊我們就傳入各個包有 AnimatedSize 的 Tab,並且用 LayoutId 這個 Widget 包住 Tab 並指定 id。指定 id 的目的是為了讓等等在排列佈局的時候可以取得相對應得子 Widget。

class MyTabBar extends StatefulWidget {
const MyTabBar({super.key});

@override
State<MyTabBar> createState() => _MyTabBarState();
}

class _MyTabBarState extends State<MyTabBar> {
int currentIndex = 0;

@override
Widget build(BuildContext context) {
const tabLength = 4;
return Container(
padding: const EdgeInsets.all(4),
color: const Color(0xFFE6E6E6),
child: CustomMultiChildLayout(
delegate: _MyTabBarLayoutDelegate(
selectedIndex: currentIndex,
length: tabLength,
),
children: <Widget>[
for (int index = 0; index < tabLength; index++)
LayoutId(
id: index,
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: currentIndex == index
? AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Text(
"Selected Tab $index",
style: const TextStyle(color: Colors.white),
),
)
: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Text("Tab $index", textAlign: TextAlign.center),
),
),
),
],
),
);
}
}

接著我們需要實作 MultiChildLayoutDelegate 定義各個子 Widget 的位置,這邊就需要一些簡單的數學計算了。首先,我們要實作 performLayout(Size size) 方法,我們必須在這個方法中設定子 Widget 的大小與位置。雖說是設定子 Widget 的大小,實際上是告訴子 Widget 一個大小限制,也就是 Constraint。

接著我們需要實作 MultiChildLayoutDelegate 定義各個子 Widget 的位置,這邊就需要一些簡單的數學計算了。首先,我們要實作 performLayout(Size size) 方法,我們必須在這個方法中設定子 Widget 的大小與位置。雖說是設定子 Widget 的大小,實際上是告訴子 Widget 一個大小限制,也就是 Constraint。

class _MyTabBarLayoutDelegate extends MultiChildLayoutDelegate {
_MyTabBarLayoutDelegate({
required this.selectedIndex,
required this.length,
});

final int selectedIndex;
final int length;

@override
void performLayout(Size size) {
// Implement it
}

@override
bool shouldRelayout(_MyTabBarLayoutDelegate oldDelegate) {
return oldDelegate.selectedIndex != selectedIndex ||
oldDelegate.length != length;
}
}

實作 MultiChildLayoutDelegate

在 Flutter 框架設計中有句話:「Constraints go down. Size go up. Parent sets position.」,這句話充分體現了 Flutter 的排版的核心機制,而 performLayout 方法所要處理的就恰恰是這一句話,筆者曾在社群聊天時分享過一個例子:

想像一下,假設今天公司要辦員工旅遊,福委想知道總共有多少員工與員工家屬要參加,這時福委就通知每個員工說:「每個人可以帶 0 ~ 3個家屬」,而這就是 Constraints go down。當員工回家問親戚朋友,最終得到總共幾人參加後,員工把這人數回報給福委,這就是 Size go up。最後福委就能根據回報的資訊得知總共有多少人,也就能安排每個員工與家屬的梯次、機票、車位等資訊,也就是 Parent sets position。

讓我們來看點實際例子。

還記得我們特殊 Tab Bar 的第一個要求嗎?

被選到 Tab 佔據所需要的寬度,剩下寬度由未被選到的 Tab 平均分配

若想完成這個需求,我們首先得先知道被選到 Tab 的寬度,在 performLayout 方法的第一行,我們就呼叫了 layoutChild 並帶入被選到的 Tab 的 id 與 Constraints,這個 Constrains 告訴了被選到的 Tab 最大可以到多大。

@override
void performLayout(Size size) {
final selectedSize = layoutChild(
selectedIndex,
BoxConstraints(
maxWidth: size.width,
maxHeight: size.height,
));
}

得到大小之後,我們就能計算出其他沒被選到的 Tab 應該要多大,並在 layoutChild 的時候嚴格指定其寬度(把 minWidth 與 maxWidth 設定為相同值)。

@override
void performLayout(Size size) {
final selectedSize = layoutChild(
selectedIndex,
BoxConstraints(
maxWidth: size.width,
maxHeight: size.height,
));

final otherChildWidth = (size.width - selectedSize.width) / (length - 1);

}

最後我們知道每個子 Widget 大小為多少之後,我們就能準確的設定其座標。透過 positionChild 方法指定每個子 Widget 的位置,我們也就能完成特製的 Tab Bar 佈局了。

@override
void performLayout(Size size) {
final selectedSize = layoutChild(
selectedIndex,
BoxConstraints(
maxWidth: size.width,
maxHeight: size.height,
));

final otherChildWidth = (size.width - selectedSize.width) / (length - 1);

double currentWidth = 0;
for (int index = 0; index < length; index++) {
if (index == selectedIndex) {
positionChild(index, Offset(currentWidth, 0));
currentWidth += selectedSize.width;
} else {
layoutChild(
index,
BoxConstraints(
minWidth: otherChildWidth,
maxWidth: otherChildWidth,
maxHeight: size.height,
));
positionChild(index, Offset(currentWidth, 0));
currentWidth += otherChildWidth;
}
}
}

完成之後,我們測試一個就能看到切換 Tab 時,Tab 有伸縮的動畫了。[Dartpad範例]

raw-image

最後我們還想讓 Tab 的背景顏色也有淡入淡出的動畫效果時,我們只要簡單的把 Container 修改為 AnimatedContainer 就好,我們就能看到變大變小的同時也有淡入淡出的效果了。[Dartpad範例]

raw-image

小結

今天分享了如何在 Flutter 中自訂 Tab Bar 特效,透過分析 Tab Bar 的行為,我們展示如何逐步實現功能,包括使用 Row 和 Expanded 佈局,以及後續添加動畫效果。最終,採用 CustomMultiChildLayout 和 AnimatedSize 實現了一個符合設計需求的 Tab Bar,確保選中 Tab 的寬度動態變化,最後再加上 AnimatedContainer 實現背景顏色的淡入淡出效果。

分享各種軟體開發技巧與心得
留言0
查看全部
avatar-img
發表第一個留言支持創作者!
本文探討如何利用 ListView 實現自動對齊的效果。深入說明如何透過覆寫 ScrollPhysics 中的相關方法來達成精確的滾動模擬,讓使用者在滑動列表時獲得更佳的體驗。讀者也能學習到如何調整滑動細節,提供開發上的新思路和技巧。
本文探討如何有效解決 Flutter 中 PageView 動畫與複雜畫面造成的卡頓問題。透過使用 Provider 優化效能,減少不必要的 Widget 重建,達成更流暢的使用體驗。本文提供範例程式碼及效能分析,讓開發者能夠理解並應用於實際產品中,從而改善應用的效能。
本文介紹如何解決 Flutter 應用程式中 PageView 的卡頓問題。透過使用 DevTools 的 Profile 模式及啟用 Track Widget Builds 功能,分析了 UI phase 的效能瓶頸,識別出 PlayerInfoGameLogView 重新建構的高成本。
本文介紹如何在 Flutter 應用中實現 Light 模式與 Dark 模式的切換,並通過使用內建的 Theme 和狀態管理套件來增強使用者體驗。我們探討瞭如何自定義 ThemeExtension 和使用 lerp 方法實現平滑的顏色轉換,並展示了獨特的切換動畫效果,讓應用更具吸引力。
本文探討如何使用 Flutter 的 Widget 測試來驗證應用程式的 Routing 功能,確保重構後仍然正常運作。我們將通過具體的範例,從設定 MockNavigatorObserver 到驗證 Routing 參數,提供清晰步驟與建議,以提高測試的可讀性和效能,是開發人員必備的測試技巧。
這篇文章介紹如何在多臺 MacBook 上同步開發工具與設定,以提高開發效率。文章重點在於如何同步 IntelliJ、IdeaVim 和 Alfred 配置,並解決因設定不同而影響開發效率的問題。透過簡單的步驟,開發者可以在不同設備上無縫運作,持續專注於開發工作,而不必因為熱鍵或工具失效而浪費時間。
本文探討如何利用 ListView 實現自動對齊的效果。深入說明如何透過覆寫 ScrollPhysics 中的相關方法來達成精確的滾動模擬,讓使用者在滑動列表時獲得更佳的體驗。讀者也能學習到如何調整滑動細節,提供開發上的新思路和技巧。
本文探討如何有效解決 Flutter 中 PageView 動畫與複雜畫面造成的卡頓問題。透過使用 Provider 優化效能,減少不必要的 Widget 重建,達成更流暢的使用體驗。本文提供範例程式碼及效能分析,讓開發者能夠理解並應用於實際產品中,從而改善應用的效能。
本文介紹如何解決 Flutter 應用程式中 PageView 的卡頓問題。透過使用 DevTools 的 Profile 模式及啟用 Track Widget Builds 功能,分析了 UI phase 的效能瓶頸,識別出 PlayerInfoGameLogView 重新建構的高成本。
本文介紹如何在 Flutter 應用中實現 Light 模式與 Dark 模式的切換,並通過使用內建的 Theme 和狀態管理套件來增強使用者體驗。我們探討瞭如何自定義 ThemeExtension 和使用 lerp 方法實現平滑的顏色轉換,並展示了獨特的切換動畫效果,讓應用更具吸引力。
本文探討如何使用 Flutter 的 Widget 測試來驗證應用程式的 Routing 功能,確保重構後仍然正常運作。我們將通過具體的範例,從設定 MockNavigatorObserver 到驗證 Routing 參數,提供清晰步驟與建議,以提高測試的可讀性和效能,是開發人員必備的測試技巧。
這篇文章介紹如何在多臺 MacBook 上同步開發工具與設定,以提高開發效率。文章重點在於如何同步 IntelliJ、IdeaVim 和 Alfred 配置,並解決因設定不同而影響開發效率的問題。透過簡單的步驟,開發者可以在不同設備上無縫運作,持續專注於開發工作,而不必因為熱鍵或工具失效而浪費時間。
本篇參與的主題活動
  駄菓子(だがし)約在江戶時代左右出現,相比當時使用進口砂糖製作、常出現在宴席、供品、禮品的上菓子 (じょうがし),用日本產的便宜黑糖或水果增添甜味的菓子則稱為雜菓子(ざがし),雜菓子的原料取得相對簡單,作為庶民的零食也較便宜。當時用一文錢也買得起雜菓子,所以雜菓子也稱一文菓子(いちもんがし)。
  駄菓子(だがし)約在江戶時代左右出現,相比當時使用進口砂糖製作、常出現在宴席、供品、禮品的上菓子 (じょうがし),用日本產的便宜黑糖或水果增添甜味的菓子則稱為雜菓子(ざがし),雜菓子的原料取得相對簡單,作為庶民的零食也較便宜。當時用一文錢也買得起雜菓子,所以雜菓子也稱一文菓子(いちもんがし)。
你可能也想看
Google News 追蹤
Thumbnail
本文探討了複利效應的重要性,並藉由巴菲特的投資理念,說明如何選擇穩定產生正報酬的資產及長期持有的核心理念。透過定期定額的投資方式,不僅能減少情緒影響,還能持續參與全球股市的發展。此外,文中介紹了使用國泰 Cube App 的便利性及低手續費,幫助投資者簡化投資流程,達成長期穩定增長的財務目標。
Thumbnail
這篇文章介紹了網站的整體架構以及開發時所使用的工具和套件,包括 Next.js、Tailwind CSS 和 socket.io 等。文章回顧了程式碼的重構與優化,幫助開發者提高工作效率,適合希望深入瞭解前端開發和網站架構的讀者。
Thumbnail
浮動(float)是早期用來創建佈局的技術。元素可以向左或向右浮動,旁邊的元素會環繞浮動元素。浮動元素通常用於圖文混排或簡單的兩欄佈局。
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
# 簡介 身為一位專注於 Vue.js 的前端開發者,這是我第一次嘗試構建 Flutter 網頁應用。讓我們開始吧! ## 第一次嘗試 ### 第一步:創建一個 Flutter 應用 首先,通過運行以下命令來創建一個新的 Flutter 項目: ```sh flutter
Thumbnail
這是一個介紹React Text Wrap Balancer套件的文章,主要內容包括套件的使用方式,常見的實作方式和一些注意事項。文章內容較長,內容大概是在介紹套件的使用方法、使用技巧和注意事項。
Thumbnail
有沒有想過,即使沒有任何編程背景,你的創意也能在六個月內轉化成真實的App?我可以以自身經歷跟你說有了 No-Code Tool (無代碼工具) 和 AI 的幫助,這一切都是可能的!你一行 code 都不需要打,甚至也無須學習任何編程語言!沒有什麼比實踐一個自小認為不可能的任務還振奮人心的事了!
Thumbnail
這是為了搭建自己想要的工作流而開始的研究工作。
Thumbnail
本文探討了複利效應的重要性,並藉由巴菲特的投資理念,說明如何選擇穩定產生正報酬的資產及長期持有的核心理念。透過定期定額的投資方式,不僅能減少情緒影響,還能持續參與全球股市的發展。此外,文中介紹了使用國泰 Cube App 的便利性及低手續費,幫助投資者簡化投資流程,達成長期穩定增長的財務目標。
Thumbnail
這篇文章介紹了網站的整體架構以及開發時所使用的工具和套件,包括 Next.js、Tailwind CSS 和 socket.io 等。文章回顧了程式碼的重構與優化,幫助開發者提高工作效率,適合希望深入瞭解前端開發和網站架構的讀者。
Thumbnail
浮動(float)是早期用來創建佈局的技術。元素可以向左或向右浮動,旁邊的元素會環繞浮動元素。浮動元素通常用於圖文混排或簡單的兩欄佈局。
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
# 簡介 身為一位專注於 Vue.js 的前端開發者,這是我第一次嘗試構建 Flutter 網頁應用。讓我們開始吧! ## 第一次嘗試 ### 第一步:創建一個 Flutter 應用 首先,通過運行以下命令來創建一個新的 Flutter 項目: ```sh flutter
Thumbnail
這是一個介紹React Text Wrap Balancer套件的文章,主要內容包括套件的使用方式,常見的實作方式和一些注意事項。文章內容較長,內容大概是在介紹套件的使用方法、使用技巧和注意事項。
Thumbnail
有沒有想過,即使沒有任何編程背景,你的創意也能在六個月內轉化成真實的App?我可以以自身經歷跟你說有了 No-Code Tool (無代碼工具) 和 AI 的幫助,這一切都是可能的!你一行 code 都不需要打,甚至也無須學習任何編程語言!沒有什麼比實踐一個自小認為不可能的任務還振奮人心的事了!
Thumbnail
這是為了搭建自己想要的工作流而開始的研究工作。