So you want Flutter for the web? Sure thing.
Run
dart create -t web fltr_web
cd fltr_web
dart pub get
dart pub add web # because template uses outdated version
dart pub add dev:webdev
dart run webdev serve
and you should be able to see your Dart web app at http://localhost:8080.
We need a Widget
class along with Key
and BuildContext
:
import 'package:web/web.dart' as web;
abstract class Widget {
const Widget({this.key});
final Key? key;
web.Element createElement(BuildContext context);
}
final class Key {
const Key(this.value);
final String value;
}
final class BuildContext {
web.Element createElement(Widget child, [int? index]) {
return child.createElement(this);
}
}
I added an abstract createElement
method that returns an HTML element and takes a BuildContext
as a parameter. For now, that class has only a single method, but it will be extended later.
Let's create the StatelessWidget
next:
abstract class StatelessWidget extends Widget {
const StatelessWidget({super.key});
Widget build(BuildContext context);
@override
web.Element createElement(BuildContext context) {
return context.createElement(build(context));
}
}
Like with Flutter, it has an abstract build
method that is called by the framework to create more basic widgets that are then eventually converted to HTML by the build context in createElement
.
There's also a StatefulWidget
which is a bit more complicated, but we'll get to that later. For now, here's the boilerplate part:
abstract class StatefulWidget extends Widget {
const StatefulWidget({super.key});
State createState();
@override
web.Element createElement(BuildContext context) {
final state = createState(); // TODO: reuse the state
return context.createElement(state.build(context));
}
}
abstract class State<W extends StatefulWidget> {
W get widget => _widget;
late W _widget;
BuildContext get context => _context;
late BuildContext _context;
void setState(VoidCallback fn) {
fn();
// TODO: schedule build
}
Widget build(BuildContext context);
}
typedef VoidCallback = void Function();
Like in Flutter, it has a createState
method to create the peer State
class which has a build
method to create the widgets based on said state. The createElement
method then converts everything to HTML.
As an example for a primitive widget (a single child render object thingy in Flutter), here's a Text
– and let's not bother with creating a TextStyle
and mapping its properties on CSS styles:
class Text extends Widget {
const Text(this.data, {super.key});
final String data;
@override
web.Element createElement(BuildContext context) {
return web.HTMLSpanElement()..text = data;
}
}
Here's a simple Column
. Note how I use createElement
on the children and provide an index so that even if the children are of the same type, they can be distinguished by index:
class Column extends Widget {
const Column({super.key, required this.children});
final List<Widget> children;
@override
web.Element createElement(BuildContext context) {
final element = web.HTMLDivElement()..className = 'column';
for (final (index, child) in children.indexed) {
element.append(context.createElement(child, index));
}
return element;
}
}
Use the column
class to style it in styles.css
(the file is part of the web template):
.column {
display: flex;
flex-direction: column;
}
To reach the ultimate goal – a counter - we also need a Button
:
class Button extends Widget {
const Button({super.key, required this.onPressed, required this.child});
final VoidCallback? onPressed;
final Widget child;
@override
web.Element createElement(BuildContext context) {
final element = web.HTMLButtonElement();
element.disabled = onPressed == null;
element.onClick.listen((_) => onPressed?.call());
element.append(child.createElement(context));
return element;
}
}
NB: The listener is added, but never removed which is a memory leak.
Here's a first example:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Button(
onPressed: () {
print('Hello');
},
child: Text('Button'),
),
],
);
}
}
To start the application and make it look like Flutter, we need runApp
:
void runApp(Widget app, {String selector = '#output'}) {
Owner(app, web.document.querySelector(selector)!).build();
}
and in main.dart
:
void main() {
runApp(MyApp());
}
Here's that internal Owner
class we'll mainly use to keep track of states:
class Owner {
Owner(this.app, this.root);
final Widget app;
final web.Element root;
void build() {
root.children.clear();
root.append(app.createElement(BuildContext(this)));
}
}
Because the "new" web
package is more low-level than the old html
package, there's no clear
method anymore to remove all children of an element collection, so here's an implementation:
extension on web.HTMLCollection {
void clear() {
var i = length;
while (--i >= 0) {
item(i)?.remove();
}
}
}
Modify the BuildContext
so it takes that Owner
:
class BuildContext {
BuildContext(this.owner);
final Owner owner;
...
}
That's all to display a simple button that print a "hello" to the console. Try it.
To make a counter, we'll use this:
class Counter extends StatefulWidget {
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
void _increment() {
setState(() => _count++);
}
@override
Widget build(BuildContext context) {
return Column(children: [
Text('Count: $_count'),
Button(onPressed: _increment, child: Text('+1')),
]);
}
}
Replace the button with Counter()
in MyApp
.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Counter(),
],
);
}
}
Right now, it should only display 0
and the "+1" button.
The tricky part is to make it work.
First, we need to keep track of states in Owner
. To find the right State
when rebuilding, we need unique identifiers. We'll use either the Key
or the class name, together with the optional index. Also, each identifier contains the identifier of its parent.
This way, the outer column should get the id
Column
, the Counter
should be called Column:Counter0
, and the Text
should be called Column:Counter0:Text
. The Button
should be called Column:Counter0:Button
and so on.
I'll use the BuildContext
to keep track of all ids:
final class BuildContext {
BuildContext(this.owner, this.id);
final Owner owner;
final String id;
web.Element createElement(Widget child, [int? index]) {
final childId = '$id:${child.key?.value ?? '${child.runtimeType}'}${index ?? ''}';
return child.createElement(BuildContext(owner, childId));
}
}
Here's the complete Owner
that stores states
as well as knows how to rebuild
the UI:
class Owner {
Owner(this.app, this.root);
final Widget app;
final web.Element root;
final states = <String, State>{};
bool _needsRebuild = false;
void rebuild() {
if (_needsRebuild) return;
_needsRebuild = true;
scheduleMicrotask(() {
build();
_needsRebuild = false;
});
}
void build() {
root.children.clear();
root.append(app.createElement(BuildContext(this, '')));
}
}
Last but not least, we need to fix the TODOs in StatefulWidget
, reusing an existing state if the owner has one and only creating a new one if there wasn't one:
@override
web.Element createElement(BuildContext context) {
final state = context.owner.states.putIfAbsent(
context.id,
() => createState().._widget = this,
).._context = context;
return context.createElement(state.build(context));
}
and making sure that a rebuild is scheduled on changes to the state.
void setState(VoidCallback fn) {
fn();
context.owner.rebuild();
}
NB: This triggers a full rebuild without any attempt to localize the change.
If I didn't miss anything while splitting the code into these fragments and documenting it, the counter application should work and we've recreated a small subset of Flutter for the web.
Making rebuilds more efficient is left as an exercise to the reader.