头图

哈喽,我是老刘

前段时间看到有人问Flutter如何实现闲鱼首页的效果
本来觉得很简单,没啥可说的
后来仔细想想还是有两个难点的
所以打算写篇文章说明一下
结果又发现一篇文章里贴太多代码,太冗长了,而且主题也不统一
所以拆成两篇
本文主要讲如何实现底部导航栏效果
后续另一篇文章会讲页面内容,主要是滑动嵌套的部分

本文主要针对初学者,所以会有很多细节部分是如何一步步完善的
有经验的朋友可以直接看最后的代码部分

image.png

闲鱼这里是一种非常标准的首页布局
一般来说,这个页面框架直接选择Scaffold就可以,大多数页面需要的效果都能实现
这里面有一个小小的难点,就是第三个图标比整个导航栏高
注意这里第三个图标高出来的部分要覆盖在内容上面,而不能把内容顶上去
比如其它按钮高度为50,那么底部导航栏的高度就是50,内容部分和底部边缘的距离只有50
而第三个按钮高度可能是60
高出来的10的高度,需要覆盖在内容上面

有些人可能会把中间那个凸出来的按钮通过Stack单独放置在上层
这样功能上是可以实现的,但是会把底部导航栏的多个按钮割裂
从代码结构上来说不是太好
我们还是希望底部导航栏是一个独立的组件,能传递给Scaffold的bottomNavigationBar参数
我们来看看如何一步一步实现这个效果

首先来实现一个简单的页面结构

内容使用一个ListView代替,底部导航栏使用一个高50的色块代替

class MyHomePage extends StatelessWidget {  
  const MyHomePage({super.key});  
  
  @override  
  Widget build(BuildContext context) {  
    return Scaffold(  
      body: ListView.builder(  
        itemCount: 100, // 设置列表项的数量为100  
        itemBuilder: (context, index) {  
          return Container(  
            height: 40,  
            color: Color(0xFF888000 + index * 20), // 使用当前索引对应的颜色  
            alignment: Alignment.center, // 将内容居中  
            child:  
                Text('Row $index', style: const TextStyle(color: Colors.white)),  
          );  
        },  
      ),  
      bottomNavigationBar: Container(  
        height: 50,  
        color: Colors.blue,  
      ),  
    );  
  }  
}  

效果如下:
image.png

接下来实现一个自定义的导航栏传递给bottomNavigationBar

先实现一个没有做任何特殊处理的导航栏

class CustomBottomNavigationBar extends StatefulWidget {  
  const CustomBottomNavigationBar({super.key});  
  
  @override  
  CustomBottomNavigationBarState createState() =>  
      CustomBottomNavigationBarState();  
}  
  
class CustomBottomNavigationBarState extends State {  
  int _selectedIndex = 0;  
  
  void _onItemTapped(int index) {  
    setState(() {  
      _selectedIndex = index;  
    });  
  }  
  
