Camera's Blog


  • 首页

  • 分类

  • 归档

  • 关于

Flutter简易动画教程指南

发表于 2019-01-10 | 分类于 flutter

英文地址:Animations in Flutter - easy guide - tutorial

origin

动画三大支柱 (The 3 pillars of an Animation)

为了实现一个动画,需要存在下面三个元素:

  1. 一个Ticker
  2. 一个Animation
  3. 一个AnimaionController

下面这里先简单介绍这些元素,随后进行更详细的解释。

Ticker

简而言之,Ticker是这样一个类,以几乎固定的间隔来发送一个信号(大约每秒60次),想想你的表,每秒都在走。

在每个tick循环中,Ticker触发回调函数,持续时间从第一个tick开始算起。

所有的Ticker即使从不同的时间开始,总会是同步的。这对动画同步是很有用的。

Animation

动画其实没什么就是特定类型的值,在动画的生命周期中会发生变化。其值随动画时间可以是线性的(如1,2,3,4,5,…),也可以是复杂的(曲线)。

AnimationController

该类用户控制(开始,停止,重复…)一个动画(或多个动画)。换句话说,以一定速率(每秒值的变化速率),使得动画的值在某一个持续时间内,从下限变化到上限。

动画控制器 (AnimationController)

该类用于控制一个动画,精确地说,应该是“over a sence”一个场景。随后我们将会看到,多个不同的动画由同一控制器控制。

所以,在动画控制器这个类中,我们可以:

  • 向前,向后播放一个场景
  • 停止一个场景
  • 将场景设置为某个值
  • 定义场景的边界值(下限,上限)

下面的伪代码,展示了该勒不同的初始化参数:

1
2
3
4
5
6
7
8
9
AnimationController controller = new AnimationController(
value: // the current value of the animation, usually 0.0 (= default)
lowerBound: // the lowest value of the animation, usually 0.0 (= default)
upperBound: // the highest value of the animation, usually 1.0 (= default)
duration: // the total duration of the whole animation (scene)
vsync: // the ticker provider
debugLabel: // a label to be used to identify the controller
// during debug session
);

大多数时候,在初始化AnimationController的时候,value,lowerBound,upperBound和debugLabel不会涉及到。

如何将动画控制器绑定到Ticker?

为了能够工作,一个AnimationController需要绑定到一个Ticker上。

通常,生成一个Ticker,链接到Stateful组件的实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class _MyStateWidget extends State<MyStateWidget>
with SingleTickerProviderStateMixin {
AnimationController _controller;

@override
void initState(){
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
}

@override
void dispose(){
_controller.dispose();
super.dispose();
}

...
}
  • 第2行,你告诉Flutter你想要一个新的Ticker,链接到MyStateWidget实例上。
  • 第8-10行,初始化控制器,场景总的持续时间设置为1000毫秒,并绑定到Ticker上(vsync:this)。隐式参数:lowerBound=0.0,upperBound=1.0。
  • 第16行,这是非常重要的,在MyStateWidget组件被销毁的时候,释放这个控制器。

TickerProviderStateMixin 还是 SingleTickerProviderStateMixin?

如果你有多个AnimationController实例,并且你想有不同的Ticker,使用TickerProviderStateMixin替换SingleTickerProviderstateMixin。

控制器已经绑定到Ticker上,但是这有什么作用呢?

多亏了ticker,每秒钟大约60次tick循环,AnimationController在给定的持续时间内,线性地从下限到上限产生值。

例如下图,在1000毫秒内产生了这些值:

我们看到,这些值在1000毫秒内从0.0(下限)变化到1.0(上限),产生了51个不同的值。

让我们展开看看代码,是如何使用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class _MyStateWidget extends State<MyStateWidget>
with SingleTickerProviderStateMixin {
AnimationController _controller;

@override
void initState(){
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_controller.addListener((){
setState((){});
});
_controller.forward();
}

@override
void dispose(){
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context){
final int percent = (_controller.value * 100.0).round();
return new Scaffold(
body: new Container(
child: new Center(
child: new Text('$percent%'),
),
),
);
}
}
  • 第12行,这一行告诉控制器,每当它的值发生变化,我们需要重新构建这个组件(通过setState())。
  • 第15行,一旦组件初始化完成,我们告诉控制器开始计数(forward(),从下限到上限)。
  • 第26行,我们取到控制器的值(_controller.value)。在这个例子中,值从0.0变化到1.0(0%到100%),我们得到表达式百分比,显示到页面中央。

动画的概念 (The notion of Animation)

如我们所见,控制器返回一系列浮点型值,呈线性变化。

某些时候,我常常:

  • 使用其他类型的值,诸如Offset,int…
  • 使用不同的值范围,而不是0.0到1.0
  • 考虑其他变体类型,而非线形地展示出一些效果
使用其他类型的值

为了能够使用其他类型的值,Animation类使用了模板。

换句话说,你可以这样定义:

1
2
3
Animation<int> integerVariation;
Animation<double> decimalVariation;
Animation<Offset> offsetVariation;
使用不同的值范围

某些时候,我们想要两个值之间的变体,而不是0.0和1.0 。

为了定义这个范围,我们将要使用Tween类。

为了说明这些,我们考虑这种情况,你可能想要角弧度从0变化到π/2。

1
Animation<double> angleAnimation = new Tween(begin:0.0,end:pi/2)
变体类型

之前有讲过,从下限到上限的默认变化方式是线性的,这个是控制器的工作方式。

如果你想要一个角度从0变化到π/2弧度,绑定Animation到AnimationController。

当你开始这个动画(通过_controller.forward()),这个angleAnimation.value将会使用_controller.value来进行插值,范围[0.0;π/2]。

下面这幅图显示了线性变体(π/2 = 1.57):

使用这些变体:

1
2
3
4
5
6
Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(
new CurvedAnimation(
parent: _controller,
curve: Curves.ease,
reverseCurve: Curves.easeOut
));

这个将会创建[0; π/2]变体:

  • 当动画从0.0 -> π/2的时候,使用Curves.ease(=forward)
  • 当动画从π/2 -> 0.0的时候,使用Curves.easeOut(=revserse)

控制动画 (Controlling the animation)

AnimationController这个类允许你通过API来控制动画(下面是最常使用的API):

  • _controller.forward({double from})

    要求控制器从下限到上限变化值,from可选参数用于控制器强制从其他值开始计数。

  • _controller.revserse({double from})

    要求控制器从上限到下限变化值,from可选参数用于控制器强制从其他值开始计数。

  • _controller.stop({bool canceled:true})

    停止运行动画。

  • _controller.reset()`

    重置动画至下限。

  • _controller.animateTo({double min,double max,Duration period})

    驱动动画从当前值变化到目标值。

  • _controller.repeat({double min,double max,Duration period})`

    按forward方向开始运行这个动画,当它完成的时候重启这个动画。

    如果定义,则min和max限制重复发生的次数。

保证安全

因为动画可能意外终止(例如,屏幕被关闭),当使用这些API的时候,可以添加.orCancel来保证安全:

1
_controller.forward().orCancel;

由于这个小技巧,在_controller被销毁前,如果Ticker被取消将不会抛出任何异常。

场景的概念

“scene”这个 词并不存在官方文档中,只是个人认为。我发现这个贴近实际,来让我解释:

正如我所说的,一个动画控制器管理一个动画。然而,我们可以理解Animation为一系列子动画,线性或堆叠地播放,这个如何链接这些子动画在一起的定义就是我所说的“scene”。

考略下面这个案例,动画整个持续时间将会是10秒,我们想要的是:

  • 前2秒,一个球从左边移动到屏幕中央
  • 然后,同是这个球,花费2秒从中心位置移动到屏幕上方中心位置
  • 最后,花费5秒淡出

你可能早已想到,我们不得不考虑三个不同的动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
///
/// Definition of the _controller with a whole duration of 10 seconds
///
AnimationController _controller = new AnimationController(
duration: const Duration(seconds: 10),
vsync: this
);

///
/// First animation that moves the ball from the left to the center
///
Animation<Offset> moveLeftToCenter = new Tween(
begin: new Offset(0.0, screenHeight /2),
end: new Offset(screenWidth /2, screenHeight /2)
).animate(_controller);

///
/// Second animation that moves the ball from the center to the top
///
Animation<Offset> moveCenterToTop = new Tween(
begin: new Offset(screenWidth /2, screenHeight /2),
end: new Offset(screenWidth /2, 0.0)
).animate(_controller);

///
/// Third animation that will be used to change the opacity of the ball to make it disappear
///
Animation<double> disappear = new Tween(
begin: 1.0,
end: 0.0
).animate(_controller);

现在的问题是,如何链接(或编排)这些子动画呢?

间隔的概念

通过使用Interval类来解决,但是什么是interval呢?

与我们脑海中首先想到的相矛盾的是,这个是与时间间隔无关的,而是一个值的范围。

如果你想到_controller,你必须记住它使得值从下限到上限变化。

通常地,这两个值分别对应在lowerBound=0.0和upperBound=1.0,这使得事情更容易考虑些,因为[0.0 -> 1.0]只不过是从0%到100%的变化。因此,如果一个场景的总持续时间是10秒,那么很有可能在5秒之后,对应的_controller.value将非常接近0.5(= 50%)。

如果我们把三个不同的动画放在一个时间线上:

如果我们为三个动画的每个考虑值的区间,得到如下:

  • moveLeftToCenter

    持续时间:2秒,开始在0秒,结束在2秒 => 范围 =[0;2] =>

    百分比:整个场景从0%到20% => [0.0;0.20]

  • moveCenterToTop

    持续时间:3秒,开始在2秒,结束在5秒 => 范围 =[2;5] =>

    百分比:整个场景从20%到50% => [0.20;0.50]

  • disapper

    持续时间:5秒,开始在5秒,结束在10秒 => 范围 = [5;10] =>
    百分比:整个场景从50%到100% => [0.50;1.0]

现在,我们有了这些百分比,我们可更新各个动画的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
///
/// Definition of the _controller with a whole duration of 10 seconds
///
AnimationController _controller = new AnimationController(
duration: const Duration(seconds: 10),
vsync: this
);

///
/// First animation that moves the ball from the left to the center
///
Animation<Offset> moveLeftToCenter = new Tween(
begin: new Offset(0.0, screenHeight /2),
end: new Offset(screenWidth /2, screenHeight /2)
).animate(
new CurvedAnimation(
parent: _controller,
curve: new Interval(
0.0,
0.20,
curve: Curves.linear,
),
),
);

///
/// Second animation that moves the ball from the center to the top
///
Animation<Offset> moveCenterToTop = new Tween(
begin: new Offset(screenWidth /2, screenHeight /2),
end: new Offset(screenWidth /2, 0.0)
).animate(
new CurvedAnimation(
parent: _controller,
curve: new Interval(
0.20,
0.50,
curve: Curves.linear,
),
),
);

///
/// Third animation that will be used to change the opacity of the ball to make it disappear
///
Animation<double> disappear = new Tween(begin: 1.0, end: 0.0)
.animate(
new CurvedAnimation(
parent: _controller,
curve: new Interval(
0.50,
1.0,
curve: Curves.linear,
),
),
);

这就是定义一个场景(或一系列动画)所需要的全部设置。当然,你也可以堆叠子动画。

响应动画状态

一个动画4个不同的状态:

  • dismissed: 在开始的时候动画停止(或还没开始)
  • forward: 动画从起点到终点播放
  • reverse: 动画反向播放,从终点向起点播放
  • completed:动画在终点结束

为了获取这个状态,我们需要监听动画的状态,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
myAnimation.addStatusListener((AnimationStatus status){
switch(status){
case AnimationStatus.dismissed:
...
break;

case AnimationStatus.forward:
...
break;

case AnimationStatus.reverse:
...
break;

case AnimationStatus.completed:
...
break;
}
});

典型用法就是状态切换。例如,一旦动画完成,我们想要反转,可以这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
myAnimation.addStatusListener((AnimationStatus status){
switch(status){
///
/// When the animation is at the beginning, we force the animation to play
///
case AnimationStatus.dismissed:
_controller.forward();
break;

///
/// When the animation is at the end, we force the animation to reverse
///
case AnimationStatus.completed:
_controller.reverse();
break;
}
});

理论到此为止,让我们开始实践吧!(Enough theory, let’s practice now !)

现在理论已经介绍完了,是时候开始实践了…

动画分析及初始化骨架

为了有这个guillotine效果,我起初需要考虑的是:

  • 页面内容本身
  • 当我们点击菜单(或hamburger)图标的时候,菜单栏能够旋转
  • 当旋转进去的时候,菜单能够重叠页面的内容,填充整个视框
  • 一旦菜单完全展示出来,在我们再次点击的时候,菜单能够旋转进去,为了是能够回到原始的位置和大小

通过这些了解,我们立即得出这样的结论我们并没有使用带有AppBar的Scafflod。(因为AppBar是固定的)。

相反,我们使用了Stack,堆叠了两层:

  • 页面内容(下层)
  • 菜单(上层)

让我们构建这个骨架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class MyPage extends StatefulWidget {
@override
_MyPageState createState() => new _MyPageState();
}

class _MyPageState extends State<MyPage>{
@override
Widget build(BuildContext context){
return SafeArea(
top: false,
bottom: false,
child: new Container(
child: new Stack(
alignment: Alignment.topLeft,
children: <Widget>[
new Page(),
new GuillotineMenu(),
],
),
),
);
}
}

class Page extends StatelessWidget {
@override
Widget build(BuildContext context){
return new Container(
padding: const EdgeInsets.only(top: 90.0),
color: Color(0xff222222),
);
}
}

class GuillotineMenu extends StatefulWidget {
@override
_GuillotineMenuState createState() => new _GuillotineMenuState();
}

class _GuillotineMenuState extends State<GuillotineMenu> {

@overrride
Widget build(BuildContext context){
return new Container(
color: Color(0xff333333),
);
}
}

这段代码的结果是一个黑屏,只显示了GuillotineMenu,覆盖了整个视图。

菜单分析

如果你仔细观看视频的话,你会看到,当菜单完全打开的时候,它是完全覆盖视框的,当打开的时候,仅有例如AppBar是可见的。

我们重写_GuillotineMenuState如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
class _GuillotineMenuState extends State<GuillotineMenu> {
double rotationAngle = 0.0;

@override
Widget build(BuildContext context){
MediaQueryData mediaQueryData = MediaQuery.of(context);
double screenWidth = mediaQueryData.size.width;
double screenHeight = mediaQueryData.size.height;

return new Transform.rotate(
angle: rotationAngle,
origin: new Offset(24.0, 56.0),
alignment: Alignment.topLeft,
child: Material(
color: Colors.transparent,
child: Container(
width: screenWidth,
height: screenHeight,
color: Color(0xFF333333),
child: new Stack(
children: <Widget>[
_buildMenuTitle(),
_buildMenuIcon(),
_buildMenuContent(),
],
),
),
),
);
}

///
/// Menu Title
///
Widget _buildMenuTitle(){
return new Positioned(
top: 32.0,
left: 40.0,
width: screenWidth,
height: 24.0,
child: new Transform.rotate(
alignment: Alignment.topLeft,
origin: Offset.zero,
angle: pi / 2.0,
child: new Center(
child: new Container(
width: double.infinity,
height: double.infinity,
child: new Opacity(
opacity: 1.0,
child: new Text('ACTIVITY',
textAlign: TextAlign.center,
style: new TextStyle(
color: Colors.white,
fontSize: 20.0,
fontWeight: FontWeight.bold,
letterSpacing: 2.0,
)),
),
),
)),
);
}

///
/// Menu Icon
///
Widget _buildMenuIcon(){
return new Positioned(
top: 32.0,
left: 4.0,
child: new IconButton(
icon: const Icon(
Icons.menu,
color: Colors.white,
),
onPressed: (){},
),
);
}

///
/// Menu content
///
Widget _buildMenuContent(){
final List<Map> _menus = <Map>[
{
"icon": Icons.person,
"title": "profile",
"color": Colors.white,
},
{
"icon": Icons.view_agenda,
"title": "feed",
"color": Colors.white,
},
{
"icon": Icons.swap_calls,
"title": "activity",
"color": Colors.cyan,
},
{
"icon": Icons.settings,
"title": "settings",
"color": Colors.white,
},
];

return new Padding(
padding: const EdgeInsets.only(left: 64.0, top: 96.0),
child: new Container(
width: double.infinity,
height: double.infinity,
child: new Column(
mainAxisAlignment: MainAxisAlignment.start,
children: _menus.map((menuItem) {
return new ListTile(
leading: new Icon(
menuItem["icon"],
color: menuItem["color"],
),
title: new Text(
menuItem["title"],
style: new TextStyle(
color: menuItem["color"],
fontSize: 24.0),
),
);
}).toList(),
),
),
);
}
}
  • 第10-13行,定义了Guillotine Menu的旋转,以及旋转中心(菜单图标的位置)

此时这段代码的结果会显示出一个未旋转的菜单屏幕(因为rotationAngle = 0.0),标题垂直展示。

菜单动画

如果你更新了rotationAngle的值(-π/2 and 0),你将会看到菜单以相应的角度旋转。

来让我们添加写动画…

正如之前所讲的,我们需要:

  • 一个SingleTickerProviderStateMixin,因为仅有一个场景
  • 一个AnimationController
  • 一个Animation,角度变化

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {

AnimationController animationControllerMenu;
Animation<double> animationMenu;

///
/// Menu Icon, onPress() handling
///
_handleMenuOpenClose(){
animationControllerMenu.forward();
}

@override
void initState(){
super.initState();

///
/// Initialization of the animation controller
///
animationControllerMenu = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this
)..addListener((){
setState((){});
});

///
/// Initialization of the menu appearance animation
///
_rotationAnimation = new Tween(
begin: -pi/2.0,
end: 0.0
).animate(animationControllerMenu);
}

@override
void dispose(){
animationControllerMenu.dispose();
super.dispose();
}

@override
Widget build(BuildContext context){
MediaQueryData mediaQueryData = MediaQuery.of(context);
double screenWidth = mediaQueryData.size.width;
double screenHeight = mediaQueryData.size.height;
double angle = animationMenu.value;

return new Transform.rotate(
angle: angle,
origin: new Offset(24.0, 56.0),
alignment: Alignment.topLeft,
child: Material(
color: Colors.transparent,
child: Container(
width: screenWidth,
height: screenHeight,
color: Color(0xFF333333),
child: new Stack(
children: <Widget>[
_buildMenuTitle(),
_buildMenuIcon(),
_buildMenuContent(),
],
),
),
),
);
}

...
///
/// Menu Icon
///
Widget _buildMenuIcon(){
return new Positioned(
top: 32.0,
left: 4.0,
child: new IconButton(
icon: const Icon(
Icons.menu,
color: Colors.white,
),
onPressed: _handleMenuOpenClose,
),
);
}
...
}

当我们按下这个菜单按钮的时候,菜单会打开,但当我们再次按下按钮时不会关闭。这里是AnimationStatus发挥的作用。

添加基于AnimationStatus的监听器,决定是否forward或reverse动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
///
/// Menu animation status
///
enum _GuillotineAnimationStatus { closed, open, animating }

class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
AnimationController animationControllerMenu;
Animation<double> animationMenu;
_GuillotineAnimationStatus menuAnimationStatus = _GuillotineAnimationStatus.closed;

_handleMenuOpenClose(){
if (menuAnimationStatus == _GuillotineAnimationStatus.closed){
animationControllerMenu.forward().orCancel;
} else if (menuAnimationStatus == _GuillotineAnimationStatus.open) {
animationControllerMenu.reverse().orCancel;
}
}

@override
void initState(){
super.initState();

///
/// Initialization of the animation controller
///
animationControllerMenu = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this
)..addListener((){
setState((){});
})..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed) {
///
/// When the animation is at the end, the menu is open
///
menuAnimationStatus = _GuillotineAnimationStatus.open;
} else if (status == AnimationStatus.dismissed) {
///
/// When the animation is at the beginning, the menu is closed
///
menuAnimationStatus = _GuillotineAnimationStatus.closed;
} else {
///
/// Otherwise the animation is running
///
menuAnimationStatus = _GuillotineAnimationStatus.animating;
}
});

...
}
...
}

此时,这个菜单已经按照预期打开或关闭,但是视频中展示的打开/关闭动作不是线性的,看起来似乎有一个反弹效果,让我们添加上这个效果。

选择下面这两个效果:

  • bounceOut,菜单打开
  • bounceIn,菜单关闭
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
...
@override
void initState(){
...
///
/// Initialization of the menu appearance animation
///
animationMenu = new Tween(
begin: -pi / 2.0,
end: 0.0
).animate(new CurvedAnimation(
parent: animationControllerMenu,
curve: Curves.bounceOut,
reverseCurve: Curves.bounceIn,
));
}
...
}

在这个实现中仍然有一些遗漏的东西……事实上,标题在打开菜单时消失,在关闭菜单时重新出现,这是一个fadeIn/fadeOut的效果,同样也要被处理成动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
AnimationController animationControllerMenu;
Animation<double> animationMenu;
Animation<double> animationTitleFadeInOut;
_GuillotineAnimationStatus menuAnimationStatus;

