Filter Menu – UI Challenge

Filter Menu – UI Challenge

In this post, I will do my first UI Challenge. As my goal I’ve picked this design by Anton Aheichanka from dribbble:

The design

Let’s get to it!

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

  1. The clipped image on top
  2. Header
  3. Profile view
  4. List header
  5. List of items
  6. Animating items filtering
  7. Animated Floating Action Button

0. Starting point

As a starting point, we will create a simple app with a Stack. Stack is a container Widget, that places widgets on top of each other. All of our widgets will be placed in it.

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MainPage(),
    );
  }
}

class MainPage extends StatefulWidget {
  MainPage({Key key}) : super(key: key);

  @override
  _MainPageState createState() => new _MainPageState();
}

class _MainPageState extends State<MainPage> {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      body: new Stack(
        children: <Widget>[],
      ),
    );
  }
}

1. The clipped image

Let’s start with an image and add it to the Stack:

class _MainPageState extends State<MainPage> {
  double _imageHeight = 256.0;
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      body: new Stack(
        children: <Widget>[
          _buildIamge()
        ],
      ),
    );
  }

  Widget _buildIamge() {
    return new Image.asset(
        'images/birds.jpg',
        fit: BoxFit.fitHeight,
        height: _imageHeight,
    );
  }
}

EDIT: Actually, I have changed it to BoxFit.cover. Also I wrapped it in Positioned.fill with bottom: null later on.

Now let’s add diagonal clipping. To do that, we need to create a CustomClipper.

class DialogonalClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    Path path = new Path();
    path.lineTo(0.0, size.height - 60.0);
    path.lineTo(size.width, size.height);
    path.lineTo(size.width, 0.0);
    path.close();
    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => true;
}

Our new class extends CustomClipper<Path> and overrides two methods. shouldReclip method tells framework when Clipper should be rerendered. We will set this to always return true for simplicity. In production app, we might want to add actual logic here. getClip method returns a Path object which describes what area of a widget should remain visible. After defining bottom left apex 60 pixels above bottom right apex, we end up with a figure we needed.

Now we just need to use our clipper with ClipPath widget.

Widget _buildIamge() {
  return new ClipPath(
    clipper: new DialogonalClipper(),
    child: new Image.asset(
        'images/birds.jpg',
        fit: BoxFit.fitHeight,
        height: _imageHeight,
    ),
  );
}

In the design, this image is a bit darker, let’s try to achieve the similar color. To do that, we will use color blending:

new Image.asset(
  'images/birds.jpg',
  fit: BoxFit.fitHeight,
  height: _imageHeight,
  colorBlendMode: BlendMode.srcOver,
  color: new Color.fromARGB(120, 20, 10, 40),
),

Since I am not really good at recognizing colors, I will say that what we get is good enough. 🙂

Full code for this stage can be found here.

2. Header

I won’t put much attention to the header. I will use slightly different icons and possibly different font since it is not that important in the scope of whole design. The top header will be a simple Row with Icons and a Text.

Widget _buildTopHeader() {
  return new Padding(
    padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 32.0),
    child: new Row(
      children: <Widget>[
        new Icon(Icons.menu, size: 32.0, color: Colors.white),
        new Expanded(
          child: new Padding(
            padding: const EdgeInsets.only(left: 8.0),
            child: new Text(
              "Timeline",
              style: new TextStyle(
                  fontSize: 20.0,
                  color: Colors.white,
                  fontWeight: FontWeight.w300),
            ),
          ),
        ),
        new Icon(Icons.linear_scale, color: Colors.white),
      ],
    ),
  );
}

Now we just need to include this widget in our stack and put it on top of the clipped image.

@override
Widget build(BuildContext context) {
  return new Scaffold(
    body: new Stack(
      children: <Widget>[
        _buildIamge(),
        _buildTopHeader(),
      ],
    ),
  );
}

Full code for this stage can be found here.

3. Profile view

Similarly to header view, let’s not put a lot of attention to this part. Simple row with a column, some paddings and we end up with this:

Widget _buildProfileRow() {
  return new Padding(
    padding: new EdgeInsets.only(left: 16.0, top: _imageHeight / 2.5),
    child: new Row(
      children: <Widget>[
        new CircleAvatar(
          minRadius: 28.0,
          maxRadius: 28.0,
          backgroundImage: new AssetImage('images/avatar.jpg'),
        ),
        new Padding(
          padding: const EdgeInsets.only(left: 16.0),
          child: new Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: <Widget>[
              new Text(
                'Ryan Barnes',
                style: new TextStyle(
                    fontSize: 26.0,
                    color: Colors.white,
                    fontWeight: FontWeight.w400),
              ),
              new Text(
                'Product designer',
                style: new TextStyle(
                    fontSize: 14.0,
                    color: Colors.white,
                    fontWeight: FontWeight.w300),
              ),
            ],
          ),
        ),
      ],
    ),
  );
}

Unfortunately, I had no idea how to find the same image so I just used my own. Now let’s add it to the stack and see how it looks.

new Stack(
  children: <Widget>[
    _buildIamge(),
    _buildTopHeader(),
    _buildProfileRow(),
  ],
),

Full code for this stage can be found here.

4. List header

We are getting closer to the actual content of design. Let’s assume that the header and list are always below the image. Now we can define buildBottomPart method which would return everything from the header below.

Widget _buildBottomPart() {
  return new Padding(
    padding: new EdgeInsets.only(top: _imageHeight),
    child: new Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        _buildMyTasksHeader(),
        _buildTasksList(),
      ],
    ),
  );
}

//TODO
Widget _buildTasksList() {
  return new Container();
}

Widget _buildMyTasksHeader() {
  return new Padding(
    padding: new EdgeInsets.only(left: 64.0),
    child: new Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        new Text(
          'My Tasks',
          style: new TextStyle(fontSize: 34.0),
        ),
        new Text(
          'FEBRUARY 8, 2015',
          style: new TextStyle(color: Colors.grey, fontSize: 12.0),
        ),
      ],
    ),
  );
}

And, as always, we need to add this view to the stack:

new Stack(
  children: <Widget>[
    _buildIamge(),
    _buildTopHeader(),
    _buildProfileRow(),
    _buildBottomPart(),
  ],
),

Full code for this stage can be found here.

5. List of items

First, let’s create a model class, which will represent tasks shown in the list. We can see that tasks have a name, category, time, color and since we can filter them, we can assume that they can have some completed flag. I will ignore the hangout task with small avatars.

class Task {
  final String name;
  final String category;
  final String time;
  final Color color;
  final bool completed;

  Task({this.name, this.category, this.time, this.color, this.completed});
}

Now we need to initialize a list of such tasks.

List<Task> tasks = [
  new Task(
      name: "Catch up with Brian",
      category: "Mobile Project",
      time: "5pm",
      color: Colors.orange,
      completed: false),
  new Task(
      name: "Make new icons",
      category: "Web App",
      time: "3pm",
      color: Colors.cyan,
      completed: true),
  new Task(
      name: "Design explorations",
      category: "Company Website",
      time: "2pm",
      color: Colors.pink,
      completed: false),
  new Task(
      name: "Lunch with Mary",
      category: "Grill House",
      time: "12pm",
      color: Colors.cyan,
      completed: true),
  new Task(
      name: "Teem Meeting",
      category: "Hangouts",
      time: "10am",
      color: Colors.cyan,
      completed: true),
];

When it comes to the actual view, we can see, that there is a vertical line going through the whole screen and hiding behind the image. To create that line we will write following method:

Widget _buildTimeline() {
  return new Positioned(
    top: 0.0,
    bottom: 0.0,
    left: 32.0,
    child: new Container(
      width: 1.0,
      color: Colors.grey[300],
    ),
  );
}

After adding it to the bottom of the stack (so as a first child), we will end up if nice vertical line going through the whole screen. Now let’s create a Widget representing a Task. It could be a StatelessWidget but we will create it stateful because we will need it later (EDIT: Actually, we won’t. I changed it to StatelessWidget later on).

