Flutter SDK is Google’s UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase. It uses dart language to build apps. So I assume you have already gone through the basics of Dart language.
So let’s start on this one by one.
1. Follow clean architecture while creating your directory structure
Keep your Models, Views & Data/Repository layers separate. Views(Widget in flutters) can be further divided into modules. You can create Utility for commonly used functions/constants.
For example,
- Create SharedPrefHelper class for storing shared preferences, where you can write all the preference keys with storing and accessing functions in that class.
- Create AppUtil class with common functions for frequently used tasks like showing snacbar or common dialogs or loader.
- Define all your app specific colors in Colors class and use it by just importing your Colors class.
Repository layer should be used separately for network, database etc., so that in future if you change your network lib then you will only have to make change in your network helper class.
Create network helper class
class NetworkHelper {
static const String baseUrl = "https://yourdomain.com/";
Future<dynamic> post(String url, String reqBody) async {
var responseJson;
try {
String cookie = await getTokenPref();
Map<String, String> myHeaders = <String, String>{
'Content-Type': 'application/json',
'Authentication': cookie,
};
final response = await http.post(
baseUrl + url,
headers: myHeaders,
body: reqBody,
);
responseJson = _returnResponse(response);
} on SocketException {
throw FetchDataException('No Internet connection'); //custom excepton
}
return responseJson;
}
//similar methods for GET, PUT, DELETE
}
Create network repository class to use Netowork helper class
class NetworkRepository {
NetworkHelper _helper = NetworkHelper();
Future<LoginModel> fetchLoginData(String body) async {
final response = await _helper.post('api/login');
return LoginModel.fromJson(response);
}
//methods for other network requests
}
And use this repository in your widget.
NetworkRepository networkRepository = NetworkRepository();
var input = LoginInput(_email.trim(), _password.trim());
var jsonBody = jsonEncode(input.toJson());
//showLoader
networkRepository.fetchLoginData(jsonBody).then((user) {
//showLoader
// do stuff with user model
}).catchError((error) {
print('login error = $error');
//hideLoader
});
2. Create generic custom widgets for repeated designs
For example, if your app has too many containers in your project with rounded corner and border, then you can create following custom widget.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class RoundedContainer extends StatelessWidget {
final double width;
final double height;
final double radius;
final double shadow;
final int bgColor;
final int borderColor;
final double borderWidth;
final Widget child;
final double padding;
RoundedContainer({
this.width,
this.height,
this.radius,
this.shadow = 0,
this.bgColor,
this.borderColor,
this.borderWidth,
this.child,
this.padding = 0,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(padding),
alignment: Alignment.center,
width: this.width,
height: this.height,
child: child,
decoration: BoxDecoration(
color: Color(bgColor),
border: Border.all(
color: Color(borderColor),
width: borderWidth
),
borderRadius: BorderRadius.all(
Radius.circular(radius),
),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
blurRadius: shadow,
),
],
),
);
}
}
and use it as
child: RoundedContainer(
width: MediaQuery.of(context).size.width * 0.45,
height: MediaQuery.of(context).size.height * 0.1,
padding: 16.0,
radius: 5,
shadow: 4,
borderColor: Colors.red,
bgColor: Colors.white,
borderWidth: 1,
child: Text('I am in rounded container')
)
3. Always wrap your root widgets in SafeArea.
SafeArea allows you to organise your UI widgets excluding top status bar and bottom navigation bar.
@override
Widget build(BuildContext context) {
return SafeArea(
child: YourWidget
};
}
4. Go through the lifecycle of Stateless and Stateful widgets
When you start with flutter you should be able to choose whether you should create stateless widget or stateful widget. While creating stateful widget you should know how it works from it’s creation to destruction.
For e.g. initState() method gets called only once in creation of widget lifecycle, but it’s not guaranteed that view has been created at this point of time. If there is a requirement to assign data to view once after creation of widget, then you may use following snippet of code in initState() to ensure the widget has been already created.
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
loadPreviousData(data);
});
}
5. Use state wisely
class _AutoReadOtpState extends State<CountdownText> {
Timer countdownTimer;
static const int WAITING_DURATION = 60;
int updatedWaitingDuration = WAITING_DURATION;
@override
void initState() {
super.initState();
if (countdownTimer == null) {
countdownTimer = Timer.periodic(
Duration(seconds: 1),
(Timer t) => setState(() {
updatedWaitingDuration -= 1;
if (updatedWaitingDuration <= 0) {
countdownTimer.cancel();
widget.onCompleted(0);
}
}),
);
}
}
@override
void dispose() {
// TODO: implement dispose
super.dispose();
countdownTimer.cancel();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
//...other widgets
Text(
"Resend otp in $updatedWaitingDuration seconds",
),
//..other widgets
],
);
}
}
In the above example, while we are updating text with updated countdown time, the complete widget is getting re-rendered which is not optimised and bad for UI performance. Here we should separate Countdown text with new stateful widget as follows.
class _CountdownTextState extends State<CountdownText> {
Timer countdownTimer;
static const int WAITING_DURATION = 60;
int updatedWaitingDuration = WAITING_DURATION;
@override
void initState() {
super.initState();
if (countdownTimer == null) {
countdownTimer = Timer.periodic(
Duration(seconds: 1),
(Timer t) => setState(() {
updatedWaitingDuration -= 1;
if (updatedWaitingDuration <= 0) {
countdownTimer.cancel();
widget.onCompleted(0);
}
}),
);
}
}
@override
void dispose() {
// TODO: implement dispose
super.dispose();
countdownTimer.cancel();
}
@override
Widget build(BuildContext context) {
return Text(
"Resend otp in $updatedWaitingDuration seconds",
);
}
}
And use this widget in above example in place of Text widget, so that on calling of setState inside that will only update Text widget, not the parent widget.
6. Use InheritedWidget for globally required data in same widget tree.
If you have long widget tree and need to access data from parent to any children widgets, then, instead of passing data from widget to widget, create Inherited widget in parent widget and then you can access that widget anywhere in any of the children widgets by just passing context.
Create new class that extends InheritedWidget
class UserDataProvider extends InheritedWidget {
final User user;
final Widget child;
UserDataProvider(
{this.user, this.child});
@override
bool updateShouldNotify(UserDataProvider oldWidget) {
return true;
}
static UserDataProvider of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<UserDataProvider>();
}
and use this inside you parent widget as follows
class _DashboardPageState extends State<DashboardPage> {
User user;
@override
void initState() {
super.initState();
// assign user here
}
@override
Widget build(BuildContext context) {
return DashboardDataProvider(
user: user,
child: _getBody(context),
);
}
7. Use SharedPrefHelper utility file for Shared Preferences as follows
class SharedPrefHelper {
static const String USER = "user";
static Future<User> setUserPref(User user) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
String userJson = jsonEncode(user);
await prefs.setString(USER, userJson);
return user;
}
static Future<User> getUserPref() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
String user = prefs.getString(USER) ?? null;
if (user == null || user == "null") {
return null;
} else {
return User.fromJson(jsonDecode(user));
}
}
}
and use this file in your widgets
@override
void initState() {
super.initState();
SharedPrefHelper.getUserPref().then((user) {
setState(() {
this.user = user;
});
});
}
8 . Wrap your root widgets in SafeArea
SafeArea widget insets child by removing padding required to OS controls like status bar, bottom navigations buttons like back button etc.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: SafeArea(
child: YourWidget(),
},
};
}
9. Use FutureBuilder for asynchronous tasks like getting data from network or database.
If your UI is completely network/database data dependant then you can wrap your root widget in FutureBuilder and display the UI according to the state of widget. Check following code snippet.
Future<User> _user;
Future<Promos> _promos;
@override
void initState() {
super.initState();
_promos = NetworkHelper.getPromos();
_user = SharedPrefHelper.getUserPref();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.wait([
_user,
_promos,
]),
builder: (context, AsyncSnapshot<List<dynamic>> snapshot) {
if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) {
user = snapshot.data[0];
promos = snapshot.data[1];
return YourDataDisplayingWidget(
promos: promos,
userId: user.id,
child: getBody(context),
);
} else if(snapshot.hasError){
return ErrorDisplayingWidget();
} else {
return ProgressBar();
}
}
}
}
10. Use callback to transfer data/actions from widgets
Declare following callback function in your widget.
typedef void NavigateToPageCallback(int pageNo)
and pass it to the child widget as follows
child: ChildWidget(
(pageNo) {
setState(() {
this.pageNo = pageNo;
});
}
)
and call that callback in ChildWidget as follows
class ChildWidget extends StatefulWidget {
final NavigateToPageCallback callback;
ChildWidget(this._scaffoldKey, this._pageNo, this.callback);
@override
_ChildWidgetState createState() => _ChildWidgetState();
}
class _ChildWidgetState extends State<ChildWidget> {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
widget.callback(2);
},
child: Text("click")
);
}
}
11. Create a new file for the colors you want to use in your app and use them by importing in your widget.
Create file named MyColors.dart
import 'dart:ui';
class MyColors {
static const int colorPrimary = 0xFF21333D;
static const int colorPrimaryDark = 0xFF1a2931;
static const int colorPrimaryLight = 0xFF29404c;
}
And use them in your widget as follows
body: Container(
color: Color(MyColors.colorPrimary),
}
12. Some UI related points to be noted
- Use expanded only in Row, Column or Flex widget when it’s not wrapped in Scrollable widget.
- Always try to use dynamic dimensions to the widget using screen width & height so that UI will look similar on different screen sizes
- Create custom text widget for fonts so that if you change font in future then you will have to make minimal updates.
- Avoid using nested scrolling widgets.
- Try to breakdown large screens into smaller widgets according to functionality which helps in code understanding and simplicity.
- Clear all the resources in dispose() callback of the stateful widget
- For iOS specific widgets use cupertino lib.
SUMMARY:
This article is a complete guide for beginners to start with Flutter. For more advanced features you can also explore libs like provider, redux, bloc, etc.
Do u have any GitHub repo to start up with flutter? Please share the link possible