Flutter Challenge: Cupertino widgets and a simple custom Drawer for iOS

Clarikagroup
12 min readApr 13, 2020

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

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

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

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.

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

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.

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, ), ),

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.

... // 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!

Originally published at https://clarikagroup.com on April 13, 2020.

--

--

Clarikagroup

We are an Agile Nearshore Software Development Company | Let’s connect! https://clarikagroup.com/