Flutter challenge #1 — Wg by Sarah-D
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 text in 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 we’re 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 on https://github.com/GaboBrandX/dribbble_challenge_1
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!