Stay up to date with
news on business
and innovation
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Create a sleek UI in Flutter - Wolt app case study

Zbigniew Górawski
Mobile Developer
Product Development
Food Delivery
Mobile Development
Flutter

Flutter is Google’s UI toolkit for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase. We are going to check its promises by recreating part of an existing application as accurately as possible. I chose the Wolt app because the Wolt team has been doing an amazing job, creating sleek UI & UX with many subtle details.


My goal for this series is to present a thought process that leads to the desired result rather than provide a copy-paste solution. We will work in a build-refactor cycle, examining potential problems and framework limitations. You may find the whole code for this series on our github: https://github.com/nomtek/flutter-meets-wolt.
Let’s dive in!

App bar buttons & menu

Let’s warm up with something that seems to be simple - app bar buttons. There are two of them - one is used to navigate back, and the other is showing a menu with two items. My first idea was to use IconButton composed with Container that has circle-shaped decoration:

class AppBarButton extends StatelessWidget {
  final IconData icon;
  final VoidCallback onPressed;
     
     const AppBarButton({Key key, this.icon, this.onPressed}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey[300]),
      child: IconButton(
        icon: Icon(icon),
        onPressed: onPressed,
      ),
    );
  }
}
     

When we put those widgets in an AppBar:

SliverAppBar(
  leading: AppBarButton(
    icon: Icons.keyboard_backspace,
    onPressed: () => Navigator.pop(context),
  ),
  actions: [
    AppBarButton(
      icon: Icons.more_horiz,
      onPressed: () { /* TODO */ },
    ),
  ],
  

The result is already quite similar to what we have in the original application:


Our first implementation has no padding and buttons are too big. When we press the button it shows a grey highlight followed by an animated, darker splash instead of opacity change. Let’s try to implement those missing features.

Size and layout

On the iPhone 8, original buttons have a size of 40x40 points, 16 points margin to the screen edge and 8 points bottom offset. Given that app bar has a height of 44 points on this iPhone it would mean that those buttons have to overlay status bar (the one with signal strength, clock and battery status) and indeed they are:

flutter_overlay

We can apply those constraints by wrapping our button widgets in Padding and Align.

    return Padding(
      padding: EdgeInsets.fromLTRB(
        position == AppBarPosition.leading ? 16 : 0, 0,
        position == AppBarPosition.trailing ? 16 : 0, 8),
      child: Align(
        alignment: Alignment.topCenter,
        child: Container(
          width: 40,
          height: 40,
(...)

  
flutter_app_bar


One difference is that the bottom offset is bigger in our implementation and we don’t even overflow status bar. The reason is that the height of our app bar is calculated based on the const double kToolbarHeight = 56.0; constant from the Flutter framework. There is no explicit way to set app bar height, eg. by constructor parameter, and the class responsible for the app bar layout, which uses this constant, is private (_SliverAppBarDelegate). This prevents us from using inheritance to override the code responsible for height computation. This delegate is, again, not exposed by the app bar (SliverAppBar), so even if we end up creating our own version, we won’t be able to use it unless we also extend SliverAppBar and override build method from its state. Since Flutter is open source this could be done in a few minutes, by copy-paste original implementation and tweaking those details, but it’s far from feasible solution as we would have to maintain our version and keep it in sync with improvements made by the Flutter team to the original classes.

It’s worth to take a note, that kToolbarHeight is also used to constraint the width of the leading widget (back button in our case), forcing it to be a square. This is how our app bar looks like with margins increased to 25 points. Notice shrunken leading button, while trailing is spaced from the screen edge as expected. This limitation has no effect in our case, as designed margin and button size is exactly matching available space.

flutter_margin

Highlight behaviour

In the Wolt app when the button is highlighted it changes the opacity of the icon. There is no highlight colour change or splash animation. We can re-create such behaviour by wrapping button in the Opacity widget. To track highlight status we have to introduce an internal state, represented by the _isHighlighted boolean property. That means we have to refactor our widget from stateless to stateful:

enum AppBarPosition {
  leading,
  trailing,
}

class AppBarButton extends StatefulWidget {
  final IconData icon;
  final VoidCallback onPressed;
  final AppBarPosition position;

  const AppBarButton({Key key, this.icon, this.onPressed, this.position}) : super(key: key);

  @override
  _AppBarButtonState createState() => _AppBarButtonState();
}

class _AppBarButtonState extends State {

  bool _isHighlighted = false;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.fromLTRB(widget.position == AppBarPosition.leading ? 16 : 0, 0,
          widget.position == AppBarPosition.trailing ? 16 : 0, 8),
      child: Align(
        alignment: Alignment.topCenter,
        child: Container(
          width: 40,
          height: 40,
          decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey[300]),
          child: Opacity(
            opacity: _isHighlighted ? 0.3 : 1.0,
            child: IconButton(
              icon: Icon(
                widget.icon,
                color: Colors.black,
              ),
              onPressed: widget.onPressed,
            ),
          ),
        ),
      ),
    );
  }
}
  


Unfortunately, IconButton we are using is not exposing onHighlightChanged callback - only onPressed, which is not enough for our needs. We have to refactor our code to use more generic button class, like RawMaterialButton where we have more control over callbacks and visual settings.

  child: RawMaterialButton(
    highlightColor: Colors.transparent,
    splashColor: Colors.transparent,
    onHighlightChanged: (isHighlighted) => setState(() {
      _isHighlighted = isHighlighted;
    }),
    child: Icon(
      widget.icon,
      color: Colors.black,
    ),
    onPressed: widget.onPressed,
  ),


Showing the menu

When the user presses menu button two things happen - the menu is shown and the button icon changes from three dots to close cross. We are going to track the current state in the boolean property _isMenuShown in the State of the screen-route. Updated menu button:

    AppBarButton(
      icon: _isMenuShown ? Icons.close : Icons.more_horiz,
      position: AppBarPosition.trailing,
      onPressed: () {
        Navigator.push(context, AppBarMenu())
            .then((_) => setState(() => _isMenuShown = false));
        setState(() => _isMenuShown = true);
      },
    ),


We will build AppBarMenu class that extends PopupRoute, as it gives us more control over UI of the menu than PopupMenuButton from the Flutter framework. On button press, Navigator widget is tasked to push our Route to the stack. Push method returns a future which completes after this route is dismissed - that’s why we set _isMenuShown to false in the then callback.

Menu look & feel

Our final task is to build a menu that will be displayed. It’s pretty straightforward - a list with two items, the less obvious parts maybe how to place it on the screen, and how to achieve the shape of a rectangle with rounded corners and triangle indicator on top. We are going to use ClipPath widget with a custom clipper to build the desired shape. An alternative solution would be to compose ClipRRect (RRect stands for rounded rectangle) with an Image widget for the top triangle. Yet another idea is to have a whole background as a nine-patch image, and there are for sure a few more feasible options to achieve the desired UI. Due to Flutter’s widget-oriented architecture, there are often multiple ways how can we compose existing primitives into more complex structures - like this fancy-shaped menu. You can preview updated buttons and the menu on the gif below:


flutter_buttons_menu

Conclusion

Flutter allowed us to recreate, very closely, UI and UX of Wolt’s piece of the interface. We were able to achieve the compelling look & feel quickly by composing native widgets, and even if there are certain limitations, due to open-source nature of the Flutter framework, achieving pixel-perfect quality is possible when needed.

You may also like