class TaskRow extends StatefulWidget {
  final Task task;
  final double dotSize = 12.0;

  const TaskRow({Key key, this.task}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return new TaskRowState();
  }
}

class TaskRowState extends State<TaskRow> {
  @override
  Widget build(BuildContext context) {
    return new Padding(
      padding: const EdgeInsets.symmetric(vertical: 16.0),
      child: new Row(
        children: <Widget>[
          new Padding(
            padding: new EdgeInsets.symmetric(horizontal: 32.0 - widget.dotSize / 2),
            child: new Container(
              height: widget.dotSize,
              width: widget.dotSize,
              decoration: new BoxDecoration(shape: BoxShape.circle, color: widget.task.color),
            ),
          ),
          new Expanded(
            child: new Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                new Text(
                  widget.task.name,
                  style: new TextStyle(fontSize: 18.0),
                ),
                new Text(
                  widget.task.category,
                  style: new TextStyle(fontSize: 12.0, color: Colors.grey),
                )
              ],
            ),
          ),
          new Padding(
            padding: const EdgeInsets.only(right: 16.0),
            child: new Text(
              widget.task.time,
              style: new TextStyle(fontSize: 12.0, color: Colors.grey),
            ),
          ),
        ],
      ),
    );
  }
}

Widget’s row contains 3 elements: a dot, which is a Container with circular boxDecoration, a Column with two Texts and a trailing Text showing Task’s time.

Now we just need to put it all together in a ListView:

Widget _buildTasksList() {
  return new Expanded(
    child: new ListView(
      children: tasks.map((task) => new TaskRow(task: task)).toList(),
    ),
  );
}

We needed to wrap ListView inside Expanded because otherwise, we couldn’t place it inside a Column.

At this moment we have a static list of tasks:

Full code for this stage can be found here.

6. Animated items filtering

Now let’s try to get collapsing effect from design. First, we need to change ListView to AnimatedList. This will allow us to easily add animations to our list. If you are interested in more details on how to use AnimatedList, I recommend this sample. So let’s update _buildTasksList method:

class _MainPageState extends State<MainPage> {
  final GlobalKey<AnimatedListState> _listKey = new GlobalKey<AnimatedListState>();

  ...

  Widget _buildTasksList() {
    return new Expanded(
      child: new AnimatedList(
        initialItemCount: tasks.length,
        key: _listKey,
        itemBuilder: (context, index, animation) {
          return new TaskRow(
            task: tasks[index],
          );
        },
      ),
    );
  }
}

AnimatedList gives us two main methods: insert(index) and remove(index, builder). Since we can only pass index, it is important to keep our data list with our view list synchronized. To do that, let’s add a ListModel class.

class ListModel {
  ListModel(this.listKey, items) : this.items = new List.of(items);

  final GlobalKey<AnimatedListState> listKey;
  final List<Task> items;

  AnimatedListState get _animatedList => listKey.currentState;

  void insert(int index, Task item) {
    items.insert(index, item);
    _animatedList.insertItem(index);
  }

  Task removeAt(int index) {
    final Task removedItem = items.removeAt(index);
    if (removedItem != null) {
      _animatedList.removeItem(
        index,
        (context, animation) => new Container(),
      );
    }
    return removedItem;
  }

  int get length => items.length;

  Task operator [](int index) => items[index];

  int indexOf(Task item) => items.indexOf(item);
}

Most important aspects of this class are insert and removeAtInsert basically adds an item to both List of Tasks as well as AnimatedList. RemoveAt does the reverse, however, we can pass a builder that will build a Widget to be drawn in place of removed item. For simplicity, we will leave a simple container there and we will come back to it later.

Now let’s change MainPageState so that it uses listModel instead of a static list of tasks.

class _MainPageState extends State<MainPage> {
  final GlobalKey<AnimatedListState> _listKey = new GlobalKey<AnimatedListState>();
  ListModel listModel;

