原文作者及地址 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 {
@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
来关闭。
导航条和按钮区(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);
@override
Widget 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 {
@override
Widget 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);
@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: <Widget>[
// 导航条
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: <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> {
@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: <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 实际高度决定的 Widget
child: 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> {
@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: <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
中 - 使用