在 Flutter 中實現一個浮動導航欄

MeFelixWang發表於2019-06-12

在 Flutter 中實現一個浮動導航欄

此圖與正文無關,只是為了好看

寫在前面

這段時間一直在學習 Flutter,在 dribble 上看到一張導航欄設計圖,就是下面這張,感覺很是喜歡,於是思考著如何在 Flutter 中實現這個效果。

在 Flutter 中實現一個浮動導航欄

設計圖作者:Lukáš Straňák

經過一番研究,大體上算是實現了效果(有些地方還是需要改進的),如下:

在 Flutter 中實現一個浮動導航欄

這篇文章和大家分享一下實現過程,一起交流、學習。

重點閱讀

實現這個效果主要用到了 AnimationControllerCustomPaint,切換導航時進行重新繪製。

首先搭建一下整個頁面的骨架:

class FloatNavigator extends StatefulWidget {
  @override
  _FloatNavigatorState createState() => _FloatNavigatorState();
}
class _FloatNavigatorState extends State<FloatNavigator>
    with SingleTickerProviderStateMixin {
    
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Stack(children: [
        Scaffold(
          appBar: AppBar(
            backgroundColor: Colors.transparent,
            elevation: 0.0,
            title: Text('Float Navigator'),
            centerTitle: true,
          ),
          backgroundColor: Color(0xFFFF0035),
        ),
        Positioned(
          bottom: 0.0,
          child: Container(
            width: width,
            child: Stack(
              overflow: Overflow.visible,
              children: <Widget>[
                //浮動圖示
                //所有圖示
              ],
            ),
          ),
        )
      ]),
    );
  }
}    
複製程式碼

這裡將圖中的導航分成兩個部分,一個是浮動圖示,另一個是所有圖示浮動圖示在點選的時候會移動到所有圖示中對應圖示的位置,而所有圖示上的圓弧狀缺口也會一起移動。

接下來,在 _FloatNavigatorState 定義一些變數,以供使用:

  int _activeIndex = 0; //啟用項
  double _height = 48.0; //導航欄高度
  double _floatRadius; //懸浮圖示半徑
  double _moveTween = 0.0; //移動補間
  double _padding = 10.0; //浮動圖示與圓弧之間的間隙
  AnimationController _animationController; //動畫控制器
  Animation<double> _moveAnimation; //移動動畫
  List _navs = [
    Icons.search,
    Icons.ondemand_video,
    Icons.music_video,
    Icons.insert_comment,
    Icons.person
  ]; //導航項
複製程式碼

接著在 initState 中對一些變數做初始化:

  @override
  void initState() {
    _floatRadius = _height * 2 / 3;
    _animationController =
        AnimationController(vsync: this, duration: Duration(milliseconds: 400));
    super.initState();
  }
複製程式碼

這裡我將懸浮圖示的半徑設定為導航欄高度的三分之二,動畫時長設定為 400 毫秒,當然這裡面的引數都是可以改動的。

接著,實現懸浮圖示:

//懸浮圖示
Positioned(
  top: _animationController.value <= 0.5
      ? (_animationController.value * _height * _padding / 2) -
          _floatRadius / 3 * 2
      : (1 - _animationController.value) *
              _height *
              _padding /
              2 -
          _floatRadius / 3 * 2,
  left: _moveTween * singleWidth +
      (singleWidth - _floatRadius) / 2 -
      _padding / 2,
  child: DecoratedBox(
    decoration:
        ShapeDecoration(shape: CircleBorder(), shadows: [
      BoxShadow(    //陰影效果
          blurRadius: _padding / 2,
          offset: Offset(0, _padding / 2),
          spreadRadius: 0,
          color: Colors.black26),
    ]),
    child: CircleAvatar(
        radius: _floatRadius - _padding, //浮動圖示和圓弧之間設定10pixel間隙
        backgroundColor: Colors.white,
        child: Icon(_navs[_activeIndex], color: Colors.black)),
  ),
)
複製程式碼

這裡的 top 值看上去很複雜,但實際上並沒什麼特別的,只是為了讓懸浮圖示上下移動而已,_animationController 產生的值為 0.0 到 1.0,因此,這裡判斷如果小於等於 0.5,就讓圖示向下移動,大於 0.5 則向上移動(移動距離可以隨意修改)。

left 做橫向移動,這裡使用的是 _moveTween,因為移動的距離是 singleWidth 的倍數(當然最終移動距離還要減去半徑及間隙,這裡的倍數是指列如從索引 0 移動到索引 3 這之間途徑的導航項長度)。

再向下就是重頭戲了,所有圖示的繪製:

CustomPaint(
  child: SizedBox(
    height: _height,
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      crossAxisAlignment: CrossAxisAlignment.center,
      children: _navs
          .asMap()
          .map((i, v) => MapEntry(
              i,
              GestureDetector(
                child: Icon(v,
                    color: _activeIndex == i
                        ? Colors.transparent
                        : Colors.grey),
                onTap: () {
                  _switchNav(i);
                },
              )))
          .values
          .toList(),
    ),
  ),
  painter: ArcPainter(
      navCount: _navs.length,
      moveTween: _moveTween,
      padding: _padding),
)
複製程式碼

