Flutter了解之自定义组件
2020-11-12 本文已影响0人
平安喜乐698
目录
1. 组合其它组件(最简单,优先考虑)
2. 自绘组件 (CustomPaint与Canvas)
当Flutter提供的现有组件无法满足需求,或者为了共享代码需要封装一些通用组件,这时就需要自定义组件。
3种方式
1. 组合其它组件(最简单,优先考虑)
通过拼装其它组件来组合成一个新的组件。
例如Container就是一个组合组件,它是由DecoratedBox、ConstrainedBox、Transform、Padding、Align等组件组成。
2. 自绘
如果遇到无法通过现有的组件来实现需要的UI时,可以通过自绘组件的方式来实现。
可以通过Flutter中提供的CustomPaint和Canvas来实现UI自绘。
例如需要一个颜色渐变的圆形进度条,而Flutter提供的CircularProgressIndicator并不支持在显示精确进度时对进度条应用渐变色(其valueColor 属性只支持执行旋转动画时变化Indicator的颜色),这时最好的方法就是自绘。
3. 实现RenderObject。
Flutter提供的自身具有UI外观的组件,如文本Text、Image都是通过相应的RenderObject渲染出来的,如Text是由RenderParagraph渲染;而Image是由RenderImage渲染。
RenderObject是一个抽象类,它定义了一个抽象方法paint(...):
void paint(PaintingContext context, Offset offset)
PaintingContext代表组件的绘制上下文,通过PaintingContext.canvas可以获得Canvas,而绘制逻辑主要是通过Canvas API来实现。子类需要重写此方法以实现自身的绘制逻辑,如RenderParagraph需要实现文本绘制逻辑,而RenderImage需要实现图片绘制逻辑。
可以发现,RenderObject中最终也是通过Canvas API来绘制的。CustomPaint只是为了方便开发者封装的一个代理类,它直接继承自SingleChildRenderObjectWidget,通过RenderCustomPaint的paint方法将Canvas和画笔Painter连接起来实现了最终的绘制(绘制逻辑在Painter中)。
自绘和实现RenderObject本质一样:都需要开发者调用Canvas API手动去绘制UI,优点是强大灵活,理论上可以实现任何外观的UI,而缺点是必须了解Canvas API细节,并且得自己去实现绘制逻辑。
1. 组合其它Widget(最简单,优先考虑)
当需要封装一些通用组件时,应该首先考虑是否可以通过组合其它组件来实现,如果可以,则应优先使用组合,因为直接通过现有组件拼装会非常简单、灵活、高效。
在抽离出单独的组件时要考虑代码规范性,如必要参数要用@required 标注,对于可选参数在特定场景需要判空或设置默认值等。这是由于使用者大多时候可能不了解组件的内部细节,所以为了保证代码健壮性,需要在用户错误地使用组件时能够兼容或报错提示(使用assert断言函数)。
例(组合)
Flutter Material组件库中的按钮默认不支持渐变背景,为了实现渐变背景按钮,自定义一个GradientButton组件,它需要支持一下功能:
背景支持渐变色
手指按下时有涟漪效果
可以支持圆角
DecoratedBox可以支持背景色渐变和圆角,InkWell在手指按下有涟漪效果,所以可以通过组合DecoratedBox和InkWell来实现GradientButton。
import 'package:flutter/material.dart';
class GradientButton extends StatelessWidget {
GradientButton({
this.colors,
this.width,
this.height,
this.onPressed,
this.borderRadius,
@required this.child,
});
// 渐变色数组
final List<Color> colors;
// 按钮宽高
final double width;
final double height;
final Widget child;
final BorderRadius borderRadius;
// 点击回调
final GestureTapCallback onPressed;
@override
Widget build(BuildContext context) {
ThemeData theme = Theme.of(context);
// 确保colors数组不空
List<Color> _colors = colors ??
[theme.primaryColor, theme.primaryColorDark ?? theme.primaryColor];
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors: _colors),
borderRadius: borderRadius,
),
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: TextStyle(fontWeight: FontWeight.bold),
child: child,
),
),
),
),
),
),
);
}
}
===========================
使用
import 'package:flutter/material.dart';
import '../widgets/index.dart';
class GradientButtonRoute extends StatefulWidget {
@override
_GradientButtonRouteState createState() => _GradientButtonRouteState();
}
class _GradientButtonRouteState extends State<GradientButtonRoute> {
@override
Widget build(BuildContext context) {
return Container(
child: Column(
children: <Widget>[
GradientButton(
colors: [Colors.orange, Colors.red],
height: 50.0,
child: Text("Submit"),
onPressed: onTap,
),
GradientButton(
height: 50.0,
colors: [Colors.lightGreen, Colors.green[700]],
child: Text("Submit"),
onPressed: onTap,
),
GradientButton(
height: 50.0,
colors: [Colors.lightBlue[300], Colors.blueAccent],
child: Text("Submit"),
onPressed: onTap,
),
],
),
);
}
onTap() {
print("button click");
}
}
![](https://img.haomeiwen.com/i5111884/963dbda21359cb6a.png)
例(组合)
以任意角度来旋转其子节点,而且可以在角度发生变化时执行一个动画以过渡到新状态,同时可以手动指定动画速度。
import 'package:flutter/widgets.dart';
class TurnBox extends StatefulWidget {
const TurnBox({
Key key,
this.turns = .0, //旋转的“圈”数,一圈为360度,如0.25圈即90度
this.speed = 200, //过渡动画执行的总时长
this.child
}) :super(key: key);
final double turns;
final int speed;
final Widget child;
@override
_TurnBoxState createState() => new _TurnBoxState();
}
class _TurnBoxState extends State<TurnBox>
with SingleTickerProviderStateMixin {
AnimationController _controller;
@override
void initState() {
super.initState();
_controller = new AnimationController(
vsync: this,
lowerBound: -double.infinity,
upperBound: double.infinity
);
_controller.value = widget.turns;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RotationTransition(
turns: _controller,
child: widget.child,
);
}
@override
void didUpdateWidget(TurnBox oldWidget) {
super.didUpdateWidget(oldWidget);
// 旋转角度发生变化时执行过渡动画
if (oldWidget.turns != widget.turns) {
_controller.animateTo(
widget.turns,
duration: Duration(milliseconds: widget.speed??200),
curve: Curves.easeOut,
);
}
}
}
import 'package:flutter/material.dart';
import '../widgets/index.dart';
class TurnBoxRoute extends StatefulWidget {
@override
_TurnBoxRouteState createState() => new _TurnBoxRouteState();
}
class _TurnBoxRouteState extends State<TurnBoxRoute> {
double _turns = .0;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: <Widget>[
TurnBox(
turns: _turns,
speed: 500,
child: Icon(Icons.refresh, size: 50,),
),
TurnBox(
turns: _turns,
speed: 1000,
child: Icon(Icons.refresh, size: 150.0,),
),
RaisedButton(
child: Text("顺时针旋转1/5圈"),
onPressed: () {
setState(() {
_turns += .2;
});
},
),
RaisedButton(
child: Text("逆时针旋转1/5圈"),
onPressed: () {
setState(() {
_turns -= .2;
});
},
)
],
),
);
}
}
当点击旋转按钮时,两个图标的旋转都会旋转1/5圈,但旋转的速度是不同的
![](https://img.haomeiwen.com/i5111884/f0f367e47028e143.png)
例(组合)
如果封装的是StatefulWidget,那么一定要注意在组件更新时是否需要同步状态。
比如要封装一个富文本展示组件MyRichText ,它可以自动处理url链接,定义如下:
class MyRichText extends StatefulWidget {
MyRichText({
Key key,
this.text, // 文本字符串
this.linkStyle, // url链接样式
}) : super(key: key);
final String text;
final TextStyle linkStyle;
@override
_MyRichTextState createState() => _MyRichTextState();
}
接下来在_MyRichTextState中要实现的功能有两个:
解析文本字符串“text”,生成TextSpan缓存起来;
在build中返回最终的富文本样式;
class _MyRichTextState extends State<MyRichText> {
TextSpan _textSpan;
@override
Widget build(BuildContext context) {
return RichText(
text: _textSpan,
);
}
TextSpan parseText(String text) {
// 耗时操作:解析文本字符串,构建出TextSpan。
// 省略具体实现。
}
@override
void initState() {
_textSpan = parseText(widget.text)
super.initState();
}
}
由于解析文本字符串,构建出TextSpan是一个耗时操作,为了不在每次build的时候都解析一次,所以在initState中对解析的结果进行了缓存,然后再build中直接使用解析的结果_textSpan。这看起来很不错,但是上面的代码有一个严重的问题,就是父组件传入的text发生变化时(组件树结构不变),那么MyRichText显示的内容不会更新,原因就是initState只会在State创建时被调用,所以在text发生变化时,parseText没有重新执行,导致_textSpan任然是旧的解析值。要解决这个问题也很简单,只需添加一个didUpdateWidget回调,然后再里面重新调用parseText即可:
@override
void didUpdateWidget(MyRichText oldWidget) {
if (widget.text != oldWidget.text) {
_textSpan = parseText(widget.text);
}
super.didUpdateWidget(oldWidget);
}
2. 自绘组件 (CustomPaint与Canvas)
对于一些复杂或不规则的UI,可能无法通过组合其它组件的方式来实现,比如需要一个正六边形、一个渐变的圆形进度条、一个棋盘等。当然,有时候可以使用图片来实现,但在一些需要动态交互的场景静态图片也是实现不了的。
自绘控件非常强大,理论上可以实现任何2D图形外观,实际上Flutter提供的所有组件最终都是通过调用Canvas绘制出来的,只不过绘制的逻辑被封装起来了。可以查看具有外观样式的组件源码,找到其对应的RenderObject对象,如Text对应的RenderParagraph对象最终会通过Canvas实现文本绘制逻辑。
绘制是比较昂贵的操作,所以在实现自绘控件时应该考虑到性能开销,下面是两条关于性能优化的建议:
1. 尽可能的利用好shouldRepaint返回值;在UI树重新build时,控件在绘制前都会先调用该方法以确定是否有必要重绘;假如绘制的UI不依赖外部状态,那么就应该始终返回false,因为外部状态改变导致重新build时不会影响UI外观;如果绘制依赖外部状态,那么就应该在shouldRepaint中判断依赖的状态是否改变,如果已改变则应返回true来重绘,反之则应返回false不需要重绘。
2. 绘制尽可能多的分层;
重要的几个类
- CustomPaint
CustomPaint({
Key key,
this.painter,
this.foregroundPainter,
this.size = Size.zero,
this.isComplex = false,
this.willChange = false,
Widget child, //子节点,可以为空
})
说明:
1. painter: 背景画笔,会显示在子节点后面
2. foregroundPainter: 前景画笔,会显示在子节点前面
3. size:当child为null时,代表默认绘制区域大小,如果有child则忽略此参数,画布尺寸则为child尺寸。如果有child但是想指定画布为特定大小,可以使用SizeBox包裹CustomPaint实现。
4. isComplex:是否复杂的绘制,如果是,Flutter会应用一些缓存策略来减少重复渲染的开销。
5. willChange:和isComplex配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变。
如果CustomPaint有子节点,为了避免子节点不必要的重绘并提高性能,通常情况下都会将子节点包裹在RepaintBoundary组件中,这样会在绘制时就会创建一个新的绘制层(Layer),其子组件将在新的Layer上绘制,而父组件将在原来Layer上绘制,也就是说RepaintBoundary 子组件的绘制将独立于父组件的绘制,RepaintBoundary会隔离其子节点和CustomPaint本身的绘制边界。示例如下:
CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
child: RepaintBoundary(child:...)),
)
- CustomPainter
画笔需要继承CustomPainter类,在画笔类中实现真正的绘制逻辑。
void paint(Canvas canvas, Size size);
说明:
1. Canvas:一个画布,包括各种绘制方法
drawLine 画线
drawPoint 画点
drawPath 画路径
drawImage 画图像
drawRect 画矩形
drawCircle 画圆
drawOval 画椭圆
drawArc 画圆弧
2. Size:当前绘制区域大小。
- 画笔Paint
Flutter提供了Paint类来实现画笔。
在Paint中,可以配置画笔的各种属性如粗细、颜色、样式等。
var paint = Paint() //创建一个画笔并配置其属性
..isAntiAlias = true //是否抗锯齿
..style = PaintingStyle.fill //画笔样式:填充
..color=Color(0x77cdb175);//画笔颜色
示例
例
import 'package:flutter/material.dart';
import 'dart:math';
class CustomPaintRoute extends StatelessWidget {
@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) {
double eWidth = size.width / 15;
double eHeight = size.height / 15;
// 画棋盘背景
var paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.fill //填充
..color = Color(0x77cdb175); //背景为纸黄色
canvas.drawRect(Offset.zero & size, paint);
// 画棋盘网格
paint
..style = PaintingStyle.stroke //线
..color = Colors.black87
..strokeWidth = 1.0;
for (int i = 0; i <= 15; ++i) {
double dy = eHeight * i;
canvas.drawLine(Offset(0, dy), Offset(size.width, dy), paint);
}
for (int i = 0; i <= 15; ++i) {
double dx = eWidth * i;
canvas.drawLine(Offset(dx, 0), Offset(dx, size.height), paint);
}
// 画一个黑子
paint
..style = PaintingStyle.fill
..color = Colors.black;
canvas.drawCircle(
Offset(size.width / 2 - eWidth / 2, size.height / 2 - eHeight / 2),
min(eWidth / 2, eHeight / 2) - 2,
paint,
);
// 画一个白子
paint.color = Colors.white;
canvas.drawCircle(
Offset(size.width / 2 + eWidth / 2, size.height / 2 - eHeight / 2),
min(eWidth / 2, eHeight / 2) - 2,
paint,
);
}
// 在实际场景中正确利用此回调可以避免重绘开销,这里简单的返回true
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
在上面五子棋的示例中,将棋盘和棋子的绘制放在了一起,这样会有一个问题:由于棋盘始终是不变的,用户每次落子时变的只是棋子,但是如果按照上面的代码来实现,每次绘制棋子时都要重新绘制一次棋盘,这是没必要的。优化的方法就是将棋盘单独抽为一个组件,并设置其shouldRepaint回调值为false,然后将棋盘组件作为背景。然后将棋子的绘制放到另一个组件中,这样每次落子时只需要绘制棋子。
![](https://img.haomeiwen.com/i5111884/a5112eae53360591.png)
例(自绘)
实现一个圆形背景渐变进度条,它支持:
支持多种背景渐变色。
任意弧度;进度条可以不是整圆。
可以自定义粗细、两端是否圆角等样式。
要实现这样的一个进度条是无法通过现有组件组合而成的,所以通过自绘方式实现,代码如下
import 'dart:math';
import 'package:flutter/material.dart';
class GradientCircularProgressIndicator extends StatelessWidget {
GradientCircularProgressIndicator({
this.strokeWidth = 2.0,
@required this.radius,
@required this.colors,
this.stops,
this.strokeCapRound = false,
this.backgroundColor = const Color(0xFFEEEEEE),
this.totalAngle = 2 * pi,
this.value
});
/// 粗细
final double strokeWidth;
/// 圆的半径
final double radius;
/// 两端是否为圆角
final bool strokeCapRound;
/// 当前进度,取值范围 [0.0-1.0]
final double value;
/// 进度条背景色
final Color backgroundColor;
/// 进度条的总弧度,2*PI为整圆,小于2*PI则不是整圆
final double totalAngle;
/// 渐变色数组
final List<Color> colors;
/// 渐变色的终止点,对应colors属性
final List<double> stops;
@override
Widget build(BuildContext context) {
double _offset = .0;
// 如果两端为圆角,则需要对起始位置进行调整,否则圆角部分会偏离起始位置
// 下面调整的角度的计算公式是通过数学几何知识得出
if (strokeCapRound) {
_offset = asin(strokeWidth / (radius * 2 - strokeWidth));
}
var _colors = colors;
if (_colors == null) {
Color color = Theme
.of(context)
.accentColor;
_colors = [color, color];
}
return Transform.rotate(
angle: -pi / 2.0 - _offset,
child: CustomPaint(
size: Size.fromRadius(radius),
painter: _GradientCircularProgressPainter(
strokeWidth: strokeWidth,
strokeCapRound: strokeCapRound,
backgroundColor: backgroundColor,
value: value,
total: totalAngle,
radius: radius,
colors: _colors,
)
),
);
}
}
// 实现画笔
class _GradientCircularProgressPainter extends CustomPainter {
_GradientCircularProgressPainter({
this.strokeWidth: 10.0,
this.strokeCapRound: false,
this.backgroundColor = const Color(0xFFEEEEEE),
this.radius,
this.total = 2 * pi,
@required this.colors,
this.stops,
this.value
});
final double strokeWidth;
final bool strokeCapRound;
final double value;
final Color backgroundColor;
final List<Color> colors;
final double total;
final double radius;
final List<double> stops;
@override
void paint(Canvas canvas, Size size) {
if (radius != null) {
size = Size.fromRadius(radius);
}
double _offset = strokeWidth / 2.0;
double _value = (value ?? .0);
_value = _value.clamp(.0, 1.0) * total;
double _start = .0;
if (strokeCapRound) {
_start = asin(strokeWidth/ (size.width - strokeWidth));
}
Rect rect = Offset(_offset, _offset) & Size(
size.width - strokeWidth,
size.height - strokeWidth
);
var paint = Paint()
..strokeCap = strokeCapRound ? StrokeCap.round : StrokeCap.butt
..style = PaintingStyle.stroke
..isAntiAlias = true
..strokeWidth = strokeWidth;
// 先画背景
if (backgroundColor != Colors.transparent) {
paint.color = backgroundColor;
canvas.drawArc(
rect,
_start,
total,
false,
paint
);
}
// 再画前景,应用渐变
if (_value > 0) {
paint.shader = SweepGradient(
startAngle: 0.0,
endAngle: _value,
colors: colors,
stops: stops,
).createShader(rect);
canvas.drawArc(
rect,
_start,
_value,
false,
paint
);
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
import 'dart:math';
import 'package:flutter/material.dart';
import '../widgets/index.dart';
class GradientCircularProgressRoute extends StatefulWidget {
@override
GradientCircularProgressRouteState createState() {
return new GradientCircularProgressRouteState();
}
}
class GradientCircularProgressRouteState
extends State<GradientCircularProgressRoute> with TickerProviderStateMixin {
AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController =
new AnimationController(vsync: this, duration: Duration(seconds: 3));
bool isForward = true;
_animationController.addStatusListener((status) {
if (status == AnimationStatus.forward) {
isForward = true;
} else if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
if (isForward) {
_animationController.reverse();
} else {
_animationController.forward();
}
} else if (status == AnimationStatus.reverse) {
isForward = false;
}
});
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
AnimatedBuilder(
animation: _animationController,
builder: (BuildContext context, Widget child) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Column(
children: <Widget>[
Wrap(
spacing: 10.0,
runSpacing: 16.0,
children: <Widget>[
GradientCircularProgressIndicator(
// No gradient
colors: [Colors.blue, Colors.blue],
radius: 50.0,
strokeWidth: 3.0,
value: _animationController.value,
),
GradientCircularProgressIndicator(
colors: [Colors.red, Colors.orange],
radius: 50.0,
strokeWidth: 3.0,
value: _animationController.value,
),
GradientCircularProgressIndicator(
colors: [Colors.red, Colors.orange, Colors.red],
radius: 50.0,
strokeWidth: 5.0,
value: _animationController.value,
),
GradientCircularProgressIndicator(
colors: [Colors.teal, Colors.cyan],
radius: 50.0,
strokeWidth: 5.0,
strokeCapRound: true,
value: CurvedAnimation(
parent: _animationController,
curve: Curves.decelerate)
.value,
),
TurnBox(
turns: 1 / 8,
child: GradientCircularProgressIndicator(
colors: [Colors.red, Colors.orange, Colors.red],
radius: 50.0,
strokeWidth: 5.0,
strokeCapRound: true,
backgroundColor: Colors.red[50],
totalAngle: 1.5 * pi,
value: CurvedAnimation(
parent: _animationController,
curve: Curves.ease)
.value),
),
RotatedBox(
quarterTurns: 1,
child: GradientCircularProgressIndicator(
colors: [Colors.blue[700], Colors.blue[200]],
radius: 50.0,
strokeWidth: 3.0,
strokeCapRound: true,
backgroundColor: Colors.transparent,
value: _animationController.value),
),
GradientCircularProgressIndicator(
colors: [
Colors.red,
Colors.amber,
Colors.cyan,
Colors.green[200],
Colors.blue,
Colors.red
],
radius: 50.0,
strokeWidth: 5.0,
strokeCapRound: true,
value: _animationController.value,
),
],
),
GradientCircularProgressIndicator(
colors: [Colors.blue[700], Colors.blue[200]],
radius: 100.0,
strokeWidth: 20.0,
value: _animationController.value,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: GradientCircularProgressIndicator(
colors: [Colors.blue[700], Colors.blue[300]],
radius: 100.0,
strokeWidth: 20.0,
value: _animationController.value,
strokeCapRound: true,
),
),
// 剪裁半圆
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: .5,
child: Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: SizedBox(
//width: 100.0,
child: TurnBox(
turns: .75,
child: GradientCircularProgressIndicator(
colors: [Colors.teal, Colors.cyan[500]],
radius: 100.0,
strokeWidth: 8.0,
value: _animationController.value,
totalAngle: pi,
strokeCapRound: true,
),
),
),
),
),
),
SizedBox(
height: 104.0,
width: 200.0,
child: Stack(
alignment: Alignment.center,
children: <Widget>[
Positioned(
height: 200.0,
top: .0,
child: TurnBox(
turns: .75,
child: GradientCircularProgressIndicator(
colors: [Colors.teal, Colors.cyan[500]],
radius: 100.0,
strokeWidth: 8.0,
value: _animationController.value,
totalAngle: pi,
strokeCapRound: true,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 10.0),
child: Text(
"${(_animationController.value * 100).toInt()}%",
style: TextStyle(
fontSize: 25.0,
color: Colors.blueGrey,
),
),
)
],
),
),
],
),
);
},
),
],
),
),
);
}
}
![](https://img.haomeiwen.com/i5111884/334de6929264c5b7.png)