此图与正文无关,只是为了好看git
这段时间一直在学习 Flutter,在 dribble 上看到一张导航栏设计图,就是下面这张,感受非常喜欢,因而思考着如何在 Flutter 中实现这个效果。github
设计图做者:Lukáš Straňákcanvas
通过一番研究,大致上算是实现了效果(有些地方仍是须要改进的),以下:app
这篇文章和你们分享一下实现过程,一块儿交流、学习。ide
实现这个效果主要用到了 AnimationController
和 CustomPaint
,切换导航时进行从新绘制。学习
首先搭建一下整个页面的骨架:动画
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>[
//浮动图标
//全部图标
],
),
),
)
]),
);
}
}
复制代码
这里将图中的导航分红两个部分,一个是浮动图标,另外一个是全部图标,浮动图标在点击的时候会移动到全部图标中对应图标的位置,而全部图标上的圆弧状缺口也会一块儿移动。ui
接下来,在 _FloatNavigatorState
定义一些变量,以供使用:this
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
中对一些变量作初始化:spa
@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),
)
复制代码
这里须要用到索引来肯定每次点击的是第几个导航,因此用到了 asMap
和 MapEntry
,ArcPainter
就是用来绘制背景的,来看一下绘制背景的实现(不要慌,_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();
}
}
复制代码
这里每次点击切换导航的时候都从新给 _moveAnimation
的 begin
和 end
赋值,来肯定要移动的真正距离,当动画执行完成后,更新当前激活项。
还有一点,差点漏了,销毁动画控制器:
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
复制代码
至此,代码就写完了,看一下动态效果:
感受导航项少一些彷佛更好看,完整代码请点这里
只能说大致上实现了这个效果,但仍是有一些不足:
这些不足仍是会让最终效果不那么完美,但现已足够。你们有什么好的想法或建议能够交流,畅所欲言。
录制了一套 Flutter 实战教程,有兴趣的能够看一下