...
@override
void initState(){
...
///
/// Initialization of the menu title fade out/in animation
///
animationTitleFadeInOut = new Tween(
begin: 1.0,
end: 0.0
).animate(new CurvedAnimation(
parent: animationControllerMenu,
curve: new Interval(
0.0,
0.5,
curve: Curves.ease,
),
));
}
...
///
/// Menu Title
///
Widget _buildMenuTitle(){
return new Positioned(
top: 32.0,
left: 40.0,
width: screenWidth,
height: 24.0,
child: new Transform.rotate(
alignment: Alignment.topLeft,
origin: Offset.zero,
angle: pi / 2.0,
child: new Center(
child: new Container(
width: double.infinity,
height: double.infinity,
child: new Opacity(
opacity: animationTitleFadeInOut.value,
child: new Text('ACTIVITY',
textAlign: TextAlign.center,
style: new TextStyle(
color: Colors.white,
fontSize: 20.0,
fontWeight: FontWeight.bold,
letterSpacing: 2.0,
)),
),
),
)),
);
}
...
}

源码

这篇文章的完成源码在GitHub。

深入学习Flutter之Transform

发表于 2019-01-10 | 分类于 flutter

英文地址:A Deep Dive Into Transform Widgets in Flutter

Transform组件简介(Introduction to the Transform widget)

Transform组件在绘制自身前对它的子组件做一些转换(例如,形状,大小,位置,方向)。

这对自定义形状以及各种各样的动画极为有用,这可以转换任何组件,扭曲成任何我们喜欢的形状,或者移动它。

探索Transform组件的类型(Exploring the types of Transform widget)

这个转换组件给我们一些构造函数帮助我们简化转换的构建。诸如缩放,旋转,平移这些常见的操作,全部可通过构造函数提供。

包括如下:

  1. Transform (默认构造函数)
  2. Transform.rotate
  3. Transform.scale
  4. Transform.translate

我们首先查看单一操作的构造函数。

Transform.rotate

如其名,这个仅仅是旋转子组件一定的角度,下面这个子组件是一个方形容器。

1
2
3
4
5
6
7
8
Transform.roate(
angle:1.0,
child:Container(
height:200.0,
width:200.0,
color:Colors.pink,
),
),

这个angle让我们给子组件设置一个旋转角度(单位为弧度)。

这个组件也允许我们指定一个我们组件旋转的方向,通过origin参数来指定方向,这里接收一个Offset,这个偏移量表示原点相对于子组件中心位置的距离。

然而在我们没有(必要)显示设置这个偏移量的时候,子组件将会围绕自己中心进行旋转,默认原点是Transform容器的中心位置,Transform大小是默认子组件的大小。

下面制作了一个动画,更好地可视化了旋转中心的位置。

如果子组件围绕正方形(Transform)的右下角旋转,它会是这样的。

以上效果代码如下:

1
2
3
4
5
6
7
8
9
Transform.rotate(
angle: 1.0,
origin: Offset(50.0, 50.0),
child: Container(
height: 100.0,
width: 100.0,
color: Colors.blue,
),
),
Transform.scale

这个scale构造函数根据给定的scale参数来缩放它的子组件。

1
2
3
4
5
6
7
8
Transform.scale(
scale: 0.5,
child: Container(
height: 200.0,
width: 200.0,
color: Colors.yellow,
),
),

这里设置scale为0.5,将会缩小容器大小为原来的一半。

类似旋转变换,我们也可以设置缩放的原点。

当我们不设置原点的时候,将采用Transform组件的中心位置。当缩放的时候,每边的变化是完全一样的。

像上一个例子,现在将原点设置到右下角,如果我们重新加载我们的应用,这个组件相比默认缩放,将会发生变化。

对应的代码如下:

1
2
3
4
5
6
7
8
9
Transform.scale(
scale: 0.5,
origin: Offset(50.0, 50.0),
child: Container(
height: 100.0,
width: 100.0,
color: Colors.blue,
),
),
Transform.translate

在X和Y方向上以指定位移量平移子组件,仅仅提供了Offset参数来指定了X和Y方向上位移量。

1
2
3
4
5
6
7
8
Transform.translate(
offset: Offset(100.0, 0.0),
child: Container(
height: 100.0,
width: 100.0,
color: Colors.yellow,
),
),

我们不能设置平移的原点,因为平移不受原点影响。因为我们给出的是偏移量而不是坐标,所以原点意义不大。

Transform(Default construct)

不像以上的其他构造函数,默认的构造函数允许我们一次做多个操作,这个是最厉害的构造函数。

相反不是接收一个像scaling或angle参数,这个构造函数直接接收一个4D Matrix作为transform参数,这允许我们执行多个操作。

例如:

1
2
3
4
5
6
7
8
9
Transform(
transform: Matrix4.skewY(0.3)..rotateZ(3.14 / 12.0),
origin: Offset(50.0, 50.0),
child: Container(
height: 100.0,
width: 100.0,
color: Colors.blue,
),
),

上面的代码会使子组件向Y倾斜0.3弧度,绕Z轴旋转π/12弧度。

Skew实际上是将子元素倾斜到一个方向,同时保持两边平行。

SkewY

SkewX

Matrix4也能让我们设置旋转和平移。

我们使用Matrix4.rotationX(),Matrix4.rotationY() 和 Matrix4.rotationZ()用于旋转,Matrix4.translation() 或Matrix4.translationValues()进行平移。

基于Flutter的3D透视

发表于 2019-01-09 | 分类于 flutter

深入学习Flutter之TextField

发表于 2019-01-08 | 分类于 flutter

文本框介绍(Introduction to TextField)

TextField组件允许收集用户的信息,TextField基本的代码简单的如下:

1
TextField()

这个创建一个基本的TextField:

从文本框检索信息(Retrieving information from a TextField)

由于文本框在Android中没有ID,所以文本不能按需索引,相反在更改时保存到一个变量中,或使用一个控制器。

  1. 最简单的方法是使用onChanged方法并将当前值存储在一个简单的变量中。下面是它的示例代码:

    1
    2
    3
    4
    5
    6
    String value = "";
    TextField(
    onChanged: (text) {
    value = text;
    }
    )
  2. 第二种方式是使用TextEditingController,这个控制被附加在该文本框中,监听同时控制文本框的文本。

    1
    2
    3
    4
    TextEditingController controller = TextEditingController();
    TextField(
    controller: controller,
    )

    使用下面代码来监听变化:

    1
    2
    3
    controller.addListener(() {
    // Do something here
    });

    获取/设置值:

    1
    2
    print(controller.text);
    controller.text = "Demo Text";

文本框其他回调 (Other callbacks from the TextField)

该组件也提供其他回调,诸如:

  1. onEditingCompleted
  2. onSubmitted
1
2
onEditingComplete: () {},
onSubmitted: (value) {},

这些回调在像这些动作上调用,如当用户在iOS上点击“done”按钮。

在文本框中使用焦点(Working with focus in TextFields)

“焦点”存在文本框上,意味着文本框处于活跃状态,键盘上任何键入将会将数据输入到焦点文本框中。

使用自动获取焦点

为了在组件创建的时候,文本框自动获取焦点,设置autofocus为true。

1
2
3
TextField(
autofocus: true,
),

这默认将焦点设置在文本框上。

使用自定义的焦点变化

如果我们想按需变更焦点而不是自动获取焦点,该如何做呢?因为我们需要以某种方式来引用下一个我们想要获取焦点的文本框,我们需要附加一个FocusNode到TextField中,用它来进行切换焦点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Initialise outside the build method
FocusNode nodeOne = FocusNode();
FocusNode nodeTwo = FocusNode();
// Do this inside the build method
TextField(
focusNode: nodeOne,
),
TextField(
focusNode: nodeTwo,
),
RaisedButton(
onPressed: () {
FocusScope.of(context).requestFocus(nodeTwo);
},
child: Text("Next Field"),
),

我们创建了两个焦点节点,并将它们附加到文本框中。当按钮按下的时候,我们使用FocusScope来请求下一个文本框获取焦点。

更改文本框的键盘属性(Changing Keyboard Properties for TextFields)

Flutter中的TextField允许您定制与键盘相关的属性。

Keyboard Type

TextField允许你自定义keyboard的类型,当TextField进入焦点中时显示。我们可以向下面这样变更keyboardType属性。

1
2
3
TextField(
keyboardType: TextInputType.number,
),

其类型包括:

  1. TextInputType.text (Normal complete keyboard)
  2. TextInputType.number (A numerical keyboard)
  3. TextInputType.emailAddress (Normal keyboard with an “@”)
  4. TextInputType.datetime (Numerical keyboard with a “/” and “:”)
  5. TextInputType.multiline (Numerical keyboard with options to enabled signed and decimal mode)
TextInputAction

变更TextField的textInputAction会让你改变键盘本身的动作按钮。

例如:

1
2
3
TextField(
textInputAction: TextInputAction.continueAction,
),

这会导致“Done”按钮被“Continue”按钮替换。

1
2
3
TextField(
textInputAction: TextInputAction.send,
),

这会导致“Done”按钮被“Send”按钮替换。

完整的列表太大,不能在这里显示,但是一定要检查它。

Autocorrect

启用或禁用对特定TextField的自动更正。使用autocorrect进行设置。

1
2
3
TextField(
autocorrect: false,
),

这也将禁用建议提示。

Text Capitalization

TextField提供了一些选项用于关于如何输入中大写用户输入的字母。

1
2
3
TextField(
textCapitalization: TextCapitalization.sentences,
),

类型包括:

  1. TextCapitalization.sentences

    这是我们预计的正常的大写形式,每个句子的首字母都要大写。

  2. TextCapitalization.characters

    所有字符大写。

  3. TextCapitalization.words

    每个单词的首字母大写。

文本样式,对齐以及光标选项(Text Style, Alignment and Cursor Options)

Flutter允许TextField中的文本样式,文对齐方式,以及光标进行自定义。

文本对齐

使用textAligin属性在光标在TextField在哪里显示。

1
2
3
TextField(
textAlign: TextAlign.center,
),

这会导致光标和文本在文本框的中间显示。

文本样式

使用style属性来更改文本的外观样式,使用它变更颜色,字体大小,这个类似于Text组件的style属性。

1
2
3
TextField(
style: TextStyle(color: Colors.red, fontWeight: FontWeight.w300),
),

自定义光标

在TextField组件中是可以直接自定义的。

允许你改变光标的颜色,宽度,边框角的弧度。例如,这里弄了一个圆形的红色光标。

1
2
3
4
5
TextField(
cursorColor: Colors.red,
cursorRadius: Radius.circular(16.0),
cursorWidth: 16.0,
),

控制大小和最大长度(Controlling the Size and Maximum Length in a TextField)

可以控制TextField中写入的最大字符数、最大行数,以及在输入文本时展开。

控制最大字符数
1
2
3
TextField(
maxLength: 4,
),

通过设置maxLength属性,将强制最大长度,并在默认情况下向TextField添加计数器。

可伸缩的文本框

某些时候,我们需要一个TextField在一行结束的时候能够扩展。这个在Flutter有点奇怪,但是很简单。为此,我们设置maxLines为null,默认是1。我们不太习惯设置null,但不过很容易搞定。

注意:将maxLines设置为一个直接值,默认情况下会将展开到指定的行数。

1
2
3
TextField(
maxLines: 3,
)

模糊文本 (Obscuring Text)

为了模糊文本,设置obscureText为true。

1
2
3
TextField(
obscureText: true,
),

最后,装饰文本框(And Finally, Decorating the TextField)

Flutter为了装饰TextField,我们使用decoration属性,接收一个InputDecoration。因为这个InputDecoration类很庞大,让我们尝试快速浏览其大部分重要的属性。

使用hint和label属性向用户提供信息提示

hint和label均为字符串,用于帮助用户理解输入到文本框的信息。不同的是一旦用户开始输入,hint会消失,而标签将会向上浮动。

hint

label
使用“icon”,“prefixIcon”,“suffixIcon”添加图标

你可以向TextFields直接添加图标,你也可以使用prefixText和suffixText添加文本。

1
2
3
4
5
TextField(
decoration: InputDecoration(
icon: Icon(Icons.print)
),
),

1
2
3
4
5
TextField(
decoration: InputDecoration(
prefixIcon: Icon(Icons.print)
),
),

类似的其他任何组件,使用“prefix”,而非“prefixIcon”
1
2
3
4
5
TextField(
decoration: InputDecoration(
prefix: CircularProgressIndicator(),
),
),

为了使用通用的组件,而不是图片,应该使用prefix字段。

1
2
3
4
5
TextField(
decoration: InputDecoration(
prefix: CircularProgressIndicator(),
),
),

像hint,label等等这样的属性,每个都有相应的style字段

为了添加hint样式,使用hintStyle,为了添加label样式,使用labelStyle。

1
2
3
4
5
6
TextField(
decoration: InputDecoration(
hintText: "Demo Text",
hintStyle: TextStyle(fontWeight: FontWeight.w300, color: Colors.red)
),
),

如果你不想要标签,但仍为用户保留信息,使用“helperText”
1
2
3
4
5
TextField(
decoration: InputDecoration(
helperText: "Hello"
),
),

使用“decoration:null”或“InputDecoraion.collapsed”来移除默认下划线
1
2
3
TextField(
decoration: InputDecoration.collapsed(hintText: "")
),

使用“border”添加一个边框
1
2
3
4
5
TextField(
decoration: InputDecoration(
border: OutlineInputBorder()
)
),

还有大量的修饰你可以深入去做,可是我们不能在一篇文章中讨论所有内容。但我希望这能让你清楚地了解自定义Flutter的TextField是多么地简单。

深入学习Flutter之ListView和ScrollPhysics

发表于 2019-01-06 | 分类于 flutter

英文地址:Flutter ListView and ScrollPhysics: A Detailed Look

探究ListView的类型

我们将先查看listview的类型,然后查看它的其他特性和整洁性修改。

列表视图的类型:

  1. ListView
  2. ListView.builder
  3. ListView.separated
  4. ListView.custom
ListView

这个是ListView类的默认构造函数,仅仅接收children列表就能够实现滚动效果。

代码一般格式如下:

1
2
3
4
5
6
7
ListView(
children: <Widget>[
ItemOne(),
ItemTwo(),
ItemThree(),
],
),

通常,这适用于少量子组件的使用,因为列表也将会构建不可见的元素,而渲染大量的元素会影响性能效率。

ListView.builder()

这个builder()构造函数来构造重复性的列表项,这个构造函数有两个主要的参数:itemCount(列表项个数),itemBuilder用于构建具体每个列表项目。

代码一般格式如下:

1
2
3
4
5
6
ListView.builder(
itemCount: itemCount,
itemBuilder: (context, position) {
return listItem();
},
),

这些列表项是惰性构造的,意味着只会构造特定数量的列表项,当用户向前滚动时,之前的列表项就会被销毁。

(巧妙的技巧)Neat trick:因为这些元素是惰性载入的,仅有被需要数量的元素被载入进去。我们可以不必需要itemCount作为一个强制参数,这样列表将会是无限的。

1
2
3
4
5
6
7
8
9
10
ListView.builder(
itemBuilder: (context, position) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(position.toString(), style: TextStyle(fontSize: 22.0),),
),
);
},
),

ListView.separated()

在separated()构造函数中,我将会生成一个列表,并且我们可以指定列表项之间的分隔符。

本质上,我们构造了两个交织的列表,一个作为主列表,另一个作为分割列表。

注意,上面的构造函数谈及的无限数量的列表项不能在这里使用,这个构造函数强制需要一个itemCount参数。

代码的一般格式如下:

1
2
3
4
5
6
7
8
9
ListView.separated(
itemBuilder: (context,position) {
return ListItem();
}
separatorBuilder: (context,position) {
return SeparationItem();
},
itemCount: itemCount,
)

这种类型的列表允许你动态构造分割符,不同类型的列表项,可拥有不同的分隔符,按需添加/移除分割符等等。

此实现可以轻松插入其他类型的元素(例如:广告),而无需修改这些列表项中间的主列表。

注意:分隔符列表长度比这些列表项少1个,因为在最后一个元素之后不存在分隔符。

ListView.custom()

这个custom()构造函数如其名,允许你使用定义功能(这些列表中的子组件是如何构建的)来构建ListView。为此,所必须的主要参数是一个SliverChildDelegate,用于构建这些列表项目。

SliverChildDelegate的类型如下:

  1. SliverChildListDelegate
  2. SliverChildBuilderDelegate

SliverChildListDelegate直接接收一个children列表,而SliverChildBuilderDelegate接收一个IndexedWidgetBuilder(我们使用的builder函数)。

您可以使用或继承它们来构建自己的委托。

ListView.builder本质上是ListView.custom使用一个SilverChildBuilderDelegate。

ListView默认构造函数的行为类似于ListView.custom使用一个SliverChildListDelegate。

探究滚动原理

为了控制滚动的发生方式,我们在ListView构造函数设置physics参数,类型如下:

NeverScrollablePhysics

使用这个,将会完全禁用滚动。

BouncingScrollPhysics

在列表结束的时候会有一个反弹效果,iOS上也是使用了类似的效果。

ClampingScrollPhysics

这是Android上使用的默认scrolling physics。列表在末尾停止,并给出一个指示它的效果。

FixedExtentScrollPhysics

这个稍微与其他的不同,这个仅对FixedExtendScrollControllers和使用它们的列表有效。例如,我们将以ListWheelScrollView为例,它生成一个具有滚轮效果的列表。

FixedExtentScrollPhysics只会滚动到项,而不会是其之间的任何偏移量。

这个例子的代码非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
FixedExtentScrollController fixedExtentScrollController =
new FixedExtentScrollController();
ListWheelScrollView(
controller: fixedExtentScrollController,
physics: FixedExtentScrollPhysics(),
children: monthsOfTheYear.map((month) {
return Card(
child: Row(
children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
month,
style: TextStyle(fontSize: 18.0),
),
)),
],
));
}).toList(),
itemExtent: 60.0,
),

更多

如何使列表中被销毁的元素保持活动状态?

Flutter提供了一个KeepAlive()小部件,该小部件使一个本来会被销毁的项保持活跃状态。在列表中,元素默认被包装在AutomaticKeepAlive小部件中。

可以通过将addAutomaticKeepAlives字段设置为false来禁用AutomaticKeepAlives。这在不需要保持元素活跃或自定义KeepAlive实现的情况下非常有用。

为什么我的列表视图在列表和外部小部件之间存在空隙??

默认情况下,ListView与外部小部件之间有填充,要移除它,将填充设置为EdgeInset.all(0.0)。

PageView和BottomNavigationBar结合使用

发表于 2019-01-06 | 分类于 flutter

参考英文地址:Flutter — PageView with BottomNavigationBar

你可能早已经使用过BottomNavigation和PageView。

现在,我们将要一起使用这个两个组件。

第一步

创建一个BottomNavigationBar和三个BottomNavigationBarItem。

这个BottomNavigationBar用于在应用的底部展示,用于视图的选择,通常大多是3个到5个之间,底部导航常由文本标签、图标或两者兼有的多种形式的项组成,提供了应用底层视图之间的快速导航,这个底部导航常常与 Scaffold配合使用,这个脚手架提供了Scaffold.bottomNavigationBar 参数。

底部导航栏类型决定了这些导航项如何展示,如果没有指定,当少4个的时候,自动设置为 BottomNavigationBarType.fixed,如果 fixedColor 指定了,将以该颜色渲染选中的导航项,未指定将会使用ThemeData.primaryColor,导航栏的背景色,默认为(ThemeData.canvasColor)Material的背景色(不透明白色),当大于等于4个的时候,设置为BottomNavigationBarType.shifting ,所有的导航项被渲染为白色,导航栏的背景色与选中导航项的背景色一致,会fixed不同的是,每点击一导航项,会有一个动画效果。

这个BottomNavigationBarItem类很少单独使用。通常嵌入在上面的一个底部导航小部件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int bottomSelectedIndex = 0;

List<BottomNavigationBarItem> buildBottomNavBarItems() {
return [
BottomNavigationBarItem(
icon: new Icon(Icons.home),
title: new Text('Red')
),
BottomNavigationBarItem(
icon: new Icon(Icons.search),
title: new Text('Blue'),
),
BottomNavigationBarItem(
icon: Icon(Icons.info_outline),
title: Text('Yellow')
)
];
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Container(),
bottomNavigationBar: BottomNavigationBar(
currentIndex: bottomSelectedIndex,
items: buildBottomNavBarItems(),
),
);
}

第二步

使用3个状态组件创建PageView。

PageView是按页展示的滚动列表。

页面视图的子组件被强制与视图窗口的大小保持一致,你可以使用PageController来控制在这个视图中可见的页面,视窗。

您可以使用PageController来控制哪个页面在视图中可见。除了能够控制PageView中内容的像素偏移量之外,PageController还允许您以页面的形式控制偏移量,即以视图窗口大小为增量。