  @override
  void initState() {
    super.initState();
    listModel = new ListModel(_listKey, tasks);
  }

  Widget _buildTasksList() {
    return new Expanded(
      child: new AnimatedList(
        initialItemCount: tasks.length,
        key: _listKey,
        itemBuilder: (context, index, animation) {
          return new TaskRow(
            task: listModel[index],
          );
        },
      ),
    );
  }
}

After adding ListModel to MainPageState, let’s add a possibility to change filtering of our tasks. To do that we can add a bool field showOnlyCompleted and change it when a user clicks on FloatingActionButton:

class _MainPageState extends State<MainPage> {
  ListModel listModel;
  bool showOnlyCompleted = false;


  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      body: new Stack(
        children: <Widget>[
          ...
          _buildFab(),
        ],
      ),
    );
  }

  Widget _buildFab() {
    return new Positioned(
      top: _imageHeight - 36.0,
      right: 16.0,
      child: new FloatingActionButton(
        onPressed: _changeFilterState,
        backgroundColor: Colors.pink,
        child: new Icon(Icons.filter_list),
      ),
    );
  }

  void _changeFilterState() {
    showOnlyCompleted = !showOnlyCompleted;
    tasks.where((task) => !task.completed).forEach((task) {
      if (showOnlyCompleted) {
        listModel.removeAt(listModel.indexOf(task));
      } else {
        listModel.insert(tasks.indexOf(task), task);
      }
    });
  }
}

How it works is that on every FloatingActionButton tap, we iterate through all non-completed tasks (because completed tasks are shown either way) and then depending on showOnlyCompleted value, we either insert them or remove them. It looks like this:

Ok, but where is the animation? In AnimatedList‘s builder, we do have an animation, which is managed by AnimatedList and can be passed to our row view. Let’s add SizeTransition and FadeTransition which will be responsible for shrinking and expanding rows depending on animation value.

class TaskRow extends StatelessWidget {
  final Animation<double> animation;

  const TaskRow({Key key, this.task, this.animation}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return new FadeTransition(
      opacity: animation,
      child: new SizeTransition(
        sizeFactor: animation,
        child: ...
      ),
    );
  }
}

Now we need to pass that transition in insert and removeAt methods.

class ListModel {
  Task removeAt(int index) {
    final Task removedItem = items.removeAt(index);
    if (removedItem != null) {
      _animatedList.removeItem(
        index,
        (context, animation) => new TaskRow(
              task: removedItem,
              animation: animation,
            ),
      );
    }
    return removedItem;
  }
}
class _MainPageState extends State<MainPage> {

  Widget _buildTasksList() {
    return new Expanded(
      child: new AnimatedList(
        initialItemCount: tasks.length,
        key: _listKey,
        itemBuilder: (context, index, animation) {
          return new TaskRow(
            task: listModel[index],
            animation: animation,
          );
        },
      ),
    );
  }

}

Our animated list looks like this:

Let’s add minor improvement to durations based on widget’s position in the list.

class ListModel {

  void insert(int index, Task item) {
    items.insert(index, item);
    _animatedList.insertItem(index, duration: new Duration(milliseconds: 150));
  }

  Task removeAt(int index) {
    ...
      _animatedList.removeItem(
        index,
        (context, animation) => new TaskRow(
              task: removedItem,
              animation: animation,
            ),
        duration: new Duration(milliseconds: (150 + 150*(index/length)).toInt())
      );
    }
    ...
  }

}

We end up with this:

Full code for this stage can be found here.

7. Animated Floating Action Button

Now it’s time to give our FloatingActionButton an animation. Let’s start by creating a new class for it.

class AnimatedFab extends StatefulWidget {
  final VoidCallback onClick;

  const AnimatedFab({Key key, this.onClick}) : super(key: key);

  @override
  _AnimatedFabState createState() => new _AnimatedFabState();
}

class _AnimatedFabState extends State<AnimatedFab> {

  @override
  Widget build(BuildContext context) {
    return _buildFabCore();
  }

