前言
最近在使用 flutter 编写 app 时遇到一个很令人头疼的设计稿,具体效果如下:



可以发现,图形是不规则的,同时这种不规则的图形在不同的情况下展示效果也不一样,如果使用图片解决,又会出现阴影不协调的问题,所以得用到裁剪属性。
ClipPath
在flutter中实现这种不规则的图形,需要用到 ClipPath 这个 widget,具体用法如下:
class HeaderLeftClipPath extends CustomClipper<Path> {
@override
Path getClip(Size size) {
const itemWidth = 168.0;
const bottomHeight = 0;
var path = Path()
..moveTo(0, size.height)
..lineTo(0, 20)
..quadraticBezierTo(0, 0, 20, 0)
..lineTo(itemWidth - 40, 0)
..quadraticBezierTo(itemWidth - 20, 0, itemWidth - 20, 20)
..lineTo(itemWidth - 20, size.height - 20 - bottomHeight)
..quadraticBezierTo(itemWidth - 20, size.height - bottomHeight, itemWidth, size.height - bottomHeight)
..lineTo(size.width, size.height - bottomHeight)
..lineTo(size.width, size.height)
..close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
class Header extends StatelessWidget {
@override
build() {
return _buildLeftHeader()
}
_buildLeftHeader() {
return ClipPath(
clipper: HeaderLeftClipPath(),
child: Container(
width: 168,
height: 60,
padding: EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: Color(0xffff0000)
),
),
);
}
}
它的作用在于根据定义的路径进行裁剪后得到需要的图形,其中绘制路径时可以使用 flutter 提供的 api 进行特殊路径的绘制,例如贝塞尔曲线,经过上述裁剪,就能得到这样一个图形,也就是订单头部的左侧导航。

接着我们再绘制中间部位的导航形状。
class HeaderCenterClipPath extends CustomClipper<Path> {
@override
Path getClip(Size size) {
var path = Path()
..moveTo(0, size.height)
..quadraticBezierTo(20, size.height, 20, size.height - 20)
..lineTo(20, 20)
..quadraticBezierTo(20, 0, 40, 0)
..lineTo(size.width - 40, 0)
..quadraticBezierTo(size.width - 20, 0, size.width - 20, 20)
..lineTo(size.width - 20, size.height - 20)
..quadraticBezierTo(size.width - 20, size.height, size.width, size.height)
..close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
class Header extends StatelessWidget {
@override
build() {
return _buildCenterHeader()
}
_buildCenterHeader() {
return ClipPath(
clipper: HeaderCenterClipPath(),
child: Container(
width: 187,
height: 60,
padding: EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: Color(0xffff0000)
),
),
);
}
}
得到形状如下:

最后再绘制右侧导航
class HeaderRightClipPath extends CustomClipper<Path> {
@override
Path getClip(Size size) {
print(size);
var path = Path()
..moveTo(0, size.height)
..quadraticBezierTo(20, size.height, 20, size.height - 20)
..lineTo(20, 20)
..quadraticBezierTo(20, 0, 40, 0)
..lineTo(size.width - 20, 0)
..quadraticBezierTo(size.width, 0, size.width, 20)
..lineTo(size.width, size.height)
..close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
class Header extends StatelessWidget {
@override
build() {
return _buildRightHeader()
}
_buildRightHeader() {
return ClipPath(
clipper: HeaderRightClipPath(),
child: Container(
width: 187,
height: 60,
padding: EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: Color(0xffff0000)
),
),
);
}
}
得到最终的图案

将他们使用 Stack 布局汇总在一起,得到效果如下。

有点丑,哈哈,主要的原因是缺少阴影,以及背景色与设计稿不符,所以现在我们给剪切后的图形添加阴影效果。需要注意的是,如果直接给 ClipPath 部件包裹 Container,并且添加阴影效果,是达不到设计稿那样的效果的,原因在于即使 Container 被裁剪,但实际的大小还是原来的大小,所以阴影部分也需要绘制来达到效果。
绘制曲线阴影
因为自身也是 flutter 的新手,对于曲线阴影这种效果也不知道如何实现,于是在 google 中搜索得到了解决方案,具体看这里。
话不多说,直接 command cv。
使用此组件,对上面的代码进行改造,得到最终效果如下:

再把背景色切换为白色:

效果更加明显,为了使头部与底部融合为一体,需要在视觉上对用户进行欺骗,所以得把底部的阴影去掉。
class HeaderContainerPath extends CustomClipper<Rect> {
@override
Rect getClip(Size size) {
// TODO: implement getClip
return Rect.fromLTRB(-10, 0, size.width, size.height);
}
@override
bool shouldReclip(CustomClipper<Rect> oldClipper) {
// TODO: implement shouldReclip
return false;
}
}
_buildHeader() {
return ClipRect(
clipper: HeaderContainerPath(),
child: Container(
height: 60,
child: Stack(
children: <Widget>[
Positioned(
left: 0,
child: _buildLeftHeader(),
),
Positioned(
left: 135 - 8.0,
child: _buildCenterHeader(),
),
Positioned(
left: 283 - 9.0,
child: _buildRightHeader(),
)
],
),
),
);
}
因为头部的裁剪是一个矩形,所以我们这里需要用到 ClipRect 这个部件,同时 CustomClipper 范型需要指定为 Rect, 同时 getClip 返回一个 Rect 对象。因为需要保留最左侧和头部的阴影,所以裁剪时,需要向左和向上偏移 10px。
Rect.fromLTRB(-10, -10, size.width, size.height)
得到效果如下:

再给底部容器添加阴影,得到一个融合为一体的容器,如图:

效果貌似还不错,不过有一点细节没有完成,那就是激活的 tab 会有一个阴影效果覆盖其它的 tab,如图所示:

如果用常规的思维来实现,那么非常麻烦,这里我们换一个思维方式来实现这个效果,添加一个渐变容器来模拟阴影效果。代码如下所示:
_buildHeader() {
return ClipRect(
clipper: HeaderContainerPath(),
child: Container(
height: 60,
child: Stack(
children: <Widget>[
Positioned(
left: 0,
child: _buildLeftHeader(),
),
Positioned(
left: 135 - 8.0,
child: _buildCenterHeader(),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: _buildShadow(), // 阴影放置在倒数第二的位置
),
Positioned(
left: 283 - 9.0,
child: _buildRightHeader(),
)
],
),
),
);
}
Widget _buildShadow() {
return Container(
height: 8,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Color.fromRGBO(255, 255,255, 0), Color.fromRGBO(0, 0, 0, 0.1)], begin: Alignment.topCenter, end: Alignment.bottomCenter)
),
);
}
结果如图:

右边的阴影有点深,是因为叠加了两层阴影,这个之后再解决。阴影容器放在倒数第二的位置是因为 flutter 没有 css 中 zIndex 的概念,层级是以代码的顺序为准,在 stack 布局中,写在最后的代码层级是最高的,所以阴影放置在倒数第二的位置,覆盖其它的 tab, 同时保证当前激活的 tab 不会被覆盖。
点击进行切换
接下来进行点击切换的讲解,因为 flutter 不存在 zIndex,所以在点击的时候,我们需要改变 widget 在代码中的位置来提升激活 tab 的层级,代码如下:
_buildHeader() {
List<Function> tabOrder = [_buildLeftHeader, _buildCenterHeader, _buildRightHeader];
Function activeOrder = tabOrder.removeAt(activeIndex); // 先移除并取出激活的 tab
tabOrder = tabOrder.reversed.toList();
tabOrder.add(_buildShadow); // 把阴影放到倒数第二的位置
tabOrder.add(activeOrder); // 最终将激活的 tab 放入最后
return ClipRect(
clipper: HeaderContainerPath(),
child: Container(
height: 60,
child: Stack(
children: tabOrder.map<Widget>((fn) => fn()).toList(),
),
),
);
}
最终效果如下:

最后奉上仓库地址: