UI Challenge – Flight Search

UI Challenge – Flight Search

In this post, I will go through another UI Challenge. I have picked Johny Vino‘s Flight Search design from 100 Mobile App Interactions which is mostly about animations and I will try to implement it as close as I can.

The design

Let’s get to it!

First, we need to decompose this view into a few smaller parts:

  1. AppBar and top buttons
  2. Initial input
  3. Airplane resize and travel
  4. Dots travel
  5. Flight stop card view
  6. Flight stop card animations
  7. Flight ticket view
  8. Flight ticket animations

0. Starting point

As a starting point, we need to create a basic Flutter app and ditch all the unnecessary stuff so we end up with only a MaterialApp and a Scaffold:

main.dart
import 'package:flight_search/home_page.dart';
import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flight Search',
      theme: new ThemeData(
        primarySwatch: Colors.red,
      ),
      home: new HomePage(),
    );
  }
}
home_page.dart
import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text("Let's get started!")),
    );
  }
}

1. AppBar and buttons

Since our AppBar is a bit expanded and all the views are going to be written on top of it, all the views in the application will be based on a Stack widget, which allows us to easily put widgets on top of each other. Now let’s create an AirAsiaBar widget.

air_asia_bar.dart
class AirAsiaBar extends StatelessWidget {
  final double height;

  const AirAsiaBar({Key key, this.height}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        new Container(
          decoration: new BoxDecoration(
            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(
            "AirAsia",
            style: TextStyle(fontFamily: 'NothingYouCouldDo', fontWeight: FontWeight.bold),
          ),
        ),
      ],
    );
  }
}

We have created a simple stack containing a Container, which will be our background as well as transparent `AppBar` which is placed on top of the container. The height of the container has been extracted because further on the bar’s height changes a little and we would like to be able to reuse the same component. You can also notice a custom FontFamily. I have downloaded it from here and added it to `pubspec.yaml`. I know it is not exactly the same but I’d say it’s close enough 🙂

pubspec.yaml
name: flight_search
description: A new Flutter project.

dependencies:
  flutter:
    sdk: flutter

dev_dependencies:
  flutter_test:
    sdk: flutter
    
flutter:
  uses-material-design: true
  fonts:
    - family: NothingYouCouldDo
      fonts:
        - asset: fonts/NothingYouCouldDo.ttf

The last thing is to add the bar to the HomePage:

home_page.dart
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: <Widget>[
          AirAsiaBar(height: 210.0),
        ],
      ),
    );
  }
}

This is how the app bar looks for now:

Now let’s add the 3 buttons on top. Since there are not the buttons we are used to using, I will create my own one:

rounded_button.dart
import 'package:flutter/material.dart';

