Flutter 底部導航詳解

升級之路發表於2018-12-20

底部導航是常見的APP佈局方式,實際上我自己常用的app都是底部導航的。Android和iOS都有官方元件可供使用。Flutter也有BottomNavigationBar,使用時有踩坑,這裡記錄一下。

一般用法

普通實現:

BottomNavigationBar botttomNavBar = BottomNavigationBar(
  items: [
    BottomNavigationBarItem(icon: Icon(Icons.code), title: Text('code')),
    BottomNavigationBarItem(icon: Icon(Icons.add), title: Text('add')),
    BottomNavigationBarItem(icon: Icon(Icons.print), title: Text('print'))
  ],
  currentIndex: _currentIndex,
  type: BottomNavigationBarType.fixed,
  onTap: (int index) {
    setState(() {
      _currentIndex = index;
    });
  },
);
複製程式碼

問:看起來很簡單,至於分析這麼多嗎?

答:emmmm,這實現優點是設計標準規範,官方元件也簡單穩定可靠。但前提是設計師接受這種設定(即使是fixed,選中圖示和文字也會有放大縮小動畫),至少中國主流的APP,navigation item都是fixed而且沒有動畫,官方元件並不提供這種選擇。

有點問題

既然設計師有要求那不能慫,分析是因為內部的_BottomNavigationTile作祟,那自己實現navigationItem控制是否選中,並且不傳currentIndex給BottomNavigationBar,應該可以吧

Widget _buildBottomNavigationBar() {
  return BottomNavigationBar(
    type: BottomNavigationBarType.fixed,
    items: [
      _buildItem(icon: Icons.code, tabItem: TabItem.code),
      _buildItem(icon: Icons.add, tabItem: TabItem.add),
      _buildItem(icon: Icons.print, tabItem: TabItem.print),
    ],
    onTap: _onSelectTab,
  );
}

// 用定製化的icon和tabItem構建BottomNavigationBarItem
BottomNavigationBarItem _buildItem({IconData icon, TabItem tabItem}) {
  String text = tabItemName(tabItem);
  return BottomNavigationBarItem(
    icon: Icon(
      icon,
      color: _colorTabMatching(item: tabItem),
    ),
    title: Text(
      text,
      style: TextStyle(
        color: _colorTabMatching(item: tabItem),
      ),
    ),
  );
}

// 切換item的顏色,選中用primaryColor,其他都是grey 
Color _colorTabMatching({TabItem item}) {
  return currentItem == item ? Theme.of(context).primaryColor : Colors.grey;
}
複製程式碼

問:效果如何?

答:嗯,不錯。等等。。。啊,怎麼有個大一點。沒道理啊,事出蹊蹺必有妖,需要從原始碼中找答案了。下圖的home明顯比mail大,對吧?

image

原始碼閱讀

主要程式碼都在bottom_navigation_bar.dart裡,bottom_navigation_bar_item.dart是item的定義

bottom_navigation_bar_item.dart

image

相當於是一個自定義的Button,用來放在BottomNavigationBar上,它實現了Material(Android)Cupertino(iOS)兩種風格。

bottom_navigation_bar.dart

image

Scaffold是Root Widget- MaterialApp的腳手架。封裝了Material Design App會用到的AppBar,Drawer,SnackBar,BottomNavigationBar等。BottomNavigationBarType有fixed 和shifting兩種樣式,超過3個才會有區別,一般為了體驗一致,我們會用fixed type。

BottomNavigationBar是一個StatefulWidget,可以按以下步驟分析這種元件:1,先看它持有的狀態,2,看下他的生命週期實現,3,再仔細分析它的build方法。

  • 持有狀態
List<AnimationController> _controllers = <AnimationController>[];
List<CurvedAnimation> _animations;

// A queue of color splashes currently being animated.
final Queue<_Circle> _circles = Queue<_Circle>();

// Last splash circle's color, and the final color of the control after
// animation is complete.
Color _backgroundColor;
複製程式碼

前面三個屬性都和動畫相關,第四個是設背景。

問:BottomNavigationBar為什麼沒有變數標記當前哪個item選中?

答:函數語言程式設計一個原則是要函式儘量純,currentIndex這個屬性依賴外邊傳入,每次變化重新觸發Render。如果自己維護,則還需要提供一個回撥方法供外部呼叫,返回最新的currentIndex值。

  • 生命週期方法
// 初始化操作,具體實現再resetState裡,對上面的這些狀態屬性初始化操作
@override
void initState() {
  super.initState();
  _resetState();
}

// 回收資源操作,一般用到動畫都需要的
@override
void dispose() {
    for (AnimationController controller in _controllers)
      controller.dispose();
    for (_Circle circle in _circles)
      circle.dispose();
    super.dispose();
  }

// 當屬性變化時Flutter系統回撥該方法。當item數量變化時直接重新初始化;當index變化,做相應動畫。
@override
void didUpdateWidget(BottomNavigationBar oldWidget) {
    super.didUpdateWidget(oldWidget);

    // No animated segue if the length of the items list changes.
    if (widget.items.length != oldWidget.items.length) {
      _resetState();
      return;
    }

    if (widget.currentIndex != oldWidget.currentIndex) {
      switch (widget.type) {
        case BottomNavigationBarType.fixed:
          break;
        case BottomNavigationBarType.shifting:
          _pushCircle(widget.currentIndex);
          break;
      }
      _controllers[oldWidget.currentIndex].reverse();
      _controllers[widget.currentIndex].forward();
    }

    if (_backgroundColor != widget.items[widget.currentIndex].backgroundColor)
      _backgroundColor = widget.items[widget.currentIndex].backgroundColor;
  }

// 下面分析
@override
Widget build(BuildContext context) {}

複製程式碼

注意:initState裡有個操作比較隱蔽:_controllers[widget.currentIndex].value = 1.0;

  • 分析build方法
 @override
  Widget build(BuildContext context) {
    // debug 檢查
    assert(debugCheckHasDirectionality(context));
    assert(debugCheckHasMaterialLocalizations(context));

    // Labels apply up to _bottomMargin padding. Remainder is media padding.
    final double additionalBottomPadding = math.max(MediaQuery.of(context).padding.bottom - _kBottomMargin, 0.0);
    
    // 根據BottomNavigationBarType設背景色,shifting才會有
    Color backgroundColor;
    switch (widget.type) {
      case BottomNavigationBarType.fixed:
        break;
      case BottomNavigationBarType.shifting:
        backgroundColor = _backgroundColor;
        break;
    }
    return Semantics( // Semantics用來實現無障礙的
      container: true,
      explicitChildNodes: true,
      child: Stack(
        children: <Widget>[
          Positioned.fill(
            child: Material( // Casts shadow.
              elevation: 8.0,
              color: backgroundColor,
            ),
          ),
          ConstrainedBox(
            constraints: BoxConstraints(minHeight: kBottomNavigationBarHeight + additionalBottomPadding),
            child: Stack(
              children: <Widget>[
                Positioned.fill(  // 點選時的圓形類波紋動畫
                  child: CustomPaint(
                    painter: _RadialPainter(
                      circles: _circles.toList(),
                      textDirection: Directionality.of(context),
                    ),
                  ),
                ),
                Material( // Splashes.
                  type: MaterialType.transparency,
                  child: Padding(
                    padding: EdgeInsets.only(bottom: additionalBottomPadding),
                    child: MediaQuery.removePadding(
                      context: context,
                      removeBottom: true, 
                      // tiles就是_BottomNavigationTile,裡面放BottomNavigationBarItem
                      child: _createContainer(_createTiles()),
                    )))]))]));
  }}