PageController也用于控制PageController.initialPage,它决定了在初次构造PageView时显示哪个页面,以及PageController,以及PageController.viewportFraction,它决定了页面大小占视窗大小的百分比,keepPage参数决定了是否使用PageStorage 保存当前页面。

第一个页面,red.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14

class Red extends StatefulWidget {
@override
_RedState createState() => _RedState();
}

class _RedState extends State<Red> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.red,
);
}
}

第二个页面,blue.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
class Blue extends StatefulWidget {
@override
_BlueState createState() => _BlueState();
}

class _BlueState extends State<Blue> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.blueAccent,
);
}
}

第三个页面,yellow.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
class Yellow extends StatefulWidget {
@override
_YellowState createState() => _YellowState();
}

class _YellowState extends State<Yellow> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.yellowAccent,
);
}
}

我们需要传入PageController来控制初始化选择的页面,以及手动地从一个导航到另一页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PageController pageController = PageController(
initialPage: 0,
keepPage: true,
);

Widget buildPageView() {
return PageView(
controller: pageController,
onPageChanged: (index) {
pageChanged(index);
},
children: <Widget>[
Red(),
Blue(),
Yellow(),
],
);
}

@override
void initState() {
super.initState();
}

在build方法体中调用buildPageView。

1
2
3
4
5
6
7
8
9
10
11
12
13
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: buildPageView(),
bottomNavigationBar: BottomNavigationBar(
currentIndex: bottomSelectedIndex,
items: buildBottomNavBarItems(),
),
);
}

第三步

我们需要做一些技巧来管理PageView和BottomNavigationBar的同步。

在当前应用程序中的状态中,有两个问题:

  1. 当我们滑动导航栏时,它的选择不会改变。
  2. 当我们选择其他的底部导航栏项时,我们的页面视图不会滚动。

为了解决第一问题,我们可以使用由PageView提供的onPageChanged事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void pageChanged(int index) {
setState(() {
bottomSelectedIndex = index;
});
}

Widget buildPageView() {
return PageView(
controller: pageController,
onPageChanged: (index) {
pageChanged(index);
},
children: <Widget>[
Red(),
Blue(),
Yellow(),
],
);
}

onPageChanged事件将会给我们当前页面的索引,我接受到页面索引在setState中设置bottomSelectedIndex索引,这样我们的底部的选择就会自动变化。

现在修复第二个问题,如果我们选择任意的BottomNavigationBarItem,然后我们的PageView应该自动滑动,为了修复这个问题,我们需要使用BottomNavigationBar的onTap事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

void bottomTapped(int index) {
setState(() {
bottomSelectedIndex = index;
pageController.animateToPage(index, duration: Duration(milliseconds: 500), curve: Curves.ease);
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: buildPageView(),
bottomNavigationBar: BottomNavigationBar(
currentIndex: bottomSelectedIndex,
onTap: (index) {
bottomTapped(index);
},
items: buildBottomNavBarItems(),
),
);
}

类似于onPageChanged事件,onTap事件也为我们提供了当前触碰的一个索引,这样基于这个索引设置bottomSelectedIndex,然后使用pageController.animateToPage移动选中的页面,其接受三个参数,index,duation,curve,返回一个future。

1
pageController.animateToPage(index, duration: Duration(milliseconds: 500), curve: Curves.ease);

最终的main.dart文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);

final String title;

@override
_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

int bottomSelectedIndex = 0;

List<BottomNavigationBarItem> buildBottomNavBarItems() {
return [
BottomNavigationBarItem(
icon: new Icon(Icons.home),
title: new Text('Red')
),
BottomNavigationBarItem(
icon: new Icon(Icons.search),
title: new Text('Blue'),
),
BottomNavigationBarItem(
icon: Icon(Icons.info_outline),
title: Text('Yellow')
)
];
}

PageController pageController = PageController(
initialPage: 0,
keepPage: true,
);

Widget buildPageView() {
return PageView(
controller: pageController,
onPageChanged: (index) {
pageChanged(index);
},
children: <Widget>[
Red(),
Blue(),
Yellow(),
],
);
}

@override
void initState() {
super.initState();
}

void pageChanged(int index) {
setState(() {
bottomSelectedIndex = index;
});
}

void bottomTapped(int index) {
setState(() {
bottomSelectedIndex = index;
pageController.animateToPage(index, duration: Duration(milliseconds: 500), curve: Curves.ease);
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: buildPageView(),
bottomNavigationBar: BottomNavigationBar(
currentIndex: bottomSelectedIndex,
onTap: (index) {
bottomTapped(index);
},
items: buildBottomNavBarItems(),
),
);
}
}



class Red extends StatefulWidget {
@override
_RedState createState() => _RedState();
}

class _RedState extends State<Red> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.red,
);
}
}

class Blue extends StatefulWidget {
@override
_BlueState createState() => _BlueState();
}

class _BlueState extends State<Blue> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.blueAccent,
);
}
}

class Yellow extends StatefulWidget {
@override
_YellowState createState() => _YellowState();
}

class _YellowState extends State<Yellow> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.yellowAccent,
);
}
}

夹层蛋糕一样的Flutter

发表于 2019-01-05 | 分类于 flutter

英文地址:The Layer Cake

Flutter 是一个极棒的UI框架,能够让你愉悦地以光速构建优美的用户界面。仅仅写下几行代码,点击保存[神奇地插入进去,亚秒级别的热重载]。你的应用能运行的十分流畅,能达到120fps的速度。然而,你是否询问过自己为何Flutter如此之快?它的秘方是什么?或者Flutter实际上是如何工作的?那么,别再去找其他的了!饮一杯新鲜的茶或咖啡,继续往下阅读吧。

也许你可能听说过Flutter全部都是关于组件的。你的应用是一个组件,一个text是一个组件,包裹组件的padding也是一个组件,你甚至可以通过一个小部件识别手势。好吧,这不是全部的事实,假若我告诉你widgets让Flutter的开发又快又简单,但是你也可以甚至不需要使用一个小部件来构建一个完整的Flutter应用程序的话。让我们更深入地研究这个框架去了解它。

四层结构(The Four Layers)

也许你在一些”Flutter简介“中已经知道了Flutter架构的概述。但是不知怎么的,你当时还准备好去理解这这些不同层次背后强大的概念。也许你和我一样,只是盯着那个被投放到墙上的幻灯片中抽象的图表看了20秒。不用担心,我来帮忙,请看下图:

flutter不同的层次结构,更多信息前往https://flutter.io/docs/resources/technical-overview

Flutter 本身包含了很多抽象层次:最顶层通常使用 Material 和 Cupertino 组件,接着是更加通用的 Widget 层,大多数情况下,你使用的大多数组件都来自该两层,这是完全没问题的。你可能已经见过(使用过)这个繁多的组件中一些(Material库中的FloatingActionButton和Scaffold,或来自widgets库中的Column和GestureDetector)组件的概率是很高的,这些都是十分常用的。

在往下,是渲染层简化了布局和绘制的处理过程,是对底层的dart:ui库另一种抽象。dart:ui是dart最后一层,主要处理与flutter引擎的通信。

简单地可以这样说,较高层次更容易处理一些,但是较低层级提供更细粒度的控制,并增加了复杂性。

1.dart:ui库 (The dart:ui library)

dart:ui 库提供了为flutter框架一些用于引导应用程序的底层服务,诸如这些类:驱动输入,图形文本,布局,渲染子系统。

所以从根本上来将,你可以仅仅通过实例化dart:ui库的相关类来编写“Flutter 应用”(例如:Canvas,Paint和 TextBox)。然而,如果你对直接在canvas上绘画熟悉的话,你就会知道,除了绘制一幅静止的简笔人物画之外,任何事情都是难以处理的。且之后,不仅要考虑到绘图,还要考虑编排布局以及触碰测试应用程序的元素。

这到底是什么意思呢?这意味着您必须手动计算布局中使用的所有坐标。然后加入绘图和触碰测试以捕捉用户输入。对每一帧都这样做,并进行追踪。只要你计划建立一个简单的应用程序,只是显示一些文本居中在一个蓝色的框,这种方法可能是可管理的。但如果你试图建立更复杂的布局,如购物应用程序,或一个小游戏,甚至不敢想加入动画、滚动或其他大家都喜欢的UI,根据我自己的经验,我告诉你,这是开发人员无尽的噩梦。

2. 渲染库(The rendering library)

Flutter Widgets库使用RenderObject层次结构实现其布局和绘制后端。通常,虽然您可以在应用程序中为实现特定的效果而使用定制的RenderBox类,但大多数时候,您与RenderObject层次结构的唯一交互将是在调试布局问题。

渲染库是dart:ui库上的第一层抽象,为你做所有的繁杂的数学运算(例如,跟踪计算的坐标等等)。为此,它使用了所谓的Renderobjects。你可以讲RenderObjects对比成汽车的引擎,它们是你实际上将你的应用带到屏幕上的组件。这颗由RenderObjects组成的树随后被Flutter布局绘制出来。为了优化这个复杂的流程,Flutter使用了一种智能算法,能聪明方式去积极地缓存这些昂贵的计算。,以保证每次迭代工作量最小化。

大多数情况下,你会发现Flutter使用的是RenderBox,而不是RenderObject。这是因为项目背后那些人认为简单的box布局协议,可以更好地构建高性能的UI。想想每个被计算的组件放置在自己的盒子中,然后与其他预先分配好的盒子一起排列。因此,如果你的布局中只有一个小组件发生了变化(例如,一个开关,一个按钮),那么系统仅仅会计算这个相对较小的盒子。

3. 组件树 (The widgets library)

组件库,可能是最有趣的层,是另一层抽象,提供了一些现成的组件,可以用在我们的应用中。你会发现该库中所有的组件分为三类,会由恰当的RenderObject为你处理:

  1. 布局(Layout)例如,Column和Row组件,能够使得我们轻松地水平或垂直排列它们。
  2. 绘制(Painting) 例如,Text和Image组件,能够让我们展示(绘制)一些内容在屏幕上。
  3. 触碰测试(Hit-Testing) 例如,GestureDetector允许我们识别不同的手势,如轻击(用于检测按钮的按下)和拖动(用于在列表中滑动)。

通常情况下,您将使用许多这些“基本”小部件,并用它们组合您自己的小部件。例如,您可以从Container中构建一个按钮,并将其封装到手势GestureDetector中以检测按钮的按下。这被称作组合优于继承。

然而,相反不是自己创建每个UI组件,Flutter团队已经创建了两个库包含了常用的组件,Material 和Cupertino(类iOS)风格。

4. Material和Cupertino库 (The Material&Cupertino library)