class RoundedButton extends StatelessWidget {
  final String text;
  final bool selected;
  final GestureTapCallback onTap;

  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;
    return Expanded(
      child: Padding(
        padding: const EdgeInsets.all(4.0),
        child: new InkWell(
          onTap: onTap,
          child: new Container(
            height: 36.0,
            decoration: new BoxDecoration(
              color: backgroundColor,
              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),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

I don’t think there is anything worth explaining here, maybe except the fact that the button is wrapped in Expanded. I did only because I didn’t want to do it multiple times when I use the button. If I would actually want to create a reusable widget, I would advise against making it Expanded. Now let’s add those buttons to the HomePage:

home_page.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: EdgeInsets.only(
                  top: MediaQuery.of(context).padding.top + 40.0),
              child: new Column(
                children: <Widget>[
                  _buildButtonsRow(),
                  Container(), //TODO: Implement a card
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildButtonsRow() {
    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),
        ],
      ),
    );
  }
}

As you can see, I placed _buildButtonsRow inside of a Column which was inside of a Positioned. The Positioned widget is needed because we need to manually put all the content under the AirAsia label but on top of the AppBar background. A Column will be needed later so we can put a card with content under the buttons. At this moment our app looks like this:

You can see full code for this step here.

2. Initial input

Now, we need to create a card that will contain most of our views. Even though it might seem pretty straightforward there are some problems that need to be solved during this part but first let’s see the code:

content_card.dart
import 'package:flight_search/multicity_input.dart';
import 'package:flutter/material.dart';

class ContentCard extends StatefulWidget {
  @override
  _ContentCardState createState() => _ContentCardState();
}

class _ContentCardState extends State<ContentCard> {
  @override
  Widget build(BuildContext context) {
    return new Card(
      elevation: 4.0,
      margin: const EdgeInsets.all(8.0),
      child: DefaultTabController(
        child: new LayoutBuilder(
          builder: (BuildContext context, BoxConstraints viewportConstraints) {
            return Column(
              children: <Widget>[
                _buildTabBar(),
                _buildContentContainer(viewportConstraints),
              ],
            );
          },
        ),
        length: 3,
      ),
    );
  }

  Widget _buildTabBar({bool showFirstOption}) {
    return Stack(
      children: <Widget>[
        new Positioned.fill(
          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(
            minHeight: viewportConstraints.maxHeight - 48.0,
          ),
          child: new IntrinsicHeight(
            child: _buildMulticityTab(),
          ),
        ),
      ),
    );
  }

  Widget _buildMulticityTab() {
    return Column(
      children: <Widget>[
        Text("Inputs"), //TODO: Add MultiCity input
        Expanded(child: Container()),
        Padding(
          padding: const EdgeInsets.only(bottom: 16.0, top: 8.0),
          child: FloatingActionButton(
            onPressed: () {},
            child: Icon(Icons.timeline, size: 36.0),
          ),
        ),
      ],
    );
  }
}

You can notice that in the design there is a gray line behind the tab indicator. To achieve this effect, we have to use a Stack in which we will place a small Container under the actual TabBar. You can see it in _buildTabBar() method.

Harder part comes with the Input view. It is worth remembering that whenever you are using any TextFields in Flutter you almost always want to wrap them inside some sort of scrollable views like CustomScrollView or ListView, so that when the keyboard appears your layout won’t get disrupted. However, in our example, we also want to place a FloatingActionButton at the bottom of the view. Technically we could place it under the ScrollView but that would cause it to be always there even if there are more fields to be scrolled to. We could also place it just inside the ScrollView, but then it wouldn’t be aligned to the bottom if there was a space for it.

The solution to that problem is using the following combination of widgets: LayoutBuilder which will provide us access to BoxContstraints, then we use SingleChildScrollView with ConstrainedBox and we pass maximum height we want our layout to have (obtained from constraints). In the end, we need IntrinsicHeight so that our view will fill as much space as it can but it will be able to render it in `ScrollView`. Now we can have a Fab that is aligned to the bottom but will also be scrollable if more content occurs.

Now let’s update home_page‘s line:
Container(), //TODO: Implement a card
with this:
Expanded(child: ContentCard()),

Finally, we can add those inputs, I think there is not much to explain here since this is all just a static view:

multicity_input.dart
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: <Widget>[
            Padding(
              padding: const EdgeInsets.fromLTRB(0.0, 0.0, 64.0, 8.0),
              child: TextFormField(
                decoration: InputDecoration(
                  icon: Icon(Icons.flight_takeoff, color: Colors.red),
                  labelText: "From",
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(0.0, 0.0, 64.0, 8.0),
              child: TextFormField(
                decoration: InputDecoration(
                  icon: Icon(Icons.flight_land, color: Colors.red),
                  labelText: "To",
                ),
              ),
            ),
            Row(
              children: <Widget>[
                Expanded(
                  child: Padding(
                    padding: const EdgeInsets.only(bottom: 8.0),
                    child: TextFormField(
                      decoration: InputDecoration(
                        icon: Icon(Icons.flight_land, color: Colors.red),
                        labelText: "To",
                      ),
                    ),
                  ),
                ),
                Container(
                  width: 64.0,
                  alignment: Alignment.center,
                  child: Icon(Icons.add_circle_outline, color: Colors.grey),
                ),
              ],
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(0.0, 0.0, 64.0, 8.0),
              child: TextFormField(
                decoration: InputDecoration(
                  icon: Icon(Icons.person, color: Colors.red),
                  labelText: "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"),
                    ),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

After adding it to ContentCard, we end up with this view:

You can see full code for this step here.

3. Plane resize and travel

Let’s settle what is about to happen now. After a user clicks on a floating action button, the view immediately changes to another tab, however, the TabBar names remain the same. Then there is a short delay after which plane starts to go forward with a simultaneous change of names in tabs (I will ignore that small animation of the names).

Let’s start with resizing animation. First, we will create a new Widget called PriceTab (to be honest I am not sure why it is a price 😀 ). We will place everything inside a Stack and we will mostly operate on Positioned widget to put widgets where we want them. In general, it is not trivial to get Widget’s size before it gets rendered but luckily we have wrapped our tab inside of LayoutBuilder so we have access to view constraints and therefore to Widget’s height. This way we will be able to calculate the widget’s padding top as followed:

price_tab.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> with TickerProviderStateMixin {
  final double _initialPlanePaddingBottom = 16.0;

  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: <Widget>[_buildPlane()],
      ),
    );
  }

  Widget _buildPlane() {
    return Positioned(
      top: _planeTopPadding,
      child: Column(
        children: <Widget>[
          _buildPlaneIcon(),
        ],
      ),
    );
  }

  Widget _buildPlaneIcon() {
    return Icon(
      Icons.airplanemode_active,
      color: Colors.red,
      size: _planeSize,
    );
  }
}

Now, we will add PriceTab widget to the ContentCard :

content_card.dart
class _ContentCardState extends State<ContentCard> {
  bool showInput = true;
  
  Widget _buildContentContainer(BoxConstraints viewportConstraints) {
    [...]
          child: new IntrinsicHeight(
            child: showInput
                ? _buildMulticityTab()
                : PriceTab(
                    height: viewportConstraints.maxHeight - 48.0,
                  ),
          ),
    [...]
  }

  Widget _buildMulticityTab() {
    [...]
          child: FloatingActionButton(
            onPressed: () => setState(() => showInput = false),
            child: Icon(Icons.timeline, size: 36.0),
          ),
    [...]
  }
}

Not so much for now, let’s add plane size animation. First by adding AnimationController and Animation objects related to the size of the plane.

price_tab.dart
class _PriceTabState extends State<PriceTab> with TickerProviderStateMixin {
  AnimationController _planeSizeAnimationController;
  Animation _planeSizeAnimation;

  double get _planeTopPadding => widget.height - _initialPlanePaddingBottom - _planeSize;

  double get _planeSize => _planeSizeAnimation.value;

  @override
  void initState() {
    super.initState();
    _initSizeAnimations();
    _planeSizeAnimationController.forward();
  }

  @override
  void dispose() {
    _planeSizeAnimationController.dispose();
    super.dispose();
  }

  Widget _buildPlane() {
    return Positioned(
      top: _planeTopPadding,
      child: Column(
        children: <Widget>[
          AnimatedPlaneIcon(animation: _planeSizeAnimation),
        ],
      ),
    );
  }

  _initSizeAnimations() {
    _planeSizeAnimationController = AnimationController(
      duration: const Duration(milliseconds: 340),
      vsync: this,
    );
    _planeSizeAnimation = Tween<double>(begin: 60.0, end: 36.0).animate(CurvedAnimation(
      parent: _planeSizeAnimationController,
      curve: Curves.easeOut,
    ));
  }
}

So what we’re doing is creating in onInit() method an _planeSizeAnimationController which is a parent of actual size animation _planeSizeAnimation. This animation will scale from 60 to 36 which is the actual size we would like to achieve. We pass it to the AnimatedPlaneIcon widget which will be rebuilt on every animation change.

animated_plane_icon.dart
class AnimatedPlaneIcon extends AnimatedWidget {
  AnimatedPlaneIcon({Key key, Animation<double> animation})
      : super(key: key, listenable: animation);

  @override
  Widget build(BuildContext context) {
    Animation<double> animation = super.listenable;
    return Icon(
      Icons.airplanemode_active,
      color: Colors.red,
      size: animation.value,
    );
  }
}

Great, now we can make this plane fly!

price_tab.dart
class _PriceTabState extends State<PriceTab> with TickerProviderStateMixin {
  final double _initialPlanePaddingBottom = 16.0;
  final double _minPlanePaddingTop = 16.0;

  AnimationController _planeSizeAnimationController;
  AnimationController _planeTravelController;
  Animation _planeSizeAnimation;
  Animation _planeTravelAnimation;

  double get _planeTopPadding =>
      _minPlanePaddingTop +
      (1 - _planeTravelAnimation.value) * _maxPlaneTopPadding;

  double get _maxPlaneTopPadding =>
      widget.height - _initialPlanePaddingBottom - _planeSize;

  double get _planeSize => _planeSizeAnimation.value;

  @override
  void initState() {
    super.initState();
    _initSizeAnimations();
    _initPlaneTravelAnimations();
    _planeSizeAnimationController.forward();
  }

  Widget _buildPlane() {
    return AnimatedBuilder(
      animation: _planeTravelAnimation,
      child: Column(
        children: <Widget>[
          AnimatedPlaneIcon(animation: _planeSizeAnimation),
        ],
      ),
      builder: (context, child) => Positioned(
            top: _planeTopPadding,
            child: child,
          ),
    );
  }

  _initSizeAnimations() {
    _planeSizeAnimationController = AnimationController(
      duration: const Duration(milliseconds: 340),
      vsync: this,
    )..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          Future.delayed(Duration(milliseconds: 500),
              () => _planeTravelController.forward());
        }
      });
    [...]
  }

