Creating a Flutter App from Sketch

Stefan Matthias Aust
ICNH
Published in
14 min readMay 13, 2019

Wouldn’t it be cool, if you could create a working Flutter app directly from a design created in Sketch? Let’s do a proof of concept implementation.

Here’s the plan: Sketch saves its design documents as zip archives. Those archives contain multiple JSON files which describe (so it seems) some meta data, the editor’s state, the pages and artboards, and all the graphical elements that comprise the design. I will navigate the zip’s file structure to find an artboard, extract the relevant data, and draw each graphical element on a Flutter canvas. The linking between artboards can then be used to navigate in Flutter. And voilá – instant app.

Because I didn’t find a format description after a few minutes of googling, this will also be an exercise of reverse engineering the Sketch file format.

Analyzing the File Format

I create the following example in Sketch:

It is quite obvious that I’m not a designer

I save my design as example.sketch. There is one page that has two artboards that have a status bar and title bar you typically find on iOS and which contain some text and some rectangles. To draw the back button, I used a vector.

As previously mentioned, a .sketch file is a zip archive:

$ file example.sketch 
example.sketch: Zip archive data, at least v2.0 to extract

It contains the following files:

$ unzip -l example.sketch 
Archive: example.sketch
Length Name
--------- ----
664 document.json
32797 pages/E025DB91-38B6-4AE4-BACB-647DF4A40CBA.json
137 user.json
696 meta.json
28858 previews/preview.png
--------- -------
63152 5 files

Let’s ignore the preview image. The user.json file seems to the editor’s state. I will ignore it. The document.json file also doesn’t contain useful information — but because it contains an empty assets collection, it might be important for “real world” sketch files that include bitmap images, symbols, or other elements besides simple rectangles and text. But I will ignore it for now.

The meta.json file is much more interesting. It contains information about the Sketch application used to create the .sketch file (which I will ignore) and the following table of contents:

{
"commit": "399b83655ed261b257875cf8e762efa1a649509e",
"pagesAndArtboards": {
"E025DB91-38B6-4AE4-BACB-647DF4A40CBA": {
"name": "Page 1",
"artboards": {
"8C823950-BF56-42E2-ACAE-3762BBC8A570": {
"name": "First"
},
"49F995AB-4992-42AB-B708-86C5C42B3CDB": {
"name": "Second"
}
}
}
},
...
}

I will use this information to load the page file(s) that contains specific artboards. I could even make loading those files on-demand.

Looking at the JSON file for the page object, I can guess the classes used to represent the data before their instances where serialized to JSON. Most JSON objects contain a _class property with the class name and a do_objectID property which seems to be a UUID identifing the instance. Objects without identifiers are probably structs instead of classes.

{
"_class": "page",
"do_objectID": "E025DB91-38B6-4AE4-BACB-647DF4A40CBA",
...
"frame": {
"_class": "rect",
"constrainProportions": false,
"height": 0,
"width": 0,
"x": 0,
"y": 0
},
...
"name": "Page 1",
...
"style": {
"_class": "style",
...
},
"layers": [
{
"_class": "artboard",
"do_objectID": "49F995AB-4992-42AB-B708-86C5C42B3CDB",
...
},
...
],
...
}

Luckily, the whole structure seems to be quite uniform. Each element has a name, a frame, some style, and layers which is a list of child elements. Other properties look less important to me.

Decoding the Sketch File in Dart

Armed with this knowledge about the file structure, I shall create classes to represent those elements in Dart. This should then help to draw the elements into a Flutter canvas, creating a SketchWidget do display such canvas and eventually implement navigation between those widgets.

Reading the zip archive is easy thank to the archive package.

Reading the Table of Contents

Let’s test this using the following Dart program:

import 'dart:io';
import 'package:archive/archive.dart';
void main() {
final file = File('example.sketch');
final bytes = file.readAsBytesSync();
final archive = ZipDecoder().decodeBytes(bytes);
for (final file in archive) {
print(file.name);
}
}

This should print the file names shown above.

The next step is to use findFile to extract the meta.json file from the archive and to decode it as JSON document, then printing it, to make sure this works, too:

import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
void main() {
final file = File('example.sketch');
final bytes = file.readAsBytesSync();
final archive = ZipDecoder().decodeBytes(bytes);
final content = archive.findFile('meta.json').content;
final data = json.decode(utf8.decode(content));
print(JsonEncoder.withIndent(' ').convert(data));
}

This should emit the data struture shown above.

If you follow along with your own Sketch file, expect the UUID to be different. Still, there should be a pagesAndArtboards property that lists at lease one page with zero or more artboards.

Extracting Artboards

To extract artboards, I create an abstract Element class of which Artboard (as well as Page and later all other graphical elements) is a subclass. I decide to use computed fields (a.k.a. getter methods) to access all information instead of storing everything in instance variables. This might be less efficient, but it is also less code to write:

abstract class Element {
Element(this.data);
final Map data; String get name => data['name']; String get clazz => data['_class']; String get id => data['do_objectID']; Iterable<Element> get layers =>
(data['layers'] as List).map((child) => Element.create(child));
...

To create concrete element subclasses, I use a static Element.create method. It needs to know about every mapping from a Sketch class name to a Dart class (I use clazz because class is a reserved keyword in Dart):

  ...  static Element create(Map data) {
final clazz = data['_class'];
switch (clazz) {
case 'artboard':
return Artboard(data);
case 'page':
return Page(data);
default:
throw 'Unknown element class $clazz';
}
}
}

Here are the first two element subclasses:

class Artboard extends Element {
Artboard(Map data) : super(data);
}
class Page extends Element {
Page(Map data) : super(data);
Iterable<Artboard> get artboards => layers.whereType<Artboard>();
}

For convenience, I also added an artboards getter to Page.

The task of reading a .sketch file and of providing access to all artboards is performed by this File class. To omit name clashes, I put all my classes into a new sketch.dart package which I will import with the name prefix sk.

class File {
final Map<String, Artboard> artboards = {};
File(List<int> bytes) {
final archive = ZipDecoder().decodeBytes(bytes);
final content = archive.findFile('meta.json').content;
final data = json.decode(utf8.decode(content));
Map paa = data['pagesAndArtboards'];
for (final k1 in paa.keys) {
final content = archive.findFile('pages/$k1.json').content;
final data = json.decode(utf8.decode(content));
final page = Page(data);
for (final artboard in page.artboards) {
artboards[artboard.name] = artboard;
}
}
}
static Future<File> named(String name) async {
return File(await io.File(name).readAsBytes());
}
}

Here is the new main function:

import 'dart:convert';
import 'package:sketch_flutter/sketch.dart' as sk;
void main() async {
final file = await sk.File.named('example.sketch');
final a = file.artboards['First'];
print(JsonEncoder.withIndent(' ').convert(a.data));
}

It should print the same data structure as before.

But now everything is nicely encapulated.

Drawing Elements

I could and probably should make my code indenpendent of Flutter. However, then, I have to create my own classes for common concepts like Rect, Color or the attributed strings used by Sketch and then define helper methods to convert them to Flutter (resp. dart:ui) equivalents. Because this is a proof of concept, I will make it dependent on Flutter because then I don’t have to create an ElementVisitor and other abstractions to separate the concerns.

Missing Elements

Looking at the JSON output, my artboards contain rectangle, text, group, and shapPath classes. Let’s define subclasses (not shown here) and add them to Element.create:

static Element create(Map data) {
final clazz = data['_class'];
switch (clazz) {
case 'artboard':
return Artboard(data);
case 'group':
return Group(data);
case 'rectangle':
return Rectangle(data);
case 'page':
return Page(data);
case 'shapePath':
return ShapePath(data);
case 'text':
return Text(data);
default:
throw 'Unknown element class $clazz';
}
}
}

Because all elements have a frame let’s use that to draw rectangles on a canvas to learn more about the coordinate of Sketch.

Sketching the Design

Here is the usual boiler-plate code to setup a Flutter app:

import 'package:flutter/material.dart';
import 'sketch.dart' as sk;
void main() => runApp(MyApp());class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: SketchPage(),
);
}
}

The SketchPage widget loads my example (which shouldn’t be hard-coded) from the application’s assets and uses a CustomPaint widget to draw the first artboard.

