Flutter Challenge #1

Abr 13, 2020

Flutter Challenge #1

Cupertino widgets and a simple custom Drawer for iOS.


This is the first of a series of challenges reproducing Dribbble concepts with Flutter. This time we will be working with Wg by Sarah-D.

First steps

Prepare the main.dart file removing everything, leaving only the MyApp class. You can also remove the debug banner and put HomePage widget as your home.

import 'package:flutter/material.dart';
import 'package:wg_by_sarah_d/home_page.dart';

void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false, // Remove the debug banner
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: HomePage(),
    );
  }
}

Then on the home_page.dart file you’ll be creating a StatefulWidget. From the initState() method call SystemChrome.setSystemUIOverlayStyle() for making the status bar transparent.

@override
void initState() {
  SystemChrome.setSystemUIOverlayStyle(
    SystemUiOverlayStyle(statusBarColor: Colors.transparent),
  );
  super.initState();
}


Finally return a CupertinoPageScaffold on the build method, setting it’s backgroundColor to #2B292A.

@override
Widget build(BuildContext context) {
  return CupertinoPageScaffold(
    backgroundColor: Color(0xFF2B292A),
  );
}

Now we have a nice black background to start composing our app.

Laying out the app

Now we’ll start laying out the design on our Flutter app.

1. Navigation Bar

The child of our scaffold will contain a Stack. The first child will be a CupertinoNavigationBar with the same background color as the scaffold and the menu icon on the left. Wrap it inside a Positioned widget to place it at the top of the screen.

You may note that the menu icon is not available in CupertinoIcons. Actually, the font file includes it but it’s not being exposed. Therefore we resorted to the Cupertino icons map (you can find it here). Now you can call that icon passing the corresponding hex code to the IconData class.

child: Stack(
  children: <Widget>[
    Positioned(
      top: 0.0,
      left: 0.0,
      right: 0.0,
      child: CupertinoNavigationBar(
        backgroundColor: Color(0xFF2B292A),
        border: Border.all(
          style: BorderStyle.none,
        ),
        actionsForegroundColor: Colors.white,
        leading: Icon(IconData(0xF394, fontFamily: CupertinoIcons.iconFont, fontPackage: CupertinoIcons.iconFontPackage)),
      ),
    ),
  ],
),

2. Welcome text

Next add a Container to the Stack after the navigation bar. Wrap it inside a Positioned widget and give it a distance of 85 dp from the top (that will let room for the navigation bar).

This Container will have full width, but for the height let’s give it the height of the screen, minus the height of the screen divided by 1.8 (this will be the size of the slides below), minus 120 dp (the height of the bottom red section).

Inside the Container we have a Column whose first child is the welcome text.

child: Stack(
  children: <Widget>[
    Positioned(
      top: 90.0,
      left: 0.0,
      right: 0.0,
      child: Container(
        width: double.infinity,
        height: MediaQuery.of(context).size.height - (MediaQuery.of(context).size.height / 1.8) - 120.0,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.max,
            children: <Widget>[
              RichText(
                textAlign: TextAlign.start,
                text: TextSpan(
                  children: [
                    TextSpan(
                      text: 'Welcome! ',
                      style: TextStyle(
                        fontWeight: FontWeight.w500,
                        fontSize: 26.0,
                      ),
                    ),
                    TextSpan(
                      text: 'Ryan',
                      style: TextStyle(
                        fontSize: 20.0,
                      ),
                    ),
                  ]                ),
              ),
            ],
          ),
        ),
      ),
    ),
  ],
),

As you can see on the code, we’re using a RichText widget to be able to use different styles (there are other approaches you can follow too to achieve the same).

3. Buttons

We will be placing this four buttons in a Row widget. For now we’ll be using the Placeholder widget to quickly mockup this section.

We’ve created a new StatelessWidget and called it SquareButton. It’s just a Column with a Placeholder for the button and another for the text, with a little space between them.

class SquareButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        Placeholder(
          color: Colors.red,
          fallbackWidth: 60.0,
          fallbackHeight: 60.0,
        ),
        SizedBox(
          height: 8.0,
        ),
        Placeholder(
          color: Colors.white,
          fallbackWidth: 60.0,
          fallbackHeight: 20.0,
        ),
      ],
    );
  }
}

Now we can proceed by adding a Row below the RichText, with four SquareButton inside. We want them to occupy the space proportionally and also to be align to the left and right to make it true to the design. The solution is very simple, just using MainAxisAlignment.spaceBetween.

... // RichText here in the same Column
Row(
  mainAxisSize: MainAxisSize.max,
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: <Widget>[
    SquareButton(),
    SquareButton(),
    SquareButton(),
    SquareButton(),
  ],
),