  Widget _buildFabCore() {
    return new FloatingActionButton(
      onPressed: widget.onClick,
      child: new Icon(Icons.filter_list),
      backgroundColor: Colors.pink,
    );
  }
}

And let’s repace old Fab with new one:

class _MainPageState extends State<MainPage> {
  Widget _buildFab() {
    return new Positioned(
      top: _imageHeight - 36.0,
      right: 16.0,
      child:new AnimatedFab(
        onClick: _changeFilterState,
      )
    );
  }
}

Now let’s start with animating core of the fab. We can see two transitions here: first is changing Icon from filter icon to close icon, second is changing color from default pink to a darker one. Since there are synchronized we will only need one AnimationController. From this point, clicking on Fab will not invoke onClick method, it will only open and close Fab widget. Let’s start with changing color:

class _AnimatedFabState extends State<AnimatedFab> with SingleTickerProviderStateMixin {
  AnimationController _animationController;
  Animation<Color> _colorAnimation;

  @override
  void initState() {
    super.initState();
    _animationController = new AnimationController(vsync: this, duration: Duration(milliseconds: 200));
    _colorAnimation = new ColorTween(begin: Colors.pink, end: Colors.pink[800])
        .animate(_animationController);
  }

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

  @override
  Widget build(BuildContext context) {
    return new AnimatedBuilder(
      animation: _animationController,
      builder: (BuildContext context, Widget child) {
        return _buildFabCore();
      },
    );
  }

  Widget _buildFabCore() {
    return new FloatingActionButton(
      onPressed: _onFabTap,
      child: new Icon(Icons.filter_list),
      backgroundColor: _colorAnimation.value,
    );
  }

  open() {
    if (_animationController.isDismissed) {
      _animationController.forward();
    }
  }

  close() {
    if (_animationController.isCompleted) {
      _animationController.reverse();
    }
  }

  _onFabTap() {
    if (_animationController.isDismissed) {
      open();
    } else {
      close();
    }
  }
}

We created two animations. AnimationController will be our main point of controlling animation. ColorAnimation can be considered as a derivative of the controller that provides Color value between pink and dark pink. Everything is wrapped inside AnimatedBuilder which rebuilds view every time controller’s value changes.

Now let’s change the icon. We will use same AnimationController but without any extra Animation. On the design we can see, that icon is shrinking vertically and then expanding as a new one. To achieve that we will use Transform widget, which will change only among Y axis. We also need to calculate the size factor since we cannot just pass animation controller’s value.

Widget _buildFabCore() {
  double scaleFactor = 2 * (_animationController.value - 0.5).abs();
  return new FloatingActionButton(
    onPressed: _onFabTap,
    child: new Transform(
        alignment: Alignment.center,
        transform: new Matrix4.identity()..scale(1.0, scaleFactor),
        child: new Icon(
          _animationController.value > 0.5 ? Icons.close : Icons.filter_list,
          color: Colors.white,
          size: 26.0,
        ),
      ),
    backgroundColor: _colorAnimation.value,
  );
}

It’s time to add an expanded background to our Fab. To do that we need to set its size to fixed values of the expanded state. That means we will also need to change the position of it in the stack. We will also wrap fab in a new stack which we will use later on.

class _AnimatedFabState extends State<AnimatedFab> with SingleTickerProviderStateMixin {
  final double expandedSize = 180.0;

  @override
  Widget build(BuildContext context) {
    return new SizedBox(
      width: expandedSize,
      height: expandedSize,
      child: new AnimatedBuilder(
        animation: _animationController,
        builder: (BuildContext context, Widget child) {
          return new Stack(
            alignment: Alignment.center,
            children: <Widget>[
              _buildFabCore(),
            ],
          );
        },
      ),
    );
  }
}
class _MainPageState extends State<MainPage> {
  Widget _buildFab() {
    return new Positioned(
      top: _imageHeight - 100.0,
      right: -40.0,
      child:new AnimatedFab(
        onClick: _changeFilterState,
      )
    );
  }
}