  _initPlaneTravelAnimations() {
    _planeTravelController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 400),
    );
    _planeTravelAnimation = CurvedAnimation(
      parent: _planeTravelController,
      curve: Curves.fastOutSlowIn,
    );
  }
}

What we’ve done:

  • We have created a new Animation Controller.
  • We have added an Animation to that controller, to get access to nice curve instead of a linear one.
  • We have changed _planeTopPadding getter so that it is dependent on _planeTravelAnimation.
  • We have added an AnimatedBuilder which will rebuild the plane icon according to Animation’s value.

The last thing for that part is to add a trail and change the names in the tabs. When it comes to the trail, we just need to add it to _buildPlane() method. For now, let’s keep the length of it as 240.0.

price_tab.dart
Widget _buildPlane() {
  return AnimatedBuilder(
    animation: _planeTravelAnimation,
    child: Column(
      children: <Widget>[
        AnimatedPlaneIcon(animation: _planeSizeAnimation),
        Container(
          width: 2.0,
          height: 240.0,
          color: Color.fromARGB(255, 200, 200, 200),
        ),
      ],
    ),
    [...]
  );
}

Regarding update of labels, I won’t describe it here but you can check it out in the full code for this part.

EDIT: There was missing - _minPlanePaddingTop in the _maxPlaneTopPadding. Add it to move the plane a bit from the bottom edge.

4. Dots travel

In order to place dots, we need to know their positions. Obviously, they have to match cards on the sides. At this moment let’s assume that we have 4 cards, each will have a height of 80.0. We can also assume that those cards will overlap a bit (we will place cards in distance of 0.8*80.0).

We can start by creating a dot class

animated_dot.dart
class AnimatedDot extends AnimatedWidget {
  final Color color;

  AnimatedDot({
    Key key,
    Animation<double> animation,
    @required this.color,
  }) : super(key: key, listenable: animation);

  @override
  Widget build(BuildContext context) {
    Animation<double> animation = super.listenable;
    return Positioned(
      top: animation.value,
      child: Container(
          height: 24.0,
          width: 24.0,
          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),
            ),
          )),
    );
  }
}

Now let’s add some things into PriceTab widget

class _PriceTabState extends State<PriceTab> with TickerProviderStateMixin {
  final List<int> _flightStops = [1, 2, 3, 4];
  final double _cardHeight = 80.0;

  AnimationController _dotsAnimationController;
  List<Animation<double>> _dotPositions = [];

  @override
  void initState() {
    super.initState();
    _initSizeAnimations();
    _initPlaneTravelAnimations();
    _initDotAnimationController();
    _initDotAnimations();
    _planeSizeAnimationController.forward();
  }

  @override
  void dispose() {
    _planeSizeAnimationController.dispose();
    _planeTravelController.dispose();
    _dotsAnimationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      child: Stack(
        alignment: Alignment.center,
        children: <Widget>[_buildPlane()]
          ..addAll(_flightStops.map(_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,
    );
  }

  Widget _buildPlane() {
    [..]
          Container(
            width: 2.0,
            height: _flightStops.length*_cardHeight*0.8, // <--- changed length of trail
            color: Color.fromARGB(255, 200, 200, 200),
          ),
    [...]
  }

  _initSizeAnimations() {
    _planeSizeAnimationController = AnimationController(
      duration: const Duration(milliseconds: 340),
      vsync: this,
    )..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          Future.delayed(Duration(milliseconds: 500), () {
            widget?.onPlaneFlightStart();
            _planeTravelController.forward();
          });
          Future.delayed(Duration(milliseconds: 700), () { // <--- dots animation start
            _dotsAnimationController.forward();
          });
        }
      });
    [...]
  }

  void _initDotAnimations() {
    //what part of whole animation takes one dot travel
    final double slideDurationInterval = 0.4;
    //what are delays between dot animations
    final double slideDelayInterval = 0.2;
    //at the bottom of the screen
    double startingMarginTop = widget.height;
    //minimal margin from the top (where first dot will be placed)
    double minMarginTop =
        _minPlanePaddingTop + _planeSize + 0.5 * (0.8 * _cardHeight);

    for (int i = 0; i < _flightStops.length; i++) {
      final start = slideDelayInterval * i;
      final end = start + slideDurationInterval;

      double finalMarginTop = minMarginTop + i * (0.8 * _cardHeight);
      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);
    }
  }

  void _initDotAnimationController() {
    _dotsAnimationController = new AnimationController(
        vsync: this, duration: Duration(milliseconds: 500));
  }
}

