通过Sketch创建一个Flutter应用(译)

想象一下,如果我们可以通过Sketch创建的设计文件来创建一个可正常使用的Flutter应用,是不是屌爆了?先让我们验证一下。

计划如下:Sketch将其设计文档另存为zip存档,这些归档文件包含多个JSON文件,这些文件描述一些元数据、编辑器的状态、页面和画板以及构成设计的所有图形元素。我们浏览zip的文件结构以查找画板,提取相关数据,然后在Flutter画布上绘制每个图形元素。然后,可以使用画板之间的链接在Flutter中切换界面。还有voilá(啥?)——即时应用程序。

谷歌了几分钟后没有找到格式描述,因此这也是一次对Sketch文件格式进行逆向工程的练习。

分析文件格式

在Sketch中创建以下示例:
image
另存为example.sketch。有一页包含两个画板,这些画板具有通常在iOS上找到的状态栏和标题栏,并且包含一些文本和一些矩形。为了绘制后退按钮,我使用了一个向量。
如前所述,.sketch文件是zip归档文件:

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

它包含以下文件:

1
2
3
4
5
6
7
8
9
10
11
$ 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

让我们忽略预览图像。 user.json文件似乎处于编辑器的状态。我会忽略它。 document.json文件也不包含有用的信息-但由于它包含一个空资产集合,因此对于“真实世界”草图文件而言可能很重要,该文件除了简单的矩形和文本外还包含位图图像,符号或其他元素。但我暂时将其忽略。

meta.json文件更加有趣。它包含有关用于创建.sketch文件(我将忽略)的Sketch应用程序的信息,以及以下目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"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"
}
}
}
},
...
}

我将使用此信息来加载包含特定画板的页面文件。我什至可以按需加载这些文件。
查看页面对象的JSON文件,我可以猜出用于表示数据的类在实例化为JSON之前的样子。大多数JSON对象都包含一个带有类名的_class属性和一个do_objectID属性,该属性似乎是标识该实例的UUID。没有标识符的对象可能是结构而不是类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"_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",
...
},
...
],
...
}

幸运的是,整个结构似乎很统一。每个元素都有一个名称,一个框架,某些样式和一些图层,这些图层是子元素的列表。其他属性对我来说不太重要。

在Dart中解码Sketch文件

掌握了有关文件结构的知识之后,我将创建类来表示Dart中的那些元素。然后,这应该有助于将元素绘制到Flutter画布中,创建一个SketchWidget来显示此类画布,并最终实现这些小部件之间的导航。
感谢压缩包,阅读zip压缩包很容易。

阅读目录

让我们使用以下Dart程序对此进行测试:

1
2
3
4
5
6
7
8
9
10
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);
}
}

打印的应该是上面显示的文件名。

下一步是使用findFile从档案中提取meta.json文件,并将其解码为JSON文档,然后将其打印出来,以确保它也起作用:

1
2
3
4
5
6
7
8
9
10
11
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));
}

这将生成上面显示的数据结构。

如果您遵循自己的Sketch文件,则期望UUID有所不同。仍然应该有一个pagesAndArtboards属性,该属性至少列出一个包含零个或多个画板的页面。

提取画板

为了提取画板,我创建了一个抽象的Element类,其中Artboard(以及Page和以后的所有其他图形元素)是该类的子类。我决定使用计算字段(也称为getter方法)访问所有信息,而不是将所有内容存储在实例变量中。这可能效率较低,但编写的代码也较少:

1
2
3
4
5
6
7
8
9
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));
...

要创建具体的元素子类,我使用静态的Element.create方法。它需要知道从Sketch类名称到Dart类的每个映射(我使用clazz是因为class是Dart中的保留关键字):

1
2
3
4
5
6
7
8
9
10
11
12
13
  ...
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';
}
}
}

这是前两个元素子类:

1
2
3
4
5
6
7
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>();
}

为了方便起见,我还向Page添加了画板getter方法。

读取.sketch文件并提供对所有画板的访问权限的任务由此File类执行。为了避免名称冲突,我将所有类放入一个新的sketch.dart包中,该包将以名称前缀sk导入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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());
}
}

主函数:

1
2
3
4
5
6
7
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));
}

它应该打印与以前相同的数据结构。
但是现在一切都很好地封装了。

绘图元素

我可以并且应该使我的代码独立于Flutter。但是,我必须为常见概念(如Rect,Color或Sketch使用的属性字符串)创建自己的类,然后定义帮助程序方法以将其转换为Flutter(resp.dart:ui)等效项。因为这是一个概念证明,所以我将使它依赖Flutter,因为那样我不必创建ElementVisitor和其他抽象来分离问题。

缺少元素

查看JSON输出,我的画板包含矩形,文本,组和shapPath类。让我们定义子类(此处未显示)并将其添加到Element.create:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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';
}
}
}

由于所有元素都有框架,因此我们可以使用它在画布上绘制矩形,以了解有关Sketch坐标的更多信息。

设计草图

这是设置Flutter应用程序的常用样板代码:

1
2
3
4
5
6
7
8
9
10
11
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(),
);
}
}

SketchPage小部件从应用程序的资源加载我的示例(不应对其进行硬编码),并使用CustomPaint小部件绘制第一个画板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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),
);
},
),
);
}
}

当然,最重要的类是ArtboardPainter。它将为每个元素绘制矩形,然后递归下降:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}
}

当然,我必须在Element中实现框架。我还注意到,我的图层可以为空,因此我不得不稍微更改该访问器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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)) ?? [];
...

结果还不错:

image

子元素似乎具有相对坐标系(这是明智的设计决策)。我还注意到画板的框架表示页面上的位置,这是我所不希望的,因此我将重写画板的getter方法,如下所示:

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

然后,在应用递归之前先转换坐标系:

1
2
3
4
5
6
7
8
9
10
11
12
13
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();
}
...

现在看上去有点feel了:

image

顺便说一句,我选择了一个与画板大小相同的模拟器来最大程度地减少问题。我实际上使用了Sketch支持的调整大小提示,但是出于概念验证的目的,我不会尝试实现它们。我假设Sketch使用与macOS分别由iOS实现的相同的自动调整大小算法。

添加颜色

黑色的笔触很无聊。让我们添加颜色。深入JSON,似乎style属性具有borders和fill属性,这是border或fill对象的列表,其对象的color属性为RGBA值。

这是代表这些样式的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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(),
);
}
}

然后,我向Element添加样式get方法:

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

接下来,我重构ArtboardPainter渲染元素的方式。通过添加如下所示的render方法,可以将其委托给Element,而不是执行该类中的所有操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
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();
}

这大大简化了ArtboardPainter类:

1
2
3
4
5
6
7
8
9
10
11
12
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;
}
}

然后,我为Rectangle覆盖渲染以最终添加颜色:

1
2
3
4
5
6
7
8
9
10
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);
}
}
}

现在,画板应如下所示:

image

显示文字

接下来显示文本。同样,我通过查看JSON来猜测结构。 Text元素的样式带有textStyle属性,可以将其封装为Flutter TextStyle类。但是,我不认为我需要使用它。相反,我要查看的是attributedString属性,我需要将其转换为Flutter TextSpan。归因字符串包含描述字体系列,字体大小和颜色的字符串属性(除了其他我将忽略的属性)。相反,我将始终将文本居中。

下面的代码有点怪异,我尝试实现一些最低要求,以便在屏幕上获取一些文本,并使用一些快捷方式。不幸的是,我不仅要向dart:ui添加依赖,还必须向flutter / widgets.dart添加依赖,因为我需要TextSpan和TextStyle。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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,
};
}

我花了些反复试验才得知Flutter需要在字体系列名称中留一个空格。因此,我创建了一些简单的映射表,可以扩展更多的条目。 Sketch还仅在字体名称中编码字体粗细,因此我也必须对此进行映射。
但是屏幕显示几乎正确:

image

绘制形状

要在第二个屏幕上绘制后退按钮形状,我需要解码ShapePath类的points属性。对于这一概念验证,我不必理会样条曲线或贝塞尔曲线,而只是简单的矢量。

查看JSON,我发现很奇怪的是,点实际上是由包含花括号的字符串表示的。不管怎样,这里有一些代码来渲染线条:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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]),
);
}
}

在使Group不再显示其矩形后,我成功了。

image

导航

实现导航的最后一件事。搜索JSON将显示以下数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
"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"
},

元素具有可选的Flow对象,该对象通过引用画板对象标识符或“魔术”字符串来定义目标。 Sketch支持多种动画(我使用默认动画,即“从右开始”)。

Dart Part

这里是dart实现:

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

然后在Element中添加get方法:

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

在Sketch中,我将第一个画板标记为初始屏幕。因此,我在JSON中搜索了一个指标,最终找到了isFlowHome属性。让我们使用此属性查找要显示的初始画板,而不是对其名称进行硬编码:

1
2
3
4
5
6
7
8
9
10
11
12
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

为了实现导航,我使用GestureDetector来检测点击,然后使用上下文的渲染框计算相对于小部件的偏移量,然后在当前画板的层次结构中搜索被击中的元素。如果存在这样的元素并且它具有Flow对象,那么我将更改场景。

我重构MyApp一次加载Sketch文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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());
});
}
}

您可以根据需要选择此LoadingPage:

1
2
3
4
5
6
7
8
class LoadingPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
}

新的SketchPage处理导航,并将其他所有内容委托给SketchArtboard小部件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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),
),
),
);
}
}
}

这是新的Artboard小部件,该小部件可检测手势并在Element中使用命中测试算法来查找被点击的元素并返回其流程对象(如果有)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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),
),
);
}
}

这是Element的hit方法:

1
2
3
4
5
6
7
8
9
10
11
12
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;
}
}

这是难题的最后一部分。我可以在通过(简单)Sketch画板创建的多个SketchPage小部件之间导航。我很自豪地展示了最终应用的动画:

image

结语

当然,还有很多东西不见了。 Sketch具有更多基本元素,可以进行变换,可以将它们分组并重新用作符号,其中包括复杂的路径,渐变,阴影等等。然后,面临将UI缩放到其他屏幕尺寸的挑战。但是,希望如此。

将Sketch设计转换为真正的Flutter小部件(如按钮和输入字段)也将非常有趣。但是,这将需要与Flutter正常工作方式截然不同的布局,因此,我没有遵循这条路线。

如果您对源代码或添加更多功能有帮助,请在下面发表评论,我们很乐意将其公开。
感谢您阅读本文,也请查看我的其他文章。

原文:Creating a Flutter App from Sketch