Skip to content

Instantly share code, notes, and snippets.

@sma
Last active October 3, 2024 14:55
Show Gist options
  • Save sma/db24bb0d8b3877c992d7748b89cc1b6c to your computer and use it in GitHub Desktop.
Save sma/db24bb0d8b3877c992d7748b89cc1b6c to your computer and use it in GitHub Desktop.
A proof of concept for a Flutter-like framework that is a Dart web application

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment