Flutter应用之《航班查询 Flight Search》
原文作者及地址 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
内存和不配置不足电脑本身就比较卡顿引起的。
设计分解
该引用所包含的功能分解:
- 顶部应用条和顶部按钮(AppBar & Top Buttons)
- 航班查询信息输入框(Initial inputs)
- 飞机图标大小变化和飞行动画(Airplane resize and travel)
- 点飞行动画(Dots travel)
- 航班航次卡视图(Flight stop card view)
- 航班航次卡动画(Flight stop card animation)
- 航班航次票务信息(Flight ticket view)
- 航班航次票务信息动画(Flight ticket animations)
应用入口(main)
作为起点,需要创建个最基本的 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 {@overrideWidget 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 {@overrideWidget build(BuildContext context) {// 应用脚手架,决定了应用的主要架构return Scaffold(// 位置居中 Widgtbody: Center(// 文本 Widgetchild: Text("Let's get started!"),),);}
}
so…
我这里使用的是 andriod
模拟器,至于怎么创建一个 flutter
项目和启动模拟器,详情 ✈
关闭右上角的 DEBUG
标记,可以通过配置 Materialapp
的 debugShowCheckedModeBanner 属性为 false
来关闭。
导航条和按钮区(AppBar and buttons)
根据设计图和最终效果,导航条为红色部分,且上面有三个按钮分别是:
ONE WAY
单程
ROUND
往返
MULTICITY
多个城市
下面来实现这两个部分的内容
导航条(AppBar)
应用的导航条本身层级应该在最底层,按钮以及后面其他的
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);@overrideWidget build(BuildContext context) {// 将导航条上所有控件放在 Stack 上,让他们有一定的堆叠关系return Stack(// stack 是个多子节点的控件children: <Widget>[// 控件容器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 {@overrideWidget build(BuildContext context) {return Scaffold(body: Stack(children: <Widget>[// 导航条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);@overrideWidget 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 {@overrideWidget build(BuildContext context) {return Scaffold(body: Stack(children: <Widget>[// 导航条AirAsiaBar(height: 210.0),Positioned.fill(child: Padding(// 查询上下文的 padding toppadding: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 40.0),child: new Column(children: <Widget>[_buildButtonRow(),Container(), // TODO: 卡片位置],),),),],),);}// 创建一个包含按钮的行空间(Row)Widget _buildButtonRow() {return Padding(padding: const EdgeInsets.all(8.0),// 行内的控件会在水平位置并排排列child: Row(children: <Widget>[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
类似只不过是在垂直方向上的排列。
效果图:
交通工具选项卡(Flight, Train, Bus)
查询系统包含三种类型交通工具,查询就需要输入一些航班或车次的相关信息,这里需要一些输入框来接受用户的输入。
卡片容器(Card
)
为了放置这些用户输入信息,我们需要到一个 Card
控件,用来放置查询输入的控件。
内容卡片控件: ContentCard
import 'package:flutter/material.dart';
//import './multicity_input.dart';// 这里涉及到 有状态控件的创建
// 有状态的控件: 在整个应用使用过程中,会与用户发送交互的控件,比如用户输入class ContentCard extends StatefulWidget {@override_ContentCardState createState() => _ContentCardState();
}class _ContentCardState extends State<ContentCard> {@overrideWidget 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: <Widget>[// 选项卡_buildTabBar(),// 选项卡内容_buildContentContainer(viewportConstraints),],);},),),);}// 创建选项卡Widget _buildTabBar({bool showFirstOption}) {return Stack(children: <Widget>[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: <Widget>[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 实际高度决定的 Widgetchild: new IntrinsicHeight(child: _buildMulticityTab(),),),),);}// 多城市选项内容容器,包含多个 input 控件Widget _buildMulticityTab() {return Column(children: <Widget>[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<ContentCard> {@overrideWidget 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: <Widget>[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中效果)
Flight 航班
航班查询需要以下用户信息:
- From 出发点
- To 目的地(可多个目的地)
- Passengers 乘客
- Departure 出发日期
- Arrival 到达日期
以上是我们查询所需要的待用户输入的信息,我们准备使用 Form:Input
去实现它。
这也是实现该模块的一个难点,需要记住的是无论任何时候你要使用 TextFields
话都最好使用一个 scrollable views
去将它们包裹起来(比如: CustomScrollView
或 ListView
),从而不至于在键盘弹出来的时候导致 Inputs
的布局
混乱。
在这个应用中我们需要用到一个图标(FloatingActionButton
)来做引导用户操作,并将它放到 ScrollView
的底部,从而
让它随着用户的操作而做相应的滚动,而不是一直固定在底部。
为了实现这一点,我们需要使用的以下控件组合:
LayoutBuilder
去访问 BoxConstraints
SinglechildScrollview
和 Constrainedbox
去获取 ScrollView
的最大高度值。
Intrinsicheight
一个不限定高度的控件去让我们的视图尽可能的有足够的空间。
Form:Inputs 实现
import 'package:flutter/material.dart';class MulticityInput extends StatelessWidget {@overrideWidget build(BuildContext context) {return Form(child: Padding(padding: const EdgeInsets.all(16.0),child: Column(children: <Widget>[// 比如这里传入的第三个参数其实是一个可选的非命名参数// 如果是命名参数就需要这样: color: Colors.red_buildTextField(Icons.flight_takeoff, "From"),_buildTextField(Icons.flight_land, "To"),Row(children: <Widget>[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: <Widget>[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
语言本身的
类方法特性 ✈。
效果图:
TODO Train 火车
TODO Bus 汽车
飞机动画(resize and travel)
卡片和用户信息输入已经有了,现在我们需要这么一个场景,点击下面的 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<PriceTab> {@overrideWidget build(BuildContext context) {return Container(width: double.infinity,child: Stack(// 将里面的控件都居中排布alignment: Alignment.center,children: <Widget>[// TODO 增加飞机图标],),);}
}
将信息面板添加到 content_card.dart
中,修改 _buildContentContainer
中的 new Intrinsicheight()
里面的 child
属性,增加判断,根据 showInput
的值。
class _ContentCardState extends State<ContentCard> {// 修改点 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 实际高度决定的 Widgetchild: new IntrinsicHeight(// 修改点 2:增加判断,点击触发状态值改变,触发UI更新child: showInput? _buildMulticityTab(): PriceTab(height: viewportConstraints.maxHeight - 48.0,),),),),);}// 多城市选项内容容器,包含多个 input 控件Widget _buildMulticityTab() {return Column(children: <Widget>[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),),),],);}
}
效果图:
飞机图标(Plane Icon)
创建飞机图标到 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<PriceTab> {// 修改点 1: 增加飞机底部和顶部间距属性,及飞机大小属性// 飞机图标距离底部间隔final double _initialPlanePaddingBottom = 16.0;// 飞机顶部间隔 = 当前 widget 高度 - 飞机底部间距 - 飞机大小double get _planeTopPadding =>widget.height - _initialPlanePaddingBottom - _planeSize;// 飞机大小double get _planeSize => 60.0;@overrideWidget build(BuildContext context) {return Container(width: double.infinity,child: Stack(// 将里面的控件都居中排布alignment: Alignment.center,children: <Widget>[// 修改点 2:创建飞机图标_buildPlane()],),);}// 修改点 3:飞机图标的控件结构Widget _buildPlane() {return Positioned(top: _planeTopPadding,child: Column(children: <Widget>[_buildPlaneIcon(),],),);}// 修改点 4:创建飞机图标Widget _buildPlaneIcon() {return Icon(Icons.airplanemode_active,color: Colors.red,size: _planeSize,);}
}
完了之后在模拟器按下 shift+r
重启应用,效果图:
大小动画(Resize Animation)
切换完成,飞机添加完成,现在来给飞机添加 resize
动画,这里将需要用到几个知识点:
with
混合器TickerProviderStateMixin
提供计时器功能,因为每个动画都必须有个TickerProvider
。动画控制器
AnimationController
用来控制动画动画类
Animation
包含了动画状态信息Tween
线性篡改值的一个动画类// 根据计时器在 36.0 - 60.0 的范围之间线性改变其值 Tween<double>(// 动画起始和初始值begin: 60.0,end: 36.0, )
创建动画的步骤:
创建动画 AnimatedPlaneIcon
类
创建 animated_plane_icon.dart
包含生成飞机图标的类。
让需要动画的控件成为一个动画控件。
import 'package:flutter/material.dart';// 飞机动画 Iconclass AnimatedPlaneIcon extends AnimatedWidget {AnimatedPlaneIcon({Key key, Animation<double> animation}): super(key: key, listenable: animation);@overrideWidget build(BuildContext context) {// 这里的 listenable 来自 上面的构造函数中调用 super 设置的 animationAnimation<double> 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<PriceTab> with TickerProviderStateMixin {// 修改点 1:增加动画和动画控制器声明// 动画控制器和动画状态AnimationController _planeSizeAnimationController;Animation _planeSizeAnimation;// ... 省略// 修改点 2:飞机的大小设置成动画的状态值// 飞机大小,有动画之后,实际大小为动画当前 Tick 的实时值// Animation 里面保存了动画相关的状态值double get _planeSize => _planeSizeAnimation.value;// ... 省略// 修改点 3:重写 iniState 调用动画初始化并触发动画(其他地方也可以触发)@overridevoid initState() {super.initState();// 控件状态初始化,动画在这里执行初始化_initSizeAnimations();// 触发动画_planeSizeAnimationController.forward();}// 修改点 4:释放动画资源,不用了就得释放@overridevoid dispose() {// 直接调用动画控制器的释放方法_planeSizeAnimationController.dispose();// 任何动画在不使用了就得释放掉super.dispose();}Widget _buildPlane() {return Positioned(top: _planeTopPadding,child: Column(children: <Widget>[// 修改点 5:构造带动画的飞机图标// 用动画 Icon 代替静态的AnimatedPlaneIcon(animation: _planeSizeAnimation),],),);}// 修改点 6:初始化动画方法// 初始化动画_initSizeAnimations() {// 控制器初始化_planeSizeAnimationController = AnimationController(duration: const Duration(microseconds: 340),// TickerProvider PriceTabstate 自身vsync: this,);// 动画状态初始化_planeSizeAnimation = Tween<double>(// 动画起始和初始值begin: 60.0,end: 36.0,).animate(CurvedAnimation(parent: _planeSizeAnimationController, curve: Curves.easeOut));}
}
效果图
price_tab.dart 完整代码✈
飞行动画(Travel Animation)
添加动画的步骤和 resize
动画一样,这里就不赘述了,直接上代码:
import 'package:flutter/material.dart';
import './animated_plane_icon.dart';class _PriceTabState extends State<PriceTab> 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;@overrideWidget build(BuildContext context) {return Container(width: double.infinity,child: Stack(// 将里面的控件都居中排布alignment: Alignment.center,children: <Widget>[_buildPlane()],),);}@overridevoid initState() {super.initState();// 控件状态初始化,动画在这里执行初始化_initPlaneSizeAnimations();// 修改点 4: 初始化_initPlaneTravelAnimations();// 触发动画_planeSizeAnimationController.forward();}@overridevoid dispose() {// 直接调用动画控制器的释放方法_planeSizeAnimationController.dispose();// 修改点 5:释放_planeTravelController.dispose();// 任何动画在不使用了就得释放掉super.dispose();}// 返回带动画的空间Widget _buildPlane() {// 修改点 6:飞机飞行动画重点,AnimatedBuilderreturn AnimatedBuilder(animation: _planeTravelAnimation,child: Column(children: <Widget>[// 用动画 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<double>(// 动画起始和初始值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(),);}});// ... 省略
}
AnimationBuilder 动画控件
在 _planeSizeAnimation
动画中,我们是根据 Tween()
中声明的 60.0 ~ 36.0
区间的动画值变化触发
_planeSize
值发生变化从而触发动画状态改变。
在这里是根据 CurvedAnimation
这个动画的 value
属性值的变化(0.0 ~ 0.1
) 触发
double get _planeTopPadding =>
_minPlanePaddingTop +
(1 - _planeTravelAnimation.value) * _maxPlaneTopPadding;
_planeTopPadding
值的更新,来触发动画。
price_tab.dart 完整代码✈
效果图
节点及其动画
为了放置节点,我们需要知道它们应该在的具体位置,先假设有 4 个节点卡片,每个的高度为 80.0
, 考虑到
节点卡片可能会重叠一点,我们将设置它们的距离为 0.8 * 80.0
。
为了方便创建节点卡片,需要创建一个节点类(与飞机相连在一起的控件都需要动画): AnimatedDot
动画点源文件 animated_dot.dart :
import 'package:flutter/material.dart';class AnimatedDot extends AnimatedWidget {final Color color;static final double size = 24.0;AnimatedDot({Key key,Animation<double> animation,@required this.color,}) : super(key: key, listenable: animation)@overrideWidget build(BuildContext context) {Animation<double> 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<int> _flightStops = [ 1, 2, 3, 4 ];
然后通过 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: <Widget>[// 用动画 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,// 修改点 2Animation<double> animation,@required this.color,// this.mTop,}) : super(key: key);@overrideWidget build(BuildContext context) {// 修改点 3Animation<double> animation = super.listenable;return Positioned(// 修改点 4top: 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<double> 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);@overrideFlightStopCardState createState() => FlightStopCardState();
}class FlightStopCardState extends State<FlightStopCard>with TickerProviderStateMixin {AnimationController _animationController;@overrideWidget build(BuildContext context) {return Container(height: FlightStopCard.height,child: new Stack(alignment: Alignment.centerLeft,children: <Widget>[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;// 卡片边缘的外边距 + 卡片宽// TODOdouble 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<PriceTab> with TickerProviderStateMixin {// ... 省略// 修改点 1:int -> FlightStop 实际数据// flight stop cardfinal List<FlightStop> _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:增加卡片 keyfinal List<GlobalKey<FlightStopCardState>> _stopKeys = [];// ... 省略@overrideWidget build(BuildContext context) {return Container(width: double.infinity,child: Stack(// 将里面的控件都居中排布alignment: Alignment.center,children: <Widget>[_buildPlane()]// 修改点 3:添加到面板上..addAll(_flightStops.map(_buildStopCard))..addAll(_flightStops.map(_mapFlightStopToDot)),),);}@overridevoid initState() {super.initState();// ... 省略// 修改点 4:初始化 _stopKeys_flightStops.forEach((stop) =>_stopKeys.add(new GlobalKey<FlightStopCardState>()));// 触发动画_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: <Widget>[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<FlightStopCard>with TickerProviderStateMixin {// 修改点 1:声明动画控制器和动画实例AnimationController _animationController;Animation<double> _cardSizeAnimation;Animation<double> _durationPositionAnimation;Animation<double> _airportsPositionAnimation;Animation<double> _datePositionAnimation;Animation<double> _pricePositionAnimation;Animation<double> _fromToPositionAnimation;Animation<double> _lineAnimation;@overrideWidget build(BuildContext context) {return Container(height: FlightStopCard.height,// 修改点 2:使用 AnimatedBuilder 监听动画child: AnimatedBuilder(// 该控件能监听动画并获取动画的状态值,然后交给 _animationControlleranimation: _animationController,builder: (context, child) => new Stack(alignment: Alignment.centerLeft,children: <Widget>[buildLine(),buildCard(),buildDurationText(),buildAirportNamesText(),buildDateText(),buildPriceText(),buildFromToTimeText(),],),),);}// ... 省略// 修改点 3:初始化动画@overridevoid initState() {super.initState();_initAllAnimations();}// 修改点 4:释放动画@overridevoid 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;// 卡片边缘的外边距 + 卡片宽// TODOdouble 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<FlightStopCardState> 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: <Widget>[_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);@overrideWidget 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: <Widget>[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: <Widget>[_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: <Widget>[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<TicketsPage>with TickerProviderStateMixin {List<FlightStopTicket> 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"),];@overrideWidget build(BuildContext context) {return Scaffold(body: new Stack(children: <Widget>[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<Widget> _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<T> extends MaterialPageRoute<T> {FadeRoute({WidgetBuilder builder,RouteSettings settings}) : super(builder: builder, settings: settings);@overrideDuration get transitionDuration => const Duration(milliseconds: 100);@overrideWidget buildTransitions(BuildContext context,Animation<double> animation,Animation<double> 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<Path> {final double radius;TicketClipper(this.radius);@overridePath 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;}@overridebool shouldReclip(CustomClipper<Path> 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<double>(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<Widget> _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
渲染到视图中。
出场动画后效果图
AJAX 实时数据(HttpClient
)
之前都是使用固定的数据,这一节将讲述怎么在 Flutter
中使用 HttpClient
来获取服务器端数据
然后渲染 UI
。
这里将涉及以下几个步骤:
创建
services
目录,存放服务端数据请求和基本处理的代码创建
services/fetch_apis.dart
用来发送请求和接受数据fetchTicket
从服务器端请求票务信息_buildFutureTicket
使用FutureBuilder
来创建和渲染异步UI
。FutureBuilder
渲染时机在于数据的完成阶段。
数据服务 fetch_apis.dart
该文件中涉及几个知识点:
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<List<FlightStopTicket>> fetchTicket() async {try {var response = await _get('/flight');List result = response['data'].toList();List<FlightStopTicket> 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<List<FlightStopTicket>>(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: <Widget>[AirAsiaBar(height: 180.0),Positioned.fill(top: MediaQuery.of(context).padding.top + 64.0,child: SingleChildScrollView(// 创建 FutureBuilderchild: _buildFutureTicket(),),),],),floatingActionButton: _buildFab(),floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,);
}
initState
中发起请求
@override
void initState() {super.initState();_post = fetchTicket();
}
效果图
可能是网络比较差,请求的时间有点久。
Widget 列表
此处包含了所有该文及该应用所使用到的相关 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) |
Flutter应用之《航班查询 Flight Search》相关推荐
- java 航班_Java实现简单航班查询系统-Go语言中文社区
#java实现简单航班管理系统 题目要求:声明一个Flight(航班)类,该类包含private域航班号(如:CA1430),起飞时间(如:10:15AM),到达时间(如:2:30PM).为该类声明合 ...
- Java实现简单航班查询系统
#java实现简单航班管理系统 题目要求:声明一个Flight(航班)类,该类包含private域航班号(如:CA1430),起飞时间(如:10:15AM),到达时间(如:2:30PM).为该类声明合 ...
- 【数据结构】小项目:航班查询系统
项目要求 1.已经给出链表定义(本系统用双链表实现更为方便,但是由于要求用单链表,所以按照规定做事) 2.信息录入(当然是添加航班与取消航班了) 3.按照起飞时间先后顺序排列(可以在插入时即顺序插入, ...
- Flutter实战5 -- 天气查询APP重构之状态管理(ScopedModel)
0x00 前言 前面四篇文章: Flutter实战1 --- 写一个天气查询的APP Flutter实战2 --- 写一个天气查询的APP FFlutter实战3 --- PC上运行Flutter A ...
- 学历查询,邮件查询,身份证查询,聊天室查询,日期时间查询,列车飞机航班查询,等各种各类的查询网
EMS全球快递邮件号查询 http://www.ems.com.cn/ems/index.jsp 中国高等教育学历网上证书查询 http://www.chsi.com.cn/xlcx/ 中国黄历查询, ...
- ## 大一java课程设计_航班查询系统(我是小白)
大一java课程设计_航班查询系统(我是小白) 备注:第一个java程序有借鉴别人的成分,因为忘了在哪个大佬上面借鉴的,所以在此备注,如有侵权,请联系删除,(仅用于学习使用,并未想盈利) 框体介绍 一 ...
- 航班动态查询接口 支持最新航班查询
在即将到来的双十二,在聚合数据平台上的航班动态查询接口将参与"暖冬不如低价活动",航班动态查询接口支持最新航班查询,包括始发.重点.航班号.航空公司.日期.准确率.始发天气.终点天 ...
- 春秋航空航班查询API
春秋航空航班查询API 1) 请求地址 https://flights.ch.com/Flights/SearchByTime 2) 调用方式:HTTP post 3) 接口描述: 接口描述详情 4) ...
- Myflight航班查询系统
1.导入命名空间,并且定义连接对象和命令对象 using System.Data.SqlClient; //定义连接对象 SqlConnection conn = null; //定义命令对象 Sql ...
最新文章
- 70页论文,图灵奖得主Yoshua Bengio一作:「生成流网络」拓展深度学习领域
- 《迷人的8051单片机》----3.4 程序
- 全局性业务架构建模工作步骤
- beforeRouteEnter,beforeRouteLeave函数
- 信息学奥赛C++语言: 单词的长度
- LSTM TF核心实现代码笔记
- sqlite 数据量_Sqlite数据库从入门到放弃
- 2018年内蒙古孕产妇、婴儿死亡率实现双下降
- Important Tips
- 最简单易懂的C语言代码实现最小二乘法线性拟合直线
- 用冰封服务器安装系统,如何使用冰封一键在线重装系统
- linux文件实时同步(rsync+inotiy)
- 银河麒麟V10(Kylin Linux V10)安装 Kibana-7.15.2
- fiddler电脑抓包和HttpCanary(小黄鸟)手机抓包教程
- 微星B450mMortar迫击炮+AMD速龙3000GE安装Windows7 SP1并使用UEFI+Nvme启动
- java cookie设置注意事项
- 中科大计算机考研录取分数线_计算机专业学校考研难度排行榜 计算机考研难度排名...
- 并发编程之LockSupport的 park 方法及线程中断响应
- [手机分享]黑莓手机7系列分享之——7100T
- 华为AC+AP综合实验