Flutter应用之《航班查询 Flight Search》

本文详细介绍了使用Flutter开发一款航班查询应用的过程,包括应用入口、导航条、按钮、航班查询输入、飞机动画、航班信息卡片动画以及数据加载等关键部分的实现,涉及多种UI组件和动画效果的创建。
摘要由CSDN通过智能技术生成

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

img

设计分解

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

  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…

img

我这里使用的是 andriod 模拟器,至于怎么创建一个 flutter 项目和启动模拟器,详情 ✈

关闭右上角的 DEBUG 标记,可以通过配置 MaterialappdebugShowCheckedModeBanner 属性为 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
            ),
          ),
        ),
      ],
    );
  }
}

如上代码,我们创建了一个简单的包含一个 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: <Widget>[
          // 导航条
          AirAsiaBar(height: 210.0),
        ],
      ),
    );
  }
}

运行效果:

img

按钮区(单程,往返,多城市)

为了能自定义按钮样式,我们自己创建一个按钮组件 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 类似只不过是在垂直方向上的排列。

效果图:

img

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

实现卡片控件,里面包含两部分: 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: <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())

效果图:

img

效果图上选项卡下面的灰色线条实现方式(利用 Positioned 控件 top:null 属性特性):

  1. TabBarContainer 放置在一个 Stack
  2. 使用
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

若叶岂知秋vip

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值