Let's iteratively create the components to create Flutter UIs from textual descriptions. How to create those descriptions or how you get them from some server shall be out of scope.
A simple HTTP REST is probably sufficient, but especially for debugging, a parallel web socket channel or server side events (SSEs) would be useful to trigger a redisplay as if the app was hot-reloaded. For production, consider caching all responses.
Here's a generic hierarchical Node
class which will be our main building block:
class Node {
const Node(
this.name, [
this.props = const {},
this.children = const [],
]);
final String name;
final Map<String, Object?> props;
final List<Node> children;
}
We could use XML to represent them, or JSON, or a custom format that resembles Dart constructors, but I'll use S-expressions according to this grammar, just for the sake of it:
node = "(" name [string] {prop} {node} ")".
prop = ":" name expr.
expr = name | number | string | "(" {expr} ")".
Nodes are enclosed in parentheses. They start with a name, followed by an optional string, followed by optional properties and children. A property starts with a colon, followed by a name and its value. Such a value can be a name, a number, a string, or a list of such things.
A string
is something in double quotes. A number
is a name
that can be parsed into an int
or double
and a name is a sequence of non-whitespace characters that is neither of the above. A sequence of expression in round parentheses is a List<Object?>
. The name nil
could be parsed as null
, but I didn't need that so I didn't bother.
(foo "bar")
is syntactic sugar for (foo :text bar)
.
A NodeParser
for this grammar needs less than 100 lines of code. I'll omit the implementation for brevity. It is straight forward.
class NodeParser {
NodeParser(this.input) : _tokens = ...;
final String input;
final Iterator<(String, int, int)> _tokens;
List<Node> parse() { ... }
}
Here's a simple UI described with such an S-expression:
(column :cross-align center :spacing 16 :padding (all 16)
(text "1" :color #ee6600)
(button "Increment"))
This is same same as:
Node(
'column',
{
'cross-align': 'center',
'spacing': 16,
'padding': ['all', 16],
},
[
Node('text', {'text', '1', 'color': '#ee6600'}),
Node('button', {'text': 'increment'},),
],
);
Here is a build function (of type NodeWidgetBuilder
) for each of the three node types used in the above example. They make use of a NodeBuilder
object that provides a lot of helper methods to translate node properties. They return a configured Widget
.
typedef NodeWidgetBuilder = Widget Function(NodeBuilder nb);
final registry = <String, NodeWidgetBuilder>{
'column': (nb) => nb.withPadding(
Column(
crossAxisAlignment: nb.getCrossAlign() ?? CrossAxisAlignment.center,
spacing: nb.getSpacing() ?? 0,
children: nb.buildChildren(),
),
),
'text': (nb) => nb.buildText()!,
'button': (nb) => TextButton(
onPressed: nb.getAction(),
child: nb.withPadding(nb.buildText() ?? nb.buildChild()),
),
};
Here's an excerpt from the NodeBuilder
:
class NodeBuilder {
NodeBuilder(this.registry, this.node);
final Map<String, NodeWidgetBuilder> registry;
final Node node;
Widget build() => (registry[node.name] ?? _error)(this);
static Widget _error(NodeBuilder nb) {
return ErrorWidget('no builder for ${nb.node.name}');
}
List<Widget> buildChildren() => node.children.map(buildChild).toList();
Widget buildChild(Node node) => NodeBuilder(registry, node).build();
Widget withPadding(Widget child) {
final padding = node.props['padding'];
if (padding == null) return child;
return Padding(
padding: switch (padding) {
['all', num all] => EdgeInsets.all(all.toDouble()),
_ => throw Exception('invalid padding $padding'),
},
child: child,
);
}
...
VoidCallback? getAction() => null;
}
Now use NodeParser('...').parse().single
to convert the example into a Node
. Then use NodeBuilder(registry, node).build()
to create a hierarchy of widgets.
We should get a Flutter UI similar to this "image":
+---------------+
| |
| 1 |
| |
| (increment) |
| |
+---------------+
That example is static, though. The button has no action yet.
For maximum flexibility, I consider property values to be expressions for a tiny Lisp-like language that are evaluated in an enviroment that is a chain of maps that bind values to names:
class Env {
Env(this.parent, this.values);
final Env? parent;
final Map<String, Object?> values;
Object? eval(Object? expr) {
...
}
}
An expr
can be anything. Non-empty lists of values are considered function calls, though, which are looked up in the environment and called with the remaining list elements as unevaluated arguments. This way, special forms like (if (= (a) 1) …)
, which must not evaluate the arguments before we know whether the first argument is true or false, can be implemented the same way as normal functions like (+ 3 4)
. This is all text-book stuff.
class Env {
...
Object? eval(Object? expr) {
if (expr is! List || expr.isEmpty) return expr;
final name = expr.first;
final args = expr.sublist(1);
for (Env? e = this; e != null; e = e.parent) {
if (e.values.containsKey(name)) {
final value = e.values[name];
if (value is Proc) return value(env, args);
if (args.isEmpty) return value;
throw Exception('not callable');
}
}
throw Exception('unbound name $name');
}
}
typedef Proc = Object? Function(Env env, List<Object?> args);
You saw the complete interpreter!
But it needs builtins to become a useful tool.
static final standard = Env(null, {
'do': form((env, args) => env.evalSeq(args)),
'if': form((env, args) => env.eval(
truthy(env.eval(args[0]))
? args[1]
: args.elementAtOrNull(2),
),
'=': proc((args) => args[0] == args[1]),
'*': proc((args) => args.cast<num>().fold<num>(1, (a, b) => a * b)),
'-': proc((args) {
final n = args[0] as num;
if (args.length == 1) return -n;
return args.skip(1).cast<num>().fold<num>(n, (a, b) => a - b);
}),
'define': form((env, args) {
switch (args[0]) {
case List func:
// (define (fac n) (if (= n 0) 1 (* n (fac (- n 1)))))
final name = func[0] as String;
final params = func.sublist(1).cast<String>();
final body = args.skip(1);
env.globals[name] = proc((args) {
return env.create(Map.fromIterables(params, args)).evalSeq(body);
});
return name;
case String name:
// (define answer 42)
env.globals[name] = env.eval(args[1]);
return name;
default:
throw Exception('define: first argument must be string or list');
}
}),
});
static Proc form(Proc proc) => proc;
static Proc proc(Object? Function(List<Object?> args) fn) {
return (env, args) => fn(env.evalLst(args));
}
static bool truthy(Object? value) => switch (value) {
null || false => false,
List list => list.isNotEmpty,
_ => true,
};
The only complicated thing is define
to create user-defined function. It adds a global variable that contains a Proc
which implements the correct lexicographic binding for its parameters before evaluating the body.
This is enough to implement the famous factorial function:
(do
(define (fac n) (if (= n 0) 1 (* n (fac (- n 1)))))
(fac 10))
Because they way, my parser works, and because I need to convert all variable references to function call, here's the real code to run this:
void main() {
final expr = NodeParser('''
(eval :expr
(do
(define (fac n) (if (= (n) 0)
1
(* (n) (fac (- (n) 1)))))
(fac (n))))
''').parse().single.props['expr'];
print(Env.standard.create({'n': 10}).eval(expr));
}
I left out error handling to keep it simple(r).
Let's change the UI to make use of evaluatable properties:
(column :cross-align center :spacing 16 :padding (all 16)
(text :bind count :color #ee6600)
(button "Increment" :action increment))
In buildText()
, I'll check for a :bind
property (because of my design decision to make names and strings indistinguishable, I cannot use :text
as it would assume a static text "count"
) and then evaluate a string as a variable and any list as a function call.
Widget buildText() {
final text =
stringProp(name) ??
switch (node.props['bind']) {
null => null,
String bind => env.eval([bind])?.toString(),
List bind => env.eval(bind)?.toString(),
final bind => throw Exception('invalid bind: $bind'),
};
...
}
In getAction
I implement a similar approach:
VoidCallback? getAction() {
return switch (node.props['action']) {
null => null,
String action => env.eval([action]) as VoidCallback?,
List action => () => env.eval(action),
final action => throw Exception('invalid action: $action'),
};
}
Next, I create a stateful widget for a counter like so:
class Counter extends StatefulWidget {
const Counter({super.key, required this.node});
final Node node;
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) {
return NodeBuilder(
registry,
Env.standard.create({
'count': _count,
'increment': () => setState(() => _count++),
}),
node,
).build();
}
}
And we're done.
We could even add Counter
to the registry:
registry['counter'] = (nb) => Counter(node: nb.node.children.single);
And then use:
(counter (column (text "count:") (text :bind count) (button "+" :action increment)))
But this requires a special stateful widget.
This observe
node can introduce (global) value notifiers.
(observe
:values ((count 0))
(column :cross-align center :spacing 16 :padding (all 16)
(text :bind count)
(button "Increment" :action (count (+ (count) 1)))))
It works with a generic NodeWidget
.
It adds an observe
handler that creates new ValueNotifier
s if they don't exist already and hacks the Env
to add a combined getter and setter so that (count)
does a notifier.value
if called without arguments and a notifier.value = arg[0]
if called with one argument. It then adds a ListenableBuilder
to the widget tree so that this part of the UI is rebuild if one of the listeners changes.
class NodeWidget extends StatefulWidget {
const NodeWidget({super.key, required this.node});
final Node node;
@override
State<NodeWidget> createState() => _NodeWidgetState();
}
class _NodeWidgetState extends State<NodeWidget> {
final _notifiers = <String, ValueNotifier<Object?>>{};
@override
void initState() {
super.initState();
registry['observe'] = (nb) {
final values = (nb.node.props['values'] as List).cast<String>();
for (final value in values) {
final notifier = _notifiers.putIfAbsent(
value,
() => ValueNotifier<Object?>(null),
);
nb.env.values[value] = Env.proc((args) {
if (args.isEmpty) return notifier.value;
if (args.length == 1) return notifier.value = args[0];
throw Exception('Too many arguments');
});
}
return ListenableBuilder(
listenable: Listenable.merge(_notifiers.values),
builder: (_, _) => nb.buildChild()!,
);
};
}
@override
void dispose() {
registry.remove('observe');
for (var n in _notifiers.values) {
n.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return NodeBuilder(registry, Env.standard, widget.node).build();
}
}
We might want to define reusable widgets:
(widget (x-text :text "")
(frame :padding (horizontal 16) :color #de87a8
(text :bind text :font-size 80)))
And use them like any built-in node:
(column
(x-text "big text"))
This would require a modification of the global registry
variable which isn't the best design and instead, the NodeBuilder
should probably use the Env
to lookup handlers, but I don't want to go back and change everything. So, we'll have to live with it. Let's assume we has an initial UI description that is read from the server that defines all user-defined widgets.
for (final node in NodeParser(initialUI).parse()) {
if (node.name != 'widget') throw ...;
if (node.children != 2) throw ...;
final [widget, body] = node.children;
registry[widget.name] = (nb) {
final env = nb.env.create(widget.props);
for (final prop in nb.node.props.entries) {
env.values[prop.key] = prop.value;
}
return NodeBuilder(registry, env, body).build();
};
}
I haven't tried this, but I think, it will work.
And there you have it, a framework for dynamically creating Flutter UIs from textual description.