Let me explain what happened. We created a list of flight stops which at this moment will be integers. We also introduced a fixed height of one stop which is 80.0. We created a _dotsAnimationController which will control all dots animations. The interesting part is how every dot is animating. Technically we could create 4 animation controllers each to every dot but there is a better solution to that. We can use an Interval curve.

Interval is an animation curve, that allows us to specify at which moment of whole animation (defined by AnimationController) we want a single animation to start. For example, if we specified in the controller the duration to 10 seconds and we created animation with an interval with start equal to 0.3 and end equal to 0.7 then our animation will start with 3-second delay and end after 4 seconds. This way we can have multiple animations overlapping with each other controller by one controller. We defined our animation to take 0.4 of time declared in a controller and each animation starts 0.2 after the previous one. We also defined what is the original and final position to every dot based on its index and card height. We have added all animations to the list, so that we can pass them to AnimatedDot widget which will handle drawing a dot.

You can see full code for this step here.

5. Flight stop card view

First, let’s create a model for a card, we will use strings because it’s easier. 🙂

flight_stop.dart
class FlightStop {
  String from;
  String to;
  String date;
  String duration;
  String price;
  String fromToTime;


  FlightStop(this.from, this.to, this.date, this.duration, this.price, this.fromToTime);
}

class TicketFlightStop {
  String from;
  String fromShort;
  String to;
  String toShort;
  String flightNumber;

  TicketFlightStop(this.from, this.fromShort, this.to, this.toShort, this.flightNumber);
}

Theoretically, this view is pretty simple and can be done with a proper usage of Rows and Columns. However, if we look closely at the design, we can see that every text in the card shows up in a slightly different way. To achieve that we need to animate each text separately, so we need to know each text’s position. That’s why we will use stack here.

flight_stop_card.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<FlightStopCard>
    with TickerProviderStateMixin {
  @override
  Widget 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(),
      right: getMarginRight(),
      child: Text(
        widget.flightStop.duration,
        style: new TextStyle(
          fontSize: 10.0,
          color: Colors.grey,
        ),
      ),
    );
  }

  Positioned buildAirportNamesText() {
    return Positioned(
      top: getMarginTop(),
      left: getMarginLeft(),
      child: Text(
        "${widget.flightStop.from} \u00B7 ${widget.flightStop.to}",
        style: new TextStyle(
          fontSize: 14.0,
          color: Colors.grey,
        ),
      ),
    );
  }

  Positioned buildDateText() {
    return Positioned(
      left: getMarginLeft(),
      child: Text(
        "${widget.flightStop.date}",
        style: new TextStyle(
          fontSize: 14.0,
          color: Colors.grey,
        ),
      ),
    );
  }

  Positioned buildPriceText() {
    return Positioned(
      right: getMarginRight(),
      child: Text(
        "${widget.flightStop.price}",
        style: new TextStyle(
            fontSize: 16.0, color: Colors.black, fontWeight: FontWeight.bold),
      ),
    );
  }

  Positioned buildFromToTimeText() {
    return Positioned(
      left: getMarginLeft(),
      bottom: getMarginBottom(),
      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 outerMargin = 8.0;
    return Positioned(
      right: widget.isLeft ? null : outerMargin,
      left: widget.isLeft ? outerMargin : null,
      child: Container(
        width: 140.0,
        height: 80.0,
        child: new Card(
          color: Colors.grey.shade100,
        ),
      ),
    );
  }

  double getMarginBottom() {
    double minBottomMargin = 8.0;
    return minBottomMargin;
  }

  double getMarginTop() {
    double minMarginTop = 8.0;
    return minMarginTop;
  }

  double getMarginLeft() {
    return getMarginHorizontal(true);
  }

  double getMarginRight() {
    return getMarginHorizontal(false);
  }

  double getMarginHorizontal(bool isTextLeft) {
    if (isTextLeft == widget.isLeft) {
      double minHorizontalMargin = 16.0;
      return minHorizontalMargin;
    } else {
      double maxHorizontalMargin = maxWidth - FlightStopCard.width;
      return maxHorizontalMargin;
    }
  }
}

The first thing worth mentioning is isLeft flag. Since the card can be placed on both sides, it will be built differently so we want to pass that information to it. The widget has specified height and width fields which are describing actual card’s dimensions. However, it is hard to say how big the actual widget will be. To get the maximum width of the widget, we are using render box’s constraints. Even though this approach might seem easy, it cannot be always used because during the first build, the framework doesn’t actually know those constraints yet. Luckily we will animate this view and on the first build it will have zero size, so we can get out with this.

Last thing worth mentioning are those buildMargin methods. All the texts in the card are either aligned top, right, bottom, left or center of the card depending on the text and if the widget isLeft. Those methods will be developed in the next section. For now, it’s important that they work 🙂

Let’s see how to place those cards inside our app:

price_tab.dart
class _PriceTabState extends State<PriceTab> with TickerProviderStateMixin {
  final 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"),
  ];

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

  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(
                flightStop: stop,
                isLeft: isLeft,
              ),
            ),
            !isLeft ? Container() : Expanded(child: Container()),
          ],
        ),
      ),
    );
  }
}

What we’ve done is depending on stop’s index we add an Expanded widget on the left or on the right of the card. Having the card also wrapped inside the Expanded we can make sure that it will take only half of the width.

You can see full code for this step here.

6. Flight Stop Card animations

Ok, now it’s time to animate those cards! In order to do so, we will use a single AnimationController with multiple Interval animations just like with dots. We will also update getMargin methods so that they will be dependent on animation’s value.

flight_stop_card.dart
class FlightStopCardState extends State<FlightStopCard>
    with TickerProviderStateMixin {
  AnimationController _animationController;
  Animation<double> _cardSizeAnimation;
  Animation<double> _durationPositionAnimation;
  Animation<double> _airportsPositionAnimation;
  Animation<double> _datePositionAnimation;
  Animation<double> _pricePositionAnimation;
  Animation<double> _fromToPositionAnimation;
  Animation<double> _lineAnimation;

  @override
  void initState() {
    super.initState();
    _animationController = new AnimationController(
        vsync: this, duration: Duration(milliseconds: 600));
    _cardSizeAnimation = new CurvedAnimation(
        parent: _animationController,
        curve: new Interval(0.0, 0.9, curve: new ElasticOutCurve(0.8)));
    _durationPositionAnimation = new CurvedAnimation(
        parent: _animationController,
        curve: new Interval(0.05, 0.95, curve: new ElasticOutCurve(0.95)));
    _airportsPositionAnimation = new CurvedAnimation(
        parent: _animationController,
        curve: new Interval(0.1, 1.0, curve: new ElasticOutCurve(0.95)));
    _datePositionAnimation = new CurvedAnimation(
        parent: _animationController,
        curve: new Interval(0.1, 0.8, curve: new ElasticOutCurve(0.95)));
    _pricePositionAnimation = new CurvedAnimation(
        parent: _animationController,
        curve: new Interval(0.0, 0.9, curve: new ElasticOutCurve(0.95)));
    _fromToPositionAnimation = new CurvedAnimation(
        parent: _animationController,
        curve: new Interval(0.1, 0.95, curve: new ElasticOutCurve(0.95)));
    _lineAnimation = new CurvedAnimation(
        parent: _animationController,
        curve: new Interval(0.0, 0.2, curve: Curves.linear));
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  void runAnimation() {
    _animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: FlightStopCard.height,
      child: AnimatedBuilder(
        animation: _animationController,
        builder: (context, child) => new Stack(
              [...]
            ),
      ),
    );
  }

  Positioned buildDurationText() {
    double animationValue = _durationPositionAnimation.value; //<---current value of animation
    return Positioned(
      top: getMarginTop(animationValue), //<--- animate vertical position
      right: getMarginRight(animationValue), //<--- animate horizontal pozition
      child: Text(
        widget.flightStop.duration,
        style: new TextStyle(
          fontSize: 10.0 * animationValue, //<--- animate fontsize
          color: Colors.grey,
        ),
      ),
    );
  }

  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, //<---animate width
          color: Color.fromARGB(255, 200, 200, 200),
        ));
  }

  Positioned buildCard() {
    double animationValue = _cardSizeAnimation.value;
    double minOuterMargin = 8.0;
    double outerMargin = minOuterMargin + (1 - animationValue) * maxWidth;
    return Positioned(
      right: widget.isLeft ? null : outerMargin,
      left: widget.isLeft ? outerMargin : null,
      child: Transform.scale( //<--- add transform.scale
        scale: animationValue,
        child: Container(
          width: 140.0,
          height: 80.0,
          child: new Card(
            color: Colors.grey.shade100,
          ),
        ),
      ),
    );
  }

  double getMarginBottom(double animationValue) {
    double minBottomMargin = 8.0;
    double bottomMargin = minBottomMargin + (1 - animationValue) * minBottomMargin;
    return bottomMargin;
  }

  double getMarginTop(double animationValue) {
    double minMarginTop = 8.0;
    double marginTop = minMarginTop + (1 - animationValue) * FlightStopCard.height * 0.5;
    return marginTop;
  }

  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 - animationValue) * maxHorizontalMargin;
      return horizontalMargin;
    } else {
      double maxHorizontalMargin = maxWidth - FlightStopCard.width;
      double horizontalMargin = animationValue * maxHorizontalMargin;
      return horizontalMargin;
    }
  }
}

We created an animation for each text we want to display. Every animation has slightly different interval values so that they create a feeling of being independent of each other. Also, we pass the parameter to ElasticOutCurve which is the actual curve of texts’ animations. We do it to decrease the bouncing effect. I won’t describe how the getMargin methods changed, they just work :). Each text widget is now passing animationValue to those methods as well as to its own font size. We have also added a public method runAnimation so that parent can decide when the card should be animated.

price_tab.dart
class _PriceTabState extends State<PriceTab> with TickerProviderStateMixin {
  final List<GlobalKey<FlightStopCardState>> _stopKeys = []; //<--- Add keys
  AnimationController _fabAnimationController;
  Animation _fabAnimation;

  @override
  void initState() {
    [...]
    _initFabAnimationController(); //<--- init fab controller
    _flightStops.forEach(
      (stop) => _stopKeys.add(new GlobalKey<FlightStopCardState>())); //<-- init card keys
    _planeSizeAnimationController.forward();
  }

  @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()), //<--- Add a fab
      ),
    );
  }

  Widget _buildStopCard(FlightStop stop) {
    [...]
              child: FlightStopCard(
                key: _stopKeys[index], //<--- Add a key
                flightStop: stop,
                isLeft: isLeft,
              ),
    [...]
  }

  Widget _buildFab() {
    return Positioned(
      bottom: 16.0,
      child: ScaleTransition(
        scale: _fabAnimation,
        child: FloatingActionButton(
          onPressed: () {},
          child: Icon(Icons.check, size: 36.0),
        ),
      ),
    );
  }

  void _initDotAnimationController() {
    _dotsAnimationController = new AnimationController(
        vsync: this, duration: Duration(milliseconds: 500))
      ..addStatusListener((status) {   //<--- Add a listener to start card animations
        if (status == AnimationStatus.completed) {
          _animateFlightStopCards().then((_) => _animateFab());
        }
      });
  }

  Future _animateFlightStopCards() async {
    return Future.forEach(_stopKeys, (GlobalKey<FlightStopCardState> stopKey) {
      return new Future.delayed(Duration(milliseconds: 250), () {
        stopKey.currentState.runAnimation();
      });
    });
  }

  void _initFabAnimationController() {
    _fabAnimationController = new AnimationController(
        vsync: this, duration: Duration(milliseconds: 300));
    _fabAnimation = new CurvedAnimation(
        parent: _fabAnimationController, curve: Curves.easeOut);
  }

  _animateFab() {
    _fabAnimationController.forward();
  }
}

In onInit() method we create GlobalKeys which we later pass to FlightStopCards so that we have access to the state and we can start the animations. We also added a FloatingActionButton so that whole view will be completed. This fab’s animation will start just after last card animation’s start. This time, we use Future.forEach to run a delayed animation start for each card. This is how it looks like:

You can see full code for this step here.

7. Flight ticket view

Ok, now we’re left with the easy part. We need to create views for the last screen. Let’s start with the model since in the design there are completely different texts on the last screen.

flight_stop_ticket.dart
class FlightStopTicket {
  String from;
  String fromShort;
  String to;
  String toShort;
  String flightNumber;

  FlightStopTicket(this.from, this.fromShort, this.to, this.toShort, this.flightNumber);
}

Now let’s create a page widget:

tickets_page.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"),
  ];

  @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(
              child: new Column(
                children: _buildTickets().toList(),
              ),
            ),
          )
        ],
      ),
      floatingActionButton: _buildFab(),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }

  Iterable<Widget> _buildTickets() {
    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),
    );
  }
}

Now let’s create a basic view of one ticket card:

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

  Container _buildCardContent() {
    TextStyle airportNameStyle =
        new TextStyle(fontSize: 16.0, fontWeight: FontWeight.w600);
    TextStyle airportShortNameStyle =
        new TextStyle(fontSize: 36.0, fontWeight: FontWeight.w200);
    TextStyle flightNumberStyle =
        new TextStyle(fontSize: 12.0, fontWeight: FontWeight.w500);
    return Container(
      height: 104.0,
      child: Row(
        mainAxisSize: MainAxisSize.max,
        children: <Widget>[
          Expanded(
            child: Padding(
              padding: const EdgeInsets.only(left: 32.0, top: 16.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Padding(
                    padding: const EdgeInsets.only(bottom: 8.0),
                    child: 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),
            ],
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.only(left: 40.0, top: 16.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Padding(
                    padding: const EdgeInsets.only(bottom: 8.0),
                    child: Text(stop.to, style: airportNameStyle),
                  ),
                  Text(stop.toShort, style: airportShortNameStyle),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

It’s all pretty basic so I won’t discuss those static views. We still need to add navigate to this view. I will use FadeRoute to do that.

price_tab.dart
Widget _buildFab() {
  [...]
    FloatingActionButton(
        onPressed: () => Navigator
            .of(context)
            .push(FadeRoute(builder: (context) => TicketsPage())), //<-- Navigation
        child: Icon(Icons.check, size: 36.0),
      ),
  [...]
}

This is what we got at this moment:

As you can see, those cards are lacking circular cuts on the sides. Let’s add them with ClipPath:

ticket_card.dart
class TicketCard extends StatelessWidget {
  @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(),
          ),
        ),
      ),
    );
  }
  [...]
}

class TicketClipper extends CustomClipper<Path> {
  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<Path> oldClipper) => true;
}

Unfortunately, using `ClipPath` was also cutting off the shadow. In order to mitigate it a bit I added a material with a very light shadow and then I clipped it with a smaller radius (If you have a better idea, please share!  🙂 ). This is what we got:

You can see full code for this step here.

8. Flight ticket animations

This time we will use a lazier approach. To animate cards coming from the bottom, we can simply add a Translation from a big number to 0. I wouldn’t say it’s a perfect solution but it’s simple and it works.

ticket_page.dart
class _TicketsPageState extends State<TicketsPage>
    with TickerProviderStateMixin {
  AnimationController cardEntranceAnimationController;
  List<Animation> ticketAnimations;
  Animation fabAnimation;

  @override
  void initState() {
    super.initState();
    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.forward();
  }

  Iterable<Widget> _buildTickets() {
    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,
            ),
      );
    });
  }

  _buildFab() {
    return ScaleTransition(
      scale: fabAnimation,
      child: FloatingActionButton(
        onPressed: () => Navigator.of(context).pop(),
        child: new Icon(Icons.fingerprint),
      ),
    );
  }
}

As you can see, we used already known AnimationControllers, Animations and Intervals. I guess that at this point we don’t have to elaborate on that. If there is anything unclear, leave a question :).

You can see full code for this step here.

And that’s it folks!

That was a long road down here but now we can compare the results:

The design
Design
The implementation
Implementation




















I hope you enjoyed this post. If so, please leave a comment or star a repo 🙂

Cheers 🙂

 

7 thoughts on “UI Challenge – Flight Search

  1. very nice and helpful article. I wish you will continue this.. thanks once again for your knowledge sharing

  2. Thanks so much for presenting this very instructive lesson on using Flutter, and for showing it can be used to build complex apps. I’m new to Flutter and plan to start building a basic Business Directory app using flutter. Can you give me any advice about how to get started, especially on the initial design? Do you provide app development services?

    1. Hi Gil,
      When it comes to learning Flutter, I think that at this moment I would start with Udacity courses. I would also read about architecture design patterns like Redux and BLoC to have that in mind from start.

      When it comes to design, I’m far from being an expert but what I would do is also start with Google mobile design and rapid prototyping courses on Udacity although this is just an idea since I didn’t do them so I can’t tell you they’re worth it.

      Generally, I do provide such services but at this moment I’m taking a break so I wouldn’t count on me 😉

      1. Thanks for your ideas and suggestions. I will take them into considerations, as I continue plugging along. I will stay in touch via your blog tutorials. Enjoy your break.

  3. Thank you for the good flutter examples. I am new to flutter so I had a hard time finding good UI examples with complete and organized code. I will continuously visit your website if you will continue posting! Thanks for sharing. Someday I hope to post good flutter codes for others to reference as you did.

Leave a Reply

Your email address will not be published.