Flutter - One Codebase For All Screens

The Kotlin and .NET multi-platforms are delivering on this promise. However, there is another big player in town which really does an amazing job.

Enter Flutter, Google’s UI toolkit for building, testing, and deploying beautiful, natively compiled, multi-platform applications from a single codebase.

What is Flutter

Flutter is:

  1. Fast, with code compiling to ARM or Intel machine code as well as JavaScript for fast performance on any device;
  2. Productive, allowing you to iterate quickly thanks to its Hot Reload which performs the code updates immediately without losing state;
  3. And Flexible, with an adaptive design that looks and feels great on the screen.

Of course, this is their sales pitch, and it looks great on paper. However, for the next few minutes we’ll jump in and write a Flutter app to see if the sales pitch holds up in practice.

Before doing that, there is one small detour we need to make. Flutter is powered by Dart, an object-oriented, strongly typed language designed for building mobile, desktop, server, and web applications. If you are not familiar with Dart, don’t worry - here is a one minute crash course outlining 10 of the most important features of the language.

Dart

Standard Library

In a new .dart file we’ll start by importing the math from Dart’s standard library.

import 'dart:math';

Classes & Methods

We’ll then define a Circle class with a radius property. In the class I’m declaring a constructor which can be used to instantiate Circle objects, and a public method to calculate the circle area.

import 'dart:math';

class Circle {
  double radius;

  Circle({required this.radius});

  double calculatorArea() {
    return pi * pow(radius, 2);
  }
}

Constructors

A few more things to note about constructors. In our case, we used curly braces to define named parameters. This makes it easier to understand what each parameter represents when creating an instance of the class. Also, Dart comes packed with a lot of DX goodies, so we can define named constructors to provide different ways to create an instance of the class.

import 'dart:math';

class Circle {
  double radius;

  Circle({required this.radius});

  double calculatorArea() {
    return pi * pow(radius, 2);
  }

  // named constructor
  Circle.unitCircle() : radius = 1.0;
}

Entry Point

Main function is the entry point into our program.

import 'dart:math';

class Circle {...}

void main () {
  ...
}

Variable Declaration && Type inference

We can define and initialize variables using either the type or the var keyword with type inference.

import 'dart:math';

class Circle {...}

void main () {
  Circle circle1 = Circle(radius: 3.0);
  var circle2 = Circle.unitCircle();
}

Nullable values

If the value of the variable is not yet known, use nullable variables which will have null as the initial value.

import 'dart:math';

class Circle {...}

void main () {
  Circle circle1 = Circle(radius: 3.0);
  var circle2 = Circle.unitCircle();

  Circle? nullableValue;
}

Collections

We can then easily initialize a few circle objects, and assign them to collections like lists and maps. By the way, type inference works with collections as well, and in this example names will be inferred to a list of strings, while movies to Map with String keys and int values.

import 'dart:math';

class Circle {...}

void main () {
  Circle circle1 = Circle(radius: 3.0);
  var circle2 = Circle.unitCircle();

  Circle? nullableValue;

  var circleList = [circle1, circle2];
  var names = ['React', 'Solid', 'Astro'];
  var movies = {
    'The Imitation Game': 2014,
    'Sneakers': 1992,
    'Antitrust': 2001,
  };
}

Loops and conditional statements

Finally we can loop through our collections and print some information to the screen.

import 'dart:math';

class Circle {...}

void main () {
  Circle circle1 = Circle(radius: 3.0);
  var circle2 = Circle.unitCircle();

  Circle? nullableValue;

  var circleList = [circle1, circle2];
  var names = ['React', 'Solid', 'Astro'];
  var movies = {
    'The Imitation Game': 2014,
    'Sneakers': 1992,
    'Antitrust': 2001,
  };

  for (var circle in circleList) {
    print('Circle radius is ${circle.radius}');
  }

  for (var entry in movies.entries) {
    print('Movie: ${entry.key}, Year: ${entry.value}');
  }
}

Dart is a pretty interesting language on its own, so please let me know in the comments if you are interested in a deep dive on this topic.

Flutter

Install

Ok, back to Flutter, the first thing we need to do is to install the Flutter SDK. The installation steps are thoroughly detailed in the documentation. Just make sure to prepare yourself mentally because you’ll need to jump through some hoops to add all the platform specific requirements to your local environment.

It’s recommended to use Visual Studio Code, since it is offering some convenient tools to configure and initialize your project.

With the first step out of the way, this is the project structure we ended up with: Android and IOS specific configuration code will be stored under the associated directories. These ones include everything we need to build a native version of our Flutter app.