這裡需要用到索引來確定每次點選的是第幾個導航,所以用到了 asMapMapEntryArcPainter 就是用來繪製背景的,來看一下繪製背景的實現(不要慌,_switchNav 方法我會在後面解釋的):

//繪製圓弧背景
class ArcPainter extends CustomPainter {
  final int navCount; //導航總數
  final double moveTween; //移動補間
  final double padding; //間隙
  ArcPainter({this.navCount, this.moveTween, this.padding});

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = (Colors.white)
      ..style = PaintingStyle.stroke; //畫筆
    double width = size.width; //導航欄總寬度,即canvas寬度
    double singleWidth = width / navCount; //單個導航項寬度
    double height = size.height; //導航欄高度,即canvas高度
    double arcRadius = height * 2 / 3; //圓弧半徑
    double restSpace = (singleWidth - arcRadius * 2) / 2; //單個導航項減去圓弧直徑後單邊剩餘寬度

    Path path = Path() //路徑
      ..relativeLineTo(moveTween * singleWidth, 0)
      ..relativeCubicTo(restSpace + padding, 0, restSpace + padding / 2,
          arcRadius, singleWidth / 2, arcRadius) //圓弧左半邊
      ..relativeCubicTo(arcRadius, 0, arcRadius - padding, -arcRadius,
          restSpace + arcRadius, -arcRadius) //圓弧右半邊
      ..relativeLineTo(width - (moveTween + 1) * singleWidth, 0)
      ..relativeLineTo(0, height)
      ..relativeLineTo(-width, 0)
      ..relativeLineTo(0, -height)
      ..close();
    paint.style = PaintingStyle.fill;
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}
複製程式碼

先將整個導航欄背景的外框繪製出來,再填充成白色,就能得到我們想要的帶圓弧形缺口的形狀。Flutter 中的繪製方法有兩種(並不完全是這樣,有的方法只有一種),拿 relativeLineTo 來說,與其對應的另一個方法是 lineTo。兩者的區別在於,relativeLineTo 在繪製結束後,會將結束點作為新的座標系原點(0,0),而 lineTo 的原點始終在左上角(這個說法不嚴謹,兩個方法的原點都是左上角,這裡的意思是,它不會移動)。我這裡使用的 relative* 方法就是因為不用繪製一筆後還要考慮下一筆開始的位置,比較方便,我很喜歡。

這裡最複雜(對我來說)的就是圓弧部分的繪製了,用到了三次貝塞爾曲線(自己手工在草稿紙上畫了一下每個點的位置,沒辦法,就是這麼菜),需要注意的是,在繪製完圓弧左半邊後,原點移動到了圓弧最底部,因此繪製右半邊圓弧的座標與左半邊是相反的,剩下的就直接畫就行。

最後一步,實現 _FloatNavigatorState 中的動畫控制方法 _switchNav:

//切換導航
_switchNav(int newIndex) {
    double oldPosition = _activeIndex.toDouble();
    double newPosition = newIndex.toDouble();
    if (oldPosition != newPosition &&
        _animationController.status != AnimationStatus.forward) {
      _animationController.reset();
      _moveAnimation = Tween(begin: oldPosition, end: newPosition).animate(
          CurvedAnimation(
              parent: _animationController, curve: Curves.easeInCubic))
        ..addListener(() {
          setState(() {
            _moveTween = _moveAnimation.value;
          });
        })
        ..addStatusListener((AnimationStatus status) {
          if (status == AnimationStatus.completed) {
            setState(() {
              _activeIndex = newIndex;
            });
          }
        });
      _animationController.forward();
    }
}
複製程式碼

這裡每次點選切換導航的時候都重新給 _moveAnimationbeginend 賦值,來確定要移動的真正距離,當動畫執行完成後,更新當前啟用項。

還有一點,差點漏了,銷燬動畫控制器:

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
複製程式碼

至此,程式碼就寫完了,看一下動態效果:

在 Flutter 中實現一個浮動導航欄
五個導航項

在 Flutter 中實現一個浮動導航欄
四個導航項

在 Flutter 中實現一個浮動導航欄
三個導航項

感覺導航項少一些似乎更好看,完整程式碼請點這裡

最後叨叨

只能說大體上實現了這個效果,但還是有一些不足:

  • 圓弧在移動的時候,途徑的導航項圖示沒有隱藏
  • 懸浮圖示中的圖示是在動畫執行結束後才切換的新圖示

這些不足還是會讓最終效果不那麼完美,但現已足夠。大家有什麼好的想法或建議可以交流,暢所欲言。

錄製了一套 Flutter 實戰教程,有興趣的可以看一下

相關文章