【Flutter】如何優美地實現一個懸浮NavigationBar
最近寫程式碼的時候遇到了一個如下的需求:
整體來說,底部的條是一個浮動的懸浮窗,有如下的三個按鈕:
- 點選左邊的要進入“主頁”
- 點選中間的按鈕要進行頁面跳轉,能夠進入“創作頁”
- 點選右邊的按鈕切換到“個人中心”頁
使用Overlay來實現懸浮效果
首先是這個視窗該如何建立的問題,顯然需要Overlay懸浮在整個視窗頂部。
但是不能直接寫在initState內,這樣會觸發“Build時重繪”的錯誤。所以我們可以利用WidgetsBinding
,來監聽Callback
,這樣可以保證在首頁Build完成時能夠立刻繪製這個懸浮的視窗。
/rootpage
@override
void didChangeDependencies() {
print('root didChangeDependencies');
super.didChangeDependencies();
var widgetsBinding = WidgetsBinding.instance;
widgetsBinding.addPostFrameCallback((callback) {
print('addPostFrameCallback');
PNavigationBar.show(context, _tabController);
});
}
我將這個放入到了didChangeDependencies
內,主要是想透過混入TickerProviderStateMixin
能夠在路由回來時重新觸發didChangeDependencies
,不過理想很豐滿。最後在實驗的過程中反倒沒有觸發,沒有找到原因,希望有感興趣的大佬可以指點一下。
理論參考:Flutter 小而美系列|TickerProviderStateMixin 對生命週期的影響 - 掘金 (juejin.cn)
使用TabBar+TabView來實現NavigationBar的效果
首先說最簡單的TabView部分
@override
Widget build(BuildContext context) {
return Scaffold(
body: TabBarView(
controller: _tabController,
children: [
HomePage(),
UserPage(),
],
),
);
}
這裡需要一個TabController,相信比較熟悉的朋友們也知道,需要混入TickerProviderStateMixin,才可以宣告
畫框的部分是主要部分。
自定義實現一個PNavigationBar
(具體的程式碼在本文最後)
整個PNavigationBar的實現非常簡單,定義了一個show,一個remove,一個refresh方法,這樣可以保證任何元件任何頁面都可以隨時控制PNavigationBar的出現和消失。
圖示的切換
因為NavigationBar是存在切換圖示的功能的,而我們透過Image.asset獲取的圖示卻沒辦法更新,所以我們需要手動呼叫overlayEntry.markNeedsBuild
方法,來對整個底部元件進行重繪
中間按鈕的實現
相信大家也會有最初跟我一樣的疑問,因為TabBar與TabView,還有TabController的數必須一致,而我們中間有一個自定義的加號按鈕,我在這裡的實現非常簡單粗暴,當然如果有更好的方法歡迎大佬指教。
我這裡只是透過簡單的運算,來將兩個元件分別控制在左邊和右邊,之後加號按鈕在中間。
當然整個TabBar的渲染邏輯其實是有問題的,想要更深入地改TabBar的排列方式,必須需要自己手寫一個TabBar。預設的排列方式就是放到Expanded
內的,具體參考了以下這篇部落格:
Flutter系列之設定TabBar的tab緊湊排列_flutter tabbar間隔-CSDN部落格
關於頁面路由的問題
最難的部分就是這裡,主要在於如何控制路由到其他介面就可以消失,再pop回來就可以顯示。
我們希望這些功能都可以在RootPage這一層實現,而不在各種子頁面的push和pop中增添程式碼負擔。
具體實現起來最初我的嘗試是didChangeDependencies
,但是最後實驗下來並沒有結果,我自己也並不知道原因。(小白是這樣的)
而我最終決定採用原始的NavigationObserver方法,這裡感謝這個元件替我實現了這個功能:
lifecycle_lite | Flutter Package (pub.dev)
於是可以透過簡單的onShow和onHide就可以實現啦!
程式碼呈現
當然還有很多細節都沒有提到,寫這個功能時遇到的問題也有不少,本人技術有限,能力有限。等程式碼再最佳化的時候可以作為庫開源給大家。現在就暫且以這種部落格的形式分享元件和程式碼。
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:picturebook/pages/test/test_page.dart';
import '../color_utils.dart';
class PNavigationBar {
static OverlayEntry? overlayEntry;
static show(BuildContext context, TabController tabController) {
var overlayState = Overlay.of(context);
overlayEntry = OverlayEntry(
maintainState: true,
builder: (BuildContext context) {
final size = MediaQuery.of(context).size;
final height = size.height;
final width = size.width;
final boxWidth = width * 0.46;
final boxHeight = 60.h;
final iconHeight = 45.h;
return Positioned(
bottom: height * 0.06,
left: (width - boxWidth) / 2,
right: (width - boxWidth) / 2,
child: Stack(
children: [
Container(
decoration: BoxDecoration(
color: ColorUtils.orange,
borderRadius: BorderRadius.circular(boxHeight / 2),
),
width: boxWidth,
height: boxHeight,
child: TabBar(
controller: tabController,
indicatorColor: Colors.transparent,
padding: EdgeInsets.zero,
onTap: (index) {
tabController.animateTo(index);
overlayEntry?.markNeedsBuild();
},
tabs: [
Padding(
padding: EdgeInsets.only(right: iconHeight / 3),
child: Container(
width: iconHeight,
height: iconHeight,
decoration: BoxDecoration(
color: Colors.white30,
borderRadius: BorderRadius.circular(iconHeight / 2),
),
child: Center(
child: Image.asset(
tabController.index == 0
? 'assets/home_1.png'
: 'assets/home_0.png',
width: iconHeight * 0.5,
)),
),
),
Padding(
padding: EdgeInsets.only(left: iconHeight / 3),
child: Container(
width: iconHeight,
height: iconHeight,
decoration: BoxDecoration(
color: Colors.white30,
borderRadius: BorderRadius.circular(iconHeight / 2),
),
child: Center(
child: Image.asset(
tabController.index == 1
? 'assets/user_1.png'
: 'assets/user_0.png',
width: iconHeight * 0.5,
)),
),
),
],
)),
Align(
alignment: Alignment.center,
child: Padding(
padding: EdgeInsets.only(top: (boxHeight - iconHeight) / 2),
child: InkWell(
onTap: () {
print('push');
Navigator.push(
context,
MaterialPageRoute(builder: (context) => TestPage()),
);
},
child: Container(
width: iconHeight,
height: iconHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(iconHeight / 2),
),
child: Center(
child: Image.asset(
'assets/add.png',
width: iconHeight * 0.5,
)),
),
),
),
),
],
),
);
},
);
overlayState.insert(overlayEntry!);
}
static remove() {
if (overlayEntry != null) {
overlayEntry!.remove();
}
}
static refresh(){
overlayEntry?.markNeedsBuild();
}
}
下面是使用的例項,非常優美簡潔:
import 'package:flutter/material.dart';
import 'package:lifecycle_lite/lifecycle_mixin.dart';
import 'package:picturebook/pages/home_page.dart';
import 'package:picturebook/pages/user_page.dart';
import 'package:picturebook/utils/navigation/navigation_util.dart';
class RootPage extends StatefulWidget {
const RootPage({super.key});
@override
State<RootPage> createState() => _RootPageState();
}
class _RootPageState extends State<RootPage>
with TickerProviderStateMixin, LifecycleStatefulMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this)..addListener(() {
PNavigationBar.refresh();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
var widgetsBinding = WidgetsBinding.instance;
widgetsBinding.addPostFrameCallback((callback) {
PNavigationBar.show(context, _tabController);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: TabBarView(
controller: _tabController,
children: [
HomePage(),
UserPage(),
],
),
);
}
@override
void whenHide() {
PNavigationBar.remove();
}
@override
void whenShow() {
PNavigationBar.show(context, _tabController);
}
}