Cross Platform vs Native

Quick side note, the cross platform versus native performance debate is a long one, but there is some good research on the topic these days. As a takeaway regarding performance, for usual business apps with minor animations and shiny looks, technology does not matter at all. For heavy animations however, Native has the most performance power to do it.

When it comes to Flutter in particular, it is a great fit for both CPU and Memory intensive tasks.

The lib is the main directory where our Dart code will reside. This is where the main entry point is stored, and where we’ll define subdirectories like screens, services, models or widgets to organize our code.

Widgets are the building blocks for the user interface, so let’s start by creating our first one by extending the Stateless Widget class.

Stateless vs Stateful Widgets

Stateless Widgets are ideal for static content since they are immutable and do not maintain any mutable state. The UI does not depend on any dynamic data, and, once created, the properties cannot change.

Stateful Widgets on the other hand can change their state over time. This means that they contain a State object that holds mutable data, and are the solution for dynamic content and user interactions.

Note however that widgets can be composed, so stateless widgets can contain stateful ones and vice versa.

Example

Back to the code, we’ll define a cons constructor, indicating that this widget can be created as a compile-time constant. This will lead to performance improvements because the instance is effectively a singleton and can be reused.

The build method is responsible for describing how to display the widget in terms of other, lower-level widgets.

// /lib/widgets/hello.dart

import 'package:flutter/material.dart';

class HelloWidget extends StatelessWidget {
  const HelloWidget({super.key});

  @override
  Widget build(BuildContext context){
    return Container(
      color: Colors.white12,
      child: const Center(
        child: Text(
          'Hello, World!',
          style: TextStyle(
            fontSize: 24,
            color: Colors.black,
          ),
        ),
      ),
    );
  }
}

We’ll override it, and then compose our UI. Note that we are using a handful of pre-designed widgets, all coming from the Dart Material package.

Container is a convenience widget that combines common painting, positioning, and sizing widgets. We’ll set the background to a 12% opacity white, and a child widget which will center our Text.

And, just like that, we completed a “hello world” example. I know things might look a bit different than what you might be used to, since this Widget approach to build UIs doesn’t feel like the best dev experience. Remember though that in Flutter the benefits outweigh the negatives, and it is one of the few options that allows you to really use a common code base and reach almost native performance.

Next, let’s make things a bit more interesting and add state to the mix. We’ll start by extending a Stateful Widget to indicate that we’ll work with mutable state.

// /lib/widgets/counter.dart

import 'package:flutter/material.dart';

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() => _CounterWidgetState();  

}

Then the createState() method will create an instance of a class which holds the state for this widget.

Note the use of the underscore before class names or variables, used to indicate that the entity is private to its library. This is a convention used to encapsulate and restrict access to certain parts of the code, ensuring that they are not accessible from outside their defining library.

In CounterWidget State we’ll start by defining a private counter variable. Then, in the build method we’ll go ahead and compose the UI just like in the previous example. Again, we are using material widgets like Column, Button or Text, but there are two new interesting additions. First, ElevatedButton accepts an on-pressed callback function which sets the state with an increment counter value. Second, we use string interpolation to insert the counter value into the Text label.

// /lib/widgets/counter.dart

import 'package:flutter/material.dart';

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _counter = 0;

  @override  
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const SizedBox(height: 20),
        ElevatedButton(
          onPressed: () => setState(() {
            _counter++;
          }),
          child: const Text('Increase counter'),
        ),
        const SizedBox(height: 10),
        Text(
          'Counter: $_counter',
          style: const TextStyle(fontSize: 18, color: Colors.black),
        ),
      ],
    );
  }
}

Finally, let’s get to an example closer to the real world, and see how we can retrieve a list of elements from the server, and then render them on the screen. We’ll start by importing the same material package, and the http module.

The UsersList is a Stateful Widget, where we’ll maintain a list of users. Note that I’m using the dynamic keyword here to easily handle the JSON data returned from the backend API.

// /lib/widgets/users_list.dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

class UsersListPage extends StatefulWidget {
  const UsersListPage({super.key});

  @override
  State<UsersListPage> createState() => _UsersListPageState();
}

class _UsersListPageState extends State<UsersListPage>  
 {
  List<dynamic> _users = [];

  @override
  void initState() {
    super.initState();
    _fetchUsers();
  }
}

The initState method in a StatefulWidget is called only once when the state object is created, so this is the perfect spot for us to retrieve the data from the server. Here, we’ll perform an HTTP call, and if the response is successful we’ll decode the response body and assign it to our internal list.