Flutter为你进行了抽象,它使得你的开发变得更加轻松。这个第四个级别,包含了来自Material设计规范预建元素和一些iOS风格的重建组件。你可以想想AlertDialog,Switch,和FloatingActionButton。如果是iOS用户,您应该熟悉CupertinoAlertDialog,CupertinoButton和CupertinoSwitch。

把它们放在一起(Putting it all Together)

RenderObjects是如何连接到Widgets?Flutter是如何创建这个布局的?或者什么是Element?

目前我们正在构建的应用程序非常简单。它只由三个stateless组件构成:SimpleApp,SimpleContainer,SimpleText,那么当我们将它传入给Flutter的runApp(SimpleApp())方法的时候发生了什么?

当第一个调用runApp()的时候,在后台发生了很多事情:

  1. Flutter 将会构建组件树,包含了我们的三个stateless组件。
  2. Flutter 该组件树向下并通过调用组件的createElement()来创建第二颗树,包含了对应的元素对象。
  3. 创建第三课树,使用恰当的RenderOjbects来进行填充,这些是通过对用组件上的元素触发createRenderObject()创建的。

下面是在Flutter经过上面描述的三个步骤的现况图:

Flutter框架创建了三个不同的树结构:组件树,元素树,渲染对象树。每个Element保存了对Widget和RenderObject的引用,你可能会有疑问:“我知道组件,但是什么是元素和渲染对象呢?”。

这个RenderObject包含了对应实际组件的所有的渲染逻辑,实例化开销是十分大的。它负责布局,绘制,点击测试。尽可能最好将这些对象保存在内存中,可能话的甚至能够重复利用他们(因为实例化开销很大)。从根本上讲,元素是不可变的Widget和可变的RenderObject 的粘合剂。Elements主要是这些这样的对象,擅长两个对象之间的对比。在这里,使widget和render对象。它们表示使用一个组件配置在树结构中特定的位置,同时也保存了相关组件和渲染对象的引用。

为什么使用三颗树而不是使用一颗树使一个好主意呢?简而言之,就是性能。每次组件树发生变化,Flutter就是使用Element树来比对比新的组件树和已经存在的RenderObjects。当一个组件的类型与之前是相同的。Flutter没必要重新创建新的开销昂贵的RenderObject,仅仅是更它可变的信息。因为Widgets是非常轻量的,实例化开销不大,也十分适合描述当前的状态(也被称作配置)。这个“笨重”的RenderObjects不是每次都会重新创建,尽可能重用,正如Simon所指出的,“The whole app acts like a huge RecyclerView”(整个应用程序就像一个巨大的可重复利用视图)。

然而,在框架中,这些元素被很好的抽象掉了,所以你不必经常处理他们。在每个build(BuildContext context)方法中传入的BuildContext实际上就是对应的封装的Element,被封装到了BuildContext接口,这就是为什么每单个组件的不同的原因。

计算下一帧 (Computing the Next Frame)

因为Widgets是不可变的,组件树每次配置的更新都需要重新构建。当我们更改我们容器的颜色为红色,框架将会触发重新构建,将会重新创建整个控件树,这正是因为它是不可变的。接下来,在元素树元素的帮助下,Flutter会将组件树第一个和渲染树的一个进行对比比较,接下来是第二个……。

Flutter遵循一个基本规则:检查这个旧的和新的组件是否源自同一类型。如果不是,将会从树中移除Widget,Element,RenderObject(包括子树),创建新的对象。如果它们是同一类型,仅更新RenderObject的配置,呈现展示组件新的配置,并向下遍历。

在我们这个例子中,这个SimpleApp组件和之前的类型相同,也与对应SimpleAppRender对象配置相同,所以不会更改任何东西。组件树中下一个是SimpleContainer组件,但是拥有不同的颜色配置。因为SimpleContainer依然需一个SimpleContainerRender对象来进行绘制,Flutter仅会更新SimpleContainerRender对象的颜色属性并寻求重新绘制,其他对象保持不变。

在重新构建小部件树并更新RenderObjects的配置之后,这三棵树的状态。注意,element和renderobject仍然是相同的实例。

这个处理是很快的,因为flutter真的很擅长创建这些简单的组件,来呈现当前的应用的配置。这些“笨重”的对象会保持不变,直到对应的组件类型从组件树移除。如果组件的类型发生变化会发生什么呢?

SimpleText被SimpleButton替换。

同样的,Flutter将会迭代新创建的组件树,比较渲染树中RenderObjects对象的类型与组件的类型。

新的组件树,SimpleText,对应的Element和SimpleTextRender将会从树中移除。

由于SimpleButton与元素树中对应位置的元素类型不匹配,Flutter将会从其他两颗树中移除对应的Element和SimpleTextRender,然后继续向下遍历新建的组件树,实例化合适的Elements和RenderObjects。

新的渲染树已经构建好了,现在将会被绘制到屏幕上,对于Flutter来说,大使用大量的优化和主动缓存策略,因为你不需要手动地去处理它。很酷,不是吗?

在Flutter中使用贝塞尔曲线裁剪小部件

发表于 2019-01-04 | 分类于 flutter

英文地址:Clipping widgets with bezier curves in Flutter

在本篇博文中,我将探究如何使用贝塞尔曲线剪切图片。

源码

源码在这里。

实现

首先,创建一个Flutter项目,称作为wavy_image_mask,假定你已经安装了Flutter,那么你在你控制台运行flutter create wavy_image_mask,使用IntelliJ IDEA打开你的项目。

来让我们创建一个名为WavyHeaderImage的StatelessWidget 控件。先在lib目录下创建wavy_header_image.dart文件,现在,我们从构建方法中返回一个占位的文本控件。

wavy_header_image.dart

1
2
3
4
5
6
7
8
import 'package:flutter/material.dart';

class WavyHeaderImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text('This is where our image will be.');
}
}

返回main文件,移除flutter为你自动生成样本代码,使用刚刚创建的WavyHeaderImage来替换Scaffold主体,如下:

main.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import 'package:flutter/material.dart';
import 'package:wavy_image_mask/wavy_header_image.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Wavy image mask',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}

class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: WavyHeaderImage(),
);
}
}

创建标题图片

现在,我需要一个好看的标题图片,我们将使用Chevanon Photography的这张图片。

在flutter添加assets

为了引入包含一般的图片或文件,我们使用Flutter的assets系统。在我们的应用的根目录创建images文件夹。添加你刚才下载的coffee_header.jpeg到图片文件里面,你的目录结构看起来如下:

为了能够使得图片在我们的代码中可用访问到,我也需要在pubspec.yaml文件中的flutter部分中的引入assets,看起来如下所示:

pubspec.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name: wavy_image_mask
description: A new Flutter project.

dependencies:
flutter:
sdk: flutter

dev_dependencies:
flutter_test:
sdk: flutter

flutter:
uses-material-design: true
assets:
- images/coffee_header.jpeg

现在我们就可以使用这张图片了。

制作一些波浪纹

比较笨的方法就是寻求设计师来在Photoshop中编辑我们的原始图片,创建波浪纹遮蔽。我也可以找一张使用白色绘制波浪纹的透明图片来贴在我们图像的顶部,把它固定到底部,结束一天的工作。

更好的方式是我们自己裁剪图片的波浪部分。该解决方案在不同屏幕尺寸上看起来更好。我们不需要引入额外的波浪纹遮蔽图片到我们的资产中。多亏了Flutter中一组与图像相关的API,我们可以轻松的实现。

使用ClipPaths

该类是我们用于在底部创建的波浪纹形状的组件是ClipPath组件。ClipPath需要两个参数:clipper和child。这个clipper是一个CustomClipper<Path>对象,决定了一个Path路径。ClipPath使用该路径来防止child组将在闭合的路径外绘制。

重新在打开我们在最开始的时候创建的wavy_header_image.dart文件,移除占位的文本组件,用于ClipPath取代。

child参数一个new Image.asset('image/coffee_header.jpeg')。这个仅仅是从图片文件夹载入图片并显示它。对于clipper,我们仅仅是调用了new BottomWaveClipper()。我们也许在同一文件中,创建一个新类BottomWaveClipper。

下面这个是现在应该有的:

wavy_header_image.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import 'package:flutter/material.dart';

class WavyHeaderImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClipPath(
child: Image.asset('images/coffee_header.jpeg'),
clipper: BottomWaveClipper(),
);
}
}

class BottomWaveClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
// This is where we decide what part of our image is going to be
// visible. If you try to run the app now, nothing will be shown.
return Path();
}

@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
绘制路径

所有的CustomClippers在它们的getClip方法中获得一个Size参数,这个size表示传给ClipPath对象的child组件的宽度和高度。在这里,是我们想要剪切的图片的coffee图片。因为我们的CustomClipper使用路径来确定剪切的区域,所以我们使用Path类中的方法来绘制这个剪切区域。

例如,如果我们想斜着裁剪图像的右下角,最终代码会得到下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@override
Path getClip(Size size) {
var path = Path();

// Draw a straight line from current point to the bottom left corner.
path.lineTo(0.0, size.height);

// Draw a straight line from current point to the top right corner.
path.lineTo(size.width, 0.0);

// Draws a straight line from current point to the first point of the path.
// In this case (0, 0), since that's where the paths start by default.
path.close();
return path;
}

因为除了底部的波浪纹,我们不想剪切任何地方。所以,我们需要的在绘制一条线到右上角前,添加一个path.lineTo(size.width,size.height)。这个修改将会返回图片本身,没有任何剪切,但是设置了实际要剪切波浪纹的部分。

现在我们学习了如何用路径画直线来剪辑,让我们来画波浪纹。因为波的底部垂直方向上低于于左下角的第一个点,所以我们要把这个点向上移动一点。同样的,右下角也比左下角高一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
var path = Path();

// Since the wave goes vertically lower than bottom left starting point,
// we'll have to make this point a little higher.
path.lineTo(0.0, size.height - 20);

// TODO: The wavy clipping magic happens here, between the bottom left and bottom right points.

// The bottom right point also isn't at the same level as its left counterpart,
// so we'll adjust that one too.
path.lineTo(size.width, size.height - 40);
path.lineTo(size.width, 0.0);
path.close();
...

这将在图像的底部创建一个轻微的夹角,如下所示:

什么是二次贝塞尔曲线?

现在,我实现最终的波浪纹。为此,我们使用quadraticBezierTo方法,这允许我们很容易地在我们的路径上创建我们的曲线。

该API极其类似于lineTo方法,但是相反不是给定一个坐标,我们要给两个:一个用于我们的端点,另一个用于所谓的控制点。

根本上来说,我们仅是绘制一条直线到我们的端点,我们的控制点就像是磁铁一样,将路径的中间部分拉向它的方向,使我们的直线扭曲成一个曲线。

端点与控制

