想象一下,如果我们可以通过Sketch创建的设计文件来创建一个可正常使用的Flutter应用,是不是屌爆了?先让我们验证一下。
计划如下:Sketch将其设计文档另存为zip存档,这些归档文件包含多个JSON文件,这些文件描述一些元数据、编辑器的状态、页面和画板以及构成设计的所有图形元素。我们浏览zip的文件结构以查找画板,提取相关数据,然后在Flutter画布上绘制每个图形元素。然后,可以使用画板之间的链接在Flutter中切换界面。还有voilá(啥?)——即时应用程序。
谷歌了几分钟后没有找到格式描述,因此这也是一次对Sketch文件格式进行逆向工程的练习。
分析文件格式
在Sketch中创建以下示例:
另存为example.sketch。有一页包含两个画板,这些画板具有通常在iOS上找到的状态栏和标题栏,并且包含一些文本和一些矩形。为了绘制后退按钮,我使用了一个向量。
如前所述,.sketch文件是zip归档文件:
1 | $ file example.sketch |
它包含以下文件: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 | import 'dart:io'; |
打印的应该是上面显示的文件名。
下一步是使用findFile从档案中提取meta.json文件,并将其解码为JSON文档,然后将其打印出来,以确保它也起作用:
1 | import 'dart:convert'; |
这将生成上面显示的数据结构。
如果您遵循自己的Sketch文件,则期望UUID有所不同。仍然应该有一个pagesAndArtboards属性,该属性至少列出一个包含零个或多个画板的页面。
提取画板
为了提取画板,我创建了一个抽象的Element类,其中Artboard(以及Page和以后的所有其他图形元素)是该类的子类。我决定使用计算字段(也称为getter方法)访问所有信息,而不是将所有内容存储在实例变量中。这可能效率较低,但编写的代码也较少:
1 | abstract class Element { |
要创建具体的元素子类,我使用静态的Element.create方法。它需要知道从Sketch类名称到Dart类的每个映射(我使用clazz是因为class是Dart中的保留关键字):
1 | ... |
这是前两个元素子类:
1 | class Artboard extends Element { |
为了方便起见,我还向Page添加了画板getter方法。
读取.sketch文件并提供对所有画板的访问权限的任务由此File类执行。为了避免名称冲突,我将所有类放入一个新的sketch.dart包中,该包将以名称前缀sk导入。
1 | class File { |
主函数:
1 | import 'dart:convert'; |
它应该打印与以前相同的数据结构。
但是现在一切都很好地封装了。
绘图元素
我可以并且应该使我的代码独立于Flutter。但是,我必须为常见概念(如Rect,Color或Sketch使用的属性字符串)创建自己的类,然后定义帮助程序方法以将其转换为Flutter(resp.dart:ui)等效项。因为这是一个概念证明,所以我将使它依赖Flutter,因为那样我不必创建ElementVisitor和其他抽象来分离问题。
缺少元素
查看JSON输出,我的画板包含矩形,文本,组和shapPath类。让我们定义子类(此处未显示)并将其添加到Element.create:
1 | static Element create(Map data) { |
由于所有元素都有框架,因此我们可以使用它在画布上绘制矩形,以了解有关Sketch坐标的更多信息。
设计草图
这是设置Flutter应用程序的常用样板代码:
1 | import 'package:flutter/material.dart'; |
SketchPage小部件从应用程序的资源加载我的示例(不应对其进行硬编码),并使用CustomPaint小部件绘制第一个画板。
1 | class SketchPage extends StatelessWidget { |
当然,最重要的类是ArtboardPainter。它将为每个元素绘制矩形,然后递归下降:
1 | class ArtboardPainter extends CustomPainter { |
当然,我必须在Element中实现框架。我还注意到,我的图层可以为空,因此我不得不稍微更改该访问器:
1 | abstract class Element { |
结果还不错:
子元素似乎具有相对坐标系(这是明智的设计决策)。我还注意到画板的框架表示页面上的位置,这是我所不希望的,因此我将重写画板的getter方法,如下所示:
1 | class Artboard extends Element { |
然后,在应用递归之前先转换坐标系:
1 | class ArtboardPainter extends CustomPainter { |
现在看上去有点feel了:
顺便说一句,我选择了一个与画板大小相同的模拟器来最大程度地减少问题。我实际上使用了Sketch支持的调整大小提示,但是出于概念验证的目的,我不会尝试实现它们。我假设Sketch使用与macOS分别由iOS实现的相同的自动调整大小算法。
添加颜色
黑色的笔触很无聊。让我们添加颜色。深入JSON,似乎style属性具有borders和fill属性,这是border或fill对象的列表,其对象的color属性为RGBA值。
这是代表这些样式的代码:
1 | class Border { |
然后,我向Element添加样式get方法:
1 | abstract class Element { |
接下来,我重构ArtboardPainter渲染元素的方式。通过添加如下所示的render方法,可以将其委托给Element,而不是执行该类中的所有操作:
1 | ... |
这大大简化了ArtboardPainter类:
1 | class ArtboardPainter extends CustomPainter { |
然后,我为Rectangle覆盖渲染以最终添加颜色:
1 | class Rectangle extends Element { |
现在,画板应如下所示:
显示文字
接下来显示文本。同样,我通过查看JSON来猜测结构。 Text元素的样式带有textStyle属性,可以将其封装为Flutter TextStyle类。但是,我不认为我需要使用它。相反,我要查看的是attributedString属性,我需要将其转换为Flutter TextSpan。归因字符串包含描述字体系列,字体大小和颜色的字符串属性(除了其他我将忽略的属性)。相反,我将始终将文本居中。
下面的代码有点怪异,我尝试实现一些最低要求,以便在屏幕上获取一些文本,并使用一些快捷方式。不幸的是,我不仅要向dart:ui添加依赖,还必须向flutter / widgets.dart添加依赖,因为我需要TextSpan和TextStyle。
1 | class Text extends Element { |
我花了些反复试验才得知Flutter需要在字体系列名称中留一个空格。因此,我创建了一些简单的映射表,可以扩展更多的条目。 Sketch还仅在字体名称中编码字体粗细,因此我也必须对此进行映射。
但是屏幕显示几乎正确:
绘制形状
要在第二个屏幕上绘制后退按钮形状,我需要解码ShapePath类的points属性。对于这一概念验证,我不必理会样条曲线或贝塞尔曲线,而只是简单的矢量。
查看JSON,我发现很奇怪的是,点实际上是由包含花括号的字符串表示的。不管怎样,这里有一些代码来渲染线条:
1 | class ShapePath extends Element { |
在使Group不再显示其矩形后,我成功了。
导航
实现导航的最后一件事。搜索JSON将显示以下数据结构:
1 | "flow": { |
元素具有可选的Flow对象,该对象通过引用画板对象标识符或“魔术”字符串来定义目标。 Sketch支持多种动画(我使用默认动画,即“从右开始”)。
Dart Part
这里是dart实现:
1 | class Flow { |
然后在Element中添加get方法:
1 | abstract class Element { |
在Sketch中,我将第一个画板标记为初始屏幕。因此,我在JSON中搜索了一个指标,最终找到了isFlowHome属性。让我们使用此属性查找要显示的初始画板,而不是对其名称进行硬编码:
1 | class Artboard extends Element { |
Flutter Part
为了实现导航,我使用GestureDetector来检测点击,然后使用上下文的渲染框计算相对于小部件的偏移量,然后在当前画板的层次结构中搜索被击中的元素。如果存在这样的元素并且它具有Flow对象,那么我将更改场景。
我重构MyApp一次加载Sketch文件:
1 | class MyApp extends StatelessWidget { |
您可以根据需要选择此LoadingPage:
1 | class LoadingPage extends StatelessWidget { |
新的SketchPage处理导航,并将其他所有内容委托给SketchArtboard小部件。
1 | class SketchPage extends StatelessWidget { |
这是新的Artboard小部件,该小部件可检测手势并在Element中使用命中测试算法来查找被点击的元素并返回其流程对象(如果有)。
1 | class Artboard extends StatelessWidget { |
这是Element的hit方法:
1 | abstract class Element { |
这是难题的最后一部分。我可以在通过(简单)Sketch画板创建的多个SketchPage小部件之间导航。我很自豪地展示了最终应用的动画:
结语
当然,还有很多东西不见了。 Sketch具有更多基本元素,可以进行变换,可以将它们分组并重新用作符号,其中包括复杂的路径,渐变,阴影等等。然后,面临将UI缩放到其他屏幕尺寸的挑战。但是,希望如此。
将Sketch设计转换为真正的Flutter小部件(如按钮和输入字段)也将非常有趣。但是,这将需要与Flutter正常工作方式截然不同的布局,因此,我没有遵循这条路线。
如果您对源代码或添加更多功能有帮助,请在下面发表评论,我们很乐意将其公开。
感谢您阅读本文,也请查看我的其他文章。