Flutter: The Best Cross-platform Framework to Start With

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

Directory structure with clean architecture

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.

One thought on “Flutter: The Best Cross-platform Framework to Start With

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.