What's a Declarative UI?
Ever since the dawn of computing, we've created interfaces using the tried and tested imperative paradigm. This has served us very well, however as interfaces have become more and more complex, it became increasingly obvious that we need a cleaner, faster and less error prone way of defining them.
What does imperative mean?
The imperative user interface paradigm is the traditional way of assembling your user interfaces. You define a set of widgets that make up your UI and hold references to them in some kind of object. When the user taps a button or some other action occurs, methods containing statements are executed in order to change the app's state. These statements may call disable()
on a text entry, load rows in a list, access the text property of a label to make it read "Hello, Adam!", or execute a command or query to fetch remote data etc.
This object-oriented way of running through instructions, calling classes and changing properties definitely works. We know this because we've been using it for years. However, it requires a lot of boilerplate code and effort to understand when an interface becomes reasonably complex. It's also difficult to understand how all the pieces of state fit together.
To that end, there are several architectural patterns such as MVC and MVVM that attempt to tackle the issue of complexity head on by splitting each part of the app into "layers". There are others like VIPER, but for the sake of brevity let's focus on these two.
MVC
So that you don't end up with a big ball of mud, it might make sense to split your application code into three distinct abstractions: the Model, the View and the Controller. The idea seems simple enough: the model drives what's displayed in the view using observation to detect changes and the controller performs actions on the model.
MVC was common in a lot of early languages such as Smalltalk-79 and has been adapted in order to fit various scenarios. This led to the creation of hierarchical model-view-controller (HMVC), model-view-adapter (MVA), model-view-presenter (MVP) and as we'll see shortly, model-view-viewmodel (MVVM).
MVVM
MVVM is a similar set of abstractions to MVC, except the controller is replaced with something called a "ViewModel". It is the ViewModel's job to only expose to the interface (usually some form of markup like XAML) exactly what is required for display.
A typical ViewModel might look something like this:
public class LoginViewModel: ViewModelBase {
public string Username { get; set; }
public string Password { get; set; }
}
The view binds itself to this ViewModel so that, when the text changes in the "Username" input, the Username
property is updated accordingly. In Microsoft technologies such as Xamarin Forms, this "binding" of the ViewModel is achieved through specific syntax in a XAML layout:
<Label
Text="{Binding Username, Mode=TwoWay}"
FontSize="48"
HorizontalOptions="Center"
VerticalOptions="CenterAndExpand" />
As you can see, you still have to explicitly specify which properties on a widget to bind (Text
) and what they are being bound to (Username
).
Both approaches above require you to manually wire up all the widgets in order for your interface to actually do something. Wouldn't it be great if all you had to do was change the state of your application and the UI magically updated to reflect these changes?
Yes... yes it would!
Enter: Declarative UIs
💡 Note: The Dart programming language is object-oriented, functional and imperative. You will still be writing functions with statements, classes to hold your data and so on, but all widget code is written in a functional & declarative way.
Simply put: declarative UIs are built to reflect the current state of your app. You are writing code that describes what it should be, rather than ateps to create it. This approach follows functional programming practices, in that your UI is now the result of the app's current state: UI = f(state)
. Here, f
represents all the build()
functions for every widget in your UI.
Writing declarative UIs lets you focus on the what (i.e., the logic involved) not the how, as that's handled by Flutter itself.
The Widget Tree
Instead of placing lots of widgets in a template of some kind and wiring these up, your UI is declared directly in Dart code as a tree:
Starting at the root node, you progressively work your way down the tree, declaring child nodes and children of those and so on to construct the UI you require. This allows for almost infinite customisability too, since everything is a widget. Assembling your UI in this manner may seem odd at first, but after a while becomes very intuitive. It also means you concentrate on composition: assembling together various widgets required to achieve the desired UI.
Here's a sample portion of a widget tree:
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget> [
Text('The Fat Earth Society.'),
Text('Oblong is Not Wrong!'),
Icon(Icons.earth, color: colorScheme.crevice),
]
);
}
I'll talk more about the build context in another article. For now, just think of it as an object holding references to other widgets. This allows us to easily access these ancestors and share state.
Updates
But what about when something changes? These updates happen in widgets that carry state, such as a StatefulWidget
. Other widgets like a StatelessWidget
are immutable and rely only on the configuration information passed to them.
In order for the Flutter framework to trigger an update of the widget tree, a widget has to call setState()
. This instructs the framework that the internal state of this widget has changed and it schedules a build accordingly. You may think that it's wasteful to just rebuild widgets rather than mutate them, but Flutter was intentionally built this way and can easily rebuild entire widget trees, even at 60fps.
Just remember, there is generally no "better" or "best" solution to a given problem. This is true for most things in computer science, including UIs. You must weigh up the positives and negatives to an approach, but sometimes things just feel right. For me, writing robust, less error-prone interface code is the cornerstone to a fantastic app experience, both for developers collaborating with you on a project and for the end user.
That's why I 💗 Flutter.