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 内存和不配置不足电脑本身就比较卡顿引起的。

设计分解

该引用所包含的功能分解:

  1. 顶部应用条和顶部按钮(AppBar & Top Buttons)
  2. 航班查询信息输入框(Initial inputs)
  3. 飞机图标大小变化和飞行动画(Airplane resize and travel)
  4. 点飞行动画(Dots travel)
  5. 航班航次卡视图(Flight stop card view)
  6. 航班航次卡动画(Flight stop card animation)
  7. 航班航次票务信息(Flight ticket view)
  8. 航班航次票务信息动画(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: [
        // 控件容器
        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
            ),
          ),
        ),
      ],
    );
  }
}

如上代码,我们创建了一个简单的包含一个 ContainerStack 控件,然后增加了一个透明的 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 类似只不过是在垂直方向上的排列。

效果图:

交通工具选项卡(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 {
  @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

实现卡片控件,里面包含两部分: TabsContent

// ... 省略

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 属性特性):

  1. TabBarContainer 放置在一个 Stack
  2. 使用 PositionedContainer 定位住
  3. 设置 Positionedtop:null 让其在垂直方向排列(Stack-Positioned-top:null在Stack中效果)

Flight 航班

航班查询需要以下用户信息:

  1. From 出发点
  2. To 目的地(可多个目的地)
  3. Passengers 乘客
  4. Departure 出发日期
  5. Arrival 到达日期

以上是我们查询所需要的待用户输入的信息,我们准备使用 Form:Input 去实现它。

这也是实现该模块的一个难点,需要记住的是无论任何时候你要使用 TextFields 话都最好使用一个 scrollable views
去将它们包裹起来(比如: CustomScrollViewListView),从而不至于在键盘弹出来的时候导致 Inputs 的布局
混乱。

在这个应用中我们需要用到一个图标(FloatingActionButton)来做引导用户操作,并将它放到 ScrollView 的底部,从而
让它随着用户的操作而做相应的滚动,而不是一直固定在底部。

为了实现这一点,我们需要使用的以下控件组合:

LayoutBuilder 去访问 BoxConstraints

SinglechildScrollviewConstrainedbox 去获取 ScrollView 的最大高度值。

Intrinsicheight 一个不限定高度的控件去让我们的视图尽可能的有足够的空间。

Form:Inputs 实现

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 语言本身的

类方法特性 ✈。

效果图:

TODO Train 火车

TODO Bus 汽车

飞机动画(resize and travel)

卡片和用户信息输入已经有了,现在我们需要这么一个场景,点击下面的 floating action button 需要

切换卡片内容区,其上面有一个图标为一个飞机图标,并且给它添加一个大小变化以及从下往上飞行的动画。

即这里涉及到三个功能部分:

  1. 点击 floating action button 图标切换场景

    我们将该场景的控件命名为: PriceTab 因为这上面将会包含班次,时间及其价格等信息。

  2. 场景上有一个飞机图标

  3. 飞机图标大小变化动画

  4. 飞机图标飞行动画

这一切都在当前的卡片容器中完成,即 TabBar 的内容不需要发生变化。

航班信息面板(PriceTab)

PriceTab 包含的内容:

  1. Container 作为容器
  2. Stack 让该面板里的内容居中布局
  3. 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),
          ),
        ),
      ],
    );
  }
}

效果图:

飞机图标(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 {

  // 修改点 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 Animation)

切换完成,飞机添加完成,现在来给飞机添加 resize 动画,这里将需要用到几个知识点:

  1. with 混合器 TickerProviderStateMixin
    提供计时器功能,因为每个动画都必须有个 TickerProvider

  2. 动画控制器 AnimationController 用来控制动画

  3. 动画类 Animation 包含了动画状态信息

  4. 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,
    );
  }
}

要点:

  1. 继承 AnimatedWidget
  2. 声明构造函数,并且调用 super()animation 动画传递给 super 对象
  3. build() 里面拿到传递进来的 animation
  4. 根据 animation 拿到动画的状态值给 Iconsize 属性

最终 Iconsize 会随着计时器发生改变从而触发 Icon 状态的改变,产生动画效果。

动画初始化

给控件添加动画有以下几个步骤:

  1. 创建初始化方法(_initSizeAnimations)

    并且在这之前,需要用到动画类的类必须要有个计时器混合器用来提供时钟(Ticker)作用。

    主要初始化动画实例(Animation:_planeSizeAnimation)和动画控制器(AnimationController:_planeSizeAnimationController)

  2. 重写 initState 初始化动画

    initState 里面执行 _initSizeAnimations() 并且调用 _planeSizeAnimationController.forward() 启动动画,

    forward() 为向前进方向执行动画,还有反方向执行的(reverse())。

  3. 启动动画(_planeSizeAnimationController.forward())

  4. 重写 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
      )
    );
  }
}

效果图

price_tab.dart 完整代码✈

飞行动画(Travel Animation)

添加动画的步骤和 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 动画添加监听动作,监听动画完成,之后启动飞行动画。

涉及新知识点:

  1. ..addStatusListener 动画状态监听器
  2. AnimationStatus 动画状态类
  3. 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 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 _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: [
        // 用动画 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 增加动画扩展:

  1. 修改继承 StatelessWidget -> AnimatedWidget
  2. 添加 animation 参数
  3. 设置 top 值为 animation.value 动画值
  4. 初始化点动画(_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)
  );
}

点动画效果图

航班信息卡片及其动画

航班信息卡片,是和上节添加的节点是一致的,一个节点对应一个信息卡片,每个有自己的独立动画,出现在节点动画之后进行。

要实现卡片信息及其动画有以下几个要点:

  1. 卡片信息类(FlightStopCard)
  2. 卡片上的信息位置都是固定的,要是先定位则需要使用到 Stack
  3. 卡片的位置分布为左右间隔分布,即左奇右偶或左偶右奇均可(这里使用左偶右奇), isLeft 来标识
  4. 为了能使用 RowColumn 将卡片分布在水平适当位置,需要使用 Expanded 控件

创建信息卡片类

卡片内的元素使用 Stack 作为容器,目的是为了让每个信息都能按照规定的要求定位。

这里关键的地方在于上下左右的间距计算方式:

  1. 上间距固定最小间距为 minTopMargin:8.0
  2. 下间距固定最小及那句为 minBottomMargin: 8.0
  3. 左右边距为 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 类添加到线条相应的位置上,需要修改:

  1. _flightStops 为航班信息实际数据
  2. _stopKeys 为每个卡片增加一个 key
  3. _buildStopCard 创建信息卡片
  4. 添加到线条上
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()),
          ],
        ),
      ),
    );
  }

  // ... 省略
}

请注意 _buildStopCardRow:children 在这里面前后都增加了这一句:

isLeft ? Container() : Expanded(child: Container())

这么做的目的是利用 Row 的特性从而是卡片只占据宽度的一半。

无动画效果图

增加动画(AnimatedBuilder)

给卡片增加动画:

  1. 动画要素: 各元素的位置值(left/right/top/bottom) 因此需要给这些值增加动画状态依赖
  2. 使用 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 文件中增加:

  1. _buildFab 创建确认按钮
  2. _initFabAnimationController 初始化确认按钮动画
  3. 添加按钮
  4. _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;
}

修改 TicketCardbuild :

@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 传递给 AnimatedBuilderbuilder 渲染到视图中。

出场动画后效果图

AJAX 实时数据(HttpClient)

之前都是使用固定的数据,这一节将讲述怎么在 Flutter 中使用 HttpClient 来获取服务器端数据

然后渲染 UI

这里将涉及以下几个步骤:

  1. 创建 services 目录,存放服务端数据请求和基本处理的代码

  2. 创建 services/fetch_apis.dart 用来发送请求和接受数据

  3. fetchTicket 从服务器端请求票务信息

  4. _buildFutureTicket 使用 FutureBuilder 来创建和渲染异步 UI

    FutureBuilder 渲染时机在于数据的完成阶段。

数据服务 fetch_apis.dart

该文件中涉及几个知识点:

  1. HttpClient 客户端请求类
  2. Future 异步数据对象
  3. async...await
  4. 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 及其说明和链接。

组件名(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)