【Flutter】如何優美地實現一個懸浮NavigationBar

ZzTzZ發表於2023-10-05

【Flutter】如何優美地實現一個懸浮NavigationBar

最近寫程式碼的時候遇到了一個如下的需求:

image

整體來說,底部的條是一個浮動的懸浮窗,有如下的三個按鈕:

  • 點選左邊的要進入“主頁”
  • 點選中間的按鈕要進行頁面跳轉,能夠進入“創作頁”
  • 點選右邊的按鈕切換到“個人中心”頁

使用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,才可以宣告

image

畫框的部分是主要部分。


自定義實現一個PNavigationBar

image

(具體的程式碼在本文最後)

整個PNavigationBar的實現非常簡單,定義了一個show,一個remove,一個refresh方法,這樣可以保證任何元件任何頁面都可以隨時控制PNavigationBar的出現和消失。

圖示的切換

因為NavigationBar是存在切換圖示的功能的,而我們透過Image.asset獲取的圖示卻沒辦法更新,所以我們需要手動呼叫overlayEntry.markNeedsBuild方法,來對整個底部元件進行重繪

image

中間按鈕的實現

相信大家也會有最初跟我一樣的疑問,因為TabBar與TabView,還有TabController的數必須一致,而我們中間有一個自定義的加號按鈕,我在這裡的實現非常簡單粗暴,當然如果有更好的方法歡迎大佬指教。

image

我這裡只是透過簡單的運算,來將兩個元件分別控制在左邊和右邊,之後加號按鈕在中間。

當然整個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);
  }
}

相關文章