class SketchPage extends StatelessWidget {
Future<sk.Artboard> _loadArtboard(BuildContext context) {
final bundle = DefaultAssetBundle.of(context);
return bundle.load('example.sketch').then((asset) {
final bytes = asset.buffer.asUint8List();
return sk.File(bytes).artboards['First'];
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<sk.Artboard>(
future: _loadArtboard(context),
builder: (context, snapshot) {
if (snapshot.hasError) return Text('${snapshot.error}');
if (!snapshot.hasData) return CircularProgressIndicator();
final artboard = snapshot.data;
return CustomPaint(
size: artboard.frame.size,
painter: ArtboardPainter(artboard),
);
},
),
);
}
}

The most important class is of course the ArtboardPainter. It will stroke rectangles for each and every element, descending recursively:

class ArtboardPainter extends CustomPainter {
final sk.Artboard artboard;
ArtboardPainter(this.artboard); @override
void paint(Canvas canvas, Size size) {
_draw(artboard, canvas);
}
void _draw(sk.Element e, Canvas canvas) {
final p = Paint()
..color = Colors.black
..style = PaintingStyle.stroke;
canvas.drawRect(e.frame, p);
for (final c in e.layers) _draw(c, canvas);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}

Of course, I have to implement frame in Element. I also noticed that I layers can be null, so I had to change that accessor slightly:

abstract class Element {
...
ui.Rect get frame {
final frame = data['frame'];
assert(frame['_class'] == 'rect');
return ui.Rect.fromLTWH(
frame['x'].toDouble(),
frame['y'].toDouble(),
frame['width'].toDouble(),
frame['height'].toDouble(),
);
}
Iterable<Element> get layers => (data['layers'] as List)?
.map((child) => Element.create(child)) ?? [];
...

The result isn’t too bad:

It looks like that child elements have a relative coordinate system (which is a sensible design decision). I also noticed that the artboard’s frame denotes the position on the page which is something, I don’t want, so I will override the frame getter for Artboard like so:

class Artboard extends Element {
...
@override
ui.Rect get frame => ui.Offset.zero & super.frame.size;
}

Then, let’s translate the coordinate system before applying the recursion:

class ArtboardPainter extends CustomPainter {
...
void _draw(sk.Element e, Canvas canvas) {
final p = Paint()
..color = Colors.black
..style = PaintingStyle.stroke;
canvas.drawRect(e.frame, p);
canvas.save();
canvas.translate(e.frame.left, e.frame.top);
for (final c in e.layers) _draw(c, canvas);
canvas.restore();
}
...

Now, it looks right:

By the way, I picked a simulator with the same size as the artboards to minimize the problems. I actually used the resize hints supported by Sketch but for this proof of concept, I will not try to implement them. I assume Sketch uses the same autoresizing algorithms as implemented by macOS resp. iOS.

Adding Color

Black strokes are boring. Let’s add color. Digging into the JSON, it seems that the style property has borders and fills properties, which are lists of border or fill objects which have a color property which is an RGBA value.

Here’s the code to represent those styles:

class Border {
final Map data;
Border(this.data) : assert(data['_class'] == 'border'); bool get isEnabled => data['isEnabled']; ui.Color get color => Style._createColor(data['color']); double get thickness => data['thickness'].toDouble();
}
class Fill {
final Map data;
Fill(this.data) : assert(data['_class'] == 'fill'); bool get isEnabled => data['isEnabled']; ui.Color get color => Style._createColor(data['color']);
}
class Style {
final Map data;
Style(this.data) : assert(data['_class'] == 'style'); List<Border> get borders => (data['borders'] as List)?
.map((b) => Border(b))?.where((b) => b.isEnabled) ?? [];
Iterable<Fill> get fills => (data['fills'] as List)?
.map((f) => Fill(f))?.where((f) => f.isEnabled) ?? [];
static ui.Color _createColor(Map data) {
assert(data['_class'] == 'color');
return ui.Color.fromARGB(
(255 * data['alpha'].toDouble()).round(),
(255 * data['red'].toDouble()).round(),
(255 * data['green'].toDouble()).round(),
(255 * data['blue'].toDouble()).round(),
);
}
}

Then I add a style getter method to Element:

abstract class Element {
...
Style get style => Style(data['style']);

Next, I refactor the way the elements are rendered by the ArtboardPainter. Instead of doing everything in that class, I delegate this to the Element by adding a render method like so:

  ...  void render(ui.Canvas canvas) {
final p = ui.Paint()
..color = ui.Color(0xFF000000)
..style = ui.PaintingStyle.stroke;
canvas.drawRect(frame, p);
renderChildren(canvas);
}
void renderChildren(ui.Canvas canvas) {
canvas.save();
canvas.translate(frame.left, frame.top);
for (final c in layers) c.render(canvas);
canvas.restore();
}

This greatly simplifies the ArtboardPainter class:

class ArtboardPainter extends CustomPainter {
final sk.Artboard artboard;
ArtboardPainter(this.artboard); @override
void paint(Canvas canvas, Size size) {
artboard.render(canvas);
}
@override
bool shouldRepaint(ArtboardPainter oldDelegate) {
return oldDelegate.artboard != artboard;
}
}

Then, I override render for Rectangle to finally add color:

class Rectangle extends Element {
Rectangle(Map data) : super(data);
@override
void render(ui.Canvas canvas) {
if (style.fills.isNotEmpty) {
final color = style.fills.first.color;
canvas.drawRect(frame, ui.Paint()..color = color);
}
}
}

Now, the artboards should look like this:

Displaying Text

Let’s display text next. Again, I’m guessing the structure by looking at the JSON. A Text element has a style with a textStyle property, which I could encapsulate as a Flutter TextStyle class. However, I don’t think, I need to use it. Instead, I’m looking at the attributedString property which I need to convert into a Flutter TextSpan. The atttributed string holds string attributes which describe the font family, the font size and the color (besides other attributes I will ignore). Instead, I will always center the text.

The following code is a bit hacky, I tried to implement the minimum to get some text on the screen, taking a few shortcuts. Unfortunately, I had to add a dependency not only to dart:ui but also to flutter/widgets.dart because I need TextSpan and TextStyle.

class Text extends Element {
Text(Map data) : super(data);
TextSpan get text {
final str = data['attributedString'];
assert(str['_class'] == 'attributedString');
final string = str['string'] as String;
final spans = <TextSpan>[];
for (Map a in str['attributes']) {
assert(a['_class'] == 'stringAttribute');
int location = a['location'].toInt();
int length = a['length'].toInt();
final text = string.substring(location, location + length);
final aa = a['attributes'];
final fa = aa['MSAttributedStringFontAttribute'];
final fn = fa != null
? fa['attributes']['name'].toString() : null;
final fs = fa != null
? fa['attributes']['size'].toDouble() : null;
final ca = aa['MSAttributedStringColorAttribute'];
final style = TextStyle(
fontFamily: fn != null
? _fontFamilyMapping[fn] ?? fn : null,
fontWeight: _fontWeightMapping[fn],
fontSize: fs,
color: ca != null ? Style._createColor(ca) : null,
);
spans.add(TextSpan(
text: text,
style: style,
));
}
if (spans.isEmpty) return TextSpan(text: '');
if (spans.length == 1) return spans.first;
return TextSpan(children: spans);
}
@override
void render(ui.Canvas canvas) {
final p = TextPainter(
text: text,
textDirection: TextDirection.ltr,
textAlign: TextAlign.center,
);
final f = frame;
p.layout(maxWidth: f.width);
p.paint(
canvas,
Offset(
f.left + (f.width - p.size.width) / 2,
f.top + (f.height - p.size.height) / 2,
),
);
}
static const _fontFamilyMapping = {
'AmericanTypewriter': 'American Typewriter',
'AmericanTypewriter-Bold': 'American Typewriter',
};
static const _fontWeightMapping = {
'AmericanTypewriter-Bold': FontWeight.bold,
};
}

It took me some trial and error to learn that Flutter needs a space in the font family name. Therefore, I created some simply mapping table one could extend with more entries. Sketch also encodes the font weight only in the font name, so I had to map this, too.

But the screens are displayed nearly correctly:

Drawing Shapes

To draw the back button shape on the second screen, I need to decode the points property of the ShapePath class. For this proof of concept, I don’t bother with splines or bézier curves, just simple vectors.

Looking at the JSON, I find it quite strange that points are actually represented by strings which also contains curly braces. Whatever, here’s some code to render the lines:

class ShapePath extends Element {
ShapePath(Map data) : super(data);
bool get isClosed => data['isClosed']; Iterable<CurvePoint> get points =>
(data['points'] as List)?.map((p) => CurvePoint(p)) ?? [];
@override
void render(ui.Canvas canvas) {
final f = frame;
var path = Path();
var first = true;
for (final c in points) {
final pt = c.point;
final x = f.left + pt.dx * f.width;
final y = f.top + pt.dy * f.height;
if (first) {
first = false;
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
if (isClosed) path.close();
final b = style.borders.first;
canvas.drawPath(
path,
Paint()
..color = b.color
..strokeWidth = b.thickness
..style = PaintingStyle.stroke);
}
}
class CurvePoint {
final Map data;
CurvePoint(this.data) : assert(data['_class'] == 'curvePoint'); ui.Offset get point => _decode(data['point']); static ui.Offset _decode(String s) {
final parts = s.substring(1, s.length - 1).split(',');
return ui.Offset(
double.parse(parts[0]),
double.parse(parts[1]),
);
}
}

After making the Group no longer display its rectangle, I’m done.

Navigation

The last thing that remains it implementing the navigation. Searching the JSON reveals these data structures:

"flow": {
"_class": "MSImmutableFlowConnection",
"do_objectID": "45E1A3D8-591A-4D01-B04B-36B58CA4CE51",
"animationType": 0,
"destinationArtboardID": "49F995AB-4992-42AB-B708-86C5C42B3CDB"
},
..."flow": {
"_class": "MSImmutableFlowConnection",
"do_objectID": "84BEAFDE-FFCE-42E5-8614-1AFC1FED8A19",
"animationType": 0,
"destinationArtboardID": "back"
},

An Element has an optional Flow object that defines the destination by referencing the artboard object identifier or the “magic” string back. Sketch supports multiple animations (I use the default, which is called “from the right”).

Dart Part

Here is my Dart implementation:

class Flow {
final Map data;
Flow(this.data)
: assert(data['_class'] == 'MSImmutableFlowConnection');
String get destination => data['destinationArtboardID']; bool get isBack => destination == 'back';
}

Then, I add a getter method to Element:

abstract class Element {
...
Flow get flow => data['flow'] != null ? Flow(data['flow']) : null;
}

In Sketch, I marked my first artboard as the initial screen. So I searched for an indicator in the JSON and eventually found a isFlowHome property. Let’s use this property to find the initial artboard to display instead of hardcoding its name:

class Artboard extends Element {
...
bool get isFlowHome => data['isFlowHome'];
}
class File {
...
Artboard get initialArtboard =>
artboards.values.firstWhere((a) => a.isFlowHome);
Artboard artboardById(String id) =>
artboards.values.firstWhere((a) => a.id == id);
...
}

Flutter Part

To implement the navigation, I use a GestureDetector to detect a tap, then use the context’s render box to compute the offset relative to the widget and then search the current artboard’s hierarchy for the element that was hit. If there is such an element and if it has a Flow object, I change the scene.

I refactor MyApp to load the Sketch file once:

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: FutureBuilder<sk.File>(
future: _load(context),
builder: (context, snapshot) {
if (!snapshot.hasData) return LoadingPage();
final file = snapshot.data;
return SketchPage(file, file.initialArtboard);
},
),
);
}
Future<sk.File> _load(BuildContext context) {
final bundle = DefaultAssetBundle.of(context);
return bundle.load('example.sketch').then((asset) {
return sk.File(asset.buffer.asUint8List());
});
}
}

This LoadingPage can be as fancy as you like:

class LoadingPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
}

The new SketchPage deals with navigation and delegates everything else to a SketchArtboard widget.

class SketchPage extends StatelessWidget {
SketchPage(this.file, this.artboard);
final sk.File file;
final sk.Artboard artboard;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Artboard(
onPressed: (flow) => _navigate(context, flow),
artboard: artboard,
),
);
}
void _navigate(BuildContext context, sk.Flow flow) {
if (flow.isBack) {
Navigator.pop(context);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SketchPage(
file,
file.artboardById(flow.destination),
),
),
);
}
}
}