在我们这个例子中,我们需要绘制两个二次贝塞尔曲线。第一条线的控制点应该将该条线的中点向下拉。类似地,第二条线应该将该条线的中点向上拉。

如果我们看下这个模型,第一条线并不完全在水平方向上的中间位置结束。同样地,第二条线的宽度比该图片的宽度的一半更宽一些。

基于目前了解的这些,我们想要向下面这样排列这些端点以及控制点:

你可能会说:“这些曲线太大了!”你说得对。然而,这些红色控制点并不是曲线接触的地方,它们只会像磁铁一样吸引直线。最终的结果会非常接近模型。

第一条曲线

第一条曲线有下面几点可了解:

  • 端点在水平方向稍稍距离中点一段距离(未达到)。
  • 端点在垂直方向上稍稍高于起始点。
  • 控制点在水平方向上位于图片宽度的1/4处。
  • 控制点在垂直方向上位于图片的底部。

基于以上这些,第一条贝塞尔曲线看起来是这样的:

1
2
3
var fisrtControlPoint = new Offset(size.width /4,size.heigh);
var firstEndPoint = new Offset(size.width /2.25,size.height-30.0);
path.quadraticBezierTo(firstControlPoint.dx,firstControlPoint.dy,firstEndPoint.dx,firstEndPoint.dy);

我们可以跳过创建Offset对象,可直接使用线x & y 坐标,但是这里仅仅是为了可读性。这样,第一眼就能看出quadraticBezierTo方法的参数是什么。

第二条曲线

第二条曲线有下面几点可了解:

  • 端点在水平方向上位于图片的右边。
  • 端点在垂直方向上比第一条曲线的端点略高一点。
  • 控制点在水平方向上位于图片宽度的3/4处。
  • 控制点在垂直方向上在端点上方大约20个像素。

伴随着一些错误和尝试,我们得到第二条曲线:

1
2
3
4
5
var secondControlPoint =
Offset(size.width - (size.width / 3.25), size.height - 65);
var secondEndPoint = Offset(size.width, size.height - 40);
path.quadraticBezierTo(secondControlPoint.dx, secondControlPoint.dy,
secondEndPoint.dx, secondEndPoint.dy);

最后的效果如下:

下面这个是WavyHeaderImage组件的完整代码:

wavy_header_image.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import 'package:flutter/material.dart';

class WavyHeaderImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClipPath(
child: Image.asset('images/coffee_header.jpeg'),
clipper: BottomWaveClipper(),
);
}
}

class BottomWaveClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
var path = new Path();
path.lineTo(0.0, size.height - 20);

var firstControlPoint = Offset(size.width / 4, size.height);
var firstEndPoint = Offset(size.width / 2.25, size.height - 30.0);
path.quadraticBezierTo(firstControlPoint.dx, firstControlPoint.dy,
firstEndPoint.dx, firstEndPoint.dy);

var secondControlPoint =
Offset(size.width - (size.width / 3.25), size.height - 65);
var secondEndPoint = Offset(size.width, size.height - 40);
path.quadraticBezierTo(secondControlPoint.dx, secondControlPoint.dy,
secondEndPoint.dx, secondEndPoint.dy);

path.lineTo(size.width, size.height - 40);
path.lineTo(size.width, 0.0);
path.close();

return path;
}

@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

从设计到Flutter #1 - 电影明细页面

发表于 2019-01-03 | 分类于 flutter

模型

我创建了一个Movie类用于保存关于电影的信息。同样的,我也创建了一个Actor类用于保存演员的名字和头像url地址。这些仅仅是传入到我们的UI组件中,以便它们知晓要显示什么。

出于样例的目的,我创建一个文件,根据最初的设计,它保存了电影所需的所有模型。

models.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Movie {
Movie({
this.bannerUrl,
this.posterUrl,
this.title,
this.rating,
this.starRating,
this.categories,
this.storyline,
this.photoUrls,
this.actors,
});

final String bannerUrl;
final String posterUrl;
final String title;
final double rating;
final int starRating;
final List<String> categories;
final String storyline;
final List<String> photoUrls;
final List<Actor> actors;
}

class Actor {
Actor({
this.name,
this.avatarUrl,
});
final String name;
final String avatarUrl;
}

数据源

我们不打算将我们的应用程序与在线电影的API牵连在一起,使用其API本身就是另一个话题,这个超出了本文的范围。相反,我仅仅是创建了一个电影的实例,并填充了与模型相同的信息。

movie_api.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import 'package:movie_details_ui/models.dart';

final Movie testMovie = Movie(
bannerUrl: 'images/banner.png',
posterUrl: 'images/poster.png',
title: 'The Secret Life of Pets',
rating: 8.0,
starRating: 4,
categories: ['Animation', 'Comedy'],
storyline: 'For their fifth fully-animated feature-film '
'collaboration, Illumination Entertainment and Universal '
'Pictures present The Secret Life of Pets, a comedy about '
'the lives our...',
photoUrls: [
'images/1.png',
'images/2.png',
'images/3.png',
'images/4.png',
],
actors: [
Actor(
name: 'Louis C.K.',
avatarUrl: 'images/louis.png',
),
Actor(
name: 'Eric Stonestreet',
avatarUrl: 'images/eric.png',
),
Actor(
name: 'Kevin Hart',
avatarUrl: 'images/kevin.png',
),
Actor(
name: 'Jenny Slate',
avatarUrl: 'images/jenny.png',
),
Actor(
name: 'Ellie Kemper',
avatarUrl: 'images/ellie.png',
),
],
);

这个使我们能够在应用程序的任何地方来轻松使用地使用testMovie作为数据源,同时还能提高我们的速度,无需担心网络问题。

我们的 main.dart 文件

这个是我们应用程序的主入口,这里没有特别的地方,仅仅是将MovieDetailsPage作为该应用所拥有的唯一页面。

main.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import 'package:flutter/material.dart';
import 'package:movie_details_ui/movie_api.dart';
import 'package:movie_details_ui/movie_details_page.dart';

void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
accentColor: const Color(0xFFFF5959),
),
home: MovieDetailsPage(testMoive),
)
}
}

在现实生活中,我们可能希望是将电影列表作为第一页显示,在用户点击一个电影后显示明细页面。在本教程,我们只关注电影明细页面。

MovieDetailsPage类

这个是我们电影明细页的入口,MovieDetailsPage接收一个Movie对象作为构造参数,并向下传给子组件。通过这种方式,随后我们可以轻松地将页面链接到一个后端。

movie_details_page.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import 'package:flutter/material.dart';
import 'package:movie_details_ui/actor_scroller.dart';
import 'package:movie_details_ui/models.dart';
import 'package:movie_details_ui/movie_detail_header.dart';
import 'package:movie_details_ui/photo_scroller.dart';
import 'package:movie_details_ui/story_line.dart';

class MovieDetailsPage extends StatelessWidget {
MovieDetailsPage(this.movie);
final Movie movie;

@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
MovieDetailHeader(movie),
Padding(
padding: const EdgeInsets.all(20.0),
child: Storyline(movie.storyline),
),
PhotoScroller(movie.photoUrls),
SizedBox(height: 20.0),
ActorScroller(movie.actors),
SizedBox(height: 50.0),
],
),
),
);
}
}

尽管这个页面包含在一个脚手架中,但我们这里不使用AppBar,因为我们想要展示我们的电影背景。主体是一个Column控件用来垂直地堆叠我们的主要的控件。最后,整个被包裹在SingleChildScrollView控件中。对于垂直方向上的边距,我们使用SizedBoxes配合height属性来实现我们想要的间距。

让我们一个个浏览每个组件,看看他们是由什么构成的。

MovieDetailHeader 电影详情页面头部

这个MovieDetailHeader是一个简单的Stack控件,包含了两个子控件。Stack是这样的一个容器,子控件能够相互在其上面堆叠地进行布局。第一个子控件是圆弧形背景图像,第二个是Row控件,包含了海报以及电影的相关信息。

因为原模型设计是将电影横幅部分地覆盖在电影信息上,我们在这里使用了一个小技巧:ArcBannerImage的底部填充间距140.0。

这只是延伸了底部可用的空间,因此我们可以部分地将横幅与电影信息覆盖起来。如果没有这个技巧,电影信息就会被放在整个横幅的顶部,从而产生一个相当丑陋的效果:

我们使用Positioned控件来将我们的电影信息固定在底部。电影信息行包含了两项:电影海报和关于电影进一步的明细信息(这是一个Column控件)。

movie_detail_header.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import 'package:flutter/material.dart';
import 'package:movie_details_ui/arc_banner_image.dart';
import 'package:movie_details_ui/models.dart';
import 'package:movie_details_ui/poster.dart';
import 'package:movie_details_ui/rating_information.dart';

class MovieDetailHeader extends StatelessWidget {
MovieDetailHeader(this.movie);
final Movie movie;

List<Widget> _buildCategoryChips(TextTheme textTheme) {
return movie.categories.map((category) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
label: Text(category),
labelStyle: textTheme.caption,
backgroundColor: Colors.black12,
),
);
}).toList();
}

@override
Widget build(BuildContext context) {
var textTheme = Theme.of(context).textTheme;

var movieInformation = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
movie.title,
style: textTheme.title,
),
SizedBox(height: 8.0),
RatingInformation(movie),
SizedBox(height: 12.0),
Row(children: _buildCategoryChips(textTheme)),
],
);

return Stack(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 140.0),
child: ArcBannerImage(movie.bannerUrl),
),
Positioned(
bottom: 0.0,
left: 16.0,
right: 16.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Poster(
movie.posterUrl,
height: 180.0,
),
SizedBox(width: 16.0),
Expanded(child: movieInformation),
],
),
),
],
);
}
}

这个Column控件包含了三个部分(子控件):电影标题,电影评分,电影分类。

有一点需要注意的是,我们这里使用了Expanded控件,否则,如果电影名过长将会被剪切掉,此外还在左边添加了一些填充,这样海报和电影信息就不会紧紧挨着了。

ArcBannerImage 圆弧横幅图片

更为整洁的Flutter UI代码的技巧

发表于 2019-01-02 | 分类于 flutter

英文地址:Putting build methods on a diet - tips and tricks for cleaner Flutter UI code

flutter是强大的,足以震颤人心。

问题1: “Padding,Padding,Padding”

我们应用程序中的大多数都是基于垂直和水平来布局内容的,这个就意味着我将会使用Column和Row控件很多次。

正因为将小部件直接放在彼此的正下方或者相邻位置看起来不是很好看,所以我们将会希望它们之间有一些空白。在控件之间填充一些空白,最明显的方式就是使用Padding控件来包裹它们(其中一个即可)。

