This challenge is based on a design on Dribbble by Jae-song,Jeong.
Link: https://dribbble.com/shots/3812962-iPhone-X-Todo-Concept
Flutter Challenges will attempt to recreate a particular app UI or design in Flutter.
This challenge will attempt the home screen of the app design.Note that the foucs will be on the UI rather than actually fetching data from a backend.
Before starting,take a look at the design at the link given above or at this link.
Understanding the app structure
The design is a concept for implementing a TODO app. It has three main parts:
- The AppBar
- The User Details
- The Card List
- The Color Transition when the list is scrolled
Getting Started
Let’s create a Flutter Project named todo_app_ui and clear the default code until we’re left with this:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Challenge TODO',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('TODO'),
centerTitle: true,
),
body: Center(),
);
}
}
We’ll go from top to bottom when recreating thr UI to make it simpler.
So first up,
The App Bar
This is the AppBar of the design:
It consists of a drawer,a centered title and a search action.
Let’s try to code the appBar:
appBar: AppBar(
title: Text(
'TODO',
style: TextStyle(fontSize: 16.0),
),
backgroundColor: appColors[cardIndex],
centerTitle: true,
actions: <Widget>[
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(Icons.search),
),
],
elevation: 0.0,
),
My appColors list holds the background colors needed for each card index.
The card index is simply the index of the card in focus,0 for now.
To add a drawer icon,simply add
drawer:Drawer(),
to the Scaffold. This not only adds a drawer icon to your AppBar,but also adds a fully functional drawer.
Also,I’ve set the Scaffold background color to the same color as the AppBar.
backgroundColor:appColors[cardIndex],
Here’s our AppBar:
So far,so good.
User Details
Below the AppBar is some text,welcoming them to the app and mentioning the number of things remaining on the list.
The layout is simple enough to understand:A Column of an Icon and 3 text elements.
The code of this part comes down to:
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 64.0, vertical: 32.0),
child: Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Icon(Icons.account_circle,
size: 45.0, color: Colors.white),
),
Padding(
padding: const EdgeInsets.fromLTRB(0.0, 16.0, 0.0, 12.0),
child: Text('Hello,Jane.',
style: TextStyle(
fontSize: 30.0,
color: Colors.white,
)),
),
Text(
'Looks like feel good.',
style: TextStyle(
color: Colors.white,
),
),
Text(
'You have 3 tasks to do today.',
style: TextStyle(color: Colors.white),
)
],
),
),
)
],
),
Note:That Row() was simply add to expand the Column to the full with of the phone.It has no other relevance to the layout.
Recreated user details and text:
Note:We’ll just combine the data text with the card list in a container,so we’ll add it later.
Moving on to the main part:
The Card List
The Card List is a list of cards which denote the category of the notes(Person,Work, Home).To make this list,we’ll use a ListView.builder().
Let’s analyse the Card elements.
The Card has a Column with:
- A Row for the icons
- Two Texts for displaying number of tasks and category
- Progress Bar for displaying the progress of the category of tasks
Also,in the design,the list does not scroll normally,rather it only scrolls on swipe and goes one position ahead or behind.So what we’ll do is,we’ll disable scrolling and write a GestureRecognizer to recognise swipes on cards and then go to the next card index.
Trying to recreate it:
ListView.builder(
physics: NeverScrollableScrollPhysics(),
itemCount: 3,
controller: scrollController,
itemBuilder: (context, position) {
return GestureDetector(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
child: Container(
width: 250.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Icon(
cardsList[position].icon,
color: appColors[position],
),
Icon(
Icons.more_vert,
color: Colors.grey,
),
],
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 4.0),
child: Text(
'${cardsList[position].tasksRemaining} Tasks',
style: TextStyle(color: Colors.grey)),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 4.0),
child: Text(
'${cardsList[position].cardTitle}',
style: TextStyle(
fontSize: 28.0,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: LinearProgressIndicator(
value: cardsList[position].taskCompletion,
),
)
],
),
)
],
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0)),
),
),
onHorizontalDragEnd: (details) {
if (details.velocity.pixelsPerSecond.dx > 0) {
if (cardIndex > 0) {
cardIndex--;
} else {
if (cardIndex < 2) {
cardIndex++;
}
}
setState(() {
scrollController.animateTo((cardIndex) * 256.0,
duration: Duration(milliseconds: 500),
curve: Curves.fastOutSlowIn);
});
}
},
);
},
)
We also have a scroll controller to animate the list which we initialise like this:
ScrollController scrollController;
void initState(){
super.initState();
scrollController = ScrollController();
}
If you notice,the list has “physics” set to NeverScrollablePhysics. This inhibits scrolling of the list itself as we don’t want it to scroll. There is also a GestureDetector wrapped around the list recognise when the user swipes on the list. Take a look at the “horizontalDragEnd:” component.When the drag ends,we check if the swipe was a left swipe or right swipe and update the index accordingly.We then animate the list to scroll to that index in 500ms.I also have a cardList[] which stores the information about the cards and sets it.
Now let’s deal with animating the backgroundcolors when the card is swiped.
The Color Transition
For a color transition we need three things:
- An AnimationController to trigger an animation.
- A CurvedAnimation to defined how the values change.
- A ColorTween to define start and end colors.
AnimationController animationController;
ColorTween colorTween;
CurvedAnimation curvedAnimation;
Declare these variables above the initState() method.
Second,we declare a “currentColor” variable to store the current color of the background and also set the appBar and scaffoldBackground to currenColor.
The main change comes in the onHorizontalDragEnd method.When the horizontal swipe ends,we have to trigger the color change animation.
onHorizontalDragEnd:(details) {
animationController =
AnimationController(vsync: this, duration: Duration(milliseconds: 500));
curvedAnimation = CurvedAnimation(
parent: animationController, curve: Curves.fastOutSlowIn);
animationController.addListener(() {
setState(() {
currentColor = colorTween.evaluate(curvedAnimation);
});
});
if (details.velocity.pixelsPerSecond.dx > 0) {
if (cardIndex > 0) {
cardIndex--;
colorTween = ColorTween(begin: currentColor, end: appColors[cardIndex]);
}
} else {
if (cardIndex < 2) {
cardIndex++;
colorTween = ColorTween(begin: currentColor, end: appColors[cardIndex]);
}
}
setState(() {
scrollController.animateTo((cardIndex) * 256.0,
duration: Duration(milliseconds: 500), curve: Curves.fastOutSlowIn);
});
colorTween.evaluate(curvedAnimation);
animationController.forward();
}
We first initialise our animation controller,the animation itself and the color tween based on whether it was swiped left or right(If swiped left,we want to go from current to next color,whereas if it was swiped right we want to go from
current to the color before).
When the animation is running,we set the currentColor to the values of the animation.
The whole code is here:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Challenge TODO',
theme: ThemeData(
primarySwatch: Colors.blue,
),
debugShowCheckedModeBanner: false,
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with TickerProviderStateMixin {
var appColors = [
Color.fromRGBO(231, 129, 109, 1.0),
Color.fromRGBO(99, 138, 223, 1.0),
Color.fromRGBO(111, 194, 173, 1.0)
];
var cardIndex = 0;
ScrollController scrollController;
var currentColor = Color.fromRGBO(231, 129, 109, 1.0);
var cardsList = [
CardItemModel("Personal", Icons.account_circle, 9, 0.83),
CardItemModel("Work", Icons.work, 12, 0.24),
CardItemModel("Home", Icons.home, 7, 0.32)
];
AnimationController animationController;
ColorTween colorTween;
CurvedAnimation curvedAnimation;
void initState() {
super.initState();
scrollController = ScrollController();
}
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: currentColor,
appBar: AppBar(
title: Text(
'TODO',
style: TextStyle(fontSize: 16.0),
),
backgroundColor: currentColor,
centerTitle: true,
actions: <Widget>[
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Icon(Icons.search),
),
],
elevation: 0.0,
),
body: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[Row(), buildHeader(), buildTaskCard()],
),
),
drawer: Drawer(),
);
}
Padding buildHeader() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 64.0, vertical: 32.0),
child: Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child:
Icon(Icons.account_circle, size: 45.0, color: Colors.white),
),
Padding(
padding: const EdgeInsets.fromLTRB(0.0, 16.0, 0.0, 12.0),
child: Text('Hello,Jane.',
style: TextStyle(
fontSize: 30.0,
color: Colors.white,
)),
),
Text(
'Looks like feel good.',
style: TextStyle(
color: Colors.white,
),
),
Text(
'You have 3 tasks to do today.',
style: TextStyle(color: Colors.white),
)
],
),
),
);
}
Column buildTaskCard() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 64.0, vertical: 16.0),
child:
Text('TODAY: JUL 21,2018', style: TextStyle(color: Colors.white)),
),
Container(
height: 350,
child: ListView.builder(
physics: NeverScrollableScrollPhysics(),
itemCount: 3,
controller: scrollController,
scrollDirection: Axis.horizontal,
itemBuilder: (context, position) {
return GestureDetector(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
child: Container(
width: 250.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Icon(
cardsList[position].icon,
color: appColors[position],
),
Icon(
Icons.more_vert,
color: Colors.grey,
),
],
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 4.0),
child: Text(
'${cardsList[position].tasksRemaining} Tasks',
style: TextStyle(color: Colors.grey)),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 4.0),
child: Text(
'${cardsList[position].cardTitle}',
style: TextStyle(
fontSize: 28.0,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: LinearProgressIndicator(
value: cardsList[position].taskCompletion,
),
)
],
),
)
],
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0)),
),
),
onHorizontalDragEnd: handleDrag,
onTap: () {
print(cardIndex);
},
);
},
),
)
],
);
}
void handleDrag(details) {
animationController =
AnimationController(vsync: this, duration: Duration(milliseconds: 500));
curvedAnimation = CurvedAnimation(
parent: animationController, curve: Curves.fastOutSlowIn);
animationController.addListener(() {
setState(() {
currentColor = colorTween.evaluate(curvedAnimation);
});
});
if (details.velocity.pixelsPerSecond.dx > 0) {
if (cardIndex > 0) {
cardIndex--;
colorTween = ColorTween(begin: currentColor, end: appColors[cardIndex]);
}
} else {
if (cardIndex < 2) {
cardIndex++;
colorTween = ColorTween(begin: currentColor, end: appColors[cardIndex]);
}
}
setState(() {
scrollController.animateTo((cardIndex) * 256.0,
duration: Duration(milliseconds: 500), curve: Curves.fastOutSlowIn);
});
colorTween.evaluate(curvedAnimation);
animationController.forward();
}
}
class CardItemModel {
String cardTitle;
IconData icon;
int tasksRemaining;
double taskCompletion;
CardItemModel(
this.cardTitle, this.icon, this.tasksRemaining, this.taskCompletion);
}