4. Service Request

This is just a simple Row. The little dot at the start is a Container with a BoxDecoration. Next to it goes a Text and finally an Icon (ellipsis).

Padding(
  padding: const EdgeInsets.only(bottom: 16.0),
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.center,
    mainAxisSize: MainAxisSize.max,
    children: <Widget>[
      Container(
        width: 7.0,
        height: 7.0,
        decoration: BoxDecoration(
          color: Color(0xFFB42827),
          borderRadius: BorderRadius.circular(5.0),
        ),
      ),
      SizedBox(
        width: 8.0,
      ),
      Text(
        'Service Request',
        style: Theme.of(context).textTheme.subtitle.copyWith(color: Colors.white),
      ),
      Expanded(child: SizedBox()), // Make a separation between widgets
      Icon(
        CupertinoIcons.ellipsis,
        color: Colors.white,
      ),
    ],
  ),
),

Finally, let’s change the alignment of the Column for better use of the space.

child: Column(
  ... // Other properties
  mainAxisAlignment: MainAxisAlignment.spaceAround,
  ... // Children widgets
),

5. Middle and bottom sections containers

Next, we should add some containers for the other two sections. Like this:

Stack(
  children: [
    ... // Navigation bar
    ... // Welcome text and buttons
    Positioned(
      bottom: 120.0,
      left: 0.0,
      right: 0.0,
      child: Container(
        height: MediaQuery.of(context).size.height / 1.8 - 90.0, // Substracting 90dp to compensate the height of status and navigation bars
      ),
    ),
    Positioned(
      bottom: 0.0,
      left: 0.0,
      right: 0.0,
      child: Container(
        height: 120.0,
        color: Color(0xFFB42827),
      ),
    ),
  ],
),

6. Bottom container

The content of this bottom container is very simple, by using a Row for placing the items.

Let’s make the left icon by decorating a Container and placing an Icon centered inside.

Container(
  width: 45.0,
  height: 45.0,
  decoration: BoxDecoration(
    borderRadius: BorderRadius.circular(25.0),
    color: Colors.white.withOpacity(0.1),
  ),
  child: Center(
    child: Icon(
      IconData(0xF391, fontFamily: CupertinoIcons.iconFont, fontPackage: CupertinoIcons.iconFontPackage),
      color: Colors.white,
    ),
  ),
),


The following texts column needs no explanation:

Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  mainAxisSize: MainAxisSize.min,
  children: <Widget>[
    Text(
      '260',
      style: Theme.of(context).textTheme.headline.copyWith(fontWeight: FontWeight.w500, color: Colors.white),
    ),
    Text(
      'My application',
      style: Theme.of(context).textTheme.caption.copyWith(color: Colors.white.withOpacity(0.5)),
    ),
  ],
),

Finally the button on the right. I’m placing an Expanded widget to push the button to the right. We’ve also removed some padding from the CupertinoButton to match the design.

Expanded(child: SizedBox()),
CupertinoButton(
  color: Colors.white,
  borderRadius: BorderRadius.circular(30.0),
  padding: const EdgeInsets.symmetric(horizontal: 32.0),
  child: Text(
    'SUBMISSION',
    style: TextStyle(
      color: Color(0xFFB42827),
      fontWeight: FontWeight.w500,
    ),
  ),
  onPressed: () {},
),

Please note that the Dribbble design is showing another fonts and icons, which we don’t have.
By now we have this:

Creating the SquareButton widget

We’ll be using the font_awesome_flutter package later, so you may want to add it now to your pubspec.yaml.

First of all, we’ll be adding two parameters to this StatelessWidget: the label String and the Icon.

final String label;
final Icon icon;

SquareButton({
  @required this.label,
  @required this.icon,
})  : assert(label != null),
      assert(icon != null);

Then replace the first Placeholder with a SizedBox that will expand the CupertinoButton that it wraps. Remove the padding from the buttton. Finally put the Icon received, resizing it a bit.

SizedBox(
  width: 60.0,
  height: 60.0,
  child: CupertinoButton(
    padding: EdgeInsets.zero,
    borderRadius: BorderRadius.circular(20.0),
    onPressed: () {},
    color: Color(0xFFB42827),
    child: Icon(icon.icon, size: 26.0,),
  ),
),

The label below is wrapped in a Center inside a Container with the previous dimensions of the Placeholder.

Container(
  width: 60.0,
  height: 20.0,
  child: Center(
    child: Text(
      label,
      style: Theme.of(context).textTheme.caption.copyWith(color: Colors.white),
    ),
  ),
),

