UIWidgets源码系列:Hello World
什么是UIWidgets
UIWidgets是Unity上的一个UI解决方案。UIWidgets是将目前流行的跨平台移动开发框架flutter在Unity上的移植。UIWidgets有如下优势:
- 站在flutter这个巨人的肩膀上,UIWidgets使得Unity游戏开发者能够在Unity游戏中嵌入复杂性和移动APP比肩的游戏UI,甚至于抛开游戏,制作完全由UI界面组成的应用。
- 站在Unity平台上,UIWidgets很容易实现在APP中嵌入复杂的3D场景,其他移动开发框架做到同样的效果难度大得多。
- 借助于Unity的跨平台特性,UIWidgets不仅跨Android和iOS,还跨Windows、Linux、MacOS、WebGL等二十多个平台。
- 所有flutter的使用文档几乎都可以直接应用于UIWidgets,学习UIWidgets的过程直接受益于flutter丰富的文档和教程。
UIWidgets的使用也很简单,不依赖任何第三方库,支持2018.3及以上的Unity版本。将源码拷贝到工程目录中就可以开始使用。此外UIWidgets已在Package Manager(国内镜像)和Asset Store上发布。不过推荐在github上clone源码,以获取到最新的代码。
本文假设您已经了解Unity的基本操作(新建工程、在场景中创建各种GameObject等)。如果没有,这些技能学习起来也非常简单。您可以访问Unity Learn查看官方的免费教程。
使用UIWidgets做一个简单的APP
打开2018.3或更高版本的Unity,新建工程
获取UIWidgets(三种途径任选一种):
- 使用git将 https://github.com/UnityTech/UIWidgets.git clone到工程目录中,放到Packages文件夹下(或Assets目录中也没问题)
- 打开Window > Packages Manager,点击搜索框左边的"Advanced",确保"Show preview packages"为被选中状态。随后,在列表中找到并点击UIWidgets,然后点击右下角的"Install"
- 打开Asset Store,搜索UIWidgets,下载并导入工程中。具体步骤和在Asset Store下载其他资源类似,在此不细讲。
在工程目录中的Assets目录下新建脚本,命名为UIWidgetsExample.cs。将以下内容粘贴进去:
using System.Collections.Generic; using Unity.UIWidgets.animation using Unity.UIWidgets.engine; using Unity.UIWidgets.foundation; using Unity.UIWidgets.material; using Unity.UIWidgets.painting; using Unity.UIWidgets.widgets; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new ExampleApp(); } } class ExampleApp : StatefulWidget { public ExampleApp(Key key = null) : base(key) { } public override State createState() { return new ExampleState(); } } class ExampleState : State<ExampleApp> { int counter = 0; public override Widget build(BuildContext context) { return new Column( children: new List<Widget> { new Text("Counter: " + this.counter), new GestureDetector( onTap: () => { this.setState(() => { this.counter++; }); }, child: new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ) ) } ); } }
在场景中新建一个Canvas:在Hierachy窗口中右键 -> UI -> Canvas。
点击Scene窗口左上角的2D按钮打开2D视角。
将UIWidgetsExample脚本添加到Canvas上:
- 从Project面板中将脚本拖到场景中的Canvas上;或者
- 点击Canvas,在Inspector窗口中点击“Add Component”,在搜索框中输入“UI Widgets Example”,找到后点击
这个简单的UIWidgets应用效果如下。
点击“Click Me”按钮,可以看到Counter后面的数字增加。
如果你觉得上述例子过于简单,可以打开场景“Samples/UIWidgetSample/UIWidgetsGallery/gallery.scene”看一下UIWidgets做出的界面。下面是一些截图。
关于目前已有的成熟的UIWidgets做的产品,可以参考凉鞋老师整理的awesome-uiwidgets。
示例程序简析
这一节我们简单讲解一下上文中的示例程序。我打算从最简单的UIWidgets程序开始,一步步扩展,最终得到这个示例程序,争取解释清楚一个基本的UIWidgets程序的框架。
最简单的UIWidgets程序长什么样儿?
我能想到的最简单的UIWidgets程序如下:
using Unity.UIWidgets.engine; public class UIWidgetsExample : UIWidgetsPanel { }
将这个脚本拖到Canvas上,一切都能正常运行,只不过场景空空如也。
使用组件
为了在Canvas上显示文字,我们为UIWidgetsPanel
提供一个createWidget()
函数,让它返回一个Text
组件。
using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new Text("Click Me"); } }
注意,原先我们只引用了engine
模块,现在增加了widgets
模块。
现在,场景的左上角出现了“Click Me”。
这里我们用到了组件(Widget)的概念。组件是搭建UIWidgets图形界面的基本单位。从UIWidgets的名字就可以看出组件在其中的地位。实际上,在UIWidgets应用中,能看到的一切都是组件。UIWidgets中有两类组件:基本组件和组合组件。
基本组件是那些简单得没法再拆分的组件,比如Text
组件。
组合组件组合组件一般会有一个或多个子组件,负责将多个组件组合在一起,比如Row
和Column
组件,负责将子组件排成一行或是一列。或者给子组件添加效果,如Center
组件,让子组件居中,或Opacity
组件,调整子组件的透明度,等等。
在使用UIWidgets绘制界面时,我们需要定义自己的组件。一般情况下,我们只需要定义组合组件,把UIWidgets的内置组件组装一下,就能满足大多数需求了。不过,如果要做出不寻常的效果,你也可以自定义一些基本组件,对其排版和绘制的过程进行精细的控制。
使用Container修饰基本组件
接着,我们给文字加上边框和背景色。为此,我们将Text
组件包装在Container
组件中。如果你熟悉HTML,你可以理解为UIWidgets里的Container
就和网页中的<dev>
标签是一样的。
using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new Container( child: new Text("Click Me") ); } }
此时界面没有任何变化。一个除了child
没有任何参数设置的Container
,表现得和其child
一模一样,是一个完全没有存在感的Container
。
现在我们给Container
加一些配置。
using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; using Unity.UIWidgets.painting; using Unity.UIWidgets.material; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ); } }
我们设置了Container
四边内边距都为20,颜色为蓝色。注意到,因为用了EdgeInsets
和Colors
类,我们引用了painting
和material
模块。
我们发现整个场景都被蓝色充满了。这是因为Container
的排版(Layout)方式导致的。如果Container
在某个方向上没有设置大小,它就会自动扩张到其父组件的大小。为了方便描述,称这样大小由父组件确定的排版方式为扩张型,也就是能有多大就有多大。现在, 因为这个Container
是整个APP的最顶层的组件,它没有父组件,排版时“父组件大小”就是显示设备的大小。
我们希望的效果是Container
的大小由内部的文字决定。解决这个问题的方法是利用Container
的另一个排版规则:如果外面的空间是不确定大小的(或者说父组件会等待子组件确定大小,以确定给它多少空间),那么Container
的排版方式就转为收缩型,即能有多小有多小,只要能容纳下子组件(并满足内边距等设置)就好。
因为接下来需要用到Column
组件,而Column
组件正是根据子组件的大小给其分配空间,因此这个问题会自然得到解决。
使用Column组件
我们先在Column
中只放一个子组件,也就是我们的Container
组件,观察效果。
using System.Collections.Generic; using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; using Unity.UIWidgets.painting; using Unity.UIWidgets.material; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new Column( children: new List<Widget> { new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ) } ); } }
可以看到,Container
组件缩小到Text
周围,并出现在顶部,左右居中。这是因为Column
组件默认是扩张型的,于是占据整个屏幕。它从顶端开始排列其子组件。而在左右方向上的对齐方式默认为居中。
现在我们在Container
上方增加描述计数器的文本。
using System.Collections.Generic; using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; using Unity.UIWidgets.painting; using Unity.UIWidgets.material; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new Column( children: new List<Widget> { new Text("Counter: 0"), new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ) } ); } }
自定义组件:无状态组件
现在我们的APP已经有了最终目标的样子,但并不能响应点击事件,也不能让计数器的值增加。为了让计数器能随点击而增加,我们首先将返回值封装为自定义的组件,命名为ExampleApp()
。简单起见,先介绍无状态组件(StatelessWidget)。
using System.Collections.Generic; using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; using Unity.UIWidgets.painting; using Unity.UIWidgets.material; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new ExampleApp(); } } public class ExampleApp : StatelessWidget { public override Widget build(BuildContext context) { return new Column( children: new List<Widget> { new Text("Counter: 0"), new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ) } ); } }
可以看到,我们将原来createWidget()
函数的返回值,直接搬到了ExampleApp
的build()
函数中。
无状态组件StatelessWidget
没有任何内部状态。出现StatelessWidget
的地方,等价于将其build()
函数的返回值替代掉它自己。因此,这一步没有效果上的改变,只是将我们需要的组件封装为一个自定义的组件。这和将几行代码封装为一个函数是类似的。
注意到
build()
函数需要一个BuildContext
参数。这个参数的意义现在可以先忽略。
自定义组件:状态组件
我们想要计数器的值随着点击按钮而改变。这就需要组件能够储存内部状态。StatelessWidget
不能完成这样的任务,因此我们需要状态组件(StatefulWidget)。
using System.Collections.Generic; using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; using Unity.UIWidgets.painting; using Unity.UIWidgets.material; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new ExampleApp(); } } public class ExampleApp : StatefulWidget { public override State createState() { return new ExampleAppState(); } } class ExampleAppState : State<ExampleApp> { public override Widget build(BuildContext context) { return new Column( children: new List<Widget> { new Text("Counter: 0"), new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ) } ); } }
可以看到,StatefulWidget
需要定义一个createState()
函数,返回其对应的State
类的对象,其build()
函数也转移到了对应的State
类中。这个State
类就代表了组件的内部状态。
因为我们要记录按钮的点击次数,我们在State
类中定义一个counter
变量。
using System.Collections.Generic; using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; using Unity.UIWidgets.painting; using Unity.UIWidgets.material; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new ExampleApp(); } } public class ExampleApp : StatefulWidget { public override State createState() { return new ExampleAppState(); } } class ExampleAppState : State<ExampleApp> { int counter = 0; public override Widget build(BuildContext context) { return new Column( children: new List<Widget> { new Text("Counter: " + counter), new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ) } ); } }
响应点击事件
为了让Container
能够检测到点击事件,给它包装一层GestureDetector
。后者可以识别许多鼠标/触屏手势。
using System.Collections.Generic; using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; using Unity.UIWidgets.painting; using Unity.UIWidgets.material; using UnityEngine; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new ExampleApp(); } } public class ExampleApp : StatefulWidget { public override State createState() { return new ExampleAppState(); } } class ExampleAppState : State<ExampleApp> { int counter = 0; public override Widget build(BuildContext context) { return new Column( children: new List<Widget> { new Text("Counter: " + counter), new GestureDetector( child: new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ), onTap: () => { Debug.Log("Clicked"); } ) } ); } }
我们将一个函数传递给onTap
属性,这个函数会在点击事件发生时执行。现在,单击这个Container
,Unity的Console窗口中会打印一行"Clicked"。
更新计数器
最后,我们修改传递给onTap
参数的函数体,让其更新计数器的值。
using System.Collections.Generic; using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; using Unity.UIWidgets.painting; using Unity.UIWidgets.material; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new ExampleApp(); } } public class ExampleApp : StatefulWidget { public override State createState() { return new ExampleAppState(); } } class ExampleAppState : State<ExampleApp> { int counter = 0; public override Widget build(BuildContext context) { return new Column( children: new List<Widget> { new Text("Counter: " + counter), new GestureDetector( child: new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ), onTap: () => { counter++; } ) } ); } }
然而,现在我们点击Container
,并没有什么作用。原因是我们并没有通知渲染系统这个组件的状态已经更新。通知的方法是将更新状态的操作封装在函数中,传递给setState()
函数。
using System.Collections.Generic; using Unity.UIWidgets.engine; using Unity.UIWidgets.widgets; using Unity.UIWidgets.painting; using Unity.UIWidgets.material; public class UIWidgetsExample : UIWidgetsPanel { protected override Widget createWidget() { return new ExampleApp(); } } public class ExampleApp : StatefulWidget { public override State createState() { return new ExampleAppState(); } } class ExampleAppState : State<ExampleApp> { int counter = 0; public override Widget build(BuildContext context) { return new Column( children: new List<Widget> { new Text("Counter: " + counter), new GestureDetector( child: new Container( padding: EdgeInsets.all(20), color: Colors.blue, child: new Text("Click Me") ), onTap: () => { setState(() => counter++); } ) } ); } }
现在,我们得到了最开始时看到的示例。
总结
本文中,我们介绍了Unity上的UI解决方案UIWidgets,以及怎样用UIWidgets制作一个简单的APP。本系列是UIWidgets源码系列,后续文章中会解析UIWidgets源码,讲解UIWidgets的原理。欢迎大家继续关注。