原文作者及地址 Marcin Szałek
文章:https://marcinszalek.pl/flutter/ui-challenge-flight-search/
仓库:https://github.com/MarcinusX/flutter_ui_challenge_flight_search
本文代码目录: GO ✈️
本文链接: https://blog.gcl666.com/2019/03/03/flutter_app_flight/#more
本文中的图片部分来自原作者文中的图片,一部分是自己截图或录制的,有些
gif
图片有些卡顿
是因为mac
内存和不配置不足电脑本身就比较卡顿引起的。
该引用所包含的功能分解:
作为起点,需要创建个最基本的 Flutter
应用,然后去掉所有不需要的一些代码。
应用运行入口函数: main
void main() => runApp(new MyApp());
MyApp
实现,基于一个 MaterialApp
import "package:flutter/material.dart";
import "flight2/home_page.dart";
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Flight Search',
theme: new ThemeData(
// 设置 app 的主色彩
primarySwatch: Colors.red,
),
// 关闭右上角的 `DEBUG` 图标
debugShowCheckedModeBanner: false,
home: new HomePage(),
);
}
}
应用的首页 Widget HomePage
:
import 'package:flutter/material.dart';
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 应用脚手架,决定了应用的主要架构
return Scaffold(
// 位置居中 Widgt
body: Center(
// 文本 Widget
child: Text("Let's get started!"),
),
);
}
}
so…
我这里使用的是 andriod
模拟器,至于怎么创建一个 flutter
项目和启动模拟器,详情 ✈
关闭右上角的 DEBUG
标记,可以通过配置 Materialapp
的 debugShowCheckedModeBanner 属性为 false
来关闭。
根据设计图和最终效果,导航条为红色部分,且上面有三个按钮分别是:
ONE WAY
单程
ROUND
往返
MULTICITY
多个城市
下面来实现这两个部分的内容
应用的导航条本身层级应该在最底层,按钮以及后面其他的
Widget
都应该在它的上面,因此为了让我们实现各个Widgets
之间有
一定的层级显示,这里需要用到一个Stack
组件,它允许我们来根据不同显示层级去放置各个Widget
。
AirAsiaBar
导航条 Widget
import 'package:flutter/material.dart';
class AirAsiaBar extends StatelessWidget {
// final 变量声明时必须初始化,且一旦赋值之后就不能发生改变
final double height;
// 声明了一个构造函数,且对 height 进行了初始化
// 即在创建 `AirAsiaBar` 的时候由调用者去初始化其高度
const AirAsiaBar({Key key, this.height}) : super(key: key);
@override
Widget build(BuildContext context) {
// 将导航条上所有控件放在 Stack 上,让他们有一定的堆叠关系
return Stack(
// stack 是个多子节点的控件
children: [
// 控件容器
new Container(
// 组织控件的渲染属性,比如:渐变,动画,颜色等等
decoration: new BoxDecoration(
// 渐变特效,从顶至下,渐变色有 colors 指定
gradient: new LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.red, const Color(0xFFE64C85)],
),
),
// 指定该导航条的高度
height: height,
),
new AppBar(
backgroundColor: Colors.transparent,
// 控制条下面的阴影部分
elevation: 0.0,
centerTitle: true,
title: new Text(
"AsiaAir",
style: TextStyle(
// 外部新增的字体
fontFamily: 'NothingYouCouldDo',
fontWeight: FontWeight.bold
),
),
),
],
);
}
}
如上代码,我们创建了一个简单的包含一个 Container
的 Stack
控件,然后增加了一个透明的 AppBar
在这个容器之上,
evelation
用来设置该 AppBar
下面的阴影部分大小的(0.0
不需要阴影)。并且我们通过给 AirAsiaBar
设置了一个
210.0 的一个高度,这样 Container
会被撑高,以便于我们后面复用它,在它上面添加更多的控件。
NothingYouCouldDo
是一个引入的外部字体,如何导入并使用字体文件 ✈ ?
完成之后,修改 home_page.dart
将导航条加到主页中
import 'package:flutter/material.dart';
import './air_asia_bar.dart';
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// 导航条
AirAsiaBar(height: 210.0),
],
),
);
}
}
运行效果:
为了能自定义按钮样式,我们自己创建一个按钮组件 rounded_button.dart: RoundedButton
。
import 'package:flutter/material.dart';
// 自定义按钮组件
class RoundedButton extends StatelessWidget {
final String text; // 按钮文本
final bool selected; // 按钮是否被选中
final GestureTapCallback onTap; // tap 手势回调
// 构造函数初始化按钮文本,状态和回调,默认非选中
const RoundedButton({Key key, this.text, this.selected = false, this.onTap})
: super(key: key);
@override
Widget build(BuildContext context) {
// 选中白色,非选中透明
Color backgroundColor = selected ? Colors.white : Colors.transparent;
// 按钮文字选中红色,非选中白色
Color textColor = selected ? Colors.red : Colors.white;
// 按钮可能多个按钮排列在一起,因此用 Expanded 包裹起来
// 让其能根据布局自适应位置
return Expanded(
// 使用 Padding 空间控制间隙,也可以使用 padding 属性,建议使用控件形式
child: Padding(
padding: const EdgeInsets.all(4.0),
child: new InkWell(
onTap: onTap,
child: new Container(
height: 36.0,
decoration: new BoxDecoration(
color: backgroundColor,
// 按钮白色 1 像素的边框
border: new Border.all(color: Colors.white, width: 1.0),
// 按钮圆角
borderRadius: new BorderRadius.circular(30.0),
),
child: new Center(
child: new Text(
text,
style: new TextStyle(color: textColor),
),
),
),
),
),
);
}
}
在主页增加按钮:
import 'package:flutter/material.dart';
import './air_asia_bar.dart';
import './rounded_button.dart';
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// 导航条
AirAsiaBar(height: 210.0),
Positioned.fill(
child: Padding(
// 查询上下文的 padding top
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 40.0
),
child: new Column(
children: [
_buildButtonRow(),
Container(), // TODO: 卡片位置
],
),
),
),
],
),
);
}
// 创建一个包含按钮的行空间(Row)
Widget _buildButtonRow() {
return Padding(
padding: const EdgeInsets.all(8.0),
// 行内的控件会在水平位置并排排列
child: Row(
children: [
new RoundedButton(text: "ONE WAY"),
new RoundedButton(text: "ROUND"),
new RoundedButton(text: "MULTICITY", selected: true),
],
),
);
}
}
上面我们声明了一个 _buildButtonRow
函数,这是一个类私有函数(因为 Dart
规定类内部凡是以下划线开头的变量和函数都属于私有的)。
这个函数里面就是创建了三个按钮,并且使用了 Row
控件,该控件会将其内部的子控件均匀并排水平排列开。
然后使用 Positioned
定位控件(相当于 css
的绝对定位可以设置 left/top/bottom/right
属性类控制其位置 )
将其放置到 Stack
上,且叠在导航条 AirAsiaBar
之上,这里使用了 Column
控件,它和 Row
类似只不过是在垂直方向上的排列。
效果图:
查询系统包含三种类型交通工具,查询就需要输入一些航班或车次的相关信息,这里需要一些输入框来接受用户的输入。
Card
)为了放置这些用户输入信息,我们需要到一个 Card
控件,用来放置查询输入的控件。
内容卡片控件: ContentCard
import 'package:flutter/material.dart';
//import './multicity_input.dart';
// 这里涉及到 有状态控件的创建
// 有状态的控件: 在整个应用使用过程中,会与用户发送交互的控件,比如用户输入
class ContentCard extends StatefulWidget {
@override
_ContentCardState createState() => _ContentCardState();
}
class _ContentCardState extends State {
@override
Widget build(BuildContext context) {
// 创建一个卡片容纳用户输入控件
return new Card(
elevation: 2.0,
margin: const EdgeInsets.all(8.0),
child: DefaultTabController(
length: 3,
child: new LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewportConstraints) {
return Column(
children: [
// 选项卡
_buildTabBar(),
// 选项卡内容
_buildContentContainer(viewportConstraints),
],
);
},
),
),
);
}
// 创建选项卡
Widget _buildTabBar({bool showFirstOption}) {
return Stack(
children: [
new Positioned.fill(
// 设置成 null 那么 Stack 的子控件会被垂直排列,而不是堆叠在一起
// 因此可以看到这个 Container 在 TabBar 的下面,如果没设置成 null
// Container 是遮挡在 TabBar 上面的
top: null,
child: new Container(
height: 2.0,
color: new Color(0xFFEEEEEE),
),
),
new TabBar(
tabs: [
Tab(text: "Flight"),
Tab(text: "Train"),
Tab(text: "Bus"),
],
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
),
],
);
}
// 选项卡内容容器
Widget _buildContentContainer(BoxConstraints viewportConstraints) {
return Expanded(
child: SingleChildScrollView(
child: new ConstrainedBox(
constraints: new BoxConstraints(
// 视图最大高度 - tabbar 的高度
minHeight: viewportConstraints.maxHeight - 48.0
),
// 创建一个高度由 child 实际高度决定的 Widget
child: new IntrinsicHeight(
child: _buildMulticityTab(),
),
),
),
);
}
// 多城市选项内容容器,包含多个 input 控件
Widget _buildMulticityTab() {
return Column(
children: [
Text("Inputs"), // TODO 添加用户信息输入框
Expanded(child: Container()),
// 底部增加了一个图标
Padding(
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
child: FloatingActionButton(
onPressed: () {},
child: Icon(Icons.timeline, size: 36.0),
),
),
],
);
}
}
创建卡片控件的时候主要有下面几个部分:
StatefulWidget
有状态控件(能和用户发生交互的控件)// 创建有状态组件方式:
// 1. 创建 StatefulWidget 子类
class ContentCard extends StatefulWidget {
// 重写 createState() 方法,在 StatefulWidget
// 生命周期中会多次调用这个方法。
_ContentCardState createState() => _ContentCardState();
}
// 2. 实现状态组件的状态类
class _ContentCardState extends State {
@override
Widget build(BuldContext context) {
return new Card(
// ...
);
}
}
build
卡片控件 new Card
实现卡片控件,里面包含两部分: Tabs
和 Content
// ... 省略
new Card(
elevation: 4.0,
margin: const EdgeInsets.all(8.0),
// TabBar 控件必须要有个控制器(TabController)
// 如果没有则必须使用这个默认的控制器
child: DefaultTabController(
// 布局控件,它下面的控件大小依赖于父控件的大小
child: new LayoutBuilder(
// ...
),
),
);
// ... 省略
_buildTabbar
// 私有函数,以下划线开头,只能内部使用
Widget _buildTabBar(bool showFirstOption) {
return Stack(
children: [
new Positioned.fill(
// ... 这里在 tabs 下方增加了一个 2 像素高的分割线
// top 设置成 null 可以让 Stack 内的子控件垂直并排分布
top: null,
child: new Container(
height: 2.0,
// ...
),
),
new TabBar(
// 三个选项卡
tabs: [
Tab(Text: "Flight"),
Tab(Text: "Train"),
Tab(Text: "Bus"),
],
// 选中的选项卡字体颜色
labelColor: Colors.black,
// 未选择的选项卡字体颜色
unselectedLabelColor: Colors.grey,
),
]
);
}
_buildContentContainer
Widget _buildContentContainer(BoxConstraints viewportConstraints) {
return Expanded(
// 可滚动的视图控件
child: SingleChildScrollView(
// 受父控件约束的盒子
child: new ConstrainedBox(
constraints: new BoxConstraints(
minHeight: viewportConstraints.maxHeight - 48.0,
),
child: new IntrinsicHeight(
// ... 高度不限制
),
),
),
);
}
HomePage
将 HomePage
中的 Container() // TODO 卡片位置
代码替换成: Expanded(child: ContentCard())
效果图上选项卡下面的灰色线条实现方式(利用 Positioned
控件 top:null
属性特性):
TabBar
和 Container
放置在一个 Stack
中Positioned
将 Container
定位住Positioned
的 top:null
让其在垂直方向排列(Stack-Positioned-top:null在Stack中效果)航班查询需要以下用户信息:
以上是我们查询所需要的待用户输入的信息,我们准备使用 Form:Input
去实现它。
这也是实现该模块的一个难点,需要记住的是无论任何时候你要使用 TextFields
话都最好使用一个 scrollable views
去将它们包裹起来(比如: CustomScrollView
或 ListView
),从而不至于在键盘弹出来的时候导致 Inputs
的布局
混乱。
在这个应用中我们需要用到一个图标(FloatingActionButton
)来做引导用户操作,并将它放到 ScrollView
的底部,从而
让它随着用户的操作而做相应的滚动,而不是一直固定在底部。
为了实现这一点,我们需要使用的以下控件组合:
LayoutBuilder
去访问 BoxConstraints
SinglechildScrollview
和 Constrainedbox
去获取 ScrollView
的最大高度值。
Intrinsicheight
一个不限定高度的控件去让我们的视图尽可能的有足够的空间。
import 'package:flutter/material.dart';
class MulticityInput extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Form(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// 比如这里传入的第三个参数其实是一个可选的非命名参数
// 如果是命名参数就需要这样: color: Colors.red
_buildTextField(Icons.flight_takeoff, "From"),
_buildTextField(Icons.flight_land, "To"),
Row(
children: [
Expanded(
child: _buildTextField(
Icons.flight_land,
"To",
padding: const EdgeInsets.only(bottom: 8.0),
),
),
Container(
width: 64.0,
alignment: Alignment.center,
child: Icon(Icons.add_circle_outline, color: Colors.grey),
),
],
),
_buildTextField(Icons.person, "Passengers"),
Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(Icons.date_range, color: Colors.red),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: TextFormField(
decoration: InputDecoration(labelText: "Departure"),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: TextFormField(
decoration: InputDecoration(labelText: "Arrival"),
),
),
),
],
),
],
),
),
);
}
// 有序的可选非命名参数 color,非命名表示调用的时候不需要传入参数名称
Widget _buildTextField(IconData icon, String text, {
Color color = Colors.red,
EdgeInsetsGeometry padding = const EdgeInsets.fromLTRB(0.0, 0.0, 64.0, 8.0),
}) {
return Padding(
padding: padding,
child: TextFormField(
decoration: InputDecoration(
// 可选命名参数需要使用 color: color 传递
icon: Icon(icon, color: color),
labelText: text,
),
),
);
}
}
然后在 content_card.dart:_buildMulticityTab
中将 Text("Inputs"), // TODO 添加用户信息输入框
修改成: new MulticityInput(),
即可。
上面我们单独为创建 Input:TextField
控件声明了个私有方法: _buildTextField
这里面涉及到方法的声明,
方法的参数等概念(比如:必须参数(icon
, text
),可选命名参数(color
, padding
)) 这些都是 Dart
语言本身的
类方法特性 ✈。
卡片和用户信息输入已经有了,现在我们需要这么一个场景,点击下面的 floating action button
需要
切换卡片内容区,其上面有一个图标为一个飞机图标,并且给它添加一个大小变化以及从下往上飞行的动画。
即这里涉及到三个功能部分:
点击 floating action button
图标切换场景
我们将该场景的控件命名为: PriceTab
因为这上面将会包含班次,时间及其价格等信息。
场景上有一个飞机图标
飞机图标大小变化动画
飞机图标飞行动画
这一切都在当前的卡片容器中完成,即
TabBar
的内容不需要发生变化。
PriceTab
)PriceTab
包含的内容:
Container
作为容器Stack
让该面板里的内容居中布局Positioned
让飞机图标相对固定在底部import 'package:flutter/material.dart';
class PriceTab extends StatefulWidget {
final double height;
const PriceTab({Key key, this.height}) : super(key: key);
@override
_PriceTabState createState() => _PriceTabState();
}
class _PriceTabState extends State {
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
child: Stack(
// 将里面的控件都居中排布
alignment: Alignment.center,
children: [
// TODO 增加飞机图标
],
),
);
}
}
将信息面板添加到 content_card.dart
中,修改 _buildContentContainer
中的 new Intrinsicheight()
里面的 child
属性,增加判断,根据 showInput
的值。
class _ContentCardState extends State {
// 修改点 1:增加点击事件标识
// 按钮点击切换时的标识,默认显示输入框,点击之后显示其他的内容(比如:PriceTab)
bool showInput = true;
// ... 省略
// 选项卡内容容器
Widget _buildContentContainer(BoxConstraints viewportConstraints) {
return Expanded(
child: SingleChildScrollView(
child: new ConstrainedBox(
constraints: new BoxConstraints(
// 视图最大高度 - tabbar 的高度
minHeight: viewportConstraints.maxHeight - 48.0
),
// 创建一个高度由 child 实际高度决定的 Widget
child: new IntrinsicHeight(
// 修改点 2:增加判断,点击触发状态值改变,触发UI更新
child: showInput
? _buildMulticityTab()
: PriceTab(
height: viewportConstraints.maxHeight - 48.0,
),
),
),
),
);
}
// 多城市选项内容容器,包含多个 input 控件
Widget _buildMulticityTab() {
return Column(
children: [
new MulticityInput(),
Expanded(child: Container()),
// 底部增加了一个图标
Padding(
padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
child: FloatingActionButton(
// 修改点 3: 增加点击事件
// 增加点击事件切换卡片内容,使用 setState 的传递个方法作为参数
onPressed: () => setState(() => showInput = false),
child: Icon(Icons.timeline, size: 36.0),
),
),
],
);
}
}
效果图:
创建飞机图标到 PriceTab
面板。
飞机底部间距 _initialplanepaddingbottom
和飞机大小 _planeSize
事先定义好值,这里使用了类 getter
方式
去声明飞机大小,说明飞机大小属性只读。
然后根据 _initialplanepaddingbottom
和 _planeSize
去计算出飞机顶部间距 _planeToppadding
值。
方法 _buildPlane
定义好飞机图标的定位方式, _buildPlaneIcon
创建飞机图标。
import 'package:flutter/material.dart';
class PriceTab extends StatefulWidget {
final double height;
const PriceTab({Key key, this.height}) : super(key: key);
@override
_PriceTabState createState() => _PriceTabState();
}
class _PriceTabState extends State {
// 修改点 1: 增加飞机底部和顶部间距属性,及飞机大小属性
// 飞机图标距离底部间隔
final double _initialPlanePaddingBottom = 16.0;
// 飞机顶部间隔 = 当前 widget 高度 - 飞机底部间距 - 飞机大小
double get _planeTopPadding =>
widget.height - _initialPlanePaddingBottom - _planeSize;
// 飞机大小
double get _planeSize => 60.0;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
child: Stack(
// 将里面的控件都居中排布
alignment: Alignment.center,
children: [
// 修改点 2:创建飞机图标
_buildPlane()
],
),
);
}
// 修改点 3:飞机图标的控件结构
Widget _buildPlane() {
return Positioned(
top: _planeTopPadding,
child: Column(
children: [
_buildPlaneIcon(),
],
),
);
}
// 修改点 4:创建飞机图标
Widget _buildPlaneIcon() {
return Icon(
Icons.airplanemode_active,
color: Colors.red,
size: _planeSize,
);
}
}
完了之后在模拟器按下 shift+r
重启应用,效果图:
切换完成,飞机添加完成,现在来给飞机添加 resize
动画,这里将需要用到几个知识点:
with
混合器 TickerProviderStateMixin
提供计时器功能,因为每个动画都必须有个 TickerProvider
。
动画控制器 AnimationController
用来控制动画
动画类 Animation
包含了动画状态信息
Tween
线性篡改值的一个动画类
// 根据计时器在 36.0 - 60.0 的范围之间线性改变其值
Tween(
// 动画起始和初始值
begin: 60.0,
end: 36.0,
)
创建动画的步骤:
AnimatedPlaneIcon
类创建 animated_plane_icon.dart
包含生成飞机图标的类。
让需要动画的控件成为一个动画控件。
import 'package:flutter/material.dart';
// 飞机动画 Icon
class AnimatedPlaneIcon extends AnimatedWidget {
AnimatedPlaneIcon({Key key, Animation animation})
: super(key: key, listenable: animation);
@override
Widget build(BuildContext context) {
// 这里的 listenable 来自 上面的构造函数中调用 super 设置的 animation
Animation animation = super.listenable;
return Icon(
Icons.airplanemode_active,
color: Colors.red,
// 动画值
size: animation.value,
);
}
}
要点:
AnimatedWidget
super()
将 animation
动画传递给 super
对象build()
里面拿到传递进来的 animation
animation
拿到动画的状态值给 Icon
的 size
属性最终 Icon
的 size
会随着计时器发生改变从而触发 Icon
状态的改变,产生动画效果。
给控件添加动画有以下几个步骤:
创建初始化方法(_initSizeAnimations
)
并且在这之前,需要用到动画类的类必须要有个计时器混合器用来提供时钟(Ticker
)作用。
主要初始化动画实例(Animation:_planeSizeAnimation
)和动画控制器(AnimationController:_planeSizeAnimationController
)
重写 initState
初始化动画
在 initState
里面执行 _initSizeAnimations()
并且调用 _planeSizeAnimationController.forward()
启动动画,
forward()
为向前进方向执行动画,还有反方向执行的(reverse()
)。
启动动画(_planeSizeAnimationController.forward()
)
重写 dispose()
销毁动画(_planeSizeAnimationController.dispose()
)
import 'package:flutter/material.dart';
import './animated_plane_icon.dart';
class _PriceTabState extends State with TickerProviderStateMixin {
// 修改点 1:增加动画和动画控制器声明
// 动画控制器和动画状态
AnimationController _planeSizeAnimationController;
Animation _planeSizeAnimation;
// ... 省略
// 修改点 2:飞机的大小设置成动画的状态值
// 飞机大小,有动画之后,实际大小为动画当前 Tick 的实时值
// Animation 里面保存了动画相关的状态值
double get _planeSize => _planeSizeAnimation.value;
// ... 省略
// 修改点 3:重写 iniState 调用动画初始化并触发动画(其他地方也可以触发)
@override
void initState() {
super.initState();
// 控件状态初始化,动画在这里执行初始化
_initSizeAnimations();
// 触发动画
_planeSizeAnimationController.forward();
}
// 修改点 4:释放动画资源,不用了就得释放
@override
void dispose() {
// 直接调用动画控制器的释放方法
_planeSizeAnimationController.dispose();
// 任何动画在不使用了就得释放掉
super.dispose();
}
Widget _buildPlane() {
return Positioned(
top: _planeTopPadding,
child: Column(
children: [
// 修改点 5:构造带动画的飞机图标
// 用动画 Icon 代替静态的
AnimatedPlaneIcon(animation: _planeSizeAnimation),
],
),
);
}
// 修改点 6:初始化动画方法
// 初始化动画
_initSizeAnimations() {
// 控制器初始化
_planeSizeAnimationController = AnimationController(
duration: const Duration(microseconds: 340),
// TickerProvider PriceTabstate 自身
vsync: this,
);
// 动画状态初始化
_planeSizeAnimation = Tween(
// 动画起始和初始值
begin: 60.0,
end: 36.0,
).animate(
CurvedAnimation(
parent: _planeSizeAnimationController, curve: Curves.easeOut
)
);
}
}
添加动画的步骤和 resize
动画一样,这里就不赘述了,直接上代码:
import 'package:flutter/material.dart';
import './animated_plane_icon.dart';
class _PriceTabState extends State with TickerProviderStateMixin {
// 动画控制器和动画状态
AnimationController _planeSizeAnimationController;
Animation _planeSizeAnimation;
// 修改点 1:增加飞行动画和控制器
AnimationController _planeTravelController;
Animation _planeTravelAnimation;
// 飞机图标距离底部间隔
final double _initialPlanePaddingBottom = 16.0;
// 修改点 2:重点位置
// 飞机最小顶部距离,决定了飞行的终点位置
final double _minPlanePaddingTop = 16.0;
// 修改点 3:飞机顶部距离随动画值变化
// 这里增加飞行动画之后,值需要根据动画状态发生改变
double get _planeTopPadding =>
_minPlanePaddingTop +
(1 - _planeTravelAnimation.value) * _maxPlaneTopPadding;
// 飞机顶部最大距离,即起始位置
double get _maxPlaneTopPadding =>
widget.height - _initialPlanePaddingBottom - _planeSize;
// 飞机大小,有动画之后,实际大小为动画当前 Tick 的实时值
// Animation 里面保存了动画相关的状态值
double get _planeSize => _planeSizeAnimation.value;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
child: Stack(
// 将里面的控件都居中排布
alignment: Alignment.center,
children: [
_buildPlane()
],
),
);
}
@override
void initState() {
super.initState();
// 控件状态初始化,动画在这里执行初始化
_initPlaneSizeAnimations();
// 修改点 4: 初始化
_initPlaneTravelAnimations();
// 触发动画
_planeSizeAnimationController.forward();
}
@override
void dispose() {
// 直接调用动画控制器的释放方法
_planeSizeAnimationController.dispose();
// 修改点 5:释放
_planeTravelController.dispose();
// 任何动画在不使用了就得释放掉
super.dispose();
}
// 返回带动画的空间
Widget _buildPlane() {
// 修改点 6:飞机飞行动画重点,AnimatedBuilder
return AnimatedBuilder(
animation: _planeTravelAnimation,
child: Column(
children: [
// 用动画 Icon 代替静态的
AnimatedPlaneIcon(animation: _planeSizeAnimation),
// 在飞机尾部增加一个垂直线条
Container(
width: 2.0,
height: 240.0,
color: Color.fromARGB(255, 200, 200, 200),
),
],
),
builder: (context, child) => Positioned(
top: _planeTopPadding,
child: child,
),
);
}
// 初始化动画
_initPlaneSizeAnimations() {
// 控制器初始化
_planeSizeAnimationController = AnimationController(
duration: const Duration(microseconds: 340),
vsync: this,
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
// 飞机大小动画结束之后启动飞行动画
Future.delayed(
Duration(microseconds: 500),
() => _planeTravelController.forward(),
);
}
});
// 动画状态初始化
_planeSizeAnimation = Tween(
// 动画起始和初始值
begin: 60.0,
end: 36.0,
).animate(
CurvedAnimation(
parent: _planeSizeAnimationController, curve: Curves.easeOut
)
);
}
// 修改点 7:初始化飞行动画
_initPlaneTravelAnimations() {
_planeTravelController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_planeTravelAnimation = CurvedAnimation(
parent: _planeTravelController,
curve: Curves.fastOutSlowIn,
);
}
}
在 _initPlaneSizeAniations
中给 resize
动画添加监听动作,监听动画完成,之后启动飞行动画。
涉及新知识点:
..addStatusListener
动画状态监听器AnimationStatus
动画状态类Future.delayed
延时// 初始化动画
_initPlaneSizeAnimations() {
// 控制器初始化
_planeSizeAnimationController = AnimationController(
duration: const Duration(microseconds: 340),
vsync: this,
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
// 飞机大小动画结束之后启动飞行动画
Future.delayed(
Duration(microseconds: 500),
() => _planeTravelController.forward(),
);
}
});
// ... 省略
}
在 _planeSizeAnimation
动画中,我们是根据 Tween()
中声明的 60.0 ~ 36.0
区间的动画值变化触发
_planeSize
值发生变化从而触发动画状态改变。
在这里是根据 CurvedAnimation
这个动画的 value
属性值的变化(0.0 ~ 0.1
) 触发
double get _planeTopPadding =>
_minPlanePaddingTop +
(1 - _planeTravelAnimation.value) * _maxPlaneTopPadding;
_planeTopPadding
值的更新,来触发动画。
为了放置节点,我们需要知道它们应该在的具体位置,先假设有 4 个节点卡片,每个的高度为 80.0
, 考虑到
节点卡片可能会重叠一点,我们将设置它们的距离为 0.8 * 80.0
。
为了方便创建节点卡片,需要创建一个节点类(与飞机相连在一起的控件都需要动画): AnimatedDot
import 'package:flutter/material.dart';
class AnimatedDot extends AnimatedWidget {
final Color color;
static final double size = 24.0;
AnimatedDot({
Key key,
Animation animation,
@required this.color,
}) : super(key: key, listenable: animation)
@override
Widget build(BuildContext context) {
Animation animation = super.listenable;
return Positioned(
top: animation.value,
child: Container(
height: size,
width: size,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: Color(0xFFDDDDDD),
width: 1.0,
),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
),
),
);
}
}
_mapFlightStopToDot
)点的个数应该是根据查询到的结果来决定的,因此需要有个数组来存储这些点数据(_flightStops
) 暂时使用一些数字来代替
final List
然后通过 map
遍历 _flightStops
回调未 _mapFlightStopToDot
取生成每一个点及其位置:
Widget _mapFlightStopToDot(stop) {
int index = _flightStops.indexOf(stop);
bool isStartOrEnd = index == 0 || index == _flightStops.length - 1;
Color color = isStartOrEnd ? Colors.red : Colors.green;
return AnimatedDot(
// animation: _dotPositions[index],
color: color,
mTop: _minPlanePaddingTop + 80.0 * 0.8 * (index + 1),
);
}
上面我们设置的位置是: _minPlanePaddingTop + 80.0 * 0.8 * (index + 1)
保证每个点能均匀分布在线条之上。
生成之后将其添加到飞机尾部线条之上:
// 返回带动画的空间
Widget _buildPlane() {
return AnimatedBuilder(
animation: _planeTravelAnimation,
child: Column(
children: [
// 用动画 Icon 代替静态的
AnimatedPlaneIcon(animation: _planeSizeAnimation),
// 在飞机尾部增加一个垂直线条
Container(
width: 2.0,
height: 240.0,
color: Color.fromARGB(255, 200, 200, 200),
),
],
),
builder: (context, child) => Positioned(
top: _planeTopPadding,
child: child,
),
);
}
修改 animated_dot.dart
增加动画扩展:
StatelessWidget
-> AnimatedWidget
animation
参数top
值为 animation.value
动画值_initDotsAnimation
和 _initDotsAnimationController
)AnimatedWidget
修改后代码:
import 'package:flutter/material.dart';
// 修改点 1: -> AnimatedWidget
class AnimatedDot extends AnimatedWidget {
final Color color;
// final double mTop;
static final double size = 24.0;
AnimatedDot({
Key key,
// 修改点 2
Animation animation,
@required this.color,
// this.mTop,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// 修改点 3
Animation animation = super.listenable;
return Positioned(
// 修改点 4
top: animation.value,
// ... 省略
);
}
}
点动画的关键在于其起始位置和结束位置
起始位置定位可视区之外,直接用 widget.height
结束位置需要根据 _minPlaneMarginTop
和 _planeSize
计算出线的初始位置,然后根据将来
卡片的高度来取舍间距(卡片的一半 80 * 0.8 * 0.5
, height = 80*0.8
)
得到最后结束位置的值:
double minMarginTop = _minPlanePaddingTop + _planeSize + 0.5 * height;
最终值:
double finalMarginTop = minMarginTop + i * height - 20.0;
_initDotAnimations() {
// 每个点的动画时长
final double slideDurationInterval = 0.4;
// 每个点的动画间隔
final double slideDelayInterval = 0.2;
final double height = 0.8 * 80;
// 起始位置
double startingMarginTop = widget.height;
double minMarginTop =
_minPlanePaddingTop + _planeSize + 0.5 * height;
for (int i = 0; i < _flightStops.length; i++) {
// 每个点开始动画的时间
final start = slideDelayInterval * i;
// 每个动画结束时间
final end = start + slideDurationInterval;
double finalMarginTop = minMarginTop + i * height - 20.0;
Animation animation = new Tween(
begin: startingMarginTop,
end: finalMarginTop
).animate(
new CurvedAnimation(
parent: _dotsAnimationController,
curve: new Interval(start, end, curve: Curves.easeOut),
),
);
_dotPositions.add(animation);
}
}
_initDotAnimationController() {
_dotsAnimationController = new AnimationController(
vsync: this,
duration: Duration(milliseconds: 500)
);
}
航班信息卡片,是和上节添加的节点是一致的,一个节点对应一个信息卡片,每个有自己的独立动画,出现在节点动画之后进行。
要实现卡片信息及其动画有以下几个要点:
FlightStopCard
)Stack
isLeft
来标识Row
和 Column
将卡片分布在水平适当位置,需要使用 Expanded
控件卡片内的元素使用 Stack
作为容器,目的是为了让每个信息都能按照规定的要求定位。
这里关键的地方在于上下左右的间距计算方式:
minTopMargin:8.0
minBottomMargin: 8.0
minHorizontalMargin:16.0
而代码中的计算方式是考虑了将来添加动画需要使用到(0.0 ~ 1.0
)动画状态值的情况(尚不完善,添加动画的时候再完善)。
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import './flight_stop.dart';
class FlightStopCard extends StatefulWidget {
final FlightStop flightStop;
// 线条左边还是右边
final bool isLeft;
static const double height = 80.0;
static const double width = 140.0;
const FlightStopCard({
Key key,
@required this.flightStop,
@required this.isLeft,
}) : super(key: key);
@override
FlightStopCardState createState() => FlightStopCardState();
}
class FlightStopCardState extends State
with TickerProviderStateMixin {
AnimationController _animationController;
@override
Widget build(BuildContext context) {
return Container(
height: FlightStopCard.height,
child: new Stack(
alignment: Alignment.centerLeft,
children: [
buildLine(),
buildCard(),
buildDurationText(),
buildAirportNamesText(),
buildDateText(),
buildPriceText(),
buildFromToTimeText(),
],
),
);
}
double get maxWidth {
RenderBox renderBox = context.findRenderObject();
BoxConstraints constraints = renderBox?.constraints;
double maxWidth = constraints?.maxWidth ?? 0.0;
return maxWidth;
}
Positioned buildDurationText() {
return Positioned(
top: getMarginTop(1.0),
right: getMarginRight(1.0),
child: Text(
widget.flightStop.duration,
style: new TextStyle(
fontSize: 10.0,
color: Colors.grey,
),
),
);
}
Positioned buildAirportNamesText() {
return Positioned(
top: getMarginTop(1.0),
left: getMarginLeft(1.0),
child: Text(
"${widget.flightStop.from} \u00B7 ${widget.flightStop.to}",
style: new TextStyle(
fontSize: 14.0,
color: Colors.grey,
),
),
);
}
Positioned buildDateText() {
return Positioned(
left: getMarginLeft(1.0),
child: Text(
"${widget.flightStop.date}",
style: new TextStyle(
fontSize: 14.0,
color: Colors.grey,
),
),
);
}
Positioned buildPriceText() {
return Positioned(
right: getMarginRight(1.0),
child: Text(
"${widget.flightStop.price}",
style: new TextStyle(
fontSize: 16.0,
),
),
);
}
Positioned buildFromToTimeText() {
return Positioned(
left: getMarginLeft(1.0),
bottom: getMarginBottom(1.0),
child: Text(
"${widget.flightStop.fromToTime}",
style: new TextStyle(
fontSize: 12.0,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
);
}
Widget buildLine() {
double maxLength = maxWidth - FlightStopCard.width;
return Align(
alignment: widget.isLeft ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
height: 2.0,
width: maxLength,
color: Color.fromARGB(255, 200, 200, 200),
),
);
}
Positioned buildCard() {
double minOuterMargin = 8.0;
// 卡片边缘的外边距 + 卡片宽
// TODO
double outerMargin =
minOuterMargin + maxWidth - FlightStopCard.width - 20.0; // + 120;// maxWidth;
return Positioned(
left: widget.isLeft ? null : outerMargin,
right: widget.isLeft ? outerMargin : null,
child: Container(
width: 140.0,
height: 80.0,
child: new Card(
color: Colors.blue.shade100,
),
),
);
}
double getMarginBottom(double animationValue) {
double minBottomMargin = 8.0;
double bottomMargin =
minBottomMargin + 0.0 * minBottomMargin;
return bottomMargin;
}
double getMarginTop(double animationValue) {
double minTopMargin = 8.0;
double topMargin = minTopMargin +
0.0 * FlightStopCard.height * 0.5;
return topMargin;
}
double getMarginLeft(double animationValue) {
return getMarginHorizontal(1.0, true);
}
double getMarginRight(double animationValue) {
return getMarginHorizontal(1.0, false);
}
double getMarginHorizontal(double animationValue, bool isTextLeft) {
if (isTextLeft == widget.isLeft) {
double minHorizontalMargin = 16.0;
double maxHorizontalMargin = maxWidth - minHorizontalMargin;
double horizontalMargin =
minHorizontalMargin + 0.0 * maxHorizontalMargin;
return horizontalMargin;
} else {
double maxHorizontalMargin = maxWidth - FlightStopCard.width;
double horizontalMargin = maxHorizontalMargin;
return horizontalMargin;
}
}
}
将创建好的 FlightStopCard
类添加到线条相应的位置上,需要修改:
_flightStops
为航班信息实际数据_stopKeys
为每个卡片增加一个 key
_buildStopCard
创建信息卡片class _PriceTabState extends State with TickerProviderStateMixin {
// ... 省略
// 修改点 1:int -> FlightStop 实际数据
// flight stop card
final List _flightStops = [
FlightStop("JFK", "ORY", "JUN 05", "6h 25m", "\$851", "9:26 am - 3:43 pm"),
FlightStop("MRG", "FTB", "JUN 20", "6h 25m", "\$532", "9:26 am - 3:43 pm"),
FlightStop("ERT", "TVS", "JUN 20", "6h 25m", "\$718", "9:26 am - 3:43 pm"),
FlightStop("KKR", "RTY", "JUN 20", "6h 25m", "\$663", "9:26 am - 3:43 pm"),
];
// 修改点 2:增加卡片 key
final List> _stopKeys = [];
// ... 省略
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
child: Stack(
// 将里面的控件都居中排布
alignment: Alignment.center,
children: [_buildPlane()]
// 修改点 3:添加到面板上
..addAll(_flightStops.map(_buildStopCard))
..addAll(_flightStops.map(_mapFlightStopToDot)),
),
);
}
@override
void initState() {
super.initState();
// ... 省略
// 修改点 4:初始化 _stopKeys
_flightStops.forEach((stop) =>
_stopKeys.add(new GlobalKey())
);
// 触发动画
_planeSizeAnimationController.forward();
}
// 修改点 5: 创建卡片
Widget _buildStopCard(FlightStop stop) {
int index = _flightStops.indexOf(stop);
double topMargin = _dotPositions[index].value -
0.5 * (FlightStopCard.height - AnimatedDot.size);
bool isLeft = index.isOdd;
return Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(top: topMargin),
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
isLeft ? Container() : Expanded(child: Container()),
Expanded(
child: FlightStopCard(
key: _stopKeys[index],
flightStop: stop,
isLeft: isLeft,
),
),
!isLeft ? Container() : Expanded(child: Container()),
],
),
),
);
}
// ... 省略
}
请注意 _buildStopCard
中 Row:children
在这里面前后都增加了这一句:
isLeft ? Container() : Expanded(child: Container())
这么做的目的是利用 Row
的特性从而是卡片只占据宽度的一半。
AnimatedBuilder
)给卡片增加动画:
left/right/top/bottom
) 因此需要给这些值增加动画状态依赖AnimatedBuilder
来监听动画的渲染过程和动画的状态值(如果需要一个动画 Widget
请使用 AnimatedWidget
)
class FlightStopCardState extends State
with TickerProviderStateMixin {
// 修改点 1:声明动画控制器和动画实例
AnimationController _animationController;
Animation _cardSizeAnimation;
Animation _durationPositionAnimation;
Animation _airportsPositionAnimation;
Animation _datePositionAnimation;
Animation _pricePositionAnimation;
Animation _fromToPositionAnimation;
Animation _lineAnimation;
@override
Widget build(BuildContext context) {
return Container(
height: FlightStopCard.height,
// 修改点 2:使用 AnimatedBuilder 监听动画
child: AnimatedBuilder(
// 该控件能监听动画并获取动画的状态值,然后交给 _animationController
animation: _animationController,
builder: (context, child) => new Stack(
alignment: Alignment.centerLeft,
children: [
buildLine(),
buildCard(),
buildDurationText(),
buildAirportNamesText(),
buildDateText(),
buildPriceText(),
buildFromToTimeText(),
],
),
),
);
}
// ... 省略
// 修改点 3:初始化动画
@override
void initState() {
super.initState();
_initAllAnimations();
}
// 修改点 4:释放动画
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
// 修改点 5:启动动画,外部调用在其他动画结束之后可调用它启动动画
void runAnimation() {
_animationController.forward();
}
// 修改点 6:初始化控制器和动画实例
void _initAllAnimations() {
_animationController = new AnimationController(
vsync: this,
duration: Duration(milliseconds: 500),
);
_cardSizeAnimation = new CurvedAnimation(
parent: _animationController,
curve: new Interval(0.0, 0.9, curve: new ElasticInOutCurve(0.8))
);
_durationPositionAnimation = new CurvedAnimation(
parent: _animationController,
curve: new Interval(0.05, 0.95, curve: new ElasticInOutCurve(0.95))
);
_airportsPositionAnimation = new CurvedAnimation(
parent: _animationController,
curve: new Interval(0.1, 1.0, curve: new ElasticInOutCurve(0.95))
);
_datePositionAnimation = new CurvedAnimation(
parent: _animationController,
curve: new Interval(0.1, 0.8, curve: new ElasticInOutCurve(0.95))
);
_pricePositionAnimation = new CurvedAnimation(
parent: _animationController,
curve: new Interval(0.0, 0.9, curve: new ElasticInOutCurve(0.95))
);
_fromToPositionAnimation = new CurvedAnimation(
parent: _animationController,
curve: new Interval(0.1, 0.95, curve: new ElasticInOutCurve(0.95))
);
_lineAnimation = new CurvedAnimation(
parent: _animationController,
curve: new Interval(0.0, 0.2, curve: Curves.linear)
);
}
Positioned buildDurationText() {
// 修改点 7:让控件的位置属性和动画状态发生关联,从而产生动画效果
// 后面的控件都一样需要这样修改,
double animationValue = _durationPositionAnimation.value;
return Positioned(
top: getMarginTop(animationValue),
right: getMarginRight(animationValue),
child: Text(
widget.flightStop.duration,
style: new TextStyle(
fontSize: 10.0 * animationValue,
color: Colors.grey,
),
),
);
}
Positioned buildAirportNamesText() {
double animationValue = _airportsPositionAnimation.value;
return Positioned(
top: getMarginTop(animationValue),
left: getMarginLeft(animationValue),
child: Text(
"${widget.flightStop.from} \u00B7 ${widget.flightStop.to}",
style: new TextStyle(
fontSize: 14.0 * animationValue,
color: Colors.grey,
),
),
);
}
Positioned buildDateText() {
double animationValue = _datePositionAnimation.value;
return Positioned(
left: getMarginLeft(animationValue),
child: Text(
"${widget.flightStop.date}",
style: new TextStyle(
fontSize: 14.0 * animationValue,
color: Colors.grey,
),
),
);
}
Positioned buildPriceText() {
double animationValue = _pricePositionAnimation.value;
return Positioned(
right: getMarginRight(animationValue),
child: Text(
"${widget.flightStop.price}",
style: new TextStyle(
fontSize: 16.0 * animationValue,
),
),
);
}
Positioned buildFromToTimeText() {
double animationValue = _fromToPositionAnimation.value;
return Positioned(
left: getMarginLeft(animationValue),
bottom: getMarginBottom(animationValue),
child: Text(
"${widget.flightStop.fromToTime}",
style: new TextStyle(
fontSize: 12.0 * animationValue,
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
);
}
Widget buildLine() {
double animationValue = _lineAnimation.value;
double maxLength = maxWidth - FlightStopCard.width;
return Align(
alignment: widget.isLeft ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
height: 2.0,
width: maxLength * animationValue,
color: Color.fromARGB(255, 200, 200, 200),
),
);
}
Positioned buildCard() {
double animationValue = _cardSizeAnimation.value;
double minOuterMargin = 8.0;
// 卡片边缘的外边距 + 卡片宽
// TODO
double outerMargin =
minOuterMargin + (1.0 - animationValue) * maxWidth; // + 120;// maxWidth;
return Positioned(
right: widget.isLeft ? null : outerMargin,
left: widget.isLeft ? outerMargin : null,
child: Transform.scale(
scale: animationValue,
child: Container(
width: 140.0,
height: 80.0,
child: new Card(
color: Colors.blue.shade100,
),
),
),
);
}
// 修改点 8:让动画状态驱动控件位置改变产生动画
double getMarginBottom(double animationValue) {
double minBottomMargin = 8.0;
double bottomMargin =
minBottomMargin + (1.0 - animationValue) * minBottomMargin;
return bottomMargin;
}
double getMarginTop(double animationValue) {
double minTopMargin = 8.0;
double topMargin = minTopMargin +
(1.0 - animationValue) * FlightStopCard.height * 0.5;
return topMargin;
}
double getMarginLeft(double animationValue) {
return getMarginHorizontal(animationValue, true);
}
double getMarginRight(double animationValue) {
return getMarginHorizontal(animationValue, false);
}
// 水平方向上的间距
double getMarginHorizontal(double animationValue, bool isTextLeft) {
if (isTextLeft == widget.isLeft) {
double minHorizontalMargin = 16.0;
double maxHorizontalMargin = maxWidth - minHorizontalMargin;
double horizontalMargin =
minHorizontalMargin + (1.0 - animationValue) * maxHorizontalMargin;
return horizontalMargin;
} else {
double maxHorizontalMargin = maxWidth - FlightStopCard.width;
double horizontalMargin = animationValue * maxHorizontalMargin;
return horizontalMargin;
}
}
}
price_tab.dart
)修改 price_tab.dart
:
增加 _animateFlightStopCards
通过之前设置的 _stopKeys
来获取每个卡片的状态,调取 runAnimation
启动
FlightStopCard
的动画:
Future _animateFlightStopCards() async {
return Future.forEach(_stopKeys, (GlobalKey stopKey) {
return new Future.delayed(Duration(milliseconds: 250), () {
// 通过 key 去获取状态启动动画
stopKey.currentState.runAnimation();
});
});
}
在点动画结束之后启动卡片动画:
_initDotAnimationController() {
_dotsAnimationController = new AnimationController(
vsync: this,
duration: Duration(milliseconds: 500)
)..addStatusListener((status) {
// 这里监听点动画结束,启动卡片动画
if (status == AnimationStatus.completed) {
_animateFlightStopCards();
}
});
}
说明:由于
Mac
的内存和硬盘不足,所以动画录制的时候有点卡
在 price_tab.dart
文件中增加:
_buildFab
创建确认按钮_initFabAnimationController
初始化确认按钮动画_animateFab
启动动画_buildFab
)Widget _buildFab() {
return Positioned(
bottom: 16.0,
child: ScaleTransition(
scale: _fabAnimation,
child: FloatingActionButton(
onPressed: () {},
child: Icon(Icons.check, size: 36.0),
),
),
);
}
void _initFabAnimationController() {
_fabAnimationController = new AnimationController(
vsync: this,
duration: Duration(milliseconds: 300)
);
_fabAnimation = new CurvedAnimation(
parent: _fabAnimationController,
curve: Curves.easeOut
);
}
initSate
, dispose
中分别调用
_initFabAnimationController()
和 _fabAnimationController.dispose();
进行初始化和释放。
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
child: Stack(
// 将里面的控件都居中排布
alignment: Alignment.center,
children: [_buildPlane()]
..addAll(_flightStops.map(_buildStopCard))
..addAll(_flightStops.map(_mapFlightStopToDot))
..add(_buildFab()),
),
);
}
addAll
添加集合, add
添加单个元素。
_initDotAnimationController() {
_dotsAnimationController = new AnimationController(
vsync: this,
duration: Duration(milliseconds: 500)
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_animateFlightStopCards().then((_) => _animateFab());
}
});
}
上面 _animateFlightStopCards
声明的时候是个 async
方法,返回的是一个 Promise
对象,
完成之后执行 (_) => _animateFab()
回调启动按钮动画。
flight_stop_card.dart
: 地址✈
price_tab.dart
: 地址✈️
️
这个页面是个独立的页面,在查询结果页面 price_tab.dart
通过点击确认按钮跳转而来的。
这个页面的数据来源于 price_tab.dart
页面查询到的结果数据。
FlightStopTicket
(flight_stop_ticket.dart)import 'package:flutter/material.dart';
class FlightStopTicket {
String from; // 出发点
String fromShort; // 出发地简称
String to; // 目的地
String toShort; // 目的地简称
String flightNumber; // 航班号
FlightStopTicket(
this.from,
this.fromShort,
this.to,
this.toShort,
this.flightNumber
);
}
TicketCard
(ticket_card.dart)import 'package:flutter/material.dart';
import './flight_stop_ticket.dart';
class TicketCard extends StatelessWidget {
final FlightStopTicket stop;
const TicketCard({Key key, this.stop}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
elevation: 2.0,
margin: const EdgeInsets.all(2.0),
child: _buildCardContent(),
);
}
// 生成票卡片上文字的样式
TextStyle _getTextStyle(double fontSize, FontWeight fontWeight) {
return new TextStyle(fontSize: fontSize, fontWeight: fontWeight) ;
}
// 生成左右两侧的文本控件
Widget _getTextWidget(EdgeInsetsGeometry padding, Text text, Text shortText, {
CrossAxisAlignment crossAxiAlignment = CrossAxisAlignment.start
}) {
return Expanded(
child: Padding(
padding: padding,
child: Column(
crossAxisAlignment: crossAxiAlignment,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: text,
),
shortText,
],
),
),
);
}
// 票信息页面容器
Container _buildCardContent() {
TextStyle airportNameStyle = _getTextStyle(16.0, FontWeight.w600);
TextStyle airportShortNameStyle = _getTextStyle(36.0, FontWeight.w200);
TextStyle flightNumberStyle = _getTextStyle(12.0, FontWeight.w500);
return Container(
height: 104.0,
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
_getTextWidget(
const EdgeInsets.only(left: 32.0, top: 16.0),
Text(stop.from, style: airportNameStyle),
Text(stop.fromShort, style: airportShortNameStyle)
),
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Icon(
Icons.airplanemode_active,
color: Colors.red,
),
),
Text(stop.flightNumber, style: flightNumberStyle),
],
),
_getTextWidget(
const EdgeInsets.only(left: 40.0, top: 16.0),
Text(stop.to, style: airportNameStyle),
Text(stop.toShort, style: airportShortNameStyle)
),
],
),
);
}
}
TicketsPage
(tickets_page.dart)import 'package:flutter/material.dart';
import './flight_stop_ticket.dart';
import './ticket_card.dart';
import '../air_asia_bar.dart';
class TicketsPage extends StatefulWidget {
@override
_TicketsPageState createState() => _TicketsPageState();
}
class _TicketsPageState extends State
with TickerProviderStateMixin {
List stops = [
new FlightStopTicket("Sahara", "SHE", "Macao", "MAC", "SE2341"),
new FlightStopTicket("Macao", "MAC", "Cape Verde", "CAP", "KU2342"),
new FlightStopTicket("Cape Verde", "CAP", "Ireland", "IRE", "KR3452"),
new FlightStopTicket("Ireland", "IRE", "Sahara", "SHE", "MR4321"),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: new Stack(
children: [
AirAsiaBar(height: 180.0),
Positioned.fill(
top: MediaQuery.of(context).padding.top + 64.0,
child: SingleChildScrollView(
child: new Column(
children: _buildTicket().toList(),
),
),
),
],
),
floatingActionButton: _buildFab(),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
);
}
Iterable _buildTicket() {
return stops.map((stop) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: TicketCard(stop: stop),
);
});
}
_buildFab() {
return FloatingActionButton(
onPressed: () => Navigator.of(context).pop(),
child: new Icon(Icons.fingerprint),
);
}
}
FadeRoute
(fade_route.dart)import 'package:flutter/material.dart';
class FadeRoute extends MaterialPageRoute {
FadeRoute({
WidgetBuilder builder,
RouteSettings settings
}) : super(builder: builder, settings: settings);
@override
Duration get transitionDuration => const Duration(milliseconds: 100);
@override
Widget buildTransitions(
BuildContext context,
Animation animation,
Animation secondaryAnimation,
Widget child
) {
if (settings.isInitialRoute) return child;
return new FadeTransition(opacity: animation, child: child);
}
}
PriceTab
(price_tab.drt)Widget _buildFab() {
return Positioned(
bottom: 16.0,
child: ScaleTransition(
scale: _fabAnimation,
child: FloatingActionButton(
onPressed: () => Navigator.of(context).push(
FadeRoute(builder: (context) => TicketsPage())
),
child: Icon(Icons.check, size: 36.0),
),
),
);
}
给 check
按钮增加点击触发路由 FadeRoute
跳转
TicketsPage
(tickets_page.dart)_buildFab() {
return FloatingActionButton(
onPressed: () => Navigator.of(context).pop(),
child: new Icon(Icons.fingerprint),
);
}
点击触发 Navigator.of(context).pop()
拿到当前页面的路由执行 pop()
相当于返回上一级页面。
TicketClipper
(ticket_card.dart)class TicketClipper extends CustomClipper {
final double radius;
TicketClipper(this.radius);
@override
Path getClip(Size size) {
var path = new Path();
path.lineTo(0.0, size.height);
path.lineTo(size.width, size.height);
path.lineTo(size.width, 0.0);
path.addOval(
Rect.fromCircle(center: Offset(0.0, size.height / 2), radius: radius)
);
path.addOval(
Rect.fromCircle(center: Offset(size.width, size.height / 2), radius: radius)
);
return path;
}
@override
bool shouldReclip(CustomClipper oldClipper) => true;
}
修改 TicketCard
的 build
:
@override
Widget build(BuildContext context) {
return ClipPath(
clipper: TicketClipper(10.0),
child: Material(
elevation: 4.0,
shadowColor: Color(0x30E5E5E5),
color: Colors.transparent,
child: ClipPath(
clipper: TicketClipper(12.0),
child: Card(
elevation: 0.0,
margin: const EdgeInsets.all(2.0),
child: _buildCardContent(),
),
),
),
);
}
上面有两个 TicketClipper
第一个半径 10.0
第二个半径 12.0
实际上是两个重叠的裁剪半圆,
可以给人一种是两张票叠在一起的感觉。
TicketCard
(ticket_card.dart)老套路,使用 AnimatedBuilder
监听动画渲染
initCardAnimations
void initCardAnimations() {
_cardEntranceAnimationController = new AnimationController(
vsync: this,
duration: Duration(milliseconds: 1100),
);
_ticketAnimations = stops.map((stop) {
int index = stops.indexOf(stop);
double start = index * 0.1;
double duration = 0.6;
double end = duration + start;
return new Tween(
begin: 800.0,
end: 0.0
).animate(
new CurvedAnimation(
parent: _cardEntranceAnimationController,
curve: new Interval(start, end, curve: Curves.decelerate)
)
);
}).toList();
_fabAnimation = new CurvedAnimation(
parent: _cardEntranceAnimationController,
curve: Interval(0.7, 1.0, curve: Curves.decelerate)
);
}
没什么特殊的地方,初始化 _cardEntranceAnimationController
控制器和 _ticketAnimations
动画实例列表,
最后别忘记了 toList()
转成列表。
然后是回退按钮的动画 _fabAnimation
。
_buildTicket
Iterable _buildTicket() {
return stops.map((stop) {
int index = stops.indexOf(stop);
return AnimatedBuilder(
animation: _cardEntranceAnimationController,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: TicketCard(stop: stop),
),
builder: (context, child) => new Transform.translate(
offset: Offset(0.0, _ticketAnimations[index].value),
child: child,
),
);
});
}
没啥好讲的,老规矩,使用 Animatedbuilder
监听动画渲染,绑定 _cardEntranceAnimationController
把
动画状态值交给控制器。
最后将需要动画的控件作为 child
传递给 AnimatedBuilder
的 builder
渲染到视图中。
HttpClient
)之前都是使用固定的数据,这一节将讲述怎么在 Flutter
中使用 HttpClient
来获取服务器端数据
然后渲染 UI
。
这里将涉及以下几个步骤:
创建 services
目录,存放服务端数据请求和基本处理的代码
创建 services/fetch_apis.dart
用来发送请求和接受数据
fetchTicket
从服务器端请求票务信息
_buildFutureTicket
使用 FutureBuilder
来创建和渲染异步 UI
。
FutureBuilder
渲染时机在于数据的完成阶段。
该文件中涉及几个知识点:
HttpClient
客户端请求类Future
异步数据对象async...await
json
类import 'dart:io';
import 'dart:convert';
import 'package:test_app/flight2/ticket_page/flight_stop_ticket.dart';
HttpClient hc = new HttpClient();
Future _get(String path) async {
var resBody;
String url = "https://www.gcl666.com/api/flutter/${path}";
var request = await hc.getUrl(Uri.parse(url));
var response = await request.close();
if (response.statusCode == 200) {
resBody = await response.transform(utf8.decoder).join();
resBody = await json.decode(resBody);
} else {
print("error");
}
return resBody;
}
Future> fetchTicket() async {
try {
var response = await _get('/flight');
List result = response['data'].toList();
List tickets = [];
for (int i = 0; i < result.length; i++) {
var item = result[i];
tickets.add(new FlightStopTicket(
item["from"],
item["fromShort"],
item["to"],
item["toShort"],
item["flightNumber"],
));
}
return tickets;
} catch (e) {
print(e);
return [];
}
}
FutureBuilder
接受异步数据FutureBuilder _buildFutureTicket() {
return FutureBuilder>(
future: _post,
builder: (context, snapshot) {
if (snapshot.hasData) {
stops = snapshot.data;
_initAnimation();
return new Column(
children: _buildTicket().toList(),
);
} else if (snapshot.hasError) {
return Text("${snapshot.error}");
}
return CircularProgressIndicator();
},
);
}
用 FutureBuilder
替换将要渲染的 UI
:
@override
Widget build(BuildContext context) {
return Scaffold(
body: new Stack(
children: [
AirAsiaBar(height: 180.0),
Positioned.fill(
top: MediaQuery.of(context).padding.top + 64.0,
child: SingleChildScrollView(
// 创建 FutureBuilder
child: _buildFutureTicket(),
),
),
],
),
floatingActionButton: _buildFab(),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
);
}
initState
中发起请求@override
void initState() {
super.initState();
_post = fetchTicket();
}
可能是网络比较差,请求的时间有点久。
此处包含了所有该文及该应用所使用到的相关 Widget
及其说明和链接。
组件名(Widget Name) | 描述(Description) | 链接(Link) |
---|---|---|
Scaffold |
应用级别的组件脚手架,包含了应用的初始结构 | Scaffold Class |
Center |
居中用的组件 | Center Class |
Text |
文本组件 | Text Class |
Stack |
层叠容器控件 | Stack Class |
InkWell |
一不可见长方型区,相当一占位用 | InkWell Class |
SingleChildScrollView |
可滚动的视图控件 | Singlechildscrollview |
名称(Name) | 链接一(Link 1) | 链接二(Link 2) |
---|---|---|
lib:material |
blog.gcl666.com | 官方文档(official) |
widget:Row |
blog.gcl666.com | 官方文档(official) |