You can implement them like this:

Row(
  mainAxisSize: MainAxisSize.max,
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: <Widget>[
    SquareButton(
      icon: Icon(FontAwesomeIcons.search),
      label: 'Lookup',
    ),
    SquareButton(
      icon: Icon(FontAwesomeIcons.userAlt),
      label: 'Customer',
    ),
    SquareButton(
      icon: Icon(FontAwesomeIcons.headset),
      label: 'Contacts',
    ),
    SquareButton(
      icon: Icon(FontAwesomeIcons.solidComments),
      label: 'Message',
    ),
  ],
),

Now it begins to take shape! 😃

The PageView

Start by creating a PageViewCardListTile widget that will be the content of the cards on the PageView.

This widget receives a title and a content values. Add a biggerContent bool with a default value of false, that will help to handle the David textin the design.

class PageViewCardListTile extends StatelessWidget {
  final String title;
  final String content;
  final bool biggerContent;

  PageViewCardListTile({
    @required this.title,
    @required this.content,
    this.biggerContent = false,
  })  : assert(title != null),
        assert(content != null);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          title,
          style: Theme.of(context).textTheme.caption,
        ),
        SizedBox(
          height: 4.0,
        ),
        Text(
          content,
          style: biggerContent ? Theme.of(context).textTheme.title : Theme.of(context).textTheme.subtitle,
        ),
      ],
    );
  }
}

Next, create a PageViewCard widget that will contain these tiles made before.

class PageViewCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 7.0),
      child: Card(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(15.0),
        ),
        margin: EdgeInsets.zero,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: <Widget>[
              PageViewCardListTile(
                title: 'Order clerk',
                content: 'David',
                biggerContent: true,
              ),
              PageViewCardListTile(
                title: 'State',
                content: 'CSC response',
              ),
              PageViewCardListTile(
                title: 'Order time',
                content: '2019-03-21 04:44',
              ),
              PageViewCardListTile(
                title: 'Condition of judgement',
                content: 'CSC Response condition. Lorem ipsum dolor sit amet, consecteture.',
              ),
              SizedBox(
                child: CupertinoButton(
                  padding: const EdgeInsets.symmetric(horizontal: 16.0),
                  child: Row(
                    mainAxisSize: MainAxisSize.max,
                    children: <Widget>[
                      Text(
                        'CSC check',
                        style: TextStyle(
                          color: Color(0xFFB42827),
                        ),
                      ),
                      Expanded(child: SizedBox()),
                      RotatedBox(
                        quarterTurns: 3,
                        child: Icon(
                          CupertinoIcons.down_arrow,
                          color: Color(0xFFB42827),
                        ),
                      ),
                    ],
                  ),
                  color: Colors.redAccent.withOpacity(0.3),
                  onPressed: () {},
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

The code above is pretty straightforward. The only thing to mention that it might be new, is the use of a RotatedBox to convert a down arrow icon, into a right arrow.

Now we need the PageView, wich we will be adding as a child of the Container we defined previously for this purpose.

So instantiate a PageController setting the viewportFraction to 0.92. This will let you see the borders of the widgets at left and right.

PageController _pageController = PageController(
  viewportFraction: 0.92,
  initialPage: 1,
);

Then define this PageView and populate it with some PageViewCard widgets. We’re using a Stack because we want to place those position tracking lines above.

child: Stack(
  children: <Widget>[
    Padding(
      padding: const EdgeInsets.only(bottom: 40.0),
      child: PageView(
        controller: _pageController,
        children: <Widget>[
          PageViewCard(),
          PageViewCard(),
          PageViewCard(),
        ],
      ),
    ),
  ],
),

Our interpretation of this widget’s behavior might not be the one the designer thought about. But it should be very close.

Here is this widget’s code:

class TrackingLines extends StatelessWidget {
  final int length;
  final int currentIndex;

  TrackingLines({
    @required this.length,
    @required this.currentIndex,
  })  : assert(length != null && length > 0),
        assert(currentIndex != null && currentIndex < length);

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: List.generate(length, (index) {
        return Padding(
          padding: const EdgeInsets.all(3.0),
          child: Container(
            width: currentIndex == index ? 15.0 : 10.0,
            height: 3.0,
            color: currentIndex == index ? Color(0xFFB42827) : Colors.grey,
          ),
        );
      }),
    );
  }
}

It receives a length and the currentIndex and updates when the currentIndex matches the line index.

For it to be updated, add a listener to the PageController on the initState() method.

_pageController.addListener(() {
  setState(() => _currentIndex = _pageController.page.round());
});

And place the TrackingLines widget inside the same Stack with the PageView.

... // PageView here
Align(
  alignment: Alignment.bottomCenter,
  child: Padding(
    padding: const EdgeInsets.only(bottom: 16.0),
    child: TrackingLines(
      length: 5,
      currentIndex: _currentIndex,
    ),
  ),
),

So let’s see how it looks for now!

The Drawer

We’re very close to the end! But now, we found a little problem. If you look at the CupertinoPageScaffold it doesn’t have a drawer property (like the Material Scaffold widget).

So, how can we implement this drawer? One option could be to combine the material Scaffold and Drawer widgets, with the other Cupertino widgets. And that wouldn’t be wrong. But we want to show you another way.

Creating the drawer layout

First of all, let’s create this layout. And we’ll be placing it right in front of what we have now. That means, at the end of our main Stack.

So, add a Container, give it a full screen height and two thirds of the screen width. And place it at the end of the Stack. Give a white color. Use Positioned to make it occupy the whole height of the screen.

Positioned(
  top: 0.0,
  bottom: 0.0,
  left: 0.0,
  child: Container(
    width: (MediaQuery.of(context).size.width / 3) * 2,
    height: double.infinity,
    color: Colors.white,
  ),
),

Now we will divide it using the same proportions we’ve been using for the bottom container and the PageView. If you remember we gave the bottom container 120dp on height, and the height of the screen / 1.8 minus 90dp (’cause of the navigation bar) to the PageView. Therefore, we’ll make the red section of the drawer with a height of the screen, minus height of the screen / 1.8, minus 90dp, minus 120dp. And it will match perfectly with the position of the cards on the PageView.

Of course, we’ll use a Stack again.

child: Stack(
  children: <Widget>[
    Container(
      width: double.infinity,
      height: MediaQuery.of(context).size.height - (MediaQuery.of(context).size.height / 1.8 - 90.0) - 120.0,
      color: Color(0xFFB42827),
    ),
  ],
),

Animating the drawer

Before continuing with the content inside the drawer, we’ll implement the animated open/close behavior.

Start by replacing the Positioned widget with an AnimatedPositioned and give a Duration of 300 ms. Declare a variable _isDrawerOpen of type bool and initialize it with false. Then replace the left property with a ternary operator to change the position based on that variable.

AnimatedPositioned(
  duration: Duration(milliseconds: 300),
  top: 0.0,
  bottom: 0.0,
  left: _isDrawerOpen ? 0.0 : -(MediaQuery.of(context).size.width / 3) * 2,
  child: Container(
    ...
  ),
),

This will hide our drawer. Now we only need to change the _isDrawerOpen value when we tap on the menu button.

On the navigation bar, wrap the Icon with a GestureDetector to be able to tap on it. Then use an anonymous function to change the state of the drawer.

leading: GestureDetector(
  onTap: () => setState(() => _isDrawerOpen = true),
  child: Icon(
    IconData(0xF394, fontFamily: CupertinoIcons.iconFont, fontPackage: CupertinoIcons.iconFontPackage),
  ),
),

Inside the red section of the drawer, add a clear icon to close the drawer.

Container(
  ... // Width and height
  color: Color(0xFFB42827),
  child: Stack(
    children: <Widget>[
      Positioned(
        top: 50.0,
        left: 10.0,
        child: GestureDetector(
          onTap: () => setState(() => _isDrawerOpen = false),
          child: Icon(
            CupertinoIcons.clear,
            color: Colors.white,
            size: 40.0,
          ),
        ),
      ),
    ],
  ),
),

We have our drawer working! But the animation is too linear. Let’s use a curve.

AnimatedPositioned(
  duration: Duration(milliseconds: 300),
  curve: Curves.easeIn,
  ...
),

Much better! 😄

Generating the shadow

Our drawer is flat. So we’re going to fix that.

Add a BoxDecoration to the white Container of our drawer, that will hold the BoxShadow for the drawer.

... // AnimatedContainer
child: Container(
  width: (MediaQuery.of(context).size.width / 3) * 2,
  height: double.infinity,
  decoration: BoxDecoration(
    color: Colors.white,
    boxShadow: [
      BoxShadow(
        color: Colors.black.withOpacity(0.3),
        blurRadius: 5.0,
      ),
    ],
  ),
  ...
),

Now we’re having a nice shadow that makes the drawer “float” above the main content.

The menu items list

Create a new widget called MenuItem. It will be used on the menu for showing the navigation options. It’s very simple, just an icon and a text. Declare the parameters for this widget, and the place the content in a Row.

class MenuItem extends StatelessWidget {
  final Icon icon;
  final String label;

  MenuItem({
    @required this.icon,
    @required this.label,
  })  : assert(icon != null),
        assert(label != null);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 42.0),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          Icon(
            icon.icon,
            color: Color(0xFFB42827),
          ),
          SizedBox(
            width: 8.0,
          ),
          Text(
            label,
            style: TextStyle(
              fontWeight: FontWeight.w500,
            ),
          ),
        ],
      ),
    );
  }
}