Here’s the new Artboard widget that detects gestures and uses a hit test algorithm in Element to find the element that was tapped and returns its flow object, if there is one.

class Artboard extends StatelessWidget {
const Artboard({
Key key,
@required this.onPressed,
@required this.artboard,
}) : super(key: key);
final ValueChanged<sk.Flow> onPressed;
final sk.Artboard artboard;
@override
build(BuildContext context) {
return GestureDetector(
onTapUp: (details) {
RenderBox box = context.findRenderObject();
final offset = box.globalToLocal(details.globalPosition);
final flow = artboard.hit(offset);
if (flow != null) onPressed(flow);
},
child: CustomPaint(
size: artboard.frame.size,
painter: ArtboardPainter(artboard),
),
);
}
}

Here is the hit method of Element:

abstract class Element {
...
Flow hit(ui.Offset offset) {
if (!frame.contains(offset)) return null;
final localOffset = offset - frame.topLeft;
for (final child in layers) {
final flow = child.hit(localOffset);
if (flow != null) return flow;
}
return flow;
}
}

This was the last piece of the puzzle. I can navigate between multiple SketchPage widgets created from (simple) Sketch artboards. I proudly present an animation of the final app:

Bottom Line

There’s of course a lot of stuff missing. Sketch has more basic elements, they can be transformed, they can be grouped and reused as symbols, there are complex paths, gradients, shadows and much more. Then, there’s the challenge to scale the UI to other screen sizes. But that’s — hopefully — just more of the same.

It would be also very interesting to convert a Sketch design into true Flutter widgets like buttons and input fields. However, this would require a very different layout than the way Flutter normally works and therefore, I didn’t follow that path.

If you’re interested in the source code or what to help to add more features, please leave a comment below, we’re happy to open souce it.

Thanks for reading this article and please check out my other articles, too.

--

--

Stefan Matthias Aust
ICNH
Editor for

App Developer · Co-Founder of I.C.N.H GmbH · Pen & paper role player