從自製 Tab Bar 特效認識 Flutter 核心機制

閱讀時間約 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 實現背景顏色的淡入淡出效果。

分享各種 Flutter 開發技巧
留言0
查看全部
發表第一個留言支持創作者!
你可能也想看
Google News 追蹤
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
這篇文章介紹了網站的整體架構以及開發時所使用的工具和套件,包括 Next.js、Tailwind CSS 和 socket.io 等。文章回顧了程式碼的重構與優化,幫助開發者提高工作效率,適合希望深入瞭解前端開發和網站架構的讀者。
Thumbnail
浮動(float)是早期用來創建佈局的技術。元素可以向左或向右浮動,旁邊的元素會環繞浮動元素。浮動元素通常用於圖文混排或簡單的兩欄佈局。
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
你好,在下最近在學習開發web,學了html css js,也得出一些心得,由於網路上已有許多教學,所以我會著重在如何開發出to do List,以及解釋我寫的程式碼。相關的教學我會直接貼網址。如果我有什麼地方出錯,或者是可以寫得更好,歡迎在下方留言,討論。 首先先介紹我的開發環境: 我用了vs
Thumbnail
# 簡介 身為一位專注於 Vue.js 的前端開發者,這是我第一次嘗試構建 Flutter 網頁應用。讓我們開始吧! ## 第一次嘗試 ### 第一步:創建一個 Flutter 應用 首先,通過運行以下命令來創建一個新的 Flutter 項目: ```sh flutter
Thumbnail
這是一個介紹React Text Wrap Balancer套件的文章,主要內容包括套件的使用方式,常見的實作方式和一些注意事項。文章內容較長,內容大概是在介紹套件的使用方法、使用技巧和注意事項。
Thumbnail
有沒有想過,即使沒有任何編程背景,你的創意也能在六個月內轉化成真實的App?我可以以自身經歷跟你說有了 No-Code Tool (無代碼工具) 和 AI 的幫助,這一切都是可能的!你一行 code 都不需要打,甚至也無須學習任何編程語言!沒有什麼比實踐一個自小認為不可能的任務還振奮人心的事了!
Thumbnail
這是為了搭建自己想要的工作流而開始的研究工作。
Thumbnail
這個秋,Chill 嗨嗨!穿搭美美去賞楓,裝備款款去露營⋯⋯你的秋天怎麼過?秋日 To Do List 等你分享! 秋季全站徵文,我們準備了五個創作主題,參賽還有機會獲得「火烤兩用鍋」,一起來看看如何參加吧~
Thumbnail
這篇文章介紹了網站的整體架構以及開發時所使用的工具和套件,包括 Next.js、Tailwind CSS 和 socket.io 等。文章回顧了程式碼的重構與優化,幫助開發者提高工作效率,適合希望深入瞭解前端開發和網站架構的讀者。
Thumbnail
浮動(float)是早期用來創建佈局的技術。元素可以向左或向右浮動,旁邊的元素會環繞浮動元素。浮動元素通常用於圖文混排或簡單的兩欄佈局。
Thumbnail
本文介紹瞭如何在SwiftUI中調整元件的對齊方式,包括置中、向左/向右/向上/向下對齊的方法。透過調整HStack、VStack以及frame的maxWidth、maxHeight和alignment屬性,可以達到想要的對齊效果。
Thumbnail
你好,在下最近在學習開發web,學了html css js,也得出一些心得,由於網路上已有許多教學,所以我會著重在如何開發出to do List,以及解釋我寫的程式碼。相關的教學我會直接貼網址。如果我有什麼地方出錯,或者是可以寫得更好,歡迎在下方留言,討論。 首先先介紹我的開發環境: 我用了vs
Thumbnail
# 簡介 身為一位專注於 Vue.js 的前端開發者,這是我第一次嘗試構建 Flutter 網頁應用。讓我們開始吧! ## 第一次嘗試 ### 第一步:創建一個 Flutter 應用 首先,通過運行以下命令來創建一個新的 Flutter 項目: ```sh flutter
Thumbnail
這是一個介紹React Text Wrap Balancer套件的文章,主要內容包括套件的使用方式,常見的實作方式和一些注意事項。文章內容較長,內容大概是在介紹套件的使用方法、使用技巧和注意事項。
Thumbnail
有沒有想過,即使沒有任何編程背景,你的創意也能在六個月內轉化成真實的App?我可以以自身經歷跟你說有了 No-Code Tool (無代碼工具) 和 AI 的幫助,這一切都是可能的!你一行 code 都不需要打,甚至也無須學習任何編程語言!沒有什麼比實踐一個自小認為不可能的任務還振奮人心的事了!
Thumbnail
這是為了搭建自己想要的工作流而開始的研究工作。