You have to add another Container below the red one. There you will be placing this menu options.

Align(
  alignment: Alignment.bottomCenter,
  child: Container(
    width: double.infinity,
    height: MediaQuery.of(context).size.height / 1.8 + 30.0,
    child: Padding(
      padding: const EdgeInsets.only(left: 46.0, top: 46.0),
      child: Column(
        children: <Widget>[
          MenuItem(
            icon: Icon(FontAwesomeIcons.solidBell),
            label: 'Message center',
          ),
          MenuItem(
            icon: Icon(FontAwesomeIcons.clipboardList),
            label: 'Ticket research',
          ),
          MenuItem(
            icon: Icon(FontAwesomeIcons.shieldAlt),
            label: 'Suggestion',
          ),
          MenuItem(
            icon: Icon(Icons.phone),
            label: 'Contact us',
          ),
        ],
      ),
    ),
  ),
),

Note that I’m harcoding everything here. Obviously you won’t want to do that on a real app.

User information

For the user information on the top of the drawer, create a separated StatelessWidget and call it UserInfo.

We’ll place the elements inside a Column, starting with a Card where we will showing the picture. We can get the rounded corners, by wraping the image in a ClipRRect. The FadeInImage.network will give us a nice transition when loading the image.

The next elements are just some texts, except fo the little circle icon at the right of the name.

class UserInfo extends StatelessWidget {
  final String picture;
  final String name;
  final String id;
  final String company;

  UserInfo({
    @required this.picture,
    @required this.name,
    @required this.id,
    @required this.company,
  }) : assert(picture != null && name != null && id != null && company != null);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Card(
          margin: EdgeInsets.zero,
          elevation: 2.0,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12.0),
          ),
          child: Container(
            width: 80.0,
            height: 80.0,
            child: ClipRRect(
              borderRadius: BorderRadius.circular(12.0),
              child: FadeInImage.assetNetwork(
                placeholder: picture,
                image: picture,
              ),
            ),
          ),
        ),
        SizedBox(
          height: 9.0,
        ),
        Row(
          children: <Widget>[
            Text(
              name,
              style: Theme.of(context).textTheme.headline.copyWith(color: Colors.white),
            ),
            SizedBox(
              width: 8.0,
            ),
            Container(
              width: 12.0,
              height: 12.0,
              decoration: BoxDecoration(
                color: Colors.white.withOpacity(0.3),
                shape: BoxShape.circle,
              ),
              child: Center(
                child: Icon(
                  CupertinoIcons.play_arrow_solid,
                  size: 8.0,
                  color: Colors.white,
                ),
              ),
            ),
          ],
        ),
        SizedBox(
          height: 6.0,
        ),
        Text(
          id,
          style: Theme.of(context).textTheme.caption.copyWith(color: Colors.white.withOpacity(0.6)),
        ),
        SizedBox(
          height: 6.0,
        ),
        Text(
          company,
          style: Theme.of(context).textTheme.caption.copyWith(color: Colors.white.withOpacity(0.6)),
        )
      ],
    );
  }
}

The last step is to add this new widget, on the same Stack where the clear icon is, on the red section of the drawer.

 Align(
  alignment: Alignment.bottomLeft,
  child: Padding(
    padding: const EdgeInsets.only(left: 46.0, bottom: 46.0),
    child: UserInfo(
      picture: 'https://shopolo.hu/wp-content/uploads/2019/04/profile1-%E2%80%93-kopija.jpeg',
      name: 'Ryan',
      id: '0023-Ryan',
      company: 'Universal Data Center',
    ),
  ),
),

And that’s it!

You can find the complete project here.

Conclusion

We’re happy with the result. It’s not perfect and there’s room for refactoring. But we hope this little challenge helps some of you to know more about this awesome UI Toolkit called Flutter.

You can know more about our Flutter Development Services!


Other posts

Agile Nearshore Software Development Services

Our nearshore software development services offer dedicated project team members with broad skill sets to competently deliver your next custom web or mobile application....

Firebase ML Kit

Firebase is a platform that brings together all kinds of tools to simplify the development of apps to help your business grow. ...

Soft Landing Atlanta: Clarika participates in the Georgia Tech University Program

We are proud to announce that we were selected to participate in Soft Landing Atlanta program....