複製程式碼
  • _BottomNavigationTile看下
  Widget _buildIcon() {
    ...
    // 構建Iocn
  }

  Widget _buildFixedLabel() {
   ....
          // 騷操作,用矩陣來給文字作動畫,更平滑
          // The font size should grow here when active, but because of the way
          // font rendering works, it doesn't grow smoothly if we just animate
          // the font size, so we use a transform instead.
          child: Transform(
            transform: Matrix4.diagonal3(
              Vector3.all(
                Tween<double>(
                  begin: _kInactiveFontSize / _kActiveFontSize,
                  end: 1.0,
                ).evaluate(animation),
              ),
            ),
            alignment: Alignment.bottomCenter,
            child: item.title,
          ),
        ),
      ),
    );
  }

  Widget _buildShiftingLabel() {
    return Align(
.....
        // shifting的label是fade動畫,只有當前選中的才會顯示label
        child: FadeTransition(
          alwaysIncludeSemantics: true,
          opacity: animation,
          child: DefaultTextStyle.merge(
            style: const TextStyle(
              fontSize: _kActiveFontSize,
              color: Colors.white,
            ),
            child: item.title,
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    int size;
    Widget label;
    // 生成不同的label
    switch (type) {
      case BottomNavigationBarType.fixed:
        size = 1;
        label = _buildFixedLabel();
        break;
      case BottomNavigationBarType.shifting:
        size = (flex * 1000.0).round();
        label = _buildShiftingLabel();
        break;
    }
    return Expanded(
    ....
                children: <Widget>[
                  _buildIcon(),
                  label,
                ],
              ),
            ),
            Semantics(
              label: indexLabel,
}
複製程式碼

改進實現

通過分析分析原始碼,發現原因是bottomNavigationBarState的initState裡_controllers[widget.currentIndex].value = 1.0設了currentIndex item動畫的初值,currentIndex的預設值是0,所以第一個圖示會大一點點。這個問題也有比較雞賊的手法可以處理(魔改原始碼什麼~),但這樣大家都覺得不妥。同事眉頭一皺,做了一個大膽的決定,不用系統元件BottomNavigationBar,自己封裝一下:

// SafeArea來相容下iPhone X,android和iOS陰影不一樣,所以區分下。
Widget _buildBottomNavigationBar() {
  return SafeArea(
      child: SizedBox(
          height: 50.0,
          child: Card(
              color: Platform.isIOS ? Colors.transparent : Colors.white,
              elevation: Platform.isIOS ? 0.0 : 8.0,
              // iphone 無陰影
              shape: RoundedRectangleBorder(),
              margin: EdgeInsets.all(0.0),
              child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    Divider(),
                    Expanded(
                      child: Row(
                          mainAxisAlignment: MainAxisAlignment.center,
                          crossAxisAlignment: CrossAxisAlignment.center,
                          children: <Widget>[
                            _buildBottomItem(
                                image: HImages.home, text: '首頁', index: 0),
                            _buildBottomItem(
                                image: HImages.stats, text: '資料', index: 1),
                            _buildBottomItem(
                                image: HImages.mine, text: '我的', index: 3)
                          ]),
                    )
                  ]))));
}

// 封裝的BottomItem,選中顏色為primaryColor,未選中grey。點選波紋效果InkResponse
Widget _buildBottomItem({String image, String text, int index}) {
  Color color =
      currentIndex == index ? Theme.of(context).primaryColor : Colors.grey;
  return Expanded(
      child: InkResponse(
          child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                Image.asset(image, color: color, width: 22.0, height: 22.0),
                Text(text, style: TextStyle(color: color, fontSize: 10.0))
              ]),
          onTap: () => setState(() => currentIndex = index)));
}
複製程式碼

image

問:這該是最終版了吧?*

答:Naive,是連iPhone X都考慮了,但細節漸變顏色,platform特性支援還沒有。。。說到特性我就佛了,一佛,我就想起西天取經,明年年初,中美合拍的西遊記即將正式開機,我繼續扮演美猴王孫悟空,我會用美猴王藝術形象努力創造一個正能量的形象,文體兩開花,弘揚中華文化,希望大家多多關注。

一些收穫

  • 元件動畫實現可以參考BottomNavigationBar,規範,
  • 文字動畫實現可以用Matrix4和Vector3,比較高階(這個在TabBar用上了),
  • 考慮給官方提個issue(區域化需求)。

本文原始碼地址:https://github.com/hyjfine/flutter-play

(完)

@子路宇, 本文版權屬於再惠研發團隊,歡迎轉載,轉載請保留出處。

相關文章