It’s been a quite long time since the last post. Since I now figured out the ultimate way to finish articles, hopefully, I’ll be able to push some more content more often. Spoiler alert: it involves beer. (08/18/2018 update): No, I’m still slow writing articles. Seems like there’s no silver bullet for being lazy.

Today, we’re turning the beautiful artist profile page mockup by pramod into a real Flutter UI. For the mock data, we’re using one of my all-time favorite musicians, Andy Fraser, and a couple of the most amazing live performances he did with Free.

Here’s what the result looks like:

The result we're making in this tutorial.

Without further ado, let’s go straight into it.

The sample project

The sample project is right here.

The starter project with assets

I’ve set up a starter branch where the resized assets have been included in a new app. This way you don’t have to deal with all the boilerplate of resizing images and creating model classes.

In a real-world scenario, all the images and content would come from a backend API. Since this tutorial is only focusing on the UI part, we use static assets.

The model classes are really simple; there’s only an Artist and a Video class.

Blurring the background image

Although we could just open Gimp or Photoshop and apply the blur effect there, we’re blurring the image using Flutter’s BackdropFilter combined with ImageFilter.blur. In a real-world scenario, it might be that we need the unblurred version of the image, and we don’t want to store two versions of the same asset.

artist_details_page.dart

import 'package:flutter/material.dart';

import 'models.dart';

class ArtistDetailsPage extends StatelessWidget {
  ArtistDetailsPage(this.artist);
  final Artist artist;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: [
          Image.asset(artist.backdropPhoto, fit: BoxFit.cover),
          BackdropFilter(
            filter: ui.ImageFilter.blur(sigmaX: 5, sigmaY: 5),
            child: Container(
              color: Colors.black.withOpacity(0.5),
              // TODO: child: _buildContent(),
            ),
          ),
        ],
      ),
    );
  }
}

To achieve the blurred background effect, we:

  • wrap the entire screen in a Stack widget with fit set to StackFit.expand
  • have the background image as the first child, with fit set to BoxFit.cover
  • as the second child, have a BackdropFilter with the filter set to ImageFilter.blur.

As we know from previous tutorials, the Stack widget is used for putting widgets on top of each other. With StackFit.expand, all the non-positioned children will fill the entire Stack.

BackdropFilter applies a filter, in our case a blur effect, to existing content and then lays out the child. We also have a slightly transparent black color as a base for our content.

With this, we end up with a blurry background image:

Step one: a screen with a blurry background image.

That’s looking a little empty. Let’s add some content.

Setting up the general structure

To keep our indentation levels sane, let’s introduce a method called buildContent. Looking at the design, our content consists of three distinct sections that are stacked vertically:

  • the circular avatar image
  • the textual info, such as artist name, location, and biography
  • the horizontal video scroller

We’ll want to wrap our content in a scroll view since there’s a chance that it will overflow on smaller screen sizes.

artist_details_page.dart
// ...
Widget _buildContent() {
  return SingleChildScrollView(
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _buildAvatar(),
        _buildInfo(),
        _buildVideoScroller(),
      ],
    ),
  );
}

Widget _buildAvatar() {
  // TODO
  return Container();
}

Widget _buildInfo() {
  // TODO
  return Container();
}

Widget _buildVideoScroller() {
  // TODO
  return Container();
}

Let’s create all these widgets one by one.

Creating the circular avatar image

The circular avatar is probably my favorite part of this beautiful design. It’s a minimal and simple effect, yet it looks terrific.

So here’s what we know:

  • the avatar is a circle, roughly 100px in size
  • it has a slightly transparent 1px solid border
  • there’s some padding between the border and the image.

With a little tinkering, we end up with the following code for our buildAvatar method:

artist_details_page.dart
// ...
Widget _buildAvatar() {
  return Container(
    width: 110,
    height: 110,
    decoration: BoxDecoration(
      shape: BoxShape.circle,
      border: Border.all(color: Colors.white30),
    ),
    margin: const EdgeInsets.only(top: 32, left: 16),
    padding: const EdgeInsets.all(3),
    child: ClipOval(
      child: Image.asset(artist.avatar),
    ),
  );
}

By setting the shape of the BoxDecoration to BoxShape.circle, all decoration elements will be drawn to a circular shape. In our case, this means the border.

To clip our image to a circle shape, we just wrap it in a ClipOval widget, which prevents its child from painting the usual straight corners.

When doing everything correctly, we end up with the result that looks like this:

The avatar with an outline.

Awesome! Now let’s populate the artist name, location, and biography into the UI.

Creating the textual info section

This is probably the most straightforward one of these sections. It’s just some stylized Text widgets in a Column, with some padding around. The most interesting part here is probably the divider.

artist_details_page.dart
// ...
Widget _buildInfo() {
  return Padding(
    padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          artist.firstName + '\n' + artist.lastName,
          style: TextStyle(
            color: Colors.white,
            fontWeight: FontWeight.bold,
            fontSize: 30,
          ),
        ),
        Text(
          artist.location,
          style: TextStyle(
            color: Colors.white.withOpacity(0.85),
            fontWeight: FontWeight.w500,
          ),
        ),
        Container(
          color: Colors.white.withOpacity(0.85),
          margin: const EdgeInsets.symmetric(vertical: 16),
          width: 225,
          height: 1,
        ),
        Text(
          artist.biography,
          style: TextStyle(
            color: Colors.white.withOpacity(0.85),
            height: 1.4,
          ),
        ),
      ],
    ),
  );
}

The Container with a height of 1.0 is just merely the divider between the location and artist biography text.

This is what we end up with:

The avatar with an outline.

It’s mind-boggling that Andy was only 17 years old when he played this. Seriously, watch it. It’s the best way to spend a couple minutes of your time.

Creating the video scroller

This is easily the most involved part of this tutorial, but still nothing to get scared of. Dynamic lists in Flutter are much more concise than in native Android or iOS.

Setting up the horizontal list

We need a horizontal list that has undefined amount of items and scrolls in the horizontal axis. We’ll probably also want to have some padding around the items and top, so it’s not right below the artist information section.

artist_details_page.dart
// ...
Widget _buildVideoScroller() {
  return Padding(
    padding: const EdgeInsets.only(top: 16),
    child: SizedBox.fromSize(
      size: Size.fromHeight(245),
      child: ListView.builder(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 8),
        itemCount: artist.videos.length,
        itemBuilder: (context, index) {
          var video = artist.videos[index];
          return VideoCard(video);
        },
      ),
    ),
  );
}

We’re using the ListView.builder constructor, since we could have an indefinite amount of videos. That way we’ll build the widgets for our items on demand, which is more efficient.

The reason we’re wrapping the list in a SizedBox is that otherwise it has unbounded height constraints. This means that our ListView will be of infinite height, and will result in an error.

Creating the video cards

Here’s what we start with:

video_card.dart

import 'models.dart';

class VideoCard extends StatelessWidget {
  VideoCard(this.video);
  final Video video;

  BoxDecoration _buildShadowAndRoundedCorners() {
    // TODO
    return BoxDecoration();
  }

  Widget _buildThumbnail() {
    // TODO
    return Placeholder();
  }

  Widget _buildInfo() {
    // TODO
    return Placeholder();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 175.0,
      padding: const EdgeInsets.all(8.0),
      margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
      decoration: _buildShadowAndRoundedCorners(),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Flexible(flex: 3, child: _buildThumbnail()),
          Flexible(flex: 2, child: _buildInfo()),
        ],
      ),
    );
  }
}

We’re using Flexible widgets with different flex factors to divide the available space. In this case, the thumbnail takes up 3/5 of the available space, which leaves 2/5 for the textual information. The magic number 5 here is simply the sum of all flex factors of the Flexible widgets in this Column.

Thanks to the Placeholder widget, we can already see the general outline of our video cards:

The power of Placeholder widgets.

Now we just have to create the parts that make up our video card.

Styling the video card

By looking at the design, we can see that the video cards have a drop shadow and rounded corners.

To stylize our video cards, we use BoxDecorations, more specifically the borderRadius and boxShadow properties. We’ll also have a semi-transparent white background color.

video_card.dart
// ...
BoxDecoration _buildShadowAndRoundedCorners() {
  return BoxDecoration(
    color: Colors.white.withOpacity(0.4),
    borderRadius: BorderRadius.circular(10),
    boxShadow: [
      BoxShadow(
        spreadRadius: 2,
        blurRadius: 10,
        color: Colors.black26,
      ),
    ],
  );
}

That gives us this look:

The video cards with a shadow and rounded corners.

Let’s add the thumbnail images to our cards.

Building the video thumbnail and the play button

Remember how we used ClipOval to clip the avatar into a circle shape? The thumbnail will use a similar approach, except instead of ClipOval, we’ll use ClipRRect. A ClipRRect is a clipped round rectangle.

video_card.dart
// ...
Widget _buildThumbnail() {
  return ClipRRect(
    borderRadius: BorderRadius.circular(8),
    child: Stack(
      children: [
        Image.asset(video.thumbnail),
        Positioned(
          bottom: 12,
          right: 12,
          child: _buildPlayButton(),
        ),
      ],
    ),
  );
}

By eyeballing the original design, we’ll end up with a radius of 8.0. Since the play button is positioned over the thumbnail, we’ll use a Stack widget.

video_card.dart
// ...
Widget _buildPlayButton() {
  return Material(
    color: Colors.black87,
    type: MaterialType.circle,
    child: InkWell(
      onTap: () {
        // TODO
      },
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Icon(
          Icons.play_arrow,
          color: Colors.white,
        ),
      ),
    ),
  );
}

The play button is just an Icon with some padding, wrapped in an InkWell which is wrapped in a Material widget.

InkWell draws a ripple touch response, but in our case, it needs a direct Material ancestor. Otherwise, the ripple will be drawn but we don’t see it, since the nearest Material to draw on is in the main Scaffold and we have overlapping content on top of that.

The play button, with touch feedback.

Almost done! Finally, the easiest part of these video cards.

Building the video info text

This is probably the most simple one of these. It’s just a Text widget with some padding around it.

video_card.dart
// ...
Widget _buildInfo() {
  return Padding(
    padding: const EdgeInsets.only(top: 16, left: 4, right: 4),
    child: Text(
      video.title,
      style: TextStyle(color: Colors.white.withOpacity(0.85)),
    ),
  );
}

And we’re done!

It doesn’t make a lot of sense without the play buttons actually opening those videos, does it? Let’s change that.

To open links, we can use the first-party url_launcher plugin.

First, we add the dependency to our pubspec file, in the dependencies block:

pubspec.yaml
dependencies:
  ...
  url_launcher: ^2.0.2

To fetch the new dependency, we can run flutter packages get or click Packages get in our IDE.

After that, integrating the plugin is really straightforward:

video_card.dart
import 'package:url_launcher/url_launcher.dart';

// ...
Widget _buildPlayButton() {
  return Material(
    // ...
    child: InkWell(
      onTap: () async {
        if (await canLaunch(video.url)) {
          await launch(video.url);
        }
      },
      child: Container(/* ... */),
    ),
  );
}

Now we have a fancy artist details page that can open the video links.

If you missed it, the sample app is here.