倒叙手法,先上效果图:
最初的想法是使用系统自带的BottomNavigationBar来实现,做到一半发现完全无法满足这奇葩合理的需求:
- 背景模糊效果
- 凸起按钮
- 非标准高度
-
indicator
动画
起初也曾想到过想要封装一个相对通用的组件,然而需求太过非标,不可避免地需要重复造车轮子,只能将其中一些思路整理出来。
框架结构
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
alignment: AlignmentDirectional.bottomStart,
children: [
// 页面
Container(
child: PageView(),
),
// 底部导航
LATabBar(),
// indicator动画
Transform.translate(),
],
),
);
}
我们使用Stack
来放置子视图,简单粗暴,从底部开始层叠:
- Container:子页面,使用
PageView
进行页面切换 - LATabBar:自定义底部导航
- Transform.translate():
indicator
动画组件
出于尽可能解耦的原因,这里把indicator
放到底部导外。
接下来,我们将重点关注底部导航的构建。
全局常量
定义全局常量 ,以针对不同设备进行适配。
底部导航
考虑到这里有一个模糊效果,使用Stack
来层叠background
和item
,首先来定义相关属性。
class LATabBar extends StatefulWidget {
final double height;
final Color backgroundColor;
final Widget background;
final List<LATabItem> items;
...
}
- height:底部导航视图高度
- backgroundColor:背景颜色(考虑后续组件化设计)
- background:自定义背景组件(模糊+凸起效果)
- items:自定义NavigationItem
自定义背景
先前说过,此方法很难封装成一个通用的组件。因为根据需求,需要自定义的东西太多,标准组件所需默认值也多。因此,只能将其中的思路记录下来。
绘制不规则图形
在iOS开发中,此类不规则背景,只需使用一个UIImageView
加载带透明度图片,随后使用view.mask
即可实现不规则背景。
而在Flutter
中,并无相应API来渲染(我太蔡了),因此,需要使用到自定义裁切ClipPath
来绘制相应不规则图形。
首先使用贝塞尔曲线,自定义一个clipper:
class RaisedClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
/**凸起高度*/
double raisedHeight = 18;
/**凸起宽度*/
double raisedWidth = 80;
/**凸起右边距*/
double paddingTrailing = 25;
Path path = Path();
path.lineTo(0, raisedHeight);
double cubicStartX = size.width - paddingTrailing - raisedWidth - 10;
// 贝塞尔曲线起始位置
path.lineTo(cubicStartX, raisedHeight);
// 绘制曲线
path.cubicTo(
cubicStartX + raisedWidth * 0.1, raisedHeight,
cubicStartX + raisedWidth * 0.25, 0,
cubicStartX + raisedWidth * 0.5, 0);
path.cubicTo(
cubicStartX + raisedWidth * 0.75, 0,
cubicStartX + raisedWidth * 0.85, raisedHeight,
size.width - paddingTrailing, raisedHeight);
path.lineTo(size.width, raisedHeight);
path.lineTo(size.width, size.height);
path.lineTo(0, size.height);
return path;
}
关于贝塞尔曲线,这里不再赘述,分享一个在线预览工具,方便调试。
模糊效果
Flutter提供了BackdropFilter
来作为高斯模糊的组件,配合使用ImageFilter
来制作模糊效果。
class BlurBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClipPath(
clipper: RaisedClipper(),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
child: Container(
color: Colors.white.withOpacity(0.5),
),
),
);
}
}
这样就完成了自定义底部导航背景视图的编码。
导航切换按钮
先上代码:
enum LATabItemStyle {
normal,
titleOnly,
iconOnly,
}
class LATabItem extends StatefulWidget {
final double width;
final String iconNormal;
final String iconSelected;
final EdgeInsets iconInsets;
final Alignment iconAlignment;
final Color titleNormalColor;
final Color titleSelectedColor;
final EdgeInsets titleInsets;
final Alignment titleAlignment;
final String title;
final GestureTapCallback onTap;
final bool selected;
final LATabItemStyle style;
const LATabItem({
Key key,
this.width,
this.iconNormal,
this.iconSelected,
this.iconInsets = EdgeInsets.zero,
this.iconAlignment = Alignment.center,
this.titleNormalColor,
this.titleSelectedColor,
this.titleInsets = EdgeInsets.zero,
this.titleAlignment = Alignment.center,
this.title,
this.onTap,
this.selected = false,
this.style = LATabItemStyle.normal}) : super(key: key);
@override
State<StatefulWidget> createState() {
return LATabItemState();
}
}
class LATabItemState extends State<LATabItem> {
@override
Widget build(BuildContext context) {
Widget content;
switch (this.widget.style) {
case LATabItemStyle.titleOnly:
break;
case LATabItemStyle.iconOnly:
content = Container(
width: this.widget.width,
alignment: this.widget.iconAlignment,
padding: this.widget.iconInsets,
child: Image.asset(this.widget.selected ? this.widget.iconSelected : this.widget.iconNormal),
);
break;
default:
break;
}
if (this.widget.width == null || this.widget.width <= 0) {
return Expanded(
child: GestureDetector(
onTap: this.widget.onTap,
child: content,
behavior: HitTestBehavior.opaque,
),
);
} else {
return GestureDetector(
onTap: this.widget.onTap,
child: content,
);
}
}
}
导航按钮完全可以使用自定义的widget
而不必拘泥于形式,可以是自定义的文字、图片、绘制的图形各种。只要将其作为GestureDetector
的child
即可响应点击事件。
需要注意的是,如果使用自适应大小的Expanded
,则需要指定GestureDetector
的behavior: HitTestBehavior.opaque
。
indicator动画
简单动画,直接使用AnimationController
及Tween
来制作动画。
- 初始化:
_animationController = AnimationController(duration: Duration(milliseconds: 100), vsync: this);
_tween = Tween(begin: leading, end: leading);
_animation = _tween.animate(_animationController)
..addListener(() {
setState(() {});
});
-
Translation
容器:
Transform.translate(
offset: Offset(_animation.value, 0),
child: Container(
margin: EdgeInsets.fromLTRB(0, 0, 0, Global.tabBarHeight + Global.paddingBottom - Global.tabBarRaisedHeight - 4),
width: 20,
height: 4,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(2)),
color: Color(0xFFB60005),
),
),
),
- 切换视图时,执行动画:
void setIndicator(int index) {
_tween.begin = _tween.end;
_animationController.reset();
_tween.end = unitWidth * index + leading;
_animationController.forward();
}
到这里,自定义底部导航完成。
最后放上demo。