  @override  
  Widget build(BuildContext context) {  
    return Container(  
      height: 50,    // 导航栏高50`  
`      clipBehavior: Clip.none,  
      color: Colors.blue,  
      child: Row(  
        mainAxisAlignment: MainAxisAlignment.spaceAround,  
        children: [  
          _buildNavItem(Icons.home, 0),  
          _buildNavItem(Icons.business, 1),  
          _buildNavItem(Icons.add, 2, isSpecial: true),  
          _buildNavItem(Icons.settings, 3),  
          _buildNavItem(Icons.person, 4),  
        ],  
      ),  
    );  
  }  
  
  Widget _buildNavItem(IconData icon, int index, {bool isSpecial = false}) {  
    return GestureDetector(  
      onTap: () => _onItemTapped(index),  
      child: Container(  
        height: isSpecial ? 60 : 40, // 特殊按钮高度60,其余40  
        width: isSpecial ? 60 : 40,  
        decoration: const BoxDecoration(  
          color: Colors.white,  
          shape: BoxShape.circle,  
        ),  
        child: Icon(  
          icon,  
          color: _selectedIndex == index ? Colors.amber[800] : Colors.grey,  
        ),  
      ),  
    );  
  }  
}

我们来看一下效果
image.png

可以看到我们虽然给中间的特殊按钮设置高度60,但是由于整个容器的高度是50,所以中间的按钮被缩小到50了
那么怎么让中间这个按钮突破50的限制呢?
很多人可能想到了使用 OverflowBox 甚至 UnconstrainedBox
这两种组件在这个场景下都会有自己的问题或者不方便的地方,感兴趣的同学可以试一下

其实这里可以巧妙的利用 Stack 组件大小计算的原理来解决这个问题
原理:如果Stack中的所有子组件都没有定位(即没有使用Positioned包裹),那么Stack的大小将会适应其最大的子组件的大小。如果Stack中有定位的子组件,那么它们不会影响Stack的大小,Stack的大小将由未定位的子组件决定。

基于这个原理,我们可以利用未定位的子组件控制 Stack 大小,进而来控制底部导航栏的高度
然后利用定位子组件来绘制底部导航栏的按钮内容
实现的代码如下:

class CustomBottomNavigationBar extends StatefulWidget {  
  const CustomBottomNavigationBar({super.key});  
  
  @override  
  CustomBottomNavigationBarState createState() =>  
      CustomBottomNavigationBarState();  
}  
  
class CustomBottomNavigationBarState extends State {  
  int _selectedIndex = 0;  
  
  void _onItemTapped(int index) {  
    setState(() {  
      _selectedIndex = index;  
    });  
  }  
  
  @override  
  Widget build(BuildContext context) {  
    return Stack(  
      clipBehavior: Clip.none,  
      children: [  
        Container(  
          height: 50,  // 导航栏高度  
          color: Colors.white, // 导航栏背景  
        ),  
        Positioned(  
          bottom: 0,  
          left: 0,  
          right: 0,  
          child: Row(  
            mainAxisAlignment: MainAxisAlignment.spaceAround,  
            children: [  
              _buildNavItem(Icons.home, 0),  
              _buildNavItem(Icons.business, 1),  
              _buildNavItem(Icons.add, 2, isSpecial: true),  
              _buildNavItem(Icons.settings, 3),  
              _buildNavItem(Icons.person, 4),  
            ],  
          ),  
        ),  
      ],  
    );  
  }  
  
  Widget _buildNavItem(IconData icon, int index, {bool isSpecial = false}) {  
    return GestureDetector(  
      onTap: () => _onItemTapped(index),  
      child: Container(  
        height: isSpecial ? 60 : 40, // 特殊按钮高度60,其余40  
        width: isSpecial ? 60 : 40,  
        decoration: const BoxDecoration(  
          color: Colors.white,  
          shape: BoxShape.circle,  
        ),  
        child: Icon(  
          icon,  
          color: _selectedIndex == index ? Colors.amber[800] : Colors.grey,  
        ),  
      ),  
    );  
  }  
} 

这里面主要就是把导航栏的主体改成了 Stack
利用其中未定位的子组件 Conatiner 设置导航栏的高度及背景
利用定位的 Row 绘制导航栏的按钮部分
这样不管按钮部分绘制多大,都不会影响导航栏的大小
效果如下:
image.png

总结

好了,模仿闲鱼首页的底部导航栏部分的实现原理就先介绍到这里了
下一篇文章我们介绍内容部分如何实现嵌套滑动
如果看到这里的同学有学习Flutter的兴趣,欢迎联系老刘,我们互相学习。
点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。
可以作为Flutter学习的知识地图。
覆盖90%开发场景的《Flutter开发手册》


程序员老刘
1 声望2 粉丝

客户端架构师,客户端团队负责人。一个月带领客户端团队从0基础迁移到Flutter 。目前团队已使用Flutter五年。