当Flutter提供的现有组件无法满足我们的需求,或者我们为了共享代码需要封装一些通用组件,这时我们就需要自定义组件。在Flutter中自定义组件有三种方式:通过组合其它组件、自绘和实现RenderObject。
组合多个Widget
这种方式是通过拼装多个组件来组合成一个新的组件。例如我们之前介绍的Container就是一个组合组件,它是由DecoratedBox、ConstrainedBox、Transform、Padding、Align等组件组成。
在Flutter中,组合的思想非常重要,Flutter提供了非常多的基础组件,而我们的界面开发其实就是按照需要组合这些组件来实现各种不同的布局而已。
自定义渐变按钮
自定义一个GradientButton组件。支持以下功能。
- 背景支持渐变色
- 手指按下时有涟漪效果
- 可以支持圆角
DecoratedBox支持背景渐变和圆角,InkWell在手指按钮后有涟漪,所以可以通过组合DecoratedBox和InkWell来实现GradientButton。
import 'package:flutter/material.dart';
class SSLGradientButton extends StatelessWidget {
const SSLGradientButton({Key? key,
this.colors,
this.width,
this.height,
this.onPressed,
this.borderRadius,
required this.child,
}) : super(key: key);
// 渐变色数组
final List<Color>? colors;
// 按钮宽高
final double? width;
final double? height;
final BorderRadius? borderRadius;
//点击回调
final GestureTapCallback? onPressed;
final Widget child;
@override
Widget build(BuildContext context) {
ThemeData theme = Theme.of(context);
//确保colors数组不空
List<Color> _colors =
colors ?? [theme.primaryColor, theme.primaryColorDark];
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors: _colors),
borderRadius: borderRadius,
//border: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
splashColor: _colors.last,
highlightColor: Colors.transparent,
borderRadius: borderRadius,
onTap: onPressed,
child: ConstrainedBox(
constraints: BoxConstraints.tightFor(height: height, width: width),
child: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: DefaultTextStyle(
style: const TextStyle(fontWeight: FontWeight.bold),
child: child,
),
),
),
),
),
),
);
}
}
使用:
SSLGradientButton(
child: Text('自定义按钮'),
colors: [Colors.lightBlue.shade300, Colors.blueAccent],
height: 50,
onPressed: (){
print('你点我干啥');
},
),
Flutter的自定义组件组合,比iOS中的自定义组件好用一些。比如iOS想要实现这个功能,要写一个继承于UIButton的类然后监听touch的位置,又要自己写渐变效果。
自绘
如果遇到无法通过现有的组件来实现需要的UI时,我们可以通过自绘组件的方式来实现,例如我们需要一个颜色渐变的圆形进度条,而Flutter提供的CircularProgressIndicator并不支持在显示精确进度时对进度条应用渐变色(其valueColor 属性只支持执行旋转动画时变化Indicator的颜色),这时最好的方法就是通过自定义组件来绘制出我们期望的外观。我们可以通过Flutter中提供的CustomPaint和Canvas来实现UI自绘。
CustomPaint 与 Canvas
对于一些复杂或不规则的UI,无法通过组合其他组件来实现。这时候可以自绘。
CustomPaint:
CustomPaint({
Key key,
this.painter,
this.foregroundPainter,
this.size = Size.zero,
this.isComplex = false,
this.willChange = false,
Widget child, //子节点,可以为空
})
painter: 背景画笔,会显示在子节点后面;foregroundPainter: 前景画笔,会显示在子节点前面size:当child为null时,代表默认绘制区域大小,如果有child则忽略此参数,画布尺寸则为child尺寸。如果有child但是想指定画布为特定大小,可以使用SizeBox包裹CustomPaint实现。isComplex:是否复杂的绘制,如果是,Flutter会应用一些缓存策略来减少重复渲染的开销。willChange:和isComplex配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变。
如果CustomPaint有子节点,为了避免子节点不必要的重绘并提高性能,通常情况下都会将子节点包裹在RepaintBoundary组件中,这样会在绘制时就会创建一个新的绘制层(Layer),其子组件将在新的Layer上绘制,而父组件将在原来Layer上绘制,也就是说RepaintBoundary 子组件的绘制将独立于父组件的绘制,RepaintBoundary会隔离其子节点和CustomPaint本身的绘制边界。示例如下:
CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
child: RepaintBoundary(child:...)),
)
CustomPainter:
CustomPainter中提定义了一个虚函数paint
void paint(Canvas canvas, Size size);
paint有两个参数:
Canvas:一个画布,包括各种绘制方法,我们列出一下常用的方法:
| API名称 | 功能 |
|---|---|
| drawLine | 画线 |
| drawPoint | 画点 |
| drawPath | 画路径 |
| drawImage | 画图像 |
| drawRect | 画矩形 |
| drawCircle | 画圆 |
| drawOval | 画椭圆 |
| drawArc | 画圆弧 |
Size:当前绘制区域大小
画笔Paint
在Paint中,我们可以配置画笔的各种属性如粗细、颜色、样式。
实现RenderObject
Flutter提供的自身具有UI外观的组件,如文本Text、Image都是通过相应的RenderObject(我们将在“Flutter核心原理”一章中详细介绍RenderObject)渲染出来的,如Text是由RenderParagraph渲染;而Image是由RenderImage渲染。RenderObject是一个抽象类,它定义了一个抽象方法paint(...):
void paint(PaintingContext context, Offset offset)
var paint = Paint() //创建一个画笔并配置其属性
..isAntiAlias = true //是否抗锯齿
..style = PaintingStyle.fill //画笔样式:填充
..color=Color(0x77cdb175);//画笔颜色
下面通过一个五子棋/盘来实践一下。
import 'package:flutter/material.dart';
import 'dart:math';
class CustomPaintRoute extends StatelessWidget {
const CustomPaintRoute({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
),
);
}
}
class MyPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
print('paint');
var rect = Offset.zero & size;
//画棋盘
drawChessboard(canvas, rect);
//画棋子
drawPieces(canvas, rect);
}
/*
* 假如我们绘制的UI不依赖外部状态,即外部状态改变不会影响我们的UI外观,
* 那么就应该返回false;如果绘制依赖外部状态,那么我们就应该在shouldRepaint中判断依赖的状态是否改变,
* 如果已改变则应返回true来重绘,反之则应返回false不需要重绘。
* */
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
void drawChessboard(Canvas canvas, Rect rect) {
//棋盘背景
var paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.fill //填充
..color = Color(0xFFDCC48C);
canvas.drawRect(rect, paint);
//画棋盘网格
paint
..style = PaintingStyle.stroke //线
..color = Colors.black38
..strokeWidth = 1.0;
//画横线
for (int i = 0; i <= 15; ++i) {
double dy = rect.top + rect.height / 15 * i;
canvas.drawLine(Offset(rect.left, dy), Offset(rect.right, dy), paint);
}
for (int i = 0; i <= 15; ++i) {
double dx = rect.left + rect.width / 15 * i;
canvas.drawLine(Offset(dx, rect.top), Offset(dx, rect.bottom), paint);
}
}
//画棋子
void drawPieces(Canvas canvas, Rect rect) {
double eWidth = rect.width / 15;
double eHeight = rect.height / 15;
//画一个黑子
var paint = Paint()
..style = PaintingStyle.fill
..color = Colors.black;
//画一个黑子
canvas.drawCircle(
Offset(rect.center.dx - eWidth / 2, rect.center.dy - eHeight / 2),
min(eWidth / 2, eHeight / 2) - 2,
paint,
);
//画一个白子
paint.color = Colors.white;
canvas.drawCircle(
Offset(rect.center.dx + eWidth / 2, rect.center.dy - eHeight / 2),
min(eWidth / 2, eHeight / 2) - 2,
paint,
);
}
}
运行后效果,
防止意外重绘
我们在上面代码中添加一个 ElevatedButton,点击后什么也不做:
class CustomPaintRoute extends StatelessWidget {
const CustomPaintRoute({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
),
//添加一个刷新button
ElevatedButton(onPressed: () {}, child: Text("刷新"))
],
),
);
}
}
点击刷新后
日志面板输出了很多 “paint”,也就是说在点击按钮的时候发生了多次重绘。
可以简单认为,刷新按钮的画布和CustomPaint的画布是同一个,刷新按钮点击时会执行一个水波动画,水波动画执行过程中画布会不停的刷新,所以就导致了CustomPaint 不停的重绘。要解决这个问题的方案很简单,给刷新按钮 或 CustomPaint 任意一个添加一个 RepaintBoundary 父组件即可,现在可以先简单认为这样做可以生成一个新的画布:
RepaintBoundary(
child: CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
),
),