Now let’s add a background. It will be a circle which changes its size depending on animation value.

class _AnimatedFabState extends State<AnimatedFab> with SingleTickerProviderStateMixin {
  final double expandedSize = 180.0;
  final double hiddenSize = 20.0;

  @override
  Widget build(BuildContext context) {
    return new SizedBox(
      ...
          return new Stack(
            alignment: Alignment.center,
            children: <Widget>[
              _buildExpandedBackground(),
              _buildFabCore(),
            ],
          );
  }

  Widget _buildExpandedBackground() {
    double size = hiddenSize + (expandedSize - hiddenSize) * _animationController.value;
    return new Container(
      height: size,
      width: size,
      decoration: new BoxDecoration(shape: BoxShape.circle, color: Colors.pink),
    );
  }
}

We have also added hiddenSize. If we just multiplied expandedSize and _animationController.value it would technically work, but at initial phase of expanding, background would be hidden behind core fab. It would create an illusion of delay which we don’t want to happen. The solution is creating minimal size of background to which it shrinks, that is hiddenSize.

We’re almost done, now we need to add icons to the expanded state. First let’s try to statiacally place the icons in right position. Since there are positioned on the circle, Transform.rotate seems like a good widget to use. For every icon we will define an angle of rotation. Notice that we will use two rotations, because after we place our icon, we need to rotate it back to default state so it won’t end up upside down.

import 'dart:math' as math;
class _AnimatedFabState extends State<AnimatedFab>with SingleTickerProviderStateMixin {

  @override
  Widget build(BuildContext context) {
    return new SizedBox(
      ...
          return new Stack(
            alignment: Alignment.center,
            children: <Widget>[
              _buildExpandedBackground(),
              _buildOption(Icons.check_circle, 0.0),
              _buildOption(Icons.flash_on, -math.pi / 3),
              _buildOption(Icons.access_time, -2 * math.pi / 3),
              _buildOption(Icons.error_outline, math.pi),
              _buildFabCore(),
            ],

  }
  Widget _buildOption(IconData icon, double angle) {
    return new Transform.rotate(
      angle: angle,
      child: new Align(
        alignment: Alignment.topCenter,
        child: new Padding(
          padding: new EdgeInsets.only(top: 8.0),
          child: new IconButton(
            onPressed: null,
            icon: new Transform.rotate(
              angle: -angle,
              child: new Icon(
                icon,
                color: Colors.white,
              ),
            ),
            iconSize: 26.0,
            alignment: Alignment.center,
            padding: new EdgeInsets.all(0.0),
          ),
        ),
      ),
    );
  }
}

Now let’s animate it! All we need to do is adapt the size of icon depending on animation’s value.

Widget _buildOption(IconData icon, double angle) {
  double iconSize = 0.0;
  if (_animationController.value > 0.8) {
    iconSize = 26.0 * (_animationController.value - 0.8) * 5;
  }
  return new Transform.rotate(
    ...
        child: new IconButton(
          ...
          iconSize: iconSize,
        ),
  );
}

The last thing to is adding a listener on icon click. In IconButton‘s onPressed method we will pass this:

_onIconClick() {
  widget.onClick();
  close();
}

Let’s see how everything works together!

Original design
My view




















Ok, so there are some differences. 😀 Let’s say it’s close enough 🙂

The full code can be found here.

If you like this post or you think you could do something better, please leave a comment.

Cheers 🙂

11 thoughts on “Filter Menu – UI Challenge

  1. Great!! I’m a newbie and this is very handy.

    I’ll keep coming here for to learn quicker.

    Nice job!

  2. This looks really impressive. I’m fairly new to Flutter, following your steps was surprisingly simple and easy to read as a whole, however. I’ll surely use it as a reference once I get into creating animated layouts.

    Thanks for sharing.

  3. thanks a lot my dear. I really enjoyed this tutorial.
    however, I was also using the its GitHub project as a reference, because, as you noted, some changes are made in repo, but the article.

    anyway, thanks again 🙂

Leave a Reply

Your email address will not be published.