[MUSIC PLAYING]
TODD VOLKERT: Hello, everyone.
My name is Todd Volkert, and I'm an engineering manager
on the Flutter team.
In this session, we're going to talk
about building a desktop design language with Flutter.
But before we get into the nitty-gritty,
let's start by talking about why you
might want to build a desktop-specific design
language in Flutter.
So it's known that Flutter comes with Material or Cupertino
or iOS widgets out of the box.
This makes it a great choice for building mobile apps
and getting rich design components right off the shelf.
What you may not have known is that the Material Design
Library actually works great on desktop, too.
And if I were building a new app from scratch,
I would definitely use it.
It's performant, beautiful, can adapt to desktop form-factors,
and has all the features you'd want out
of a widget library straight out of the box.
Sometimes, however, your use case calls for a custom UI.
Or you might want your app to look like it's
using native platform widgets.
Maybe it even embeds native platform widgets--
or any number of other reasons that
brings you to the conclusion that you kind of need
to forge your own path.
Also, desktops can be different.
The idioms of desktops are so well-established that customers
will sometimes have very specific preconceived notions
of what their app should look like,
and those notions can stretch way
outside the bounds of Material Design.
And that last point really hits home for me,
because I experienced that exact scenario.
Here's an app I wrote 14 years ago that's still in use today,
but it's being sunset and will soon stop working,
so it needs to be reimplemented from scratch.
Let's take a quick tour and imagine
writing this app in Flutter to see what widgets we
would need to build.
And right off the bat, we notice that this doesn't look anything
like a Material app.
It's a dense display, boring, corporate REST app
that very much looks like it was written 14 years ago.
We have an old-school tab bar for navigation.
On the first tab, the user can use their keyboard
to quickly navigate around this grid of text inputs.
If we try to add a new row to the grid,
we're going to need a list button, as well
as this pull-down sheet and some stylized buttons.
Then we've got buttons that respond to mouse hover.
On the second tab, there's a split-pane layout,
a selectable list for you on the left,
and then a table view on the right that
supports lazy loading, sortable columns, resizable columns,
and the ability to edit rows inline using arbitrary
widgets as cell editors.
If we try to add an expense, there's
a list button again, but also a calendar button
for selecting the date and a spinner widget
to copy the expense across days.
The third tab contains just a text area.
And on the final tab, there are some interesting layouts here--
notably, a scrollable table with fixed column headers
and a custom grid pattern.
And once the invoice is submitted,
the app displays a watermark to show the users that the invoice
is now read-only.
So this app has been used for 14 years,
and its users have expressed a very strong desire
to not have to relearn a new user interface.
So our task is to write a Flutter app that's
a pixel-perfect replica of the existing app
so that we can swap it out without our users even
noticing.
In fact, before we can even attempt to write the app,
we need to start by replicating the design
language of the app, which brings us back
to the point of this talk.
So now that we've covered why you
might want to go about this, let's
take a quick look at how Flutter makes this kind of thing
possible.
Flutter was designed from the start
to have a layered architecture that allows
you to hook in at any layer.
In fact, the Material and Cupertino libraries
are layers built on top of the widget layer.
So for our mission, we can choose
to disregard the Material and Cupertino libraries
and swap out the top layer of the framework
with our own package that's also built
on top of the widget layer.
We'll call this the Chicago design language.
OK.
Now that we've got the 10,000-foot view,
let's go through the process of actually creating one such
widget in our target widget set.
The widget we'll be working with is a spinner widget.
It's relatively simple, but the lessons
learned here can then be applied to any number of desktop design
widgets.
So looking at this widget, how would you
go about building it in Flutter?
Well, it looks really simple.
It looks like you could create a decorated box
around a row, where the leftmost entry in the row
is the spinner content and the rightmost entry in the row
is a column containing the buttons.
So here we've attempted that approach,
leaving out the decorated box, because that's trivial.
And it sort of works, but not really.
What happens when you put this in the container that specifies
unbounded width constraints?
In that case, the expanded widget
will force an infinite width, and the row
will be unable to lay out.
You'll get an exception.
For that matter, what happens if you
put this in a container that specifies unbounded height
constraints?
Well, then the cross-axis alignment of stretch
will force an infinite height, and you'll
get a similar exception.
However, if we try to fix that latter problem by adjusting
the cross-axis alignment, then the vertical divider
disappears.
There's also the problem of the horizontal divider
between the buttons not showing up.
But if we try to fix that by giving
the column a cross-axis alignment of stretch,
then we get that same old exception again,
this time because the column is passed unbounded width
constraints by the row.
So our attempt to write the spinner widget
using simple rows and columns didn't work.
What are our options?
Well, when it comes to layout, there's
often many ways to approach the problem.
But whenever you find a widget that you absolutely
can't implement using only composition of other widgets,
Flutter allows you to get the customization
you need by going one layer deeper in the framework.
Before we get to coding, let's give
a quick roadmap of the areas we'll be working in.
You may know that Flutter's widgets are immutable.
You can see this in the fact that most widgets have
a const constructor, meaning they can be created
as compile-time constants.
And yet, UIs are very much not immutable.
Different parts of your UI will change
as the user interacts with it.
In fact, generally, your UI will be in constant flux.
So if widgets are immutable, but the UI isn't, how
does Flutter manage the changing state of the UI?
Well, Flutter deals in three parallel trees--
technically, more, but three that
are relevant to this discussion.
Those trees are the widget tree, which
contains widgets, the element tree, which contains elements,
and the render tree, which contains-- you
guessed it-- render objects.
Widgets, as we just covered, are immutable, and simply describe
the configuration for elements, which
are the actual instantiation of widgets in the tree.
Render objects are what actually get laid out and painted
on the screen.
And correspondingly, they're where layout is implemented.
If you're interested in more information about these three
trees and how they interact, there's
a great TED Talk by Ian Hickson available on YouTube
called "The Mahogany Staircase."
Now we can get to the code.
First let's code up our widget.
Rather than StatelessWidget or StatefulWidget,
which you're likely already familiar with,
we'll create a RenderObjectWidget,
which, because it's tied to a render object,
will allow us to write our custom layout.
This widget takes three child widgets--
the spinner content and the two buttons--
and lays them out according to our specifications.
It will then paint the border, and the dividers as well.
This means that we'll still need a widget that
creates the spinner content and creates the buttons,
and then passes them to this raw spinner.
We'll come back to that in a little bit.
There are two methods we need to implement here,
the first of which is createElement,
which brings us over to the second of those trees
that we talked about.
Let's look at the code for the element.
Though you never touch them directly,
StatelessWidget and StatefulWidget
actually have elements, too.
They're called, creatively, StatelessElement
and StatefulElement.
In our case here, we'll be creating
a RenderObjectElement, as required
by the RenderObjectWidget.
Our spinner element will have three child elements
to mirror the three child widgets.
Since this element has an enumerated set of children,
we also create a corresponding enum
to represent the slots that those children will
occupy in the spinner element.
Child models in Flutter framework are opaque.
So rather than enumerating our children via public API,
we tell the framework how to visit our children.
Next up, mounting the spinner element to the element tree
will take the child widgets and instantiate or inflate them
into the corresponding child elements.
Similarly, we wire up what to do when our widget tree has been
updated and we have a new widget configuration for this element.
You'll notice that this looks exactly the same as what
we did in the mount method.
That's because the updateChild method that we're calling
is smart enough to see whether our child element should
be inflated or updated.
Now we wire up what to do when we're
told to insert a child into this element's render object,
and what to do when we're told to remove
a child from this element's render object.
Note here that we're presupposing
the existence of API and a render object
class that doesn't exist yet--
namely, the setters for content, up button, and down button.
We're going to write that API in just a few seconds.
Back in our raw spinner widget, the other method
we needed to implement was createRenderObject,
which brings us, finally, to our render object, where we'll
get to write our custom layout.
Back to the code.
We start by implementing those API methods
that we called from our element class
and said we would write in just a few seconds, the setters
for the content and the up buttons.
And finally, we get to the place where we actually
do the layout.
Here we're asking the button what its intrinsic width is,
or the width that it would like to be,
then laying out the content, telling it what range of sizes
it's allowed to be while reserving space
for the buttons in the border.
Once the content's been laid out,
we lay the buttons out as well, telling them each
to be half the height of our content.
Then finally, we set our own size
based on the size of our content in the buttons.
The render object is also where we
ask our children to paint themselves
at the appropriate location in our coordinate space,
as well as painting anything else that our widget calls for,
which in this case is the gradient behind the buttons
and the border and the dividers, the code of which
is redacted here.
OK.
We're almost done.
Remember how we said we still needed a widget that
created the spinner content and created the buttons,
and then pass them to our raw spinner widget?
Well, now it's time to circle back and create that.
It's actually not uncommon to have your public API be
a stateful or stateless widget that
is composed of one or more private RenderObjectWidgets,
and that's the pattern we're using here.
We create a controller class that
manages the selected index of the spinner and fires events
when the selected index changes.
And then we define an item builder
so that we can build the content of the spinner
lazily as the selected index changes.
The state object will listen to the controller
and manage a corresponding index state variable.
And the build method, it will create the two buttons, as well
as the content, by invoking the widgets' item builder.
Then as planned, it passes those widgets to our raw spinner
that we've already coded up.
The buttons themselves are simple StatefulWidgets
that know how to paint a directional arrow
and respond to taps.
The button state object maintains a pressed state
variable that it manages in the tap event handlers.
Its build function leverages the GestureDetector widget to do
the heavy lifting, and it uses a custom painter to paint
the directional arrows.
Phew.
That was a lot.
Let's take a breath and celebrate
that we just got our basic spinner widget working.
OK.
You ready for the next steps?
Let's write it up to handle mouse input
and not just touch input.
Actually, the gesture detector works both for touch and mouse
input.
So we're already done with that part.
Now onto keyboard handling.
This is a little more involved than mouse input.
Unlike a mouse cursor, which exists over a specific pixel
and so tells the framework where the input should be directed,
keyboard input will be directed to the focused widget, which
means that in order to receive keyboard input,
we need to make our widget focusable.
It's worth pointing out that things like focusability
are already built in to the widgets that
come with Flutter, like those in the Material and Cupertino
libraries.
But since we're writing our own widget from scratch,
we have to worry about these things that would normally be
handled for us automatically.
It's sometimes said that in Flutter, everything's a widget.
And in this case, it bears out.
In order to make our widget focusable,
we wrap it in a Focus widget.
The controller, if you will, for a Focus widget
is called a FocusNode.
In this case, it will be managed by our spinner state object.
And we'll register for focus change events so as to maintain
a _isFocused state variable that will then be used to draw
our focus indicator.
So now our widget can gain keyboard focus,
which means that it will be sent key events that occur
when our widget has the focus.
Again, unlike a mouse, which has a primary button that
can be automatically mapped to the tap gesture,
there are a great many keys on a keyboard.
So we need to tell our widget which key
combinations it cares about.
For our purposes, we'll tell our spinner
to respond to up and down arrow keys.
This is as simple as registering an onKey handler with our Focus
widget, and then responding to the arrow keys
by updating the selected index in our spinner controller.
The final bit that's important to add for all widgets
is semantic information, to make it understandable by systems
designed to improve accessibility,
such as screen readers.
Just as it was with focus, accessibility support
is built in to the widgets that come with the Material
and Cupertino libraries.
But since we're hand-rolling our own widget here, it's up to us
to add that support.
This is done with the Semantics widget,
which has a ton of properties you
can use to describe the semantic meaning of a widget,
as well as how certain generic gestures should
be handled by the widget.
So we just covered the steps required to build one widget.
But can we really use that knowledge
to build our target design language?
Yes.
Although there are some advanced concepts
beyond the scope of this talk that you would need
to learn in order to build some of our widgets,
the concepts covered here are all applicable.
In fact, this app right here is a Flutter app.
It's a kitchen-sink demo of the widgets
that have been created as part of the Chicago widget set.
It contains all the widgets we would
need to build our invoice app in Flutter, even the table views
with editable rows.
So how about that invoice app?
Did we accomplish our mission?
Let's have a look.
Here you can see before and after versions
of the original app versus the reimplemented Flutter app.
It looks like we were pretty successful.
There's a few minuscule differences
in the layouts, which I can go back and fix,
but honestly, it kind of helped to leave them in.
Otherwise, you might not have believed that these
were actually different apps.
So hopefully from this talk you've learned that, one,
Flutter works well for desktop and web apps; two,
Flutter is capable of handling any crazy design you throw
at it, even when that crazy design is an old, boring legacy
app; and three, how to go about writing your own desktop
widgets in Flutter if the need should ever arise.
Thank you for staying with me through this talk.
If you're interested in digging deeper into the topics we've
covered here today, the source code
for the Chicago design language is available on GitHub.
Also, check out these other tech talks on Flutter at Google I/O.