考虑下面例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
Column(
children: [
Text('First line of text'),
const Padding(
padding: EdgeInests.only(top: 8.0),
child: Text('Second line of text.'),
),
const Padding(
padding: EdgeInsets.only(top:8.0),
child: Text('Third line of text.'),
),
],
),

我们在Column控件中有三个Text控件,它们彼此之间在垂直方向上有8.0的边距。

争议点:“Hidden Widget”

到处使用Padding控件的问题是它们会开始模糊UI代码的业务逻辑。它们通过增加缩进和行数的形式来造成视觉上的混乱。

我们希望能够尽可能突出实际的控件,每个缩进级别都很重要。如果我们能够在这个过程中减少行数,那也是很好的。

解决方法:使用SizeBoxes

为了克服隐藏控件的问题,我可以使用SizedBox控件来替换所有的Padding控件,使用SizeBoxes将允许减少缩进级别以及行数:

1
2
3
4
5
6
7
8
9
Column(
children: [
Text('First line of text.'),
const sizedBox(height:8.0),
Text('Second line of text.'),
const SizeBox(height:8.0),
Text('Third line of text.'),
]
)

这种方式也适用于Row,因为是在水平方向上来布局子控件,我们可以在SizedBox的width属性来拥有水平边距。

问题2: 过多附加的回调

点击或触摸可以说是用户于我们应用程序最常见的交互方式。

为了允许用户在我们应用程序中点击某些地方,我可以使用GestureDetector控件,当使用GestureDetectors,我将包裹我们原始的控件在里面,在onTap构造参数指定一个回调。

考虑如下代码(取自inKino app):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
...
final List<Event> events;

@override
Widget build(BuildContext context) {
return GridView.builder(
...
itemBuilder: (_,int index) {
final event = events[index];
return GestureDectector(
onTap:() {
Navigator.push(
context,
MaterialPageRoute(
builder:(_) => EventDetailsPage(event),
),
);
},
child: EventGridItem(event:event),
);
},
);
}

inKino 应用有一个网格布局的电影发布帖,当用户点击其中一个的时候,它们应该被带入电影明细页面。

争议点:UI代码和逻辑混乱

我们的构建方法应该只包含与构建应用程序UI相关的最小代码,onTap回调中包含的逻辑与构建UI完全无关。它使我们的构建方法变得杂乱,增加了不必要的噪声。

在这里,我很快地可以明确的是Navigator.push压入新的路由,它是EventDetailsPage,这样点击其中一个,就会打开明细页面。然而若涉及更多地onTap的回调,它可能需要更加深入地阅读来理解。

解决方法:抽取逻辑代码到私有方法中

这个问题可以通过将onTap回调提取到一个命名良好的private方法中来更好地解决。在这里,我们创建了一个名为_openEventDetails 的新方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
...
final List<Event> events;

void _openEventDetails(Event event) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EventDetailsPage(event),
),
);
}

@override
Widget build(BuildContext context) {
return GridView.builder(
...
itemBuilder: (_, int index) {
final event = events[index];

return GestureDetector(
// :-)
onTap: () => _openEventDetails(event),
child: EventGridItem(event: event),
);
},
);
}

由于onTap回调现在被提取到一个命名良好的private方法中,我们不再需要通读整个代码。现在很容易理解调用回调时发生了什么,只需看一眼即可,我们还在构建方法中省略了许多行代码,并且只关注于读取与UI相关的代码

问题3: if 到处都是

某些时候,我们Columns(或Row)的子控件不打算总是可见的。例如,一部电影由于未知原因丢失了故事情节的细节,在UI界面显示空白的文本组件是毫无意义的。

根据条件向Column(或Row)添加子控件的通常惯例写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class EventDetailsPage extends StatelessWidget {
EventDetailsPage(this.event);
final Event event;

@override
Widget build(BuildContext context) {
final children = <Widget>[
_HeaderWidget(),
];

// :-(
if (event.storyline != null) {
children.add(StorylineWidget(...));
}

// :-(
if (event.actors.isNotEmpty) {
children.add(ActorList(...));
}

return Scaffold(
...
body: Column(children: children),
);
}
}

有条件地向Column添加子项的要旨是很简单的:我们初始化一个本地控件列表,如果满足某些条件,我们就添加必要的孩子到里面。最后我们将这个控件列表传入到我们的Column的children参数中。

争议点:if 无处不在

尽管这个是有效的,但是if语句很快就会被用烂。

虽然这个很容易理解和直接,但是它们在我们的构建方法中占据了不必要的垂直空间,特别是在拥有三个及以上起始点将会变得更加麻烦。

解决方法: 全局工具方法

为了解决这个问题,我们可以创建一个全局的工具方法来为我们提供这样的功能,下面代码是也是Flutter主框架使用的使用的一种模式:

lib/widget_utils.dart:

1
2
3
4
5
void addIfNonNull(Widget child,List children) {
if (child != null) {
children.add(child);
}
}

相反不是重复性的逻辑基于条件添加一系列控件,我们创建了一个全局的工具方法。

一旦定义过后,我们可导入该文件,使用该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import 'widget_utils.dart';

class EventDetailsPage extends StatelessWidget {
EventDetailsPage(this.event);
final Event event;

Widget _buildStoryline() =>
event.storyline != null ? StorylineWidget(...) : null;

Widget _buildActorList() =>
event.actors.isNotEmpty ? ActorList(...) : null;

@override
Widget build(BuildContext context) {
final children = <Widget>[
_HeaderWidget(),
];

// :-)
addIfNonNull(_buildStoryline(), children);
addIfNonNull(_buildActorList(), children);

return Scaffold(
...
body: Column(children: children),
);
}
}

这里所做的是,_buildMyWidget()方法将会返回一个小部件或空值,这取决于条件是否为真,这可以为我们节省很多垂直空间,特别是如果我们添加大量基于条件添加控件的时候。

问题4: 括号地狱

这个可能是在我们布局代码中,最为常见的问题。最常见的抱怨就是Flutter UI的代码往往会疯狂地缩进,从而产生很多的括号。

考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
...
@override
Widget build(BuildContext context) {
final backgroundColor =
useAlternateBackground ? const Color(0xFFF5F5F5) : Colors.white;

return Material(
color: backgroundColor,
child: InkWell(
onTap: () => _navigateToEventDetails(context),
child: Padding(
padding: const EdgeInsets.symmetric(...),
child: Row(
children: [
Column(
children: [
Text(
hoursAndMins.format(show.start),
style: const TextStyle(...),
),
Text(
hoursAndMins.format(show.end),
style: const TextStyle(...),
),
],
),
const SizedBox(width: 20.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
show.title,
style: const TextStyle(...),
),
const SizedBox(height: 4.0),
Text(show.theaterAndAuditorium),
const SizedBox(height: 8.0),
Container(
// Presentation method chip.
// Styling redacted for brevity ...
child: Text(show.presentationMethod),
),
],
),
),
],
),
),
),
);
}
争议点: 你仍在写Lisp吗?

Lisp是一种古老的语言,拥有一个语法能使你使用很多的括号,我看过很多次Flutter UI代码与Lisp的对比,有很多相似的地方。

虽然上面的代码是有效的,但是很难看。缩进的级别很深,存在大量的混乱,很难去追踪发生什么,在那个地方发生的。

看下结尾的括号:

1
2
3
4
5
6
7
8
9
10
11
12
                      ),
),
),
],
),
),
],
),
),
),
);
}

深度嵌套,即使是好的IDE,也很难添加新的元素到我们的布局中,更不用说是阅读UI代码了。

解决方法: 重构不同的UI部件为单独的控件

在我们的列表块中有两个不同的部分组成:右边部分和右边部分。

左边部分包含了该电影的开始和结束时间的信息,右边部分包含了诸如电影标题,影院,以及是2D还是3D电影的信息,为了使代码更具可读性,我们首先将代码分割成两个不同的部分:_LeftPart和_RightPart。

因为这个presentation method控件在垂直方向上引入了非常多负责且很深的嵌套。我们将其分离成单独的控件_PresentationMethod。注意:不要将你的构建方法分割成单独的方法,这个是一种性能反面模式,在软件工程中,一个反面模式(anti-pattern或antipattern)指的是在实践中明显出现但又低效或是有待优化的设计模式,是用来解决问题的带有共同性的不良方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
...
@override
Widget build(BuildContext context) {
final backgroundColor =
useAlternateBackground ? const Color(0xFFF5F5F5) : Colors.white;

return Material(
color: backgroundColor,
child: InkWell(
onTap: () => _navigateToEventDetails(context),
child: Padding(
padding: const EdgeInsets.symmetric(...),
child: Row(
children: [
_LeftPart(),
const SizedBox(width: 20.0),
_RightPart(),
],
),
),
),
);
}

class _LeftPart extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
hoursAndMins.format(show.start),
style: const TextStyle(...),
),
Text(
hoursAndMins.format(show.end),
style: const TextStyle(...),
),
],
);
}
}

class _RightPart extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
show.title,
style: const TextStyle(...),
),
const SizedBox(height: 4.0),
Text(show.theaterAndAuditorium),
const SizedBox(height: 8.0),
// The presentation method is in the right part.
// See the widget below.
_PresentationMethodChip(),
],
),
);
}
}

class _PresentationMethodChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
// Presentation method chip.
// Styling redacted for brevity ...
child: Text(
show.presentationMethod,
style: const TextStyle(...),
),
);
}
}

通过这些变化,这个缩进级别比之前的少了一半以上。如今,很容易地浏览UI代码,并能看到发生了什么以及进行定位。

此外,发明自己代码格式化风格

我不认这个是与上面类似的问题,但是仍然非常重要。

为了说明这个问题,查看下面的代码:

1
2
3
4
Column(
children:[Row(children:
[Text('Hello'),Text('World'),
Text('!')])])

这样的是非常不可靠的,不是吗?这当然不是在好的代码库中你要看见的。

争议点: 不使用dartfmt

上面的样本代码并不遵守Dart通用的代码格式规范,看起来像是代码作者自己创建了一套它们自己的代码格式。这个是不好的,因为阅读这样的代码需要花费额外的注意力。

解决方法:使用dartfmt即可

幸运地是,我们拥有一个官方的格式化工具,为我们处理代码的格式化。此外,由于也存在formatter monopoly,我们停止争论那个是最好的格式化工具,转而关注代码。

1
2
3
4
5
6
7
8
9
10
11
Column(
children: [
Row(
children: [
Text('Hello'),
Text('World'),
],
),
Text('!'),
],
)

这样看起来好多了,格式化代码是必须的,总是能够使用dartfmt来格式化并记住你的分号。

12

编号15077

12 日志
1 分类
0%
© 2019 编号15077