// /lib/widgets/users_list.dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

class UsersListPage extends StatefulWidget { ... }

class _UsersListPageState extends State<UsersListPage> {
  List<dynamic> _users = [];

  @override
  void initState() {
    super.initState();
    _fetchUsers();
  }

  Future<void> _fetchUsers() async {
    const callUrl = 'https://jsonplaceholder.typicode.com/users';

    final response = await http.get(Uri.parse(callUrl));
    if (response.statusCode == 200) {
      setState(() {
        _users = json.decode(response.body);
      });
    } else {
      throw Exception('Failed to load users');
    }
  }
}

Then, in the build method, apart from some layout specific widgets, we’ll use a List view to iterate through the users, and render them into a UI list.

// /lib/widgets/users_list.dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

class UsersListPage extends StatefulWidget { ... }

class _UsersListPageState extends State<UsersListPage> {
  List<dynamic> _users = [];

  @override
  void initState() {
    super.initState();
    _fetchUsers();
  }

  Future<void> _fetchUsers() async {
    const callUrl = 'https://jsonplaceholder.typicode.com/users';

    final response = await http.get(Uri.parse(callUrl));
    if (response.statusCode == 200) {
      setState(() {
        _users = json.decode(response.body);
      });
    } else {
      throw Exception('Failed to load users');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('User List'),
      ),
      body: _users.isEmpty
        ? Center(child: CircularProgressIndicator())
        : ListView.builder(
            itemCount: _users.length,
            itemBuilder: (context, index) {
              final user = _users[index];
              return ListTile(
                title: Text(user['name']),
                subtitle: Text(user['email']),  
              );
            },
          ),
    );
  }
}

The ListView is a commonly used scrolling widget in Flutter which is ideal for displaying a large number of items in a scrolling container. What’s more interesting is that this widget will be converted into native code for each platform your app will address. So, compared to other cross platform or hybrid solutions, Flutter does not rely on a Web View to render its UI, hence the overall better performance.

To spice things up, we could also define a Text Field for search purposes. When the on change event is triggered, the filter users method is called, where we are simply iterating through the list of users, and searching for string matches. For convenience reasons, we’ll store the filtered results in a separate list, so we’ll need to first define it at the top of our class, and then update the List View source accordingly.

// /lib/widgets/users_list.dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

class UsersListPage extends StatefulWidget {
  // ...
}

class _UsersListPageState extends State<UsersListPage> {
  List<dynamic> _users = [];
  List<dynamic> _filteredUsers = [];
  final TextEditingController _searchController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _fetchUsers();
  }

  Future<void>  
 _fetchUsers() async {
    // ...
  }

  void _filterUsers(String name) {
    setState(() {
      _filteredUsers = _users
        .where((user) =>
            user['name']
                .toString()
                .toLowerCase()
                .contains(name.toLowerCase()))
        .toList();
    });
  }
}

It is also important to note that the search text field has an associated controller. This is an instance of the Text Editing Controller class provided by Flutter and it allows you to read the current value of the text field, listen for changes, and control the selection and composing region.

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('Users list')),
    body: Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(8.0),  

          child: TextField(
            controller: _searchController,
            onChanged: _filterUsers,
            decoration: const InputDecoration(
              labelText: 'Search',
              border: OutlineInputBorder(),
            ),
          ),
        ),
        Expanded(
          child: _filteredUsers.isEmpty
              ? const Center(child: CircularProgressIndicator())
              : ListView.builder(
                  itemCount: _filteredUsers.length,
                  itemBuilder: (context, index) {
                    final user = _filteredUsers[index];
                    return ListTile(
                      title: Text(user['name']),  

                      subtitle: Text(user['email']),  

                    );
                  },
                ),
        ),
      ],
    ),
  );
}

Remember that it is important to dispose of the controller when it is no longer needed to free up resources. This is done by overriding the dispose method in your stateful widget.

class UsersListPage extends StatefulWidget {
  // ...
}

class _UsersListPageState extends State<UsersListPage> {
  List<dynamic> _users = [];
  List<dynamic> _filteredUsers = [];
  final TextEditingController _searchController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _fetchUsers();
  }

  @override
  void dispose() {
    _searchController.dispose();
    super.dispose();
  }

  Future<void> _fetchUsers() async {
    // ...
  }

  void _filterUsers(String name) {
    // ...
  }
}

If you like this fast-paced style but want a deeper dive into frontend concepts, take a look at my Yes JS course, or check out one of my other videos interesting as well.

Until next time, thank you for reading!