JavaScript-ESP32-和-ESP8266-物联网开发教程-四-

57 阅读1小时+

JavaScript ESP32 和 ESP8266 物联网开发教程(四)

原文:IoT Development for ESP32 and ESP8266 with JavaScript

协议:CC BY-NC-SA 4.0

八、图形基础

本章和接下来的两章将向您展示只使用低成本微控制器和小型廉价触摸屏来创建现代用户界面是多么简单。本章首先阐述如何为物联网产品添加显示器,从而提供更好的用户体验,并比过去更具成本效益和实用性。之后的章节涵盖了微控制器上图形的基础知识,包括优化和约束的重要背景知识、如何将图形素材添加到项目中的信息,以及各种绘图方法的介绍。在接下来的两章中提供了更详细的信息,这两章描述了可修改的 SDK 中的以下内容:

  • *Poco,*一个用于嵌入式系统的渲染引擎,您可以使用它在显示器上绘图

  • Piu,一个面向对象的用户界面框架,使用 Poco 进行绘图,简化了创建复杂用户交互的过程

有了这些知识,你就可以开始构建内置显示器的物联网产品,并向你的朋友和同事解释这一目标对你的产品来说触手可及。

为什么要加显示器?

在今天的电脑和手机上,具有漂亮用户界面的显示器被认为是理所当然的;然而,它们在物联网产品上仍然很少见。你可能很熟悉设置和使用用户界面极其有限的物联网产品有多困难,例如只有一个按钮和几个闪烁灯的设备。很明显,在这些产品中增加显示器可以提供更好的用户体验,使产品对客户更有价值。以下是需要考虑的一些好处:

  • 显示屏传达的信息远比几个脉冲状态灯或警报声要多。显示屏向用户详细展示产品正在做什么,如果有问题,它会告诉用户哪里出了问题。

  • 显示屏可以包含产品所有功能的完整配置选项。用几个按钮和旋钮通常无法实现如此高的配置精度。

  • 显示器使用户能够直接执行复杂的交互,而不需要其他设备。相比之下,用户下载并安装移动应用程序以与产品交互,并在能够开始配置之前将应用程序与产品配对。

  • 丰富的图形显示让您可以将图像和动画结合起来,为产品带来风格和特色,使用户更加愉快,并强化制造商的品牌形象。

集成显示器有这么多好处,为什么更多的物联网产品不包括一个呢?主要原因是成本。包含显示器的物联网产品往往是高端型号,通常是所谓的“英雄”产品,旨在展示品牌,但预计不会卖出多少台。但是给产品加个屏幕真的贵得让人望而却步吗?曾经,答案是肯定的。以下是制造商不在产品中添加显示屏的一些常见原因:

  • 显示器本身很贵。在添加微控制器和通信组件之前,一个小触摸屏的价格可以轻松达到 20 美元。

  • 与显示器交互的软件需要增加更多的 RAM,用于构建用户界面的图形素材(图像和字体)需要增加更多的存储空间。

  • 为了获得可接受的动画帧速率,需要一个具有硬件图形加速功能的特殊微处理器,即 GPU。

  • 微控制器的图形编程需要高度专业化的技能,这使得找到合格的工程师更加困难,雇佣他们也更加昂贵。

  • 微控制器的图形和用户界面 SDK 的许可成本太高。

  • 为嵌入式系统准备图形素材既耗时又容易出错。

这些在过去都是合理的理由,但今天的情况完全不同。不幸的是,大多数从事物联网产品的产品规划者、设计师和工程师都没有意识到,可以用不到 10 美元的价格获得触摸屏、微控制器、RAM 和 ROM 来提供精美的现代用户界面,即使是非常小批量的产品(轻松地低于 10,000 台)。此外,同一微控制器还可以提供 Wi-Fi 和蓝牙支持。软件和素材问题由 Poco 和 Piu 解决。

克服硬件限制

今天的计算机和移动电话中的硬件被设计成以惊人的效率执行极其复杂的图形操作。这种非凡的性能是通过复杂的硬件和软件的结合实现的。不足为奇的是,典型的微控制器没有相同的图形硬件,也缺乏运行相同复杂图形算法的速度和内存。

这些差异的自然结果是,当由微控制器驱动的物联网产品包含显示器时,它们提供的用户界面往往显得非常原始,就像 20 世纪 80 年代和 90 年代初个人计算机时代初期的计算机和视频游戏一样。在某些方面,这是有道理的,因为像早期的个人电脑和视频游戏一样,这些微控制器远不如现代电脑强大。但今天的 ESP32 运行速度比顶级 1992 年 Macintosh IIfx 中的微处理器快 6 倍,因此现代微控制器的性能足以达到或超过早期硬件的性能。

可修改的 SDK 通过应用在现代高速显示总线、大量内存和 GPU 之前的早期硬件上使用的技术,在微控制器上实现了出色的图形效果。这些实现的灵感来自于已经成功用于计算机动画、视频游戏、字体等的经典技术。现代微控制器仍然受内存限制,但它们更快,因此更多的计算是可能的。这使得一些旧硬件上没有的技术成为可能。

这些技术如何工作的细节超出了本书的范围。如果您有兴趣了解更多,可以在可修改的 SDK 中找到实现它们的所有代码。这本书着重于如何使用这些功能为你的产品构建一个优秀的用户界面。

像素速率影响帧速率

在现代移动应用和网页中,帧速率是图形性能的基本衡量标准。帧速率越高,动画越流畅。电脑和手机中的 GPU 非常强大,它能够更新每一帧显示的每一个像素。出于多种原因,微控制器无法做到这一点;然而,可以实现每秒 30 帧甚至 60 帧(fps)的动画。

因为微控制器在更新整个显示器时不能呈现高帧速率,所以解决方案是仅更新显示器的子集。您可以设计您的用户界面,以便每次只更新相对较小的显示部分。这大大减少了微控制器所需的工作,因此用户可以看到流畅的高帧率动画,就像在移动应用程序或网页上一样。

为了使用微控制器实现高帧速率,从像素速率(每秒更新的像素数)的角度考虑会有所帮助。ESP32 和 ESP8266 使用 SPI 总线与显示器通信,这种连接以 40 MHz 的频率运行,像素速率约为每秒 1,000,000 像素,约为 15 fps。由于其他因素,实现全部理论像素速率通常是不可能的;尽管如此,如果您的应用程序仅更新每帧中约 40%的像素(每帧约 30,000 像素的像素速率),它可以实现 30 fps 的可靠帧速率。在本书中使用的 QVGA (320 x 240)显示器上,30,000 像素约占总显示面积的 40%,这足以创建一个流畅、引人注目的用户界面。每帧仅更新 10,000 个像素可实现 60 fps。

您可能希望在每一帧上更新的屏幕区域必须是一个矩形。这将通过将运动限制在显示器的一个区域来限制设计的可能性。幸运的是,情况并非如此。您很快就会知道,您可以同时更新几个不同的区域,这可以让用户在整个屏幕上产生运动的印象,即使实际像素中只有一小部分在变化。

绘图框架

大多数用于微控制器的图形库都是即时模式API,这意味着当您调用绘图函数时,渲染器会执行所请求的绘图操作。另一方面,Poco 是一个保留模式渲染器,其工作方式如下:

  1. 你告诉 Poco 你什么时候开始画画。

  2. 当您调用绘图函数时,它们不会立即绘制,而是添加到绘图命令列表中。

  3. 当你告诉 Poco 你已经画好了,它会执行所有的绘图命令。

显然,保留模式呈现更复杂,维护绘图命令列表需要额外的内存。通常,在微控制器上,您试图保持软件简单和内存使用尽可能少,但保留模式的以下优势证明了其成本的合理性:

  • 保留模式渲染消除了闪烁。例如,当您在即时模式渲染器中绘制屏幕背景时,屏幕的所有像素都以背景色绘制;当您随后绘制组成用户界面的控件、文本和图片时,用户可能会首先看到没有这些用户界面元素的背景屏幕,从而导致令人分心的瞬间闪烁。由于保留模式渲染会在将结果发送到屏幕之前执行所有绘制命令,因此它会在将背景擦除与微控制器上用户界面元素的绘制传输到显示器之前,将它们结合起来,从而消除闪烁。

  • 保留模式通过减少从微控制器传输到显示器的像素数量来提高性能。考虑到在每个用户界面中都有一些重叠的像素,例如,按钮的背景和它的文本标签。在即时模式渲染器中,重叠像素被发送到显示器两次,而在保留模式渲染器中,每个像素每帧仅被发送一次。因为渲染像素比将其传输到显示器要快得多,所以这提高了整体像素速率。

  • 保留模式通过启用高效的像素混合来提高渲染质量。现代计算机图形大量使用混合来平滑对象的边缘,例如,使用反锯齿字体来消除锐边(“锯齿”)。这就是为什么今天的电脑和手机上的文字看起来比 20 世纪 80 年代的屏幕文字清晰得多。混合在计算上更加复杂,而且有足够的性能来完成它,因为微控制器要快得多;然而,混合也需要访问当前正在绘制的像素后面的像素*。在典型的微控制器硬件中,前一个像素存储在显示器的存储器中,而不是微控制器的存储器中,这使得它要么完全不可用,要么访问速度慢得不切实际。保留模式渲染器,因为它只在完全渲染时将像素传输到显示器,所以内存中始终有可用的像素的当前值,因此能够有效地执行混合。*

保留模式渲染器还有其他优点,但这三个优点应该足以让您相信,内存和复杂性成本证明使用像 Poco 这样的保留模式渲染器而不是更常见的即时模式渲染器是合理的。用户界面渲染的质量如此之高,以至于用户感觉他们正在使用一种更高质量的产品——一种属于他们的计算机和手机而不是计算机历史博物馆的产品。

扫描线渲染

QVGA 显示器有 76,800 个像素,这意味着 16 位像素的显示器需要 153,600 字节的内存来存储一个完整的帧。ESP8266 的总内存约为 80 KB,如果您的物联网产品不使用任何其他内存,则仅够半帧使用!ESP32 有更多,但在启动时,在内存中保存整个帧会占用总空闲内存的 50%或更多。本书中使用的显示器包括单帧的内存,因此微控制器不必存储整个帧,但它需要内存来呈现帧。为了最大限度地减少所需的内存,Poco 渲染器实现了扫描线渲染,这是一种将帧分成水平条带的技术,小至单行像素;在每一条被渲染后,它立即被传输到显示器。这种方法比一次渲染整个帧更复杂,但它将单个 16 位 QVGA 显示器的渲染内存需求从 153,600 字节减少到 480 字节,即一条 240 像素扫描线每像素两个字节,内存节省了 99.68%!

每个渲染的条带都有一些性能开销,因此通过增加条带的大小来减少条带的数量是有好处的——但当然这也会增加所需的内存。随着每一行被添加到一个条中,性能优势会有所下降,因此增加超过大约八条扫描线通常是不值得的。如果您的项目有一些空闲内存或需要最高性能的渲染,您可能希望让 Poco 一次渲染几条扫描线;接下来的章节将解释如何进行配置。

许多现代微控制器,包括 ESP32 和 ESP8266,都能够使用 SPI 异步向显示器传输数据,这意味着微控制器可以在传输数据的同时执行其他工作。Poco 使用异步 SPI 传输来呈现显示器的下一部分,同时将前一部分传输到显示器,这种简单的并行处理可以显著提升性能。要使用这种技术,Poco 必须有足够的内存来容纳至少两条扫描线:正在传输的先前渲染的扫描线和正在渲染的当前扫描线。因为这种技术提供了如此显著的性能提升,所以默认情况下,Poco 分配两条扫描线。

限制绘图区域

正如您在“像素速率影响帧速率”和“扫描线渲染”部分看到的,微控制器上图形使用的一项关键技术是部分更新显示,而不是一次更新全部。请注意 Poco 和 Piu 中该技术的以下方面:

  • 在 Poco 中——Poco 渲染引擎支持将绘图限制在显示器的子部分的特性被称为剪辑。Poco 用单个矩形来描述裁剪区域;每个绘制操作中与该裁剪矩形相交的部分被绘制,而落在裁剪矩形之外的任何操作部分都不被绘制。Poco 使用此功能实现扫描线渲染(Piu 使用此功能实现部分帧更新)。它也可用于您的应用程序中,例如,绘制图像的子集。

  • 在 Piu 中–更新显示器的最小可能区域可提高微控制器的渲染性能;然而,在一般情况下,确定要更新的最小可能区域是相当困难的。Poco 无法为您确定最佳的绘图区域,因为作为一个渲染引擎,它不知道您的代码正在绘制什么。另一方面,Piu 是一个用户界面框架,它完全了解组成屏幕显示的不同对象。因此,Piu 能够在后台自动为您计算最小的可能更新区域。

为了理解计算最小可能更新区域的挑战,让我们看一个弹跳球的例子。在每一帧中,球移动一定数量的像素。在图 8-1 中,球向右下方移动了几个像素。包围球的先前和当前位置的最小矩形是对屏幕上要更新的最小可能区域的良好的第一估计。

img/474664_1_En_8_Fig1_HTML.jpg

图 8-1

球轻微移动,一个更新矩形

现在考虑球移动更长距离的情况(图 8-2 ):包围先前和当前位置的最小矩形包括许多实际上没有改变的像素,但是它们被重新绘制,因为它们包含在要更新的区域中。

img/474664_1_En_8_Fig2_HTML.jpg

图 8-2

球移动得更远,一个更新矩形

如图 8-3 所示,在这种情况下,Piu 认为更新两个独立的区域更有效:包围球的先前位置的区域,填充为背景色,以及包围球的当前位置的区域。

img/474664_1_En_8_Fig3_HTML.jpg

图 8-3

球移动得更远,两个更新矩形

Piu 实际上更进一步。在第一个示例中,球只移动了一小段距离——这段距离导致当前位置与前一个位置重叠——Piu 识别出单个封闭矩形不是最小的可能更新区域;因此(如图 8-4 所示),在这种情况下,它更新三个单独的矩形,这避免了不必要地更新许多没有改变的背景像素。

img/474664_1_En_8_Fig4_HTML.jpg

图 8-4

球轻微移动,三个更新矩形

优化单个弹跳球的绘图区域所涉及的计算已经非常复杂,在有多个弹跳球有时会重叠的应用程序中,这些计算甚至会更加复杂。Piu 自动为您计算最小矩形集;这确实需要时间和内存,但是它带来的性能提升是值得的。这是因为渲染性能在很大程度上受到应用程序像素速率的限制,Piu 会自动最小化代码的像素速率。

像素

每个显示器都包含像素,但并非所有显示器都有相同种类的像素。像素有不同的大小和颜色。这种情况一直存在,但很容易忘记,因为几乎所有现代计算机和移动设备都支持相同的 24 位彩色像素格式。像嵌入式开发的许多领域一样,像素格式的多样性在一定程度上是试图保持低硬件成本的结果。能够显示颜色的显示器往往价格更高,但除了价格之外,还有其他因素会影响所使用的像素格式。例如,ePaper 显示器(通常指的是开发它的公司的名字,E Ink)使用的技术只能显示几种颜色,通常是黑色、白色和一些灰色阴影,不需要存储多种颜色的像素格式。

像素格式

大多数显示器支持单一类型的像素。本书大多数示例中使用的 QVGA 彩色显示器使用 16 位彩色像素,其中 5 位用于红色,6 位用于绿色,5 位用于蓝色。你的手机可能有 24 位的彩色像素,红色、绿色和蓝色各有 8 位。虽然这两种像素都足以显示全色用户体验,但 24 位彩色像素能够显示 256 倍多的颜色(16,777,216 对 65,536)。这种差异意味着嵌入式设备上的图像可能没有那么精致,特别是在充满相似颜色的区域,如日落。对于照片来说,这可能是个问题,但对于由微控制器驱动的用户界面来说,如果界面设计考虑到这一限制,这通常不是问题。

除了 16 位颜色,少数显示器仅支持 8 位颜色。这是非常有限的,只允许 256 种颜色。每个像素包含 3 位红色、3 位绿色和 2 位蓝色。使用这种像素类型的显示器可以构建一个合理的用户界面,但在限制范围内仔细选择看起来不错的颜色需要一些工作。在某些情况下,在支持 16 位像素的显示器上使用 8 位彩色像素可能是有益的。这显然不会提高质量,但它确实减少了资源所需的存储空间和渲染图像所需的时间。如果您发现您的项目很难适应可用的存储空间,或者如果渲染性能不是您所需要的,在 16 位显示器上使用 8 位彩色像素可能是一个可行的解决方案。

也有 4 位彩色像素,但用这些很难达到专业的效果,所以这里不讨论。然而,4 位灰度像素——可以显示 14 级灰度加上黑白——非常有用。不能显示颜色的 ePaper 显示器只需要灰色像素;由于大多数 ePaper 显示器只能显示几级灰度,所以 4 位灰度像素就足够了。灰度渲染甚至比彩色渲染更快。您可以在 16 位彩色显示器上使用 4 位灰色像素,以节省更多存储空间。还有 8 位灰度像素,可以显示 254 级灰度加黑白;这些提供了极好的质量,但是对于许多实际目的,4 位灰度渲染在质量上与 8 位灰度像素几乎没有区别。

有些显示器只是黑白的。这些显示器往往体积小、质量低,更多地用于工业物联网产品,而不是消费物联网产品。对于这些显示器来说,1 位像素就足够了;然而,以每像素 1 位的速度渲染是非常困难的。Poco 渲染器不直接支持 1 位像素显示。相反,显示驱动器接收 4 位灰色像素,然后在将图像传输到显示器时将其缩小为 1 位。

为像素格式配置主机

在第一章中,你学习了如何使用mcconfig命令行工具构建一个主机。在命令行上,使用-p选项传递目标硬件平台的名称——例如,-p esp32来构建 ESP32。对于包含显示器的设备目标,如 Moddable 和 M5Stack 的开发板,会自动为您配置默认像素格式。例如,当你为 Moddable One、Moddable Two 或 M5Stack FIRE 构建时,像素格式设置为rgb565le,为 16 位彩色像素;对于带有 ePaper 显示屏的 Moddable Three,它被设置为gray16,用于 4 位灰色像素。

16 位像素最常见的显示驱动程序是 ILI9341 驱动程序,它实现了 Moddable 和 M5Stack 开发板中的显示控制器所使用的 MIPI 显示标准。硬件使用 16 位像素,但驱动程序也支持其他像素格式。您可以通过使用-f选项在命令行上指定格式来试验不同的像素格式。例如,要使用 4 位灰色像素:

> mcconfig -d -m -p esp32/moddable_two -f gray16

以这种方式配置主机时,ILI9341 驱动程序会在将 Poco 渲染的 4 位灰色像素传输到显示器时,将其转换为 16 位彩色像素。但是还有更多的变化在发生:

  • 当您更改像素格式时,Poco 渲染器本身会重新编译。在此示例中,对 16 位像素渲染的所有支持都被移除,并替换为对 4 位灰色像素渲染的支持。这是 Poco 使用的一种技术,它在保持代码小的同时仍然支持许多不同的像素格式。

  • Poco 要求某些图形素材以与显示器相同的像素格式存储,这通常要求您以兼容的格式重新创建图形。但是因为这是乏味的、耗时的、容易出错的,mcconfig自动调用可修改的 SDK 中的其他工具来将您的素材转换成兼容的格式。这意味着您可以通过指定不同的格式来切换像素格式,这就像重新构建您的项目来尝试不同的格式并权衡利弊一样简单。

ILI9341 驱动程序还支持 8 位彩色和 8 位灰色像素。您可以通过在-f命令行选项中分别指定rgb332gray256来使用带有mcconfig的选项。

如果您发现最适合您产品的像素格式不同于默认格式,您可以在项目清单中指定您的首选格式。这样,您就不需要在每次构建时都记得在命令行中包含它。为此,在清单的config部分定义一个format属性:

"config": {
    "format": "gray256"
},

选择显示器的自由

虽然大量可用的像素格式看起来令人困惑,但它在创建产品时为您提供了多种选择。您可以选择最符合质量、成本和尺寸要求的显示器。Poco 能够渲染适合您显示器的像素,因此您不必根据软件栈的限制来选择显示器。在下一节中,您将学习如何自动转换项目中的图形资源,以匹配您正在使用的显示器。

图形素材

使用 Poco 和 Piu 构建的用户界面由三种不同的元素组成:矩形、位图图像和文本。这就是一切;没有绘制直线、圆、圆角矩形、圆弧、样条曲线或渐变的图形操作。起初,这似乎有点太简单了,您可能会得出结论,用这么少的绘图操作来构建一个现代化的用户界面是不可能的。在接下来的章节中,您将看到如何将这些元素结合起来,以创建一个在廉价微控制器上运行良好的丰富用户体验。这一节重点介绍图形素材,即用于构建用户界面的图像和字体。

面具

使用 Poco 和 Piu 构建用户界面时,最常见的资源类型是遮罩。蒙版是灰度图像;你可以把它想象成一个形状。因为蒙版包含灰色像素,而不仅仅是黑色和白色像素,所以它可以具有平滑的边缘。图 8-5 显示了一个圆的两个版本,第一个是灰度蒙版,第二个是简单的 1 位蒙版,它们的边缘被放大以示区别;请注意灰度蒙版放大中的灰色边缘。

img/474664_1_En_8_Fig5_HTML.jpg

图 8-5

灰度掩码(左)和 1 位掩码(右)

当 Poco 渲染灰度遮罩时,它不会将其绘制为图像。如果是这样,白色像素会隐藏背景,如图 8-6 所示。

img/474664_1_En_8_Fig6_HTML.jpg

图 8-6

如果绘制为图像,则使用灰度遮罩

相反,Poco 通过将黑色像素视为纯色(完全不透明),将白色像素视为透明(完全不可见),并将两者之间的灰度级别视为不同的混合级别来渲染遮罩。对应于图 8-6 的结果如图 8-7 所示,其中黑色圆圈叠加在背景上(通过透明白色像素可见),圆圈的灰色边缘与背景融合,消除了任何锯齿边缘。

img/474664_1_En_8_Fig7_HTML.jpg

图 8-7

绘制为蒙版的灰度蒙版

你可能想在你的用户界面中包含颜色,在这种情况下,灰色图像似乎不是一个显而易见的解决方案。但是,Poco 允许您以任何颜色绘制灰度蒙版。黑色像素被您选择的颜色替换,灰色像素将该颜色与背景混合。图 8-8 显示了用蓝色绘制的相同圆形遮罩(在本书的印刷版本中显示为灰色)。

img/474664_1_En_8_Fig8_HTML.jpg

图 8-8

绘制为彩色蒙版的灰度蒙版

以各种颜色绘制单个灰度遮罩的能力非常强大,因为它使单个图形资源能够以不同的颜色显示。这减少了所需资源的数量,节省了项目中的存储空间。

图 8-9 显示了一些用作用户界面元素的灰度遮罩示例。

img/474664_1_En_8_Fig9_HTML.jpg

图 8-9

用作用户界面元素的灰度遮罩

正如您在“像素格式”一节中所知道的,Poco 定义了两种不同的灰度像素:4 位和 8 位。所有 Poco 遮罩都是 4 位灰度,这允许最小的存储大小和最快的渲染,而不会牺牲太多质量。

将遮罩添加到项目中

您可以将遮罩作为 PNG 文件添加到项目中,PNG 文件是桌面应用程序、移动应用程序和用户界面元素网页使用的同一种图像文件。能够在您的项目中使用 PNG 文件是很方便的;但是,ESP32 和 ESP8266 不能有效地处理 PNG 图像,因为解码 PNG 图像需要内存和 CPU 开销。相反,构建工具将您的 PNG 文件转换成可以在这些微控制器上有效处理的格式。由于这种自动转换,您没有必要理解这些非标准图像格式的细节(尽管细节可以在可修改的 SDK 中找到)。

要在您的项目中包含一个 PNG 蒙版图像,将其添加到您的项目清单文件的resources部分,如清单 8-1 所示。

"resources": {
    "*-mask": [
        "./assets/arrow",
        "./assets/thermometer"
    ]
}

Listing 8-1.

请记住,清单中指定的资源不包括文件扩展名。在清单 8-1 的例子中,图像文件的文件名为arrow.pngthermometer.png

掩模压缩

灰度掩模足够小,可以用在针对微控制器的产品中。之前在图 8-9 中显示的温度计图像存储为 4 位灰度掩模时为 2458 字节。尽管如此,如果再小一点就好了。Poco 有一个解决方案:它包括一个专门针对 4 位灰度图像的压缩算法。该算法针对微控制器进行了优化,因此不需要太多的 CPU 时间或额外的内存。

对于温度计图像,压缩算法将数据大小减少到 813 字节,比原始未压缩版本小 67%。压缩率因图像而异。对于包含较大连续黑白区域的图像,Poco 蒙版压缩率会有所提高。

未压缩的遮罩

在绘制用户界面的遮罩时,将几个相关元素组合在一个图形文件中通常会很方便。许多图形设计师更喜欢这种工作方式,因为它使修改蒙版更快更容易。因为 Poco 支持裁剪渲染,所以它在绘制时只能使用遮罩的一部分,所以您可以选择以这种方式组织图形文件。图 8-10 中的遮罩显示了 Wi-Fi 连接的几种不同状态,它们被组合在一个图形文件中。

img/474664_1_En_8_Fig10_HTML.jpg

图 8-10

多个遮罩组合在一个图形文件中

如前所述,您可以压缩这些组合的遮罩图像。但是,对包含多个图像的遮罩使用压缩会有性能损失。这是因为要渲染压缩图像的一部分,解压缩器必须跳过目标区域上方和左侧的图像部分,这需要额外的时间。对于某些项目,压缩带来的存储大小减少的好处比性能降低更重要。您可以通过将掩码添加到清单的*-alpha部分而不是*-mask部分来保持其不被压缩(参见清单 8-2 )。当然,您的清单可能同时包含*-mask*-alpha,以压缩一些遮罩,而不压缩其他遮罩。

"resources": {
    "*-alpha": [
        "./assets/wifi-states"
    ]
}

Listing 8-2.

字体

字体是嵌入式开发中一个独特的挑战。你的电脑和手机有几十种,甚至几百种内置字体。这些字体中的一种或多种包括 Unicode 标准中定义的几乎所有字符,这意味着没有您的设备不能显示的文本字符。在微控制器上,没有内置字体;项目中唯一可用的字体是您包含在项目中的字体。

有许多字体可供您的计算机使用,如果能够在您的物联网产品中使用这些相同的字体,将会非常方便。您电脑上的大多数字体(如果不是全部的话)都是以基于由 Apple 创建的 TrueType 可缩放字体技术的格式存储的(OpenType 字体格式是 TrueType 的衍生)。在微控制器上渲染这些字体是可能的,但具有挑战性,渲染所需的代码、内存和 CPU 资源的数量使其在许多项目中不切实际。本书中的示例使用了一种更简单的字体格式,一种高质量的位图字体。ESP32 上提供了 TrueType 兼容的渲染器,本节将对此进行介绍。

将 TrueType 字体转换为位图字体

尽管在所有项目中使用 TrueType 字体可能不切实际,但您仍然可以在您的计算机上使用这些字体,方法是使用您的计算机将 TrueType 字体转换为微控制器可以轻松处理的格式。TrueType 字体以特定的磅值呈现在位图中,所有字符都存储在一个位图中。位图使用 4 位灰色像素,而不是黑白像素,以保持原始字体的抗锯齿效果。此外,还会生成一个.fnt文件,该文件在 Unicode 字符代码和字体位图中的矩形之间进行映射。这种结合了位图图像和地图文件的字体格式被称为 BMFont ,意为“位图字体”BMFont 有几种变体;可修改的 SDK 使用二进制 BMFont 格式。图 8-11 显示了 16 磅大小的开放 Sans 字体在 BMFont 格式下的样子。

img/474664_1_En_8_Fig11_HTML.jpg

图 8-11

BMFont 格式的字体字符图像

请注意,字符的排列顺序不同于 Unicode 或 ASCII 标准中的顺序。例如,字母 A、B 和 C 不会按顺序出现。相反,字符是按高度排列的,通过最大限度地减少未使用的空白空间,使位图图像尽可能小。

可用于创建这些位图文件的工具不是可修改 SDK 的一部分。71 Squared 的字形设计器很好用。可修改的 SDK 包括一套 BMFont 格式的预建字体,因此您可以开始开发,而无需在工具上进行任何额外投资。

BMFont 格式的每种字体都有两个文件:一个图像文件,通常是 PNG 格式,一个字体映射文件,文件扩展名为.fnt。这两个文件应该有相同的名称,不同的文件扩展名,如OpenSans-Regular-16.pngOpenSans-Regular-16.fnt。要将这些添加到您的项目中,请在您的项目清单中包含该名称,如清单 8-3 所示。

"resources": {
    "*-mask": [
        "./assets/OpenSans-Regular-16"
    ]
}

Listing 8-3.

请注意,*-mask部分与用于压缩灰度蒙版的部分相同。以这种方式包含的字体也被压缩;然而,不是压缩整个图像,而是单独压缩每个字符。这使得每个字符能够被直接解压缩,避免了跳过每个字形上方和左侧的像素所需的开销。

压缩的字形与来自.fnt文件的数据合并成一个资源。这导致紧凑的字体文件仍然保持优良的质量,并可以有效地渲染。前面的 Open Sans 16-point 字体示例总共仅使用 6228 字节的存储空间来存储压缩字符以及布局和呈现所需的字体度量信息。此外,因为字体使用与灰度蒙版相同的压缩格式存储,所以它们也可以用任何颜色渲染。

BMFont 格式不要求字体为灰度。这种格式很受游戏设计者的欢迎,因为它使他们能够在游戏中包含创造性的、丰富多彩的字体。Poco 和 Piu 支持全色字体。它们不常用在微控制器上,因为它们需要更多的存储空间。如果您想尝试一下,可修改的 SDK 包含一些示例,可以帮助您入门。

使用可缩放字体

BMFont 格式既方便又高效,但是它消除了 TrueType 字体的一个主要优点:将字体缩放到任意大小的能力。如果您的项目使用三种不同大小的相同字体,您需要包括它的三个不同版本,每个版本对应一个点大小。可以在一些更强大的微控制器上直接使用可缩放字体,包括 ESP32。Monotype Imaging 是一家领先的字体和字体技术提供商,它提供了一种针对微控制器优化的可缩放 TrueType 字体的高质量实现。Monotype Spark 可缩放字体渲染器已经与可修改的 SDK 集成,因此可以与 Poco 和 Piu 一起使用。有关更多信息,请联系 Moddable 或 Monotype。

版权字体

对于商业产品,您需要确保您有权使用您产品中包含的任何字体。就像书籍和计算机软件一样,字体可以由它们的创作者拥有版权。幸运的是,在公共领域或在自由和开源软件许可下,有许多优秀的字体可用。谷歌为 Android 创建的 Open Sans 字体就是这样一种字体,在物联网产品的用户界面中工作良好。

彩色图像

虽然灰度蒙版是构建用户界面的强大工具,但有时您需要全色图像。Poco 使用未压缩的位图来支持彩色图像。这些位图提供了极好的质量和性能;然而,它们可能非常大,因此通常在微控制器的接口中很少使用。

对于彩色图像,可以使用标准的 JPEG 和 PNG 文件。与灰度遮罩一样,mcconfig在构建时将它们转换为构建目标的最佳格式。要在您的项目中包含彩色图像,请将它们添加到清单的resources部分的*-color部分(参见清单 8-4 )。请注意,.jpg.png文件扩展名被省略。

"resources": {
    "*-color": [
        "./quack"
    ]
}

Listing 8-4.

全色图像是完全不透明的;它们没有混合或透明区域。图 8-12 显示了在一个简单的用户界面中呈现的来自前面清单片段的quack JPEG 图像。

img/474664_1_En_8_Fig12_HTML.jpg

图 8-12

全色图像的渲染

该形状是一个矩形,因为图像中的所有像素都是绘制的。存储在 PNG 文件中的图像可能包含可选的 *alpha 通道。*alpha 通道就像一个灰度蒙版:它指示图像中的哪些像素应该被绘制,哪些应该被跳过,以及哪些应该与背景混合。Alpha 通道通常是在使用 Adobe Photoshop 等工具编辑图像时创建的。Poco 支持渲染 alpha 通道;您通过将图像放入清单的 resources 部分的*-alpha部分来表明您想要保留 alpha 通道(参见清单 8-5 )。

"resources": {
    "*-alpha": [
        "./quack-with-alpha"
    ]
}

Listing 8-5.

图 8-13 显示了结果。鸭子图像与图 8-12 中使用的图像相同;但是,增加了一个 alpha 通道来遮盖背景。因此,在渲染图像时,只绘制了鸭子。

img/474664_1_En_8_Fig13_HTML.jpg

图 8-13

用 alpha 通道渲染全色图像

当您在项目中包含带有 alpha 通道的图像时,构建工具会创建两个单独的图像:全色图像(就像您将图像放入了*-color部分一样)和未压缩的 alpha 通道(作为 4 位灰度蒙版)。颜色资源命名为quack-color.bmp,压缩后的蒙版资源命名为quack-alpha.bm4。图 8-14 显示了用于遮蔽鸭子图像绘制的 alpha 通道。

img/474664_1_En_8_Fig14_HTML.jpg

图 8-14

图 8-13 中使用的阿尔法通道

当 Poco 和 Piu 渲染图像时,它们同时使用彩色图像和遮罩。您将在接下来的两章中学习如何做到这一点。

显示旋转

每个显示器都有一个自然方向,这意味着有一个边缘是“顶部”这个方向由硬件绘制的第一个像素的位置和从那里开始绘制的方向来定义。自然方向由硬件决定,不能更改。不过,为了有效地旋转显示器上的图像,通常需要将屏幕的不同边缘视为顶部。这在自动旋转显示器上的图像以匹配用户手持设备的方向的移动设备上很常见。这种能力也存在于大多数 LCD 电视中,因此用户可以最方便地安装显示器,然后手动调整方向以“正面朝上”显示图像

虽然许多物联网产品不允许用户通过配置或旋转设备来改变方向,但这些产品可能仍然需要旋转显示器,例如,当产品的设计要求横向方向,但显示器的原生方向是纵向模式时,或者当显示器在产品中颠倒安装以节省空间时(这似乎不常见,但确实会发生)。此外,有时硬件设计人员错误地将显示器装反了,为了节省时间,要求软件团队进行补偿。出于这些原因以及更多原因,在 0 度、90 度、180 度和 270 度方向渲染用户界面的能力对于许多物联网产品来说是必要的。

如以下部分所述,旋转显示器有两种不同的方法:适用于所有显示器的软件方法和适用于某些显示器的硬件方法。

在软件中旋转

旋转用户界面的最常见技术是将整个界面绘制到屏幕外存储缓冲区中,就好像显示器处于旋转的方向一样。然后,当像素被传输到显示器时,它们被转换以匹配硬件方向。这种方法在低成本的微控制器上是不可行的,因为没有足够的内存在屏幕外缓冲区中存储一个完整的帧。

Poco 采用了一种非常不同的方法:它在构建期间将所有图像素材旋转到所需的方向,这样它们就不需要在运行时旋转。这种旋转与任何所需的像素格式转换同时执行。然后,当嵌入式设备上的应用程序进行绘图调用时,Poco 只需要将绘图坐标旋转到目标方向。完成这两个步骤后,Poco 照常渲染,结果在显示器上旋转显示。这种方法几乎没有可测量的运行时开销——不使用额外的内存,并且只运行少量的额外代码来执行坐标转换——因此它几乎是一个零成本的特性。因为软件旋转完全在 Poco 渲染器中实现,所以它适用于所有显示器。

当使用软件旋转时,您可以通过将-r命令行选项改为mcconfig来改变方向。支持的旋转值为 0、90、180 和 270。

> mcconfig -d -m -p esp/moddable_one -r 90

与像素格式配置一样,您也可以在项目清单中指定软件旋转:

"config": {
    "rotation": 90
}

软件轮换有一个明显的限制:轮换在构建时是固定的,因此在运行时不能改变。因此,这种技术适用于物联网产品用户界面需要与显示器的自然方向不同的方向的情况,但不适用于需要响应用户操作(如转动屏幕)的情况。硬件轮换(如果可用)克服了这一限制。

硬件旋转

当显示器从微控制器接收像素时,硬件旋转使用显示控制器的功能来旋转图像。使用硬件旋转要求显示控制器和显示驱动程序都支持该功能。对于 MIPI 兼容的显示控制器,ILI9341 驱动程序完全支持硬件旋转。

硬件轮换完全是在运行时执行的,所以在您的构建命令行或项目清单中没有什么需要定义的。事实上,不要在项目中同时使用硬件和软件轮换,这一点很重要;它们不是为协同工作而设计的,所以结果是不可预测的。

使用硬件轮换时,您在运行时设置轮换,而不是在构建时配置它。您使用screen全局变量与显示驱动程序通信。对于支持硬件旋转的显示器,screen全局有一个rotation属性;您可以通过查看是否定义了该属性来检查是否支持硬件旋转。

if (screen.rotation === undefined)
    trace("no hardware rotation\n");
else
    trace("hardware rotation!\n");

要更改旋转,请设置rotation属性:

screen.rotation = 270;

您的代码可以读取screen.rotation来检索当前旋转:

trace(`Rotation is now ${screen.rotation}\n`);

当硬件旋转改变时,显示不变。在用户看到改变的方向之前,必须重新绘制显示的全部内容。如果您在更改旋转后只更新了屏幕的一部分,用户将看到以原始方向绘制的部分显示和以新方向绘制的其他部分。

M5Stack 目标的主机支持使用 Piu 自动旋转项目的用户界面,以匹配硬件方向。这导致了与移动电话相同的行为,该移动电话具有根据用户如何握持该设备来调整的显示器。该功能之所以成为可能,是因为 M5Stack 内置了一个加速度传感器,可提供当前的设备方向。对于不想使用此功能的 M5Stack 项目,可以在项目清单中禁用它。

"config": {
    "autorotate": false
}

差不多吧?

在本章中,您已经了解了用于图形的 Poco 渲染引擎和 Piu 用户界面框架,这两者都可以用于构建运行在廉价微控制器(包括 ESP32 和 ESP8266)上的物联网产品的用户界面。Poco 和 Piu 具有相似的图形功能,因为 Piu 使用 Poco 进行渲染。当您开始创建自己的项目时,您必须决定是使用 Poco API、Piu API,还是两者都使用。本节解释了一些不同之处,以帮助您做出选择。

Poco 和 Piu 是本质上不同的 API:

  • Poco 是一个图形 API。您进行函数调用,最终导致部分屏幕被绘制。

  • Piu 是一个面向对象的 API,用于构建用户体验。您可以使用 Piu 创建用户界面对象,如文本标签、按钮和图像。将这些对象添加到应用程序会导致部分屏幕被绘制;您不需要自己调用绘图函数。

Piu 会为您处理许多细节,因此您可能会编写较少的代码;例如,它调用 Poco 在必要时呈现用户界面对象。因为您告诉 Piu 当前屏幕上所有活动的用户界面对象,所以当您移动、更改、显示或隐藏一个元素时,Piu 能够最小化所需的绘图量。例如,使用 Piu,您只需一行代码就可以使用遮罩来更改用户界面元素的颜色;Piu 确定屏幕上哪些像素必须更新,并自动绘制改变的元素以及与其相交的任何对象。相比之下,Poco 不了解应用程序的用户界面,因此您必须编写代码来刷新屏幕并最小化更新区域。这样做的代码通常从简单开始,但是随着用户界面变得越来越复杂,维护起来就变得越来越困难。

Piu 使用内存来跟踪活动的用户界面对象,因此比单独使用 Poco 使用更多的内存。当然,如果您不使用 Piu,您的代码必须跟踪活动的用户界面对象本身,这也需要内存。

Piu 内置了对触摸事件的响应支持。事实上,Piu 自动支持多点触控。(可改装一号和可改装二号的显示屏都支持两个触摸点。)作为图形引擎,Poco 专注于画图,不支持触摸输入,所以你的应用必须直接与触摸输入驱动交互;虽然这并不难做到,但是您需要编写和维护更多的代码。

也许使用 Piu 的最大好处是,作为一个框架,它提供了你的项目的基本结构。以下预定义的对象为您的项目提供了一个定义明确、设计良好的组织,由 Piu 本身的高效实现提供支持:

  • Application对象维护全局状态,并在整个应用程序生命周期内存在。

  • TextureSkin对象组织您的图形资源。

  • Style对象使用级联样式表(如 web 上的 CSS)来管理字体、大小和样式。

  • ContainerContent对象定义了用户界面的元素。

  • Behavior对象将用于特定目的的事件处理程序组合在一起,例如提供触摸按钮行为。

  • 每个对象都实现了一个独特的过渡,或者是整个显示,或者是部分显示。

当您使用 Poco 时,您必须自己设计和实现应用程序结构。如果您的项目用户界面看起来有点类似于移动应用程序、桌面应用程序或网页,使用 Piu 可能是个好主意,因为它是为此而设计和优化的。如果您喜欢编写用户界面框架,或者如果您的用户体验非常不同(例如,一个游戏),那么直接使用 Poco 可能是正确的选择。

有些项目有标准的用户界面风格,但需要提供部分屏幕的特殊呈现。这方面的一个例子是显示传感器数据实时图表的物联网产品;屏幕上的按钮和标签非常适合 Piu,但是使用 Poco 可以更有效地呈现图形。像这样的项目的解决方案是在屏幕上使用 Piu,为了绘制图形,嵌入一个 Piu Port对象,它允许您在 Piu 布局中发出类似于 Poco 的绘制命令。

结论

下一章将进一步讨论 Poco 及其图形框架 Commodetto,下一章将讨论 Piu。当您通读这两章时,请考虑您自己项目的需求,以及是高级 Piu 用户界面 API 还是低级 Poco 图形渲染 API 更合适。Poco 和 Piu 使用起来非常不同,因此值得对它们进行试验,以了解哪一个最适合您的需求。

九、使用 Poco 绘制图形

Poco 渲染器是本书中所有图形和用户界面代码的核心。正如您在第八章中了解到的,Poco 的设计和实施经过优化,可在许多物联网产品中使用的廉价微控制器上提供高质量、高性能的图形。本章通过一系列例子介绍了 Poco API 的所有主要功能。Poco 这个名称是古典音乐中的一个术语,意思是“一点点”,反映了渲染引擎的紧凑大小和范围。

Poco 是 *Commodetto、*的一部分,Commodetto 是一个图形库,它提供位图、来自资源的图形素材实例、屏幕外图形缓冲区、显示驱动程序等等。本章中的一些例子使用了这些 Commodetto 特性。Commodetto 这个名字也是一个来自古典音乐的术语,意思是“悠闲”,反映了图形库的易用性。

安装 Poco 主机

您可以按照第一章中描述的模式运行本章的所有示例:使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。

所有 Poco 示例都需要使用屏幕,这使得您的mcconfig命令行必须为您的开发板指定一个带有屏幕驱动程序的平台。这些示例旨在在分辨率为 240 x 320 的屏幕上运行。以下命令行用于可修改 1、可修改 2 和 M5Stack FIRE:

> mcconfig -d -m -p esp/moddable_one
> mcconfig -d -m -p esp32/moddable_two
> mcconfig -d -m -p esp32/m5stack_fire

如果使用试验板和跳线将屏幕连接到开发板,请遵循第一章中的说明。为 ESP32 提供的接线与esp32/moddable_zero目标一起工作;对于 ESP8266 和esp/moddable_zero目标也是如此。

如果你的设备没有屏幕,你可以在可修改的 SDK 提供的桌面模拟器上运行本章的例子。以下命令行适用于 macOS、Windows 和 Linux:

> mcconfig -d -m -p mac
> mcconfig -d -m -p win
> mcconfig -d -m -p lin

本章的主机在$EXAMPLES/ch9-poco/host目录中。从命令行导航到这个目录,用mcconfig安装它。

如果您正在使用桌面模拟器,请确保在安装示例之前将屏幕尺寸更改为 240 x 320。你可以通过从应用程序工具栏的尺寸菜单中选择 240 x 320 来实现。

准备画画

要使用 Poco 渲染器,您需要从commodetto/Poco模块导入Poco类:

import Poco from "commodetto/Poco";

Poco 是一个通用渲染器。它呈现的像素可以发送到屏幕、内存缓冲区、文件或网络。Poco 不知道如何向这些目的地发送像素;相反,它将像素输出到PixelsOut类的实例,并且PixelsOut的每个子类都知道如何将像素发送到特定的目的地。例如,显示驱动程序是PixelsOut的一个子类,它知道如何向屏幕发送像素。PixelsOut的另一个子类BufferOut,将像素发送到一个内存缓冲区(你将在本章的“高效渲染渐变”一节中看到)。

当您实例化Poco时,您为 Poco 提供了一个PixelsOut类的实例来调用渲染像素。本章的主持人自动为开发板的显示驱动程序创建一个PixelsOut的实例,并将其存储在screen全局变量中。要使用屏幕,只需将screen传递给Poco构造函数。

let poco = new Poco(screen);

显示驱动程序的像素格式和显示尺寸在主机清单中配置。screen实例有widthheight属性,但是这些不包括软件旋转的影响。相反,在使用 Poco 时,使用Poco实例的widthheight属性来获取应用了任何旋转调整(硬件或软件)的显示边界。

trace(`Display width is ${poco.width} pixels.`);
trace(`Display height is ${poco.height} pixels.`);

正如在第八章中提到的,Poco 是一个保留模式渲染器,这意味着它不是立即执行绘图命令,而是建立一个绘图操作列表来一次渲染所有内容。这个显示列表需要内存。默认显示列表是 1,024 字节。如果图形溢出了显示列表分配,则需要增加显示列表分配。如果您的项目没有使用所有默认的显示列表分配,您可以减少它以释放内存供其他用途。以下示例将显示列表调整为 4 KB:

let poco = new Poco(screen, {displayListLength: 4096});

您可以通过查看xsbug中仪表板的“Poco display list used”行来监控您的项目使用了多少显示列表(参见图 9-1 )。

img/474664_1_En_9_Fig1_HTML.jpg

图 9-1

xsbug仪表板中使用的监控显示列表

Poco 还为渲染分配内存。默认渲染缓冲区是两条硬件扫描线。一条硬件扫描线的宽度从screen.width开始。如果你的产品内存非常紧张,你可以减少到一条扫描线,尽管不能更小。

let poco = new Poco(screen, {pixels: screen.width});

当 Poco 一次渲染几条扫描线时,它能够更快地渲染。下面的代码将渲染缓冲区增加到八条完整的扫描线,同时将显示列表设置为 2 KB。

let poco = new Poco(screen,
          {displayListLength: 2048, pixels: screen.width * 8});

作为优化,Poco 共享为显示列表和渲染缓冲区分配的内存。如果正在渲染的帧的显示列表没有完全填满,Poco 会在渲染缓冲区中包含那些未使用的字节,这通常会使渲染速度稍快一些。

Poco 提供的三种基本绘图操作是绘制矩形、位图和文本。正如在第八章中提到的,这听起来可能不多,但是你可以结合这些元素来创造丰富的用户体验。下一节将详细介绍它们。

绘制矩形

绘制矩形是 Poco 提供的三种基本绘制操作中最简单的一种。在介绍第一个绘图操作时,本节还介绍了一些使用 Poco 绘图的基础知识。

填满屏幕

$EXAMPLES/ch9-poco/rectangle示例简单地用纯色填充整个屏幕。代码如清单 9-1 所示。

let poco = new Poco(screen);
let white = poco.makeColor(255, 255, 255);
poco.begin();
    poco.fillRectangle(white, 0, 0, poco.width, poco.height);
poco.end();

Listing 9-1.

第一行调用Poco构造函数来创建Poco的实例。该实例将渲染像素传送给screen。这一步对于本章中的所有示例都是通用的,因此将从这里显示的其余示例中省略。

让我们依次看看本例中调用的每个方法:

  1. poco.makeColor的三个参数接收红色、绿色和蓝色分量,每个分量的范围从 0(无)到 255(全)。这里指定的颜色是白色,所以红色、绿色和蓝色分量都是 255。makeColor方法将这三个值组合成一个值,这个值最适合呈现给目的地(本例中为screen)。根据目的地,Poco 使用不同的算法从颜色分量创建颜色值。因此,您应该只将由makeColor返回的值传递给创建它的同一个Poco实例。

  2. poco.begin的调用告诉 Poco 你开始渲染一个新的帧。在此之后发生的所有绘图操作都将添加到框架的显示列表中。

  3. 调用poco.fillRectangle向显示列表添加一个命令来绘制一个全屏白色矩形。颜色是第一个参数,接着是 xy 坐标,然后是宽度和高度。坐标平面将(0,0)放在屏幕的左上角,高度和宽度向下和向右移动。

  4. poco.end的调用告诉 Poco 您已经完成了对该帧的绘制操作。Poco 然后渲染像素,并发送给screen;这可能需要一些时间,取决于显示器的大小、微控制器的速度以及渲染帧的难度。在可修改的一个或可修改的两个上,它很快完成。

Important

Poco 不会自动用颜色填充背景,因为这会降低渲染性能。这意味着您的代码必须绘制到帧中的每个像素。如果不为像素指定颜色,Poco 会输出未定义的颜色。确保您的代码用一种颜色填充背景,如本例所示,或者确保您进行的绘图调用组合覆盖每个像素。

更新部分屏幕

当您调用begin方法时,您可以选择指定要更新的屏幕区域。您可能还记得,更新屏幕的较小部分是实现更高帧速率的一种技术。

下面的示例用红色填充 20 x 20 像素的正方形;显示器上的其他像素保持不变。如果将这段代码添加到前面的rectangle示例中,屏幕将是白色的,除了左上角的一个红色小方块。

let red = poco.makeColor(255, 0, 0);
poco.begin(0, 0, 20, 20);
    poco.fillRectangle(red, 0, 0, 20, 20);
poco.end();

在这里,对begin的调用定义了要绘制的区域——称为更新区域— ,即显示屏左上角的 20 x 20 的正方形。仅绘制更新区域中的像素,因此更新区域外的白色像素保持不变。当您不带参数调用begin时,就像在rectangle的例子中,更新区域是整个屏幕。在这个例子中,对fillRectangle的调用使用了与对begin的调用相同的坐标和尺寸,用红色像素填充了整个更新区域。

如前所述,beginend之间的代码必须进行覆盖每个像素的绘制调用,以生成正确的结果——但是如果代码在对begin的调用中指定的区域之外绘制,会发生什么呢?考虑下面的例子,它用指定全屏的参数调用fillRectangle:

let red = poco.makeColor(255, 0, 0);
poco.begin(0, 0, 20, 20);
    poco.fillRectangle(red, 0, 0, poco.width, poco.height);
poco.end();

此示例产生的结果与前面的示例完全相同。Poco 没有响应fillRectangle的全屏绘制请求,而是将fillRectangle的输出限制在对begin的调用中指定的更新区域。这种方法对于许多呈现情况都很方便,尤其是对于动画,因为它使您能够限制要更新的区域,而无需更改代码来将其绘制限制在更新区域。

绘制随机矩形

一个经典的计算机图形演示是在随机位置连续渲染随机大小的随机彩色矩形。$EXAMPLES/ch9-poco/random-rectangles示例正是这样做的,通过在对 Poco 的begin方法的调用中指定坐标,将绘制限制到当前正在绘制的矩形。如果你运行这个例子,你会看到如图 9-2 所示的动画版本。

img/474664_1_En_9_Fig2_HTML.jpg

图 9-2

random-rectangles动画渲染

第一步是实例化Poco并清空屏幕:

let black = poco.makeColor(0, 0, 0);
poco.begin();
    poco.fillRectangle(black, 0, 0, poco.width, poco.height);
poco.end();

接下来,一个重复计时器(列表 9-2 )被安排以每秒 60 帧的速度运行。当计时器触发时,随机坐标和矩形尺寸连同随机颜色一起生成。begin方法将绘制限制在矩形区域。

Timer.repeat(function() {
    let x = Math.random() * poco.width;
    let y = Math.random() * poco.height;
    let width = (Math.random() * 50) + 5;
    let height = (Math.random() * 50) + 5;
    let color = poco.makeColor(255 * Math.random(),
                     255 * Math.random(), 255 * Math.random());
    poco.begin(x, y, width, height);
        poco.fillRectangle(color, 0, 0, poco.width,
                           poco.height);
    poco.end();
}, 16);

Listing 9-2.

随机值都是浮点的,因为对Math.random的调用返回一个从 0 到 1 的数。所有 Poco 函数都期望坐标的整数值,因此makeColorbegin自动将提供的浮点数四舍五入为最接近的整数。在第十一章中,你将学习如何添加你自己的随机整数函数,通过消除这些浮点运算来提高性能。

绘制混合矩形

到目前为止绘制的矩形都是实心的:像素是完全不透明的,完全遮住了后面的像素。混合矩形将单一颜色与其后面的像素结合在一起,产生了一种如同戴着有色眼镜一样的效果。混合矩形在用户界面中用于提供分层效果和绘制阴影。

要绘制一个混合矩形,使用blendRectangle方法。参数与fillRectangle类似,增加了混合级别作为第二个参数。混合级别是一个从 0 到 255 的数字,其中 0 表示完全透明(完全不可见),255 表示完全不透明。下面的线条以 128 (50%)的混合级别在整个屏幕上混合红色。像所有其他绘图操作一样,这必须发生在调用beginend之间。

poco.blendRectangle(red, 128, 0, 0, poco.width, poco.height);

如果你给blendRectangle一个 0 的混合级别,它会完全忽略绘制操作,甚至不会添加一个条目到显示列表。如果您传递的混合级别为 255,blendRectangle的行为与fillRectangle完全一样。

为了探究混合矩形的外观及其渲染性能,$EXAMPLES/ch9-poco/blended-rectangle示例制作了一个混合矩形的动画。图 9-3 显示了混合矩形在屏幕上几个位置的图像。

img/474664_1_En_9_Fig3_HTML.jpg

图 9-3

来自blended-rectangle动画的渲染图

动画的背景由四个彩色条组成—白色、红色、绿色和蓝色。这些条由drawBars辅助函数绘制,如清单 9-3 所示。

function drawBars(poco) {
    let w = poco.width;
    let h = poco.height / 4;
    poco.fillRectangle(poco.makeColor(255, 255, 255),
                       0, 0, w, h);
    poco.fillRectangle(poco.makeColor(255, 0, 0),
                       0, h, w, h);
    poco.fillRectangle(poco.makeColor(0, 255, 0),
                       0, h * 2, w, h);
    poco.fillRectangle(poco.makeColor(0, 0, 255),
                       0, h * 3, w, h);
}

Listing 9-3.

当示例开始时,它通过绘制彩色条覆盖整个屏幕。请注意,drawBars并不是以一次调用fillRectangle来用纯色填充整个屏幕开始的,而是绘制了四个独立的条带,它们组合起来覆盖了整个屏幕区域。

poco.begin();
    drawBars(poco);
poco.end();

接下来,定义变量来控制混合黑盒的动画,该黑盒从屏幕的顶部中心下降到底部(参见清单 9-4 )。

let boxSize = 30;
let boxBlend = 64;
let boxStep = 2;
let boxColor = poco.makeColor(0, 0, 0);
let x = (poco.width - boxSize) / 2, y = 0;

Listing 9-4.

以像素为单位的框的大小由boxSize定义。混合水平是 64 (25%)。在动画的每一帧上,盒子步进两个像素,如boxStep所定义的。boxColor变量定义了要用黑色绘制的盒子。最后,盒子左上角的初始坐标被设置在xy变量中。

盒子的运动通过一个重复的计时器来激活,如清单 9-5 所示。对begin的调用指定了一个包含盒子的当前和先前位置的绘图区域,确保在一次操作中完全擦除先前位置并完全绘制新位置。对drawBars的调用指定了填充屏幕的坐标,但是这些坐标仅限于传递给begin的更新区域。定时器回调函数结束时, y 坐标增加boxStep。一旦盒子滑离屏幕底部,y 坐标将被重置为 0,以继续从屏幕顶部开始制作动画。

Timer.repeat(function() {
    poco.begin(x, y - boxStep, boxSize, boxSize + boxStep * 2);
        drawBars(poco);
        poco.blendRectangle(boxColor, boxBlend, x, y, boxSize,
                            boxSize);
    poco.end();

    y += boxStep;
    if (y >= poco.height)
        y = 0;
}, 16);

Listing 9-5.

该动画在 ESP32 和 ESP8266 上以每秒 60 帧的速度流畅运行。这是因为代码优化了绘图区域,因此微控制器每秒仅向显示器发送约 60,000 像素,或不到一个完整的帧。这些像素的渲染和传输分布在 60 个帧中。与渲染全帧相比,这将渲染和传输的像素数量减少了 98.6%。通过更改控制动画的变量进行实验,以查看更改长方体大小、混合级别和长方体颜色的效果。

当运行这个示例时,当盒子返回到顶部时,您可能会注意到在屏幕底部有一个小的盒子假象。可以修改代码来消除工件,但是这样做会使代码更加复杂。这是 Piu 自动处理的细节之一,您将在第十章中看到。

绘制位图

绘制位图是 Poco 提供的第二个基本绘制操作。它用于遮罩位图和图像位图。因为有这么多不同种类的位图,而且在构建用户界面时位图有这么多的用途,所以有几个不同的函数来绘制位图。本节向您介绍一些最常用的函数。

绘画蒙版

正如您在第八章中了解到的,掩码是微控制器构建用户界面时最常用的位图类型。原因有很多:它们提供了出色的质量,因为它们支持抗锯齿,它们可以用不同的颜色渲染,它们可以快速渲染,并且它们可以被压缩以最小化存储需求。

掩码存储在资源中。您可以通过将遮罩图像包含在项目清单中来选择要在项目中使用的遮罩图像,如清单 9-6 所示(正如您在第八章的“向项目添加遮罩”一节中所学)。

"resources": {
    "*-mask": [
        "./assets/mask"
    ]
}

Listing 9-6.

要使用蒙版位图,您必须首先访问存储它的资源。资源只是数据;使用 Poco API 渲染遮罩需要 Poco 位图对象。Commodetto 提供了从资源数据创建 Poco 对象的函数。

要从压缩蒙版实例化一个位图对象,使用 Commodetto 的parseRLE函数。(“RLE”代表“游程编码”,用于压缩掩码的算法。)下面的代码检索资源并使用parseRLE创建位图对象:

import parseRLE from "commodetto/parseRLE";

let mask = parseRLE(new Resource("mask-alpha.bm4"));

在这个小例子中,有一些重要的细节需要理解:

  • 正如您在第五章中看到的,Resource构造函数引用闪存中的资源数据,而不是将其加载到 RAM 中。parseRLE函数还引用数据,而不是将数据从闪存复制到 RAM 然而,parseRLE确实为引用该数据的 Poco 位图对象分配了少量的 RAM。

  • 注意,加载资源的路径是mask-alpha.bm4,而不是mask.png。记住,在构建时运行的工具将 PNG 文件转换成微控制器的优化格式,这些工具将优化的图像数据放入bm4类型的文件中。因为图像被用作 alpha 通道,所以名称后会附加-alpha。因此,运行在微控制器上的代码需要加载不同于原始名称的数据。(Piu 会自动为您使用正确的名称和分机。)

一旦有了蒙版的位图对象,就可以通过调用drawGray方法来绘制蒙版:

poco.drawGray(mask, red, 10, 20);

第一个参数是遮罩,第二个参数是要应用的颜色,最后两个参数是 xy 坐标。请注意,您没有指定尺寸;Poco 总是以原始大小渲染位图,而不应用任何缩放。这样做是因为高质量的缩放会使用更多的 CPU 时间,并增加 Poco 中的渲染代码量。

parseRLE返回的蒙版位图对象具有widthheight属性,以像素为单位给出位图的尺寸。当您更改图形资源的尺寸时,通过使绘图自动适应,这些在您的绘图中会很有用。例如,下面的代码在蒙版后面的区域绘制了一个蓝色矩形,因此蒙版没有绘制的任何像素都是蓝色的,并且蒙版中具有透明度的任何像素都与蓝色背景混合。蓝色背景矩形的大小总是与蒙版的大小精确匹配。

poco.fillRectangle(blue, 10, 20, mask.width, mask.height);
poco.drawGray(mask, red, 10, 20);

使用未压缩的遮罩

从第八章到你知道,只画一个压缩蒙版的子集会有一些低效,因为解压缩器必须跳过你想要画的图像上面和左边的部分。您可以使用未压缩的遮罩。为此,将掩码图像放在清单资源的*-alpha部分(而不是*-mask部分),使其以未压缩的形式存储。然后,不使用parseRLE来加载它,而是使用资源扩展为.bmpparseBMP

import parseBMP from "commodetto/parseBMP";

let mask = parseBMP(new Resource("mask-alpha.bmp"));

在压缩和未压缩遮罩之间切换时,请记住执行以下操作:

  • 将资源放在正确的部分:*-alpha表示未压缩,*-mask表示压缩。

  • 使用正确的加载函数实例化位图:parseBMP表示未压缩,parseRLE表示压缩。

  • 在资源名中使用正确的扩展名:.bmp表示未压缩,而.bm4表示压缩。

一旦你有了位图,你就可以使用drawGray来渲染蒙版,不管它们是压缩的还是未压缩的。

绘制遮罩的一部分

图 9-4 (你在第八章中第一次看到的)中的图像是一张未压缩的蒙版图像,其中包含描绘几种不同 Wi-Fi 状态的图标。

img/474664_1_En_9_Fig4_HTML.jpg

图 9-4

Wi-Fi 图标条

这个图像的一个明显用途是绘制一个反映当前 Wi-Fi 状态的图标。您的应用程序将希望一次只绘制一个图标,以反映当前状态。如前一节所述,出于效率的原因,组合不同状态的图像不应被压缩。

要仅绘制位图的一部分,您需要指定一个源矩形,即要使用的位图区域。在$EXAMPLES/ch9-poco/wifi-icons示例中,源矩形的 xy 坐标、宽度和高度作为绘图坐标后的可选参数传递给drawGray。每个单独的状态图标为 27 像素见方。来自wifi-icons示例的以下代码绘制了四个状态图标,如图 9-5 所示:

img/474664_1_En_9_Fig5_HTML.jpg

图 9-5

从 Wi-Fi 图标条创建的图标

poco.drawGray(mask, black, 10, 20, 0, 0, 27, 27);    // top left
poco.drawGray(mask, black, 37, 20, 0, 27, 27, 27);   // bottom left
poco.drawGray(mask, black, 10, 47, 112, 0, 27, 27);  // top right
poco.drawGray(mask, black, 37, 47, 112, 27, 27, 27); // bottom right

淡入淡出遮罩

淡入或淡出图像是用户界面中常见的过渡。drawGray方法有一个将蒙版与背景像素混合的选项。这与混合矩形的想法是一样的,但是使用遮罩可以混合任何形状。$EXAMPLES/ch9-poco/fade-mask示例淡入淡出一个音量图标,如图 9-6 所示。

img/474664_1_En_9_Fig6_HTML.jpg

图 9-6

来自fade-mask动画的渲染图

混合级别在drawGray的可选第九个参数中指定。与blendRectangle中一样,混合级别是一个从 0 到 255 的数字,其中 0 表示完全透明,255 表示完全不透明。

清单 9-7 显示了来自fade-mask示例的代码,它将遮罩资源从透明渐变为不透明。与blended-rectangle示例中相同的drawBars函数(列表 9-3 )在背景上绘制蒙版。

let mask = parseRLE(new Resource("mask-alpha.bm4"));
let maskBlend = 0;
let blendStep = 4;
let maskColor = poco.makeColor(0, 0, 255);
Timer.repeat(function() {
    let y = (poco.height / 4) - (mask.height / 2);
    poco.begin(30, y, mask.width, mask.height);
        drawBars(poco);
        poco.drawGray(mask, maskColor, 30, y,
                     0, 0, mask.width, mask.height, maskBlend);
    poco.end();

    maskBlend += blendStep;
    if (maskBlend > 255)
        maskBlend = 0;
}, 16);

Listing 9-7.

请注意,要使用混合级别,您还必须提供源矩形,即使是在绘制整个蒙版时。位图矩形的尺寸—在本例中为mask.widthmask.height—用于源矩形;这确保了当素材的维度改变时,代码不需要改变。

绘制彩色图像

您可以使用 JPEG 和 PNG 文件将彩色图像添加到项目中。构建工具将它们转换为未压缩的位图,以便在设备上呈现,因为在微控制器上使用 JPEG 和 PNG 压缩格式来构建高性能用户界面通常是不切实际的。位图存储在 BMP 文件中(扩展名为.bmp),因为没有压缩,所以可能会很大。例如,对于使用 16 位像素的显示器,40 像素见方的图像占用 3200 字节的存储空间。

如前所述,使用parseBMP函数为 BMP 图像创建 Poco 位图,并使用drawBitmap方法绘制它,将绘制图像的位置的 xy 坐标作为参数传递。

let image = parseBMP(new Resource("quack-color.bmp"));
poco.drawBitmap(image, 30, 40);

drawGray一样,通过指定源矩形,您可以选择只绘制图像的一部分。以下示例仅绘制图像的左上象限:

poco.drawBitmap(image, 30, 40, 0, 0,
                image.width / 2, image.height / 2);

绘制 JPEG 图像

由于它们的内存和 CPU 需求,压缩的 JPEG 图像不是在微控制器上存储图像的好的通用方式;但是,当您需要在相对较小的空间内存储大量图像时,它们非常有用,例如,幻灯片放映或在用户界面中使用的图像集合。Commodetto 包括一个 JPEG 解压缩器,您可以与 Poco 一起使用,在您的项目中绘制 JPEG 图像。本节解释了两种不同的方法。

在资源中存储 JPEG 数据

如您所知,构建工具会自动将清单中的图像转换为 BMP 文件。如果您想保持 JPEG 文件的原始压缩格式,请将 JPEG 图像放在清单的data部分,而不是resources部分(参见清单 9-8 )。data部分的内容总是被复制而没有任何转换。

"data": {
    "*": [
        "./piano"
    ]
}

Listing 9-8.

下一节介绍的绘制 JPEG 图像的方法与软件显示旋转不兼容。这是因为软件旋转依赖于在构建时旋转映像,这里清单告诉构建工具不要转换映像。这些绘制 JPEG 图像的技术仅在您使用硬件旋转或软件旋转为 0 度时有效。

从内存中绘制 JPEG 图像

在电脑和手机上,JPEG 图像通常会被解压缩为屏幕外位图;然后,当需要 JPEG 图像时,绘制位图。这种方法提供了出色的渲染性能,因为解压缩 JPEG 图像的复杂操作只发生一次。但是,存储解压缩的 JPEG 图像会占用大量内存。因此,这种方法通常只适用于相对较小图像的微控制器。

以下示例使用loadJPEG函数将包含 JPEG 数据的资源解压缩为 Poco 位图。一旦图像在位图中,您使用drawBitmap来渲染它,如前所述。

import loadJPEG from "commodetto/loadJPEG";

let piano = loadJPEG(new Resource("piano.jpg"));
poco.drawBitmap(piano, 0, 0);

loadJPEG的调用需要一些时间来完成,因为解压缩 JPEG 图像对于微控制器来说是一个相对困难的操作。该时间因图像大小、压缩级别和微控制器性能而异。

在解压缩过程中绘制 JPEG 图像

如果您没有足够的内存来保存完整的解压缩 JPEG 图像,您仍然可以显示图像,方法是在解压缩时按块显示图像。这个例子演示了如何将一个全屏(240 x 320)的 JPEG 图像直接解压缩到屏幕上。当你运行这个例子时,你会看到如图 9-7 所示的屏幕。

img/474664_1_En_9_Fig7_HTML.jpg

图 9-7

来自draw-jpeg示例的 JPEG 图像

首先使用JPEG类为 JPEG 图像创建一个 Poco 位图:

import JPEG from "commodetto/readJPEG";
let jpeg = new JPEG(new Resource("harvard.jpg"));

JPEG 解压缩程序总是一次解码一个块。块的大小因 JPEG 图像的压缩方式而异,介于 8 x 8 和 16 x 16 像素之间。随着块被解压缩,您的代码可以将它们直接绘制到屏幕上。

清单 9-9 显示了来自draw-jpeg示例的代码,它将 JPEG 图像解压缩到屏幕上。read方法解压缩图像的一个块,并将其作为 Poco 位图返回。位图对象包括提供 JPEG 图像中块的坐标的xy属性,以及提供块的尺寸的widthheight属性。当有更多的块要显示时,JPEG类的ready属性返回true,当所有的块都被解码后返回false

while (jpeg.ready) {
    let block = jpeg.read();
    poco.begin(block.x, block.y, block.width, block.height);
        poco.drawBitmap(block, block.x, block.y);
    poco.end();
}

Listing 9-9.

用彩色图像填充

用纹理填充屏幕区域可以创建比纯色更有趣的用户界面。$EXAMPLES/ch9-poco/pattern-fill示例演示了如何平铺一幅地球图像来覆盖屏幕的一部分,如图 9-8 所示。

img/474664_1_En_9_Fig8_HTML.jpg

图 9-8

来自pattern-fill示例的重复地球纹理

使用纹理图案的大图像需要更多的存储空间。一个好的替代方法是使用可以平铺的小图案。您的代码可以简单地多次绘制小图像;然而,向drawBitmap发出所有这些调用需要时间,这样做可能会溢出 Poco 的显示列表。更好的选择是使用 Poco 的fillPattern方法,用 Poco 位图平铺一个矩形区域。例如,下面是如何用存储在名为tile的变量中的位图填充整个屏幕:

poco.fillPattern(tile, 0, 0, poco.width, poco.height);

位图后的参数是要填充的矩形的 xy 坐标、宽度和高度。fillPattern方法还支持可选的源矩形,这使您可以只使用位图的一部分来显示图块。例如(如图 9-9 所示),来自pattern-fill示例的图像组合了同一纹理的 11 个不同版本,每个版本位于动画的不同步骤。

img/474664_1_En_9_Fig9_HTML.jpg

图 9-9

图片来自pattern-fill示例

pattern-fill示例使用源矩形用动画图案填充屏幕区域。清单 9-10 显示了创建动画的代码。定时器用于在组合图像中的八个不同图像中顺序移动。phase变量跟踪动画模式的八个步骤中的哪一个要绘制。

let tile = parseBMP(new Resource("tiles-color.bmp"));
let size = 30;
let x = 40, y = 50;
let phase = 0;
Timer.repeat(function() {
    poco.begin(x, y, size * 5, size * 5);
        poco.fillPattern(tile, x, y, size * 5, size * 5,
                         phase * size, 0, size, size);
    poco.end();

    phase = (phase + 1) % 8;
}, 66);

Listing 9-10.

绘制蒙版彩色图像

通过蒙版(alpha 通道)绘制彩色图像是移动应用程序和网页中的一种常见技术。正如你在第八章中看到的,它能让你画出任何形状的全色图像,而不仅仅是矩形。使用 Poco 的drawMasked方法,可以通过未压缩的灰度蒙版绘制未压缩的彩色图像。

调用接受许多参数,除了一个之外,其余都是必需的。这些是按顺序排列的参数:

  • image–彩色位图图像。

  • xy–要绘制的坐标。

  • sxsyswsh–要从彩色位图中使用的源矩形。

  • mask–掩膜位图(未压缩的 4 位灰度;不支持压缩掩码)。

  • mask_sxmask_sy–要从掩码位图中使用的源矩形左上角的坐标。(宽度和高度与彩色位图源矩形的宽度和高度相同。)

  • blend–*(可选)*混合等级,从 0 到 255;默认为 255(完全不透明)。

要尝试通过蒙版绘制彩色图像,您需要一个图像和一个蒙版。$EXAMPLES/ch9-poco/masked-image示例使用图 9-10 中的圆形蒙版,用图 9-11 中的火车图像创建聚光灯效果。

img/474664_1_En_9_Fig11_HTML.jpg

图 9-11

来自masked-image示例的列车图像

img/474664_1_En_9_Fig10_HTML.jpg

图 9-10

来自masked-image示例的圆形遮罩

蒙版和彩色图像是用parseBMP加载的,因为它们都是未压缩的:

let image = parseBMP(new Resource("train-color.bmp"));
let mask = parseBMP(new Resource("mask_circle.bmp"));

如以下代码所示,绘图位置被设置为xy变量中的坐标(30,30)。变量sx是源矩形的左侧;它被初始化到图像的右侧,因此火车渲染从火车的前面开始。step变量被设置为 2,以在每帧上将训练推进两个像素。

let x = 30, y = 30;
let sx = image.width - mask.width;
let step = 2;

清单 9-11 显示了制作动画的代码。计时器被用来定时开动火车。画的位置总是不变的,火车穿过遮罩。通过调整图像源矩形的左边缘sx,火车移动。

Timer.repeat(function() {
    poco.begin(x, y, mask.width, mask.height);
        poco.fillRectangle(gray, x, y, mask.width, mask.height);
        poco.drawMasked(image, x, y,
                   sx, 0, mask.width, mask.height, mask, 0, 0);
    poco.end();

    sx -= step;
    if (sx <= 0)
        sx = image.width - mask.width;
}, 16);

Listing 9-11.

图 9-12 显示了通过蒙版绘制部分列车的结果。请注意,蒙版的边缘与灰色背景融合在一起。

img/474664_1_En_9_Fig12_HTML.jpg

图 9-12

具有默认混合级别(255)的掩蔽序列

drawMasked的可选blend参数改变每个像素的相对不透明度。图 9-13 显示了使用 128(大约 50%)的混合级别渲染的相同火车图像。请注意,所有的像素,不仅仅是边缘,都与背景融为一体。

img/474664_1_En_9_Fig13_HTML.jpg

图 9-13

混合等级为 128 的掩蔽序列

绘图文本

Poco 支持的第三个也是最后一个基本绘图操作是绘制文本。要绘制文本,首先需要一种字体。字体存储为位图,通常被压缩。

在您的应用程序中,使用parseBMF函数从资源中加载字体。对于压缩字体,扩展名为.bf4。本章根据在用 Piu 构建的应用程序中通常使用的惯例,确定一个名称由连字符分隔部分组成的字体资源(如第十章中进一步描述的)。

import parseBMF from "commodetto/parseBMF";

let regular16 = parseBMF(new Resource("OpenSans-Regular-16.bf4"));
let bold28 = parseBMF(new Resource("OpenSans-Semibold-28.bf4"));

Poco 对您的项目可能包含的字体数量没有限制。当然,目标微控制器上可用的闪存存储空间会限制项目中字体的数量和大小。

字体中的字符是灰度遮罩,因此可以用任何颜色绘制。drawText方法需要文本字符串、字体、颜色和绘图坐标作为参数。坐标指定绘制的第一个字符左上角的位置。下面一行从屏幕的左上角开始,用 16 磅、常规粗细的黑色 Open Sans 绘制字符串Hello:

poco.drawText("Hello", regular16, black, 0, 0);

绘制文本阴影

您可以通过绘制文本两次来实现投影效果,每次使用不同的坐标,首先作为阴影,然后作为主要文本。$EXAMPLES/ch9-poco/text-shadow示例首先在主文本要去的地方的右边向下画阴影颜色的文本,然后用在主坐标上画的主颜色的相同字符串覆盖它。这导致文本如图 9-14 所示。

img/474664_1_En_9_Fig14_HTML.jpg

图 9-14

text-shadow示例绘制的文本

let text = "Drop Shadow";
poco.drawText(text, bold28, lightGray, 0 + 2, 100 + 2);
poco.drawText(text, bold28, blue, 0, 100);

测量文本

所绘制文本的高度与字体的高度相同,字体的高度包含在 font 对象的height属性中。使用getTextWidth方法确定所绘制文本的宽度。下面的代码在绘制文本之前用绿色填充文本后面的区域:

let text = "Hello";
let width = poco.getTextWidth(text, regular16);
poco.fillRectangle(green, 0, 0, width, regular16.height);
poco.drawText(text, regular16, black, 0, 0);

Note

字体被传递给getTextWidth,因为它包含每个字符的尺寸。注意不要用一种字体量,用另一种字体画;它们的测量值可能不同,因此您可能会得到意想不到的结果。

截断文本

如果要绘制的文本宽度超过了可用空间,常见的解决方案是绘制省略号(...)在文字被截断的地方。当您告诉方法可用于绘制的宽度时,drawText方法会自动为您完成这项工作。

下面的示例在单行上绘制一个句子,将其截断到屏幕的宽度。结果如图 9-15 所示。

img/474664_1_En_9_Fig15_HTML.jpg

图 9-15

两种不同字体的截断文本

let text = "JavaScript is one of the world's most widely used
            programming languages.";
poco.drawText(text, regular16, black, 0, 0, poco.width);
poco.drawText(text, bold28, black, 0, 40, poco.width);

环绕文本

在某些情况下,您可能想要在显示器的多行上绘制文本。在支持来自世界各地的书面语言的一般情况下,这种自动换行是有挑战性的。这个例子展示了一种基本的方法,这种方法足以应付使用罗马字符编写的语言的常见情况。

该示例使用String对象的split方法创建一个包含字符串单词的数组:

let text = "JavaScript is one of the world's most widely used
            programming languages.";
text = text.split(" ");

然后它遍历所有的单词,一次一个,如清单 9-12 所示。如果行上有足够的空间来容纳当前单词,或者如果单词比整行宽,则绘制文本;否则,width被重置为全行宽度,y增加字体高度,以便在下一行继续绘图。

let width = poco.width;
let y = 0;
let font = regular16;
let spaceWidth = poco.getTextWidth(" ", font);
while (text.length) {
    let wordWidth = poco.getTextWidth(text[0], font);
    if ((wordWidth < width) || (width === poco.width)) {
        poco.drawText(text[0], font, black, poco.width - width, y);
        text.shift();
    }
    width -= wordWidth + spaceWidth;
    if (width <= 0) {
        width = poco.width;
        y += font.height;
    }
}

Listing 9-12.

图 9-16 显示了字体分别设置为regular16bold28时运行该示例的结果。

img/474664_1_En_9_Fig16_HTML.jpg

图 9-16

text-wrap字体大小为 16(左)和 28(右)的示例

其他绘图技术

Poco 和 Commodetto 提供了许多工具来简化和优化特定需求的绘图。下面几节介绍其中的三种:使用裁剪将文本限制在一个框内,使用原点轻松地重用绘图代码,以及在屏幕外绘制以有效地呈现渐变。

将文本限制在一个框中

如你所知,Poco 不在你调用 Poco 的begin方法时定义的更新区域外绘制;通过将初始剪辑区域设置为与更新区域相同,它剪辑到该区域。您的代码还可以在绘制过程中调整剪辑区域。剪辑区域总是被begin定义的更新区域所限制;您可以缩小剪裁区域,但可以将其扩展到初始绘图区域之外。

一个有用的地方是滚动条——适合屏幕一部分的滚动文本信息。文本决不能画在滚动条的边界之外,而是应该一直画到它的边缘。$EXAMPLES/ch9-poco/text-ticker示例演示了如何做到这一点;图 9-17 显示了该示例的渲染图。

img/474664_1_En_9_Fig17_HTML.jpg

图 9-17

text-ticker示例绘制的滚动条

列表 9-13 显示了一些贯穿绘图代码的变量。外面有一个黑色的框架,它的像素大小存储在frame变量中。框架内有一个小的空白,不能画文字;它的像素大小存储在margin变量中。为跑马灯文本保留的区域的宽度存储在tickerWidth中。总的widthheight由这些值计算得出。

let frame = 3;
let margin = 2;
let x = 10, y = 60;
let tickerWidth = 200;
let width = tickerWidth + frame * 2 + margin * 2;
let height = regular16.height + frame * 2 + margin * 2;

Listing 9-13.

在开始绘制之前,将对文本进行一次测量,以避免渲染过程中的冗余计算。结果存储在textWidth中。

let text = "JavaScript is one of the world's most widely used
            programming languages.";
let textWidth = poco.getTextWidth(text, regular16);

变量dx存储文本相对于滚动条文本区域左边缘的当前水平偏移量。文本从右边缘开始,并从那里滚动。

let dx = tickerWidth;

股票代码由两部分组成。首先,绘制黑色框架和黄色股票背景:

poco.fillRectangle(black, x, y, width, height);
poco.fillRectangle(yellow, x + frame, y + frame,
                   tickerWidth + margin * 2,
                   regular16.height + margin * 2);

接下来,绘制文本(列表 9-14 )。该示例首先使用clip方法来更改剪辑区域。它用裁剪矩形的 xy 坐标、宽度和高度调用clip。这会将当前剪辑区域推送到栈上,然后将其与所请求的剪辑相交。不带参数调用clip弹出剪辑栈并恢复前一个剪辑。这种方法使得嵌套裁剪区域更改变得容易。

poco.clip(x + frame + margin, y + frame + margin, tickerWidth,
          regular16.height);
poco.drawText(text, regular16, black, x + frame + margin + dx,
              y + frame);
poco.clip();

Listing 9-14.

最后,滚动条的水平偏移量被提前,为下一个动画帧做准备。当文本完全滚离左边缘时,它会重置为从右边缘再次滚入。

dx -= 2;
if (dx < -textWidth)
    dx = tickerWidth;

轻松重用绘图代码

在调用 Poco 的begin方法后,绘图的原点(0,0)位于屏幕的左上角,到目前为止,在所有示例中原点都保持在那里。您可以使用origin方法来偏移原点。这简化了编写在屏幕上不同位置绘制用户界面元素的函数。$EXAMPLES/ch9-poco/origin示例使用origin方法在不同位置绘制相同的带黑框的黄色矩形,如图 9-18 所示。

img/474664_1_En_9_Fig18_HTML.jpg

图 9-18

origin示例绘制的矩形

以下来自origin示例的函数绘制了一个带有黑框的黄色矩形:

function drawFrame() {
    poco.fillRectangle(black, 0, 0, 20, 20);
    poco.fillRectangle(yellow, 2, 2, 16, 16);
}

在此功能中,绘图在原点完成。在调用drawFrame之前移动原点会导致图形出现在屏幕上的不同位置。清单 9-15 显示了来自origin示例的代码,该示例在每次调用drawFrame之前调用origin方法来偏移原点。结果就是你在图 9-19 中看到的四个矩形。

drawFrame();
poco.origin(20, 20);
drawFrame();
poco.origin(20, 20);
drawFrame();
poco.origin();
poco.origin();
poco.origin(0, 65);
drawFrame();
poco.origin();

Listing 9-15.

原点从(0,0)开始。对poco.origin(20, 20)的第一次调用将原点移动到(20,20)。因为这些值是相对的,所以第二次调用poco.origin(20, 20)会将原点移动到(40,40)。

方法将当前原点存储在栈上。不带参数调用origin弹出原点栈,恢复之前的原点。与clip方法一样,这种方法使得嵌套原点的改变变得容易。在这个例子中,对poco.origin(0, 65)的调用发生在栈上的所有项目都被移除之后,所以原点回到(0,0)。调用后,原点在(0,65)。

虽然最后一次调用origin可能看起来没有必要,因为没有执行进一步的绘制,但是如果在调用end方法之前没有完全清除原点或剪辑栈,Poco 认为这是一个错误。如果出现这种不平衡的情况,end方法会报告一个错误。

高效渲染渐变

您的项目不限于在构建时创建的位图;您也可以在项目运行时创建位图。您已经看到了一个这样的例子:loadJPEG函数在内存中从压缩的 JPEG 数据创建一个位图。因为这些位图必须存储在 RAM 中,所以它们受到可用内存量的限制。您可以使用BufferOut类在运行时创建一个位图,这也为位图创建了一个虚拟屏幕。这使您能够像在物理屏幕上绘图一样,使用 Poco 在屏幕外绘制位图。

import BufferOut from "commodetto/BufferOut";

$EXAMPLES/ch9-poco/offscreen示例创建一个屏幕外位图,给位图绘制一个简单的渐变,然后在屏幕上将位图动画化。创建屏幕外位图时,需要指定其宽度和高度以及新位图的像素格式。这里像素格式设置为poco.pixelsOut.pixelFormat,这样屏幕外位图和屏幕具有相同的像素格式。

let offscreen = new BufferOut({width: 64, height: 64,
                     pixelFormat: poco.pixelsOut.pixelFormat});

这个屏幕外位图是一个 64 x 64 像素的正方形。为了绘制它,您创建另一个绑定到offscreenPoco实例,而不是像第一个实例那样绑定到screen

let pocoOff = new Poco(offscreen);

然后,该示例使用pocoOff绘制位图,就像绘制到屏幕上一样。清单 9-16 显示了它用来绘制如图 9-19 所示的灰度渐变的代码。

img/474664_1_En_9_Fig19_HTML.jpg

图 9-19

offscreen示例绘制的灰度渐变

pocoOff.begin();
    for (let i = 64; i >= 1; i--) {
        let gray = (i * 4) - 1;
        let color = pocoOff.makeColor(gray, gray, gray);
        pocoOff.fillRectangle(color, 0, 0, i, i);
    }
pocoOff.end();

Listing 9-16.

附加到offscreen的位图可从其bitmap属性中获得。下面一行将屏幕外位图绘制到屏幕上:

poco.drawBitmap(offscreen.bitmap, 0, 0);

渲染这个屏幕外位图的内容需要绘制 64 个不同的矩形,每个矩形的大小和颜色都略有不同。在动画中一遍又一遍地绘制这些矩形对于微控制器来说计算量太大。幸运的是,绘制屏幕外位图要容易得多。

offscreen的例子通过以不同的速度左右滑动屏幕外位图的 19 个副本来制作动画。清单 9-17 显示了动画代码,图 9-20 显示了动画的渲染。

img/474664_1_En_9_Fig20_HTML.jpg

图 9-20

offscreen动画的渲染

let step = 1;
let direction = +1;
Timer.repeat(function() {
    poco.begin(0, 0, 240, 240);
        poco.fillRectangle(white, 0, 0, poco.width, poco.height);
        for (let i = 0; i < 19; i += 1)
            poco.drawBitmap(offscreen.bitmap, i * step, i * 10);

        step += direction;
        if (step > 40) {
            step = 40;
            direction = -1;
        }
        else if (step < 1) {
             step = 0;
             direction = +1;
        }
    poco.end();
}, 33);

Listing 9-17.

触摸输入

如果您使用 Poco 来绘制产品的用户界面,并且希望加入触摸功能,您需要通过直接从触摸输入驱动程序中读取来实现对触摸输入的支持。当您使用 Piu 时,触摸输入会自动为您处理。幸运的是,阅读触摸输入并不十分困难。

访问触摸驱动程序

最常见的电容式触摸输入是 FocalTech FT6206。该部件用于可修改的一个和可修改的两个板。您将 touch 驱动程序导入到项目中,并创建一个实例,如下所示:

import FT6206 from "ft6206";
let touch = new FT6206;

旧的电阻式触摸屏通常使用 XPT2046 触摸控制器。

import XPT2046 from "xpt2046";
let touch = new XPT2046;

两个触摸驱动程序实现了相同的 API,所以一旦你实例化了驱动程序,你从它们读取的代码对两者来说是相同的。

读取触摸输入

要从触摸驱动程序中检索触摸点,您需要调用read方法。您将一个接触点数组传递给read调用,驱动程序更新这些点。通常,在实例化触摸驱动程序以最小化内存管理器和垃圾收集器所做的工作之后,分配一次触摸点。下面的代码分配了一个只有一个接触点的数组。该数组被分配给触摸输入驱动程序实例的points属性。

touch.points = [{}];

要检索当前触摸点,用点数组调用read:

touch.read(touch.points);

驱动程序为每个触摸点设置state属性。state属性的值如下:

  • 0–无接触

  • 1–触摸输入开始(手指向下)

  • 2–触摸输入继续(手指仍向下)

  • 3–触摸输入端(手指抬起)

对于除 0 之外的所有状态值,触摸点的xy属性指示当前触摸位置。清单 9-18 中的代码,摘自$EXAMPLES/ch9-poco/touch,每秒钟对触控驱动进行 30 次采样,将当前状态输出到调试控制台。

Timer.repeat(function() {
    let points = touch.points;
    let point = points[0];
    touch.read(points);
    switch (point.state) {
        case 0:
            trace("no touch\n");
            break;
        case 1:
            trace(`touch begin @ ${point.x}, ${point.y}\n`);
            break;
        case 2:
            trace(`touch continue @ ${point.x}, ${point.y}\n`);
            break;
        case 3:
            trace(`touch end @ ${point.x}, ${point.y}\n`);
            break;
    }
}, 33);

Listing 9-18.

FT6206 的某些版本不能可靠地产生触摸结束状态。运行该示例时,可以看到组件的行为。如果没有产生触摸结束状态,当触摸点进入状态 0(无触摸)时,可以确定触摸序列已经结束。

使用多点触控

read方法采用点的数组而不是单个点的原因是它可以支持多点触摸。FT6206 电容式触摸传感器支持两个同时触摸点,只要它们不是靠得太近。要使用多点触摸,只需要传递一个包含两个点的数组。

touch.points = [{}, {}];
touch.read(touch.points);

应用旋转

触摸驱动程序总是提供既没有应用硬件旋转也没有应用软件旋转的点。如果您使用旋转,您需要将其应用到触摸点。如您所料,Piu 会为您旋转接触点。

您可以使用清单 9-19 中的代码来转换 90 度、180 度和 270 度旋转的坐标。

if (90 === rotation) {
    const x = point.x;
    point.x = point.y;
    point.y = screen.height - x;
}
else if (180 === rotation) {
    point.x = screen.width - point.x;
    point.y = screen.height - point.y;
}
else if (270 === rotation) {
    const x = point.x;
    point.x = screen.width - point.y;
    point.y = x;
}

Listing 9-19.

结论

Poco 渲染器提供了构建物联网产品用户界面所需的所有基本工具。您可以使用许多不同的选项来绘制矩形、位图和文本。渲染功能包括消除文本锯齿、以任何颜色绘制的灰度蒙版,以及通过 alpha 通道蒙版渲染彩色图像。您可以使用裁剪来限制更新的屏幕区域,从而优化渲染性能。

Poco 给了你很大的控制权——但是这种权力也带来了一些不便。您必须加载资源并调用适当的函数来解析它们,您必须计算要更新的屏幕区域,并且您必须注意旋转的一些细节。下一章将介绍 Piu 用户界面框架,它将为您处理这些细节。

十、使用 Piu 构建用户界面

Piu 是一个面向对象的用户界面框架,它简化了创建复杂用户界面的过程。它使用 Poco 渲染器进行绘制。本章概述了 Piu 的工作原理,并通过一系列例子介绍了它的一些主要功能。Piu 这个名字在古典音乐中的意思是“更多”,反映了 Piu 在 Poco 基础上构建的非常丰富的功能集。

请记住,学习一个新的用户界面框架可能具有挑战性。每个框架都有自己的方式来解决构建用户界面的问题,并有自己的 API 套件来解决这个问题。要完全理解 Piu 的复杂性,仅仅遵循本章中的例子是不够的。本章的目的是教你 Piu 最重要和最常用的特性,展示它们的简单使用示例,并充分解释它们,以便你可以在自己的产品的用户界面中使用它们。

如果你已经知道层叠样式表,或者 CSS ,一种定义样式的语言——例如,文本——最常用于设计用 HTML 编写的网页,那么 Piu 的某些部分对你来说会很熟悉。Piu 和 CSS 的相似之处绝非偶然;Piu 整合了许多 CSS 约定,为开发 web 和物联网产品的开发人员提供一致性。

关键概念

在深入研究代码之前,理解 Piu 背后的一些关键概念是很重要的。如果您是使用面向对象的用户界面框架的新手,本节中的信息尤其重要,因为它能让您以正确的心态使用 Piu。如果您已经习惯了使用面向对象的框架,这一节仍然很重要,因为它介绍了 Piu 特有的信息。

一切都是物体

需要掌握的最重要的概念是,Piu 应用程序中用户界面的每个元素都有相应的 JavaScript 对象。JavaScript 对象是 Piu 提供的类的实例。Piu 与本书中介绍的其他可修改的 SDK 特性不同,因为您不必导入大多数 Piu 类。相反,Piu 将常用类的构造函数存储在全局变量中,使您可以在应用程序的任何模块中轻松使用它们。

每个 Piu 应用程序都从同一个对象开始:Piu Application类的一个实例。本章的宿主创建实例,因此本章中的示例都不需要创建它。清单 10-1 展示了主机如何通过调用Application构造函数来实例化Application实例。

new Application(null, {
    displayListLength: 8192,
    commandListLength: 4096,
    skin: new Skin({fill: "white"}),
    Behavior: AppBehavior
});

Listing 10-1.

现在不要担心各种属性的细节。请注意,这里使用了 Poco 的displayListLength属性,因为 Piu 使用 Poco 进行绘图。

作为Application构造函数的一部分,Piu 将实例存储在application全局变量中。这些例子通过application全局变量访问Application实例。

application对象是 Piu 应用程序的根。可以把它想象成一个容器,容纳所有出现在屏幕上的图形元素。添加到容器中的图形元素被称为内容对象。要在屏幕上显示一个内容对象,需要创建它的一个实例,并将其添加到application对象中。例如,为了显示一行文本,您创建 Piu 的Label类的一个实例,一种内容对象,并将其添加到application对象中。

Note

本章通过类的大写名称引用类,如“the Label class”,并通过未大写的类名引用类的实例,如“a label object”(或简称为“a label”)。

您可以创建 Piu 内容对象,而不需要将它们添加到application对象中,但是它们只有在添加后才会被绘制。当你使用 Piu 时,你不会自己调用绘图函数。内容对象知道如何绘制自己;它们会根据需要调用绘图函数来更新屏幕。

当然,您也可以从屏幕上删除内容对象。正如您可能已经猜到的,您可以通过将它们从application对象中移除来实现这一点。

每个用户界面元素都是一个内容对象

如您所知,Piu 应用程序中用户界面的每个元素都与一个内容对象相关联。更具体地说,每个用户界面元素都与一个从Content类继承的类的实例相关联。这样的类有很多,包括前面提到的Label类。在本章中,您将了解各种类型的内容对象。

Note

在本章中,“content对象”指的是Content类的实例,而通用术语内容对象指的是从Content类继承的任何类的实例。

创建内容对象时,在 JavaScript 字典中指定其属性。对于label对象,属性包括标签的字符串和文本样式。字典被传递给类的构造函数。

let sampleLabel = new Label(null, {
    style: textStyle,
    string: "Hello"
});

内容对象的属性可以随时更改。通过在实例中设置属性的值来更改属性,通常使用调用构造函数时用于初始化属性的相同属性名。

sampleLabel.style = OpenSansBold12;
sampleLabel.string = "Goodbye";

当您更改添加到application对象的内容对象的属性时,屏幕会自动更新。Piu 通过使显示的适当部分无效来进行更新,内容对象调用所需的绘图函数来更新屏幕。

并非所有 Piu 对象都是内容对象

除了内容对象之外,Piu 还有其他几种对象,本节将介绍其中最常见的对象。它们都用于以某种方式修改内容对象——它们的外观、行为方式或动画方式。这些对象都不是从Content类继承的。定义它们的类将在这里介绍,并在本章后面更详细地描述。

定义外观

SkinTextureStyle类修改内容对象的外观:skintexture对象被内容对象用来用颜色和图像填充区域,而style对象定义文本的外观,包括它的字体和颜色。例如,在前面的部分中,sampleLabel用一个字典实例化,该字典包含一个设置为名为textStylestyle对象的style属性。style对象不与单个内容对象相关联;相反,它可以应用于一个或多个label对象和其他内容对象。

类似地,skin对象通过内容对象的skin属性与内容对象相关联,并且像style对象一样,它们可以由许多内容对象共享。另一方面,Texture类不被内容对象直接使用;texture对象通过skin对象的texture属性与skin对象相关联,并且可以被多个skin对象共享。

与内容对象一样,您可以使用传递给构造函数的字典来指定skintexturestyle对象的属性。与内容对象不同,skintexturestyle对象的属性不能更改。这意味着,例如,要更改标签使用的字体,您需要更改label对象的style属性,而不是style对象的font属性。

控制行为

行为执行动作以响应事件,例如屏幕上的轻击、传感器值的变化或定时器的到期。内容对象的行为由Behavior类的子类定义。行为是 Piu 实现事件驱动编程风格的一部分。如果你是事件驱动编程的新手,不要担心;本章详细解释了behavior对象和事件如何在 Piu 中工作。

内容对象必须具有分配给它的行为,才能响应事件。内容对象不需要有指定的行为,但是如果没有行为,它就不会响应事件。通常一个内容对象有自己的一个Behavior子类的实例,尽管多个内容对象可能共享一个behavior实例。

鼓舞

要制作内容对象的动画,可以使用TimelineTransition类。您可以通过更改内容对象的属性使其移动、改变颜色、淡入或淡出等来制作动画,并且可以将一个内容对象替换为另一个内容对象,例如,在屏幕之间移动。

内容对象没有timelinetransition属性;相反,timelinetransition对象是指它们动画化的内容对象。

安装 Piu 主机

您可以按照第一章中描述的模式运行本章的所有示例:使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。

所有 Piu 示例都需要使用屏幕,这使得您的mcconfig命令行必须为您的开发板指定一个带有屏幕驱动程序的平台。这些示例旨在 320 x 240 分辨率的屏幕上运行。以下命令行用于可修改 1、可修改 2 和 M5Stack FIRE:

> mcconfig -d -m -p esp/moddable_one
> mcconfig -d -m -p esp32/moddable_two
> mcconfig -d -m -p esp32/m5stack_fire

如果使用试验板和跳线将屏幕连接到开发板,请遵循第一章中的说明。为 ESP32 提供的接线与esp32/moddable_zero目标一起工作;对于 ESP8266 和esp/moddable_zero目标也是如此。

可修改零、可修改一、可修改二和 M5Stack FIRE 的屏幕驱动程序支持使用硬件旋转。主机配置屏幕旋转,使其以横向(320 x 240)方向呈现像素,而不是默认的纵向(240 x 320)方向。

如果你的设备没有屏幕,你可以在可修改的 SDK 提供的桌面模拟器上运行本章的例子。以下命令行适用于 macOS、Windows 和 Linux:

> mcconfig -d -m -p mac
> mcconfig -d -m -p win
> mcconfig -d -m -p lin

本章的主机在$EXAMPLES/ch10-piu/host目录中。从命令行导航到这个目录,用mcconfig安装它。

如果您正在使用桌面模拟器,请确保在安装示例之前将屏幕尺寸更改为 320 x 240。你可以通过从应用程序工具栏的尺寸菜单中选择 320 x 240 来实现。

Piu 的“你好,世界”

当您运行$EXAMPLES/ch10-piu/helloworld示例时,您会看到如图 10-1 所示的屏幕。

img/474664_1_En_10_Fig1_HTML.jpg

图 10-1

helloworld举例

这不是最令人兴奋的用户界面,但它是演示创建 Piu 对象以构建简单屏幕的基础的良好起点。显示的文本在一个label对象中定义,示例中的第一行代码创建了一个样式—Style类的一个实例— ,以定义文本的外观。

const textStyle = new Style({
    font: "24px Open Sans"
});

下一节将介绍font属性的细节。现在,请注意,style对象的属性是在传递给Style类的构造函数的字典中定义的。正如您之前所了解的,这是 Piu 内容对象和定义其外观的对象的约定。每个 Piu 对象都需要指定某些属性,而其他属性是可选的。例如,Style构造函数需要一个font属性,但是与颜色、水平和垂直对齐以及行高相关的属性是可选的。

然后,helloworld示例创建一个内容对象:一个名为sampleLabellabel对象(参见清单 10-2 )。一个label对象用一种样式在一行上呈现文本。

const sampleLabel = new Label(null, {
    style: textStyle,
    string: "Hello, World",
    top: 0, bottom: 0, left: 0, right: 0
});

Listing 10-2.

string属性指定标签显示的文本,style属性定义文本的样式(textStyle,如前所述)。topbottomleftright属性通过指定label对象和其容器application对象之间的边距来定义标签的位置;将这些全部设置为 0 会使label对象充满整个屏幕。默认情况下,文本水平和垂直居中,因此文本绘制在屏幕中央。

Note

请记住,topbottomleftright不是绝对坐标,而是指定从父容器的相应边缘开始的边距。

正如您在前面所学的,简单地创建一个内容对象并不能让它出现在屏幕上。您必须将内容对象添加到application对象中,以便 Piu 绘制它们。这是通过调用application.add来完成的。

application.add(sampleLabel);

在这个例子中,textStyle仅被附加到一个内容对象,但是回想一下,一个style对象可以被附加到多个内容对象。您可以添加第二个标签,该标签使用相同的样式,但在不同的位置显示不同的文本。例如,将清单 10-3 中的代码添加到示例中,会在屏幕的右下角显示文本Second string

application.add(new Label(null, {
    style: textStyle,
    string: "Second string",
    bottom: 0, right: 0
}));

Listing 10-3.

注意,这个标签没有指定topleft属性。如果指定了标签的bottom属性,但没有指定top,反之亦然,标签的高度就是其style属性指定的样式中文本的高度。同样,如果您只指定了leftright中的一个,标签的宽度就是其样式中文本的宽度。

字体

style应用于内容对象时,style对象的font属性指示用于绘制文本的字体。字体通常是存储在资源中的压缩位图,如第八章所述。Piu 不包含任何内置字体;相反,宿主和应用程序可以在其清单中包含字体。本章的宿主提供了两种字体,所有绘制文本的示例都使用这两种字体。清单 10-4 显示了包含字体的清单片段。

"resources": {
    "*-alpha": [
        "./OpenSans-Regular-24",
        "./OpenSans-Semibold-16"
    ]
},

Listing 10-4.

字体名称

Piu 使用样式的font属性来定位要使用的字体资源。本节介绍字体的命名约定,下一节解释这些名称如何映射到字体资源。

helloworld示例中,字体名称由字符串"24px Open Sans"指定。字体名称的 Piu 格式是 CSS 字体名称格式的子集。Piu 字体名称由五部分组成,顺序如下:

表 10-2

大小关键字

|

关键字

|

等效尺寸

| | --- | --- | | xx-small | 9px | | x-small | 10px | | small | 13px | | medium | 16px | | large | 18px | | x-large | 24px | | xx-large | 32px |

表 10-1

权重关键词

|

关键字

|

等价数

| | --- | --- | | ultralight | 100 | | thin | 200 | | light | 300 | | normal | 400 | | medium | 500 | | semibold | 600 | | bold | 700 | | heavy | 800 | | black | 900 |

img/474664_1_En_10_Fig2_HTML.jpg

图 10-2

字体大小

  1. 样式–*(可选)*字体样式,指定为italic,正常则省略。

  2. 粗细–*(可选)*字体的粗细。您可以使用与 CSS 中相同的关键字和数值(例如,lightbold800)。每个关键字都有一个等价的数值;比如重量light相当于300bold就是700。表 10-1 列出了重量关键字及其等效数值。如果省略了字体名称的这一部分,则使用默认值normal ( 400)。

  3. 拉伸–*(可选)*字符之间的间距,指定为condensed或正常时省略。

  4. 大小–字体的高度,以像素为单位。高度从下行字母的底部延伸到典型大写字母的顶部,如图 10-2 所示。您可以使用 CSS 中的绝对大小关键字(例如,x-smallmedium)或以像素为单位指定大小(如在24px)。请注意,实际高度因字体系列而异。表 10-2 列出了尺寸关键字及其对应的像素尺寸。

  5. 系列–字体系列的名称(例如Times New RomanOpen Sans)。

表 10-3 列出并解释了可能在文本样式的font属性中指定的字体名称示例。

表 10-3

字体名称示例

|

更多字体 Name

|

说明

| | --- | --- | | 24px Open Sans | 字体家族为Open Sans,大小为24px。没有指定弹力,重量,或者款式,所以都是正常的。 | | bold 16px Fira Sans | 字体家族为Fira Sans,大小为16px,粗细为bold(相当于700)。没有指定拉伸或样式,所以它们都是正常的。 | | italic bold medium Open Sans | 字体系列为Open Sans,字号为medium,或16px。重量为bold(相当于700),样式为italic。没有指定拉伸,所以很正常。 | | italic bold condensed small Open Sans | 字体系列为Open Sans,字号为small,或13px。拉伸为condensed,重量为bold(相当于700,款式为italic。 |

字体资源

字体名24px Open Sans是指存储在名为OpenSans-Regular-24.fnt的资源中的字体。尽管字体名称和资源名称明显相似,但它们并不完全相同。Piu 通过应用一组规则从字体名称创建资源名称,从字体名称获取字体的资源数据。您需要了解这些规则,以便将代码中指定的字体名称与项目清单中包含的字体资源相匹配。

下面的列表按顺序显示了资源名的各个部分(不包括.fnt扩展名),并解释了 Piu 如何从字体名生成它们。

Note

这里的关键字(比如LightRegular)是区分大小写的,所以它们的大写是有意义的。

  1. 系列–字体系列的名称,去掉任何空格。比如Open Sans变成了OpenSans

  2. 连字符(-)–将字体系列名称与其后面的部分分开的连字符。

  3. 拉伸–字体拉伸正常时省略;否则,Condensed

  4. 粗细–字体粗细正常时省略;否则,字体粗细—例如,LightBold,或数值,如200

  5. 样式——字体样式正常时省略;否则,Italic

  6. Regular–如果 stretch、weight 和 style 都正常,资源名称将包含关键字Regular来代替这三个关键字。

  7. 连字符(-)–将弹力、重量和款式(或Regular)与后面的尺码分开的连字符。

  8. 大小–以像素为单位的高度,以数字表示——例如,1624

表 10-4 给出了 Piu 字体名称和它们映射到的资源名称的例子。

表 10-4

映射到资源名称的字体名称示例

|

更多字体 Name

|

资源名称

|

笔记

| | --- | --- | --- | | 24px Open Sans | OpenSans-Regular-24.fnt | 字体系列名称中的空格将被删除。因为 stretch、weight 和 style 都是正常的,所以在资源名称中使用了Regular来代替这三个部分。 | | bold 16px Fira Sans | FiraSans-Bold-16.fnt | 字体大小移动到末尾,样式bold在资源名称中大写。 | | italic bold 16px Open Sans | OpenSans-BoldItalic-16.fnt | 虽然字体名称将italic放在bold之前,但是资源名称指定了BoldItalic,因为权重总是在样式之前。还要注意在BoldItalic之间没有空格或连字符。 |

当创建您自己的位图字体文件时,根据 Piu 的资源命名约定命名文件。这样做可以确保当您的代码指定字体名称时,Piu 会在您的资源中找到相应的字体数据。

关于字体的附加说明

Piu 从 CSS 中提取的字体命名约定是为了方便开发人员,同时又有足够的表现力来构建复杂的用户界面。它们还为 web 开发人员提供了一致性。然而,尽管 CSS 功能强大,但一些开发人员发现它更令人困惑,而不是有所帮助。如果您愿意,您可以简单地使用字体资源的名称作为字体名称。例如,helloworld示例中的textStyle可以定义如下:

const textStyle = new Style({
    font: "OpenSans-Regular-24"
});

请记住,只有那些包含在清单中或由宿主提供的字体才可用于您的项目。在许多情况下,那只是一些字体。如果您指定了未安装的字体,Piu 将无法渲染它。这不同于桌面和 web 开发环境,在那里总是有后备字体。

因为每个字体资源只对应一个系列、宽度、粗细、样式和大小,所以每个变体都需要一个单独的资源。如果你创建了一个文本样式,它的font属性是24px Open Sans,你必须有一个名为OpenSans-Regular-24.fnt的字体资源。即使您有相关的字体资源可用,如OpenSans-Regular-12.fnt,Piu 也无法调整其大小以匹配您的文本样式中指定的24px大小。这也不同于桌面和 web 开发环境,在这些环境中,可调整大小的字体很常见。

添加颜色

$EXAMPLES/ch10-piu/helloworld-color示例为helloworld示例添加了颜色,使其更加有趣。它与helloworld相比有一些变化。

首先,helloworld-color中的style对象指定了一个color属性,该属性使标签用黄色绘制字符串:

const textStyle = new Style({
    font: "24px Open Sans",
    color: "yellow"
});

该示例还创建了一个名为labelBackgroundskin对象。皮肤控制内容对象背景的绘制。这里的skin对象用十六进制表示法指定了fill属性,颜色为蓝色的#1932ab

const labelBackground = new Skin({
    fill: "#1932ab"
});

sampleLabel对象(清单 10-5 )添加了一个skin属性来设置其背景,使得标签用labelBackground中指定的蓝色阴影填充其背景。

const sampleLabel = new Label(null, {
    left: 0, right: 0, top: 0, bottom: 0,
    style: textStyle,
    string: "Hello, World",
    skin: labelBackground
});

Listing 10-5.

当您在设备上运行helloworld-color示例时,您会看到与helloworld相同的文本和布局,但是是蓝色背景上的黄色文本,而不是白色背景上的黑色文本。

当没有指定skin属性时,如在helloworld示例中,标签不为其背景绘制任何东西,导致文本出现在它后面的任何东西的前面。背景是白色的,因为在没有皮肤的情况下,文本出现在application对象本身的前面(由主机创建,如“一切都是对象”一节所示);因为主机将应用程序的skin属性设置为白色,这是整个屏幕的背景色。

指定颜色

helloworld-color示例的style对象中的color属性被设置为一个颜色名称,而skin对象中的fill属性用十六进制表示一种颜色。如本节所述,您可以用任何一种方式为这两个属性指定颜色。

示例的style对象中的color属性被设置为字符串"yellow"。Piu 支持 18 种颜色名称:blacksilvergraywhitemaroonredpurplefuchsiagreenlimeoliveyellownavybluetealaquaorangetransparent。颜色及其 RGB 值取自 CSS Level 2 规范。

示例的skin对象中的fill属性是"#1932ab",用十六进制表示法指定的蓝色阴影。如清单 10-6 所示,Piu 支持将颜色指定为四种十六进制符号中任意一种的字符串:"#RGB""#RGBA""#RRGGBB""#RRGGBBAA"。在这些符号中,A代表“alpha 通道”,代表颜色的透明度:alpha 值为0xFF表示完全不透明,0 表示完全透明,介于两者之间的值执行混合。(alpha 值与某些 Poco 渲染函数中使用的混合级别相同,如blendRectangledrawGray。)

const redSkin = new Skin({
    fill: "#f00"
});
const blendedRedSkin = new Skin({
    fill: "#f008"
});
const greenSkin = new Skin({
    fill: "#00ff00"
});
const blendedGreenSkin = new Skin({
    fill: "#00ff0080"
});

Listing 10-6.

CSS 中也使用了所有这些形式的十六进制颜色符号。

根据状态改变颜色

在本章的前面,你已经知道了skinstyle对象的属性是不能改变的。因此,例如,您不能通过改变内容对象的skinstyle对象的color属性来改变内容对象的颜色;相反,您创建一个不同的skinstyle对象来改变颜色。然而,还有另一种更常见、更方便的改变颜色的方法。

通常你想改变用户界面元素的颜色是为了显示它的当前状态。例如,一个按钮可能有三种状态:禁用、启用但未被点击,以及启用并被点击。或者显示传感器读数的标签可以具有当读数在目标值的 5%以内、在目标值的 15%以内以及偏离目标值超过 15%时的状态。为了支持这些情况,每个内容对象都有一个state属性,一个指示其当前状态的数字。Piu 使用state属性和style对象的属性来改变用户界面元素的外观。

state属性只是一个数字;数字对应的州由你作为开发者决定。当内容对象改变状态时,用户界面如何改变也由您决定。例如,您可以选择使按钮在禁用时为浅灰色,在启用但未被点击时为绿色,在启用并被点击时为深绿色。

改变内容对象颜色的一个简单方法是使用它们的state属性作为skinstyle对象中属性的索引。通过将skinstyle对象的fillcolor属性设置为两种、三种或四种颜色的数组,而不是表示单一颜色的字符串,可以做到这一点。例如:

const blackAndWhiteStyle = new Style({
    color: ["black", "white"]
});

根据这个例子,清单 10-7 创建了一个带有黑色文本的标签,因为状态为 0,索引 0 处的颜色为"black"

const sampleLabel = new Label(null, {
    top: 0, bottom: 0, left: 0, right: 0,
    style: blackAndWhiteStyle,
    state: 0,
    string: "Hello, World"
});

Listing 10-7.

当您更改state属性时,用户界面元素将使用其样式中的相应颜色重新绘制。将此处的状态更改为 1 会导致用白色文本重新绘制标签。

sampleLabel.state = 1;

您也可以对状态使用非整数值,从而混合周围状态的颜色。例如,您可以将本例中的文本设置为灰色,如下所示:

sampleLabel.state = 0.5;

从概念上来说,为状态指定小数值的能力似乎有些奇怪;例如,一个按钮处于禁用和启用状态之间意味着什么?然而,有一些有趣的用途,比如当你在两种状态之间制作动画时:你可以创建一个有两种颜色的style,并通过以小的增量将标签的state从 0 变到 1,将标签从第一种颜色慢慢淡化到第二种颜色。

用行为回应事件

一旦在屏幕上有了一些内容对象,下一步就是让它们执行动作来响应事件。您可以对behavior对象执行此操作。

对象是方法的集合。通过设置内容对象的behavior属性,可以将一个behavior对象附加到内容对象上。当内容对象接收到一个事件时,它在它的behavior对象中查找对应于该事件的方法;如果找到与事件同名的方法,它将调用该方法来处理事件。

Piu 定义了一组根据需要触发的事件。例如,当手指放在内容对象上时触发onTouchBegan事件,当手指移开时触发onTouchEnded事件。清单 10-8 中的TraceBehavior类包含通过跟踪调试控制台来响应 Piu 的onTouchBeganonTouchEnded事件的方法。

class TraceBehavior extends Behavior {
    onTouchBegan(label) {
        trace("touch began\n");
    }
    onTouchEnded(label) {
        trace("touch ended\n");
    }
}

Listing 10-8.

由 Piu 定义和触发的事件称为低级事件。你也可以定义你自己的事件,使用任何你喜欢的名字;这些被称为高水平赛事。例如,您可以创建一个onSensorValueChanged事件,当传感器的值改变时,您的应用程序会触发该事件。本节的其余部分介绍一些常用的低级事件;在本章的后面,你将学习如何定义和触发你自己的高级事件。

“你好,世界”与行为

$EXAMPLES/ch10-piu/helloworld-behavior示例向helloworld示例添加了一个行为,使字符串"Hello, World"在您点击屏幕时一次显示一个字符。这个简单的行为展示了 Piu 如何将事件传递给内容对象。

helloworld-behavior例子中的sampleLabel对象(列表 10-9 )类似于来自helloworld的对象。然而,有三个重要的区别:

  • sampleLabelstring属性以空字符串开始,因此它可以响应点击一次填充一个字符。

  • active属性被设置为true。此属性指定内容对象是否应响应触摸事件。如果设置为true,Piu 将触发onTouchBegan等触摸相关事件。默认值是false,所以你必须显式设置activetrue来使内容可点击。

  • 在传递给构造函数的字典中指定了一个Behavior属性。这将sampleLabel的行为设置为LabelBehavior类。

const sampleLabel = new Label(null, {
    top: 0, bottom: 0, left: 0, right: 0,
    style: textStyle,
    string: "",
    active: true,
    Behavior: LabelBehavior
});
sampleLabel.message = "Hello, World";

Listing 10-9.

LabelBehavior是扩展内置Behavior类的类:

class LabelBehavior extends Behavior {
    ...
}

sampleLabel被创建时,Piu 也会创建一个LabelBehavior的实例,并将其赋给sampleLabelbehavior属性。注意,Behavior属性在传递给Label构造函数的字典中是大写的,而创建的实例的behavior属性是小写的;这是因为属性名遵循与它们所取值相同的大小写约定:类被传递给Behavior属性中的构造函数(按照约定,JavaScript 中的类名是大写的),而sampleLabelbehavior属性包含类的一个实例(按照约定,JavaScript 中的实例名是小写的)。

LabelBehavior只有一个方法onTouchBegan,如清单 10-10 所示。这个方法的参数是label对象本身。行为中调用的所有事件处理程序方法的第一个参数是它们所附加的内容对象。当被调用时,该方法将字符串"Hello, World"中的下一个字符添加到label对象中,直到添加完所有字符。然后,它将label对象的active属性设置为false,以阻止其接收进一步的触摸事件。

onTouchBegan(label) {
    const message = label.message;
    label.string = message.substring(0, label.string.length + 1);
    if (label.string === message)
        label.active = false;
}

Listing 10-10.

这就是实现一个基本触摸行为所要做的一切。当您运行该示例并点击label对象(覆盖整个屏幕)时,Piu 触发该对象的onTouchBegan事件。然后,label对象检查它的行为,看它是否有一个onTouchBegan方法;确实如此,所以它调用该方法,将对自身的引用作为第一个参数传递。

许多低级事件都有可能对您的项目有用的附加参数。例如,onTouchBegan事件也传递这四个参数:

  • id–用于支持多点触摸的触摸点的标识符。这个例子只支持一个触摸点,所以id总是 0。id值是一个来自触摸控制器的数字,使您能够区分屏幕上的不同触摸点。

  • xy–事件的全局坐标,即触摸点的坐标,以像素为单位。

  • ticks–事件的全局时间,以毫秒为单位。该值不是一天中的时间,与 UTC 无关;它仅用于确定两个事件之间经过的时间。

当您第一次处理一个事件时,很好地理解它的一个好方法是向行为添加一个方法来跟踪它接收到的调试控制台的参数。例如,为了观察如何以及何时调用onTouchBegan的细节,将helloworld-behavior示例更改为清单 10-11 中所示的函数。

onTouchBegan(label, id, x, y, ticks) {
    trace(`id: ${id}\n`);
    trace(`{x, y}: {${x}, ${y}}\n`);
    trace(`ticks: ${ticks}\n`);
}

Listing 10-11.

onTimeChangedonDisplaying事件

本节介绍这些常用的低级事件:

  • onTimeChanged事件让您可以访问内置于每个 Piu 内容对象中的时钟。

  • 在内容对象出现在屏幕上之前,onDisplaying事件给你的行为一个自我配置的机会。

这些事件是通过$EXAMPLES/ch10-piu/helloworld-ticking示例引入的,它与helloworld-behavior示例相似,都是一次将一个角色"Hello, World"添加到屏幕上;然而,它不是在轻击屏幕时添加字符,而是每隔一段时间添加一次。请注意以下关于此示例的内容:

  • 除了其active属性没有设置为true之外,sampleLabel对象与helloworld-behavior中的对象相同,因为它不响应触摸事件。

  • LabelBehavior类包括onDisplayingonTimeChanged方法(清单 10-12 )而不是onTouchBegan方法。他们的第一个参数是对与行为相关的label对象的引用,就像 Piu 定义的所有事件一样。

class LabelBehavior extends Behavior {
    onDisplaying(label) {
        ...
    }
    onTimeChanged(label) {
        ...
    }
}

Listing 10-12.

在内容对象被添加到application对象之后,但在用户看到它之前,onDisplaying事件被触发。这对于初始化对象的状态非常有用,尤其是那些可能被隐藏并在以后显示多次的内容对象。onDisplaying事件的一个常见用途是启动一个计时器,该计时器用于动画显示内容对象的外观。

因为动画是现代用户界面中如此普遍的一部分,Piu 给每个内容对象一个内置时钟。时钟以内容对象的interval属性指定的时间间隔“滴答”作响。属性和时钟都以毫秒表示时间。每当时钟滴答声响起,它就会产生一个onTimeChanged事件。时钟并不总是运行,最初是停止的;您使用内容对象的startstop方法来控制它的时钟何时运行。

在这个例子中,行为的onDisplaying方法(清单 10-13 )通过重置index属性开始,该属性存储在任何给定时间label对象中的字符串的字符数。代码将interval属性设置为 250 毫秒,以请求每四分之一秒生成一次onTimeChanged事件。最后,该方法通过调用其start方法启动标签的时钟。

onDisplaying(label) {
    this.index = 0;
    label.interval = 250;
    label.start();
}

Listing 10-13.

行为的onTimeChanged方法(清单 10-14 )在每个间隔向label对象的string属性添加一个新字符。它使用了substring方法,返回字符串的一部分。substring的参数分别指定要包含的第一个字符和要排除的第一个字符的索引。当显示完完整的字符串后,onTimeChanged调用标签的stop方法来阻止时钟滴答作响,这样onTimeChanged就不再被触发。

onTimeChanged(label) {
    const message = label.message;
    this.index += 1;
    if (this.index > message.length)
        label.stop();
    else
        label.string = message.substring(0, this.index);
}

Listing 10-14.

后面的例子展示了如何使用内容对象的时钟来驱动动画。

添加图像

图像是构建用户界面的基础部分。正如 Piu 使用skin对象用纯色填充屏幕区域一样,它也使用它们用图像填充屏幕区域,使任何内容对象都能绘制图像。skin对象中的纹理指定了要使用的图像。

为了展示如何呈现图像,$EXAMPLES/ch10-piu/js-icon示例绘制了 JavaScript 徽标。该示例绘制了如图 10-3 所示的屏幕。

img/474664_1_En_10_Fig3_HTML.jpg

图 10-3

js-icon举例

一个skin对象用于创建图标。第一步是通过实例化一个texture对象来创建对要使用的图像文件的引用。传递给Texture构造函数的字典的path属性是包含图像的资源的名称。

const jsLogoTexture = new Texture({
    path: "js.png"
});

注意资源名有一个.png扩展名,而不是第九章中 Poco 的.bmp。虽然 PNG 图像仍被转换为另一种格式以在微控制器上呈现,但 Piu 知道这种转换,并自动将.png扩展名更改为设备的正确扩展名。

helloworld-color示例中,您使用了一个带有fill属性的skin对象来创建纯色背景。在这个例子中,您使用了一个texture属性以及heightwidth属性来创建一个皮肤jsLogoSkin,它使用jsLogoTexture来填充内容对象。heightwidth属性被设置为匹配js.png图像文件的尺寸,100 x 100 像素。

const jsLogoSkin = new Skin({
    texture: jsLogoTexture,
    height: 100, width: 100
});

最后一步是创建一个引用jsLogoSkincontent对象:

const jsLogo = new Content(null, {
    skin: jsLogoSkin
});

因为皮肤被定义为 100 x 100 像素,所以默认情况下,jsLogo内容对象具有相同的尺寸。

绘制图像的一部分

您可能想知道为什么之前必须在Skin构造函数中指定heightwidth属性。为什么皮肤没有默认使用整个图像?原因是skin对象的一个特性,它允许你只绘制部分纹理。要指定要绘制的纹理区域,可以使用xyheightwidth属性以像素为单位定义源矩形。xy属性默认为 0,但是heightwidth属性是必需的。

清单 10-15 中的代码是js-icon示例中jsLogoSkin的替代。在这里,皮肤被定义为从图像的右下角开始绘制一个 70 x 70 像素的正方形。结果如图 10-4 所示。

img/474664_1_En_10_Fig4_HTML.jpg

图 10-4

js-icon裁剪示例jsLogoSkin

const jsLogoSkin = new Skin({
    texture: jsLogoTexture,
    x: 24, y: 30,
    height: 70, width: 70
});

Listing 10-15.

很少绘制单个图标的一部分。毕竟,如果你只想画右下角,不妨把图像文件裁剪掉,节省一些存储空间。然而,在单个图像中存储几个图标通常很方便,在这种情况下,能够绘制图像的一部分非常有用。下一节将介绍一个例子。

从一幅图像中绘制多个图标

回想一下图 10-5 中的图标,你第一次看到这些图标是在第八章中。这些图标显示 Wi-Fi 连接的几种不同状态,并组合成一个图像。

img/474664_1_En_10_Fig5_HTML.jpg

图 10-5

Wi-Fi 图标

图标被组织成统一的网格,其中的列和行如下:

  • 每列是 Wi-Fi 图标的不同状态,代表信号强度从弱到强。

  • 每一行都是 Wi-Fi 图标的不同变体。顶行是开放式 Wi-Fi 接入点变体,底行是安全 Wi-Fi 接入点变体。

正如 Piu 使用内容对象的state属性来确定从样式中绘制哪种颜色一样,它可以使用内容对象的statevariant属性来确定从包含图标网格的纹理中绘制哪个图标。为此,这里包含纹理的皮肤必须指定每列的宽度和每行的高度,分别使用用于创建皮肤的字典中的statesvariants属性(参见清单 10-16 )。

const wifiTexture = new Texture({
    path: "wifi-strip.png"
});

const wifiSkin = new Skin({
    texture: wifiTexture,
    width: 28, height: 28,
    states: 28,
    variants: 28
});

Listing 10-16.

本例中的图像包含 28 像素见方的图标,因此statesvariants属性都是 28。此外,heightwidth属性都被设置为 28,这样皮肤的大小正好是一个图标的大小。

$EXAMPLES/ch10-piu/wifi-status示例一次从该图像中绘制一个图标,每秒改变一次图标。它从左上角的图标开始(statevariant都是 0),如清单 10-17 所示。

const wifiIcon = new Content(null, {
    skin: wifiSkin,
    state: 0,
    variant: 0,
    Behavior: WifiIconBehavior
});

Listing 10-17.

内容对象的statevariant属性可以随时更新。本示例将它们更改为从左到右一次移动一个图标,首先穿过顶行,然后穿过底行;然后,它返回到最上面一行,无限期地重复这个过程。如清单 10-18 所示,wifiIcon行为中的onDisplayingonTimeChanged事件处理程序使用内容对象的内置时钟来驱动动画(正如您在helloworld-ticking示例中看到的那样):该行为在每个 tick 上改变variant属性,移动一行图标;当到达一行中的最后一个图标时,它改变state属性以切换到另一行。

class WifiIconBehavior extends Behavior {
    onDisplaying(content) {
        content.interval = 1000;
        content.start();
    }
    onTimeChanged(content) {
        let variant = content.variant + 1;
        if (variant > 4) {
            variant = 0;
            content.state = content.state ? 0 : 1;
        }
        content.variant = variant;
    }
}

Listing 10-18.

使用面具

压缩的灰度蒙版比全彩色位图图像更有效地存储灰度图像,而且(正如你在第八章中了解到的)蒙版可以用任何颜色绘制。用户界面中绘制的许多图标只有一种颜色,因此可以存储为遮罩。一个texture对象可以引用一个蒙版图像资源和一个彩色位图资源,使你的用户界面可以同时包含这两种资源。

向应用程序添加蒙版图像与添加全色位图非常相似。$EXAMPLES/ch10-piu/mask-icon示例显示一个存储为遮罩的图标。当你点击图标时,它会改变颜色。

这个例子的textureskin属性(清单 10-19 )应该看起来很熟悉。关键的区别在于,maskSettingsSkin用两种颜色指定了一个color属性,"orange"用于当内容对象的state属性的值为 0 时,"yellow"用于当内容对象的值为 1 时。(注意,指定一个皮肤的颜色有两种不同的方法:当你使用一个皮肤绘制蒙版纹理时,你指定color属性;要创建纯色背景,需要指定fill属性。)

const maskSettingsTexture = new Texture({
    path: "settings-mask.png"
});

const maskSettingsSkin = new Skin({
    texture: maskSettingsTexture,
    width: 80, height: 80,
    color: ["orange", "yellow"]
});

Listing 10-19.

像往常一样,您必须创建一个引用皮肤的内容对象。清单 10-20 展示了本例中创建的对象:一个content对象,它也有一个行为和一个active属性(设置为true,这样该对象可以接收触摸事件)。

const maskSettingsIcon = new Content(null, {
    skin: maskSettingsSkin,
    state: 0,
    active: true,
    Behavior: SettingsIconBehavior
});

Listing 10-20.

第一次绘制图标时,遮罩被绘制为橙色,因为state值为 0 意味着它使用了color属性中数组索引 0 处的颜色。

如清单 10-21 所示,该示例使用在点击开始和结束时触发的onTouchBeganonTouchEnded事件来提供触摸反馈:

  • maskSettingsIcon接收到一个onTouchBegan事件时,它的行为将它的状态设置为 1,导致它用其color属性的索引 1 处的颜色重画——在本例中是黄色。

  • maskSettingsIcon接收到一个onTouchEnded事件时,它的行为将其状态更改为 0,使图标再次变为橙色。

class SettingsIconBehavior extends Behavior {
    onTouchBegan(content) {
        content.state = 1;
    }
    onTouchEnded(content) {
        content.state = 0;
    }
}

Listing 10-21.

平铺图像

您可以通过平铺皮肤纹理来绘制重复的图案。这是减少存储空间的另一种方法,因为您可以使用单个背景拼贴而不是整个屏幕大小的图像文件。

平铺单个图像

$EXAMPLES/ch10-piu/tiled-background示例使用图 10-6 中的图像创建如图 10-7 所示的平铺背景。

img/474664_1_En_10_Fig7_HTML.jpg

图 10-7

tiled-background举例

img/474664_1_En_10_Fig6_HTML.jpg

图 10-6

图片来自tiled-background示例

像图标的皮肤一样,平铺的皮肤使用一个texture对象并定义heightwidth属性来指定要绘制的纹理区域(在本例中是所有区域)。如清单 10-22 所示,您还包含了一个tiles属性——一个具有leftrighttopbottom属性的对象,指示纹理的不同部分要平铺;这里它们都是 0,因为这个例子使用整个图像作为重复的图块。(下一节,关于绘制 9 片图像,解释了如何将这四个属性与其他值一起使用。)

const tileTexture = new Texture({
    path: "tile.png"
});

const tileSkin = new Skin({
    texture: tileTexture,
    height: 50, width: 50,
    tiles: {
        left: 0, right: 0, top: 0, bottom: 0
    }
});

Listing 10-22.

当你将tileSkin附加到一个全屏content对象上时,它会绘制如图 10-7 所示:

const background = new Content(null, {
    left: 0, right: 0, top: 0, bottom: 0,
    skin: tileSkin
});

绘制 9-用拼贴修补图像

9 片图像用于有效地绘制不同大小的矩形,例如圆角矩形。术语“9 补丁”来自 Android 移动操作系统,尽管这个概念在其他地方也广泛使用;它指的是将图像素材分成九个部分的方式,稍后您会看到这一点。许多有趣的效果可以用 9 片图像来创建。Piu 通过使用平铺的表皮融入了这一概念。

回想一下前面的内容,一个tiles对象的属性表示纹理的不同部分需要平铺。更具体地说,这些属性通过指定距离图像边缘的像素数来定义 9 片图像的各个部分,如图 10-8 所示,其中一个tiles对象的属性都指定为 14。图中的浅灰色线条描绘了九个部分,并为每个部分分配了一个编号。整个图像是 56 像素的正方形。

img/474664_1_En_10_Fig8_HTML.jpg

图 10-8

用九个部分描绘的圆角矩形

该图像的平铺皮肤将如清单 10-23 中所定义。

const tileSkin = new Skin({
    texture: tileTexture,
    height: 56, width: 56,
    tiles: {
        left: 14, right: 14, top: 14, bottom: 14
    }
});

Listing 10-23.

当此皮肤应用于内容对象时,Piu 使用以下规则绘制图像的九个部分:

  • 区域 1、3、7 和 9 分别在内容对象的相应角落绘制一次。

  • 区域 2 和 8 分别在内容对象的顶部和底部水平重复。

  • 区域 4 和 6 分别沿着内容对象的左侧和右侧垂直重复。

  • 区域 5 垂直和水平重复,以填充内容对象中间未被其他区块覆盖的空间。

图 10-9 显示了这个平铺的皮肤是如何被具有以下尺寸(从左到右)的内容对象渲染的:28 x 28、56 x 56、110 x 100 和 70 x 165。请注意,图像的九个部分只是重复,从不调整大小。

img/474664_1_En_10_Fig9_HTML.jpg

图 10-9

tileSkin以不同的尺寸渲染

$EXAMPLES/ch10-piu/rounded-buttons示例使用一个简单的纯色圆角矩形来创建不同大小的按钮(图 10-10 )。

img/474664_1_En_10_Fig10_HTML.jpg

图 10-10

rounded-buttons举例

本例中的皮肤定义如清单 10-24 所示。它看起来类似于前面的皮肤示例,但是图像素材更小,并且tiles对象的leftrighttopbottom属性都被设置为 5。它还指定了一个color属性;平铺的皮肤可以使用遮罩。

const roundedTexture = new Texture({
    path: "button.png"
});

const roundedSkin = new Skin({
    texture: roundedTexture,
    width: 30, height: 30,
    color: ["#ff9900", "#ffd699"],
    tiles: {
        top: 5, bottom: 5, left: 5, right: 5
    }
});

Listing 10-24.

这个例子中的三个按钮是labeltext对象(列表 10-25 )。它们有不同的高度和宽度,但是roundedSkin如前所述平铺其纹理以适应所有不同的尺寸。

const button1 = new Label(null, {
    top: 10, left: 10,
    skin: roundedSkin,
    style: smallTextStyle,
    string: "Option 1",
    active: true,
    Behavior: ButtonBehavior
});

const button2 = new Label(null, {
    top: 60, left: 10,
    skin: roundedSkin,
    style: textStyle,
    string: "Option 2",
    active: true,
    Behavior: ButtonBehavior
});

const button3 = new Text(null, {
    top: 120, left: 10, width: 90,
    skin: roundedSkin,
    style: textStyle,
    string: "Option 3",
    active: true,
    Behavior: ButtonBehavior
});

Listing 10-25.

回想一下,label对象在单行上呈现文本;这个例子为第三个按钮使用了一个text对象来说明text对象,不同于label对象,可以在多行上呈现文本。

这个例子中的行为ButtonBehaviormask-icon例子中的SettingsIconBehavior是相同的,当点击按钮时onTouchBeganonTouchEnded方法提供反馈。

构建复合用户界面元素

真实产品的用户界面由更复杂的元素组成,而不仅仅是屏幕中间的一串文本或一个图标。本章最初的例子使用一个简单的结构来介绍基本的 Piu 概念;现在,您已经准备好将这些元素放在一起构建更复杂的界面了。

application对象添加内容对象创建了一个名为*包容层次的树形数据结构。*到目前为止,简单的例子已经创建了一个两层的容器层次结构,其中application对象位于根,内容对象作为叶,但是层次结构中可以有许多层。

容器层次结构通过将内容对象放入称为容器的组中来组织用户界面中的内容对象。容器由Container类实现,这是一个关键的内置 Piu 类。application对象本身是一个容器,这就是它能够保存其他内容对象的方式。包容层次结构不仅仅是将内容对象组合在一起;它还会影响对象的绘制方式以及它们接收事件的方式。

如果您曾经用 HTML 或其他面向对象的用户界面框架构建过用户界面,那么对包容层次结构的概念应该很熟悉。如果没有,下一节中的例子将引导您完成构建包容层次结构的步骤。

创建标题

像本章前面的例子一样,$EXAMPLES/ch10-piu/header例子向屏幕添加了文本和图标。但是它没有像那些例子中那样把它们当作独立的元素,而是把它们组合成一个复合用户界面元素,如图 10-11 所示的标题。

img/474664_1_En_10_Fig11_HTML.jpg

图 10-11

header举例

header示例中的jsLogoheaderText对象(列表 10-26 )类似于前面示例中的contentlabel对象。

const jsLogo = new Content(null, {
    left: 10,
    skin: jsLogoSkin
});

const headerText = new Label(null, {
    style: textStyle,
    string: "Example"
});

Listing 10-26.

header对象(清单 10-27 )是Container类的一个实例。Container类继承了Content类,并扩展了它持有其他内容对象的能力。

const header = new Container(null, {
    top: 0, height: 50, left: 0, right: 0,
    skin: headerSkin,
    contents: [
        jsLogo,
        headerText
    ]
});

Listing 10-27.

header对象包含jsLogoheaderText对象,它们被放在contents属性数组中。skin属性给header对象一个蓝色背景(因为headerSkin有一个fill属性"#1932ab")。

因为jsLogoheaderText对象包含在header对象中,当header对象被添加到application对象中时,所有的元素——蓝色背景、图标和文本——都会出现在屏幕上:

application.add(header);

同样,移除header对象会使所有元素消失,在屏幕上移动header对象会同时移动它包含的所有元素。

当一个内容对象被添加到一个容器中时,该内容对象被称为该容器的一个子对象,或者简称为子对象;相应地,容器被称为内容对象的父对象,或者简称为父对象。在本例中,标题是父容器,文本和图标是标题的子对象。

您可以使用对象的container属性来访问其父容器对象,并使用length属性来确定容器中子对象的数量。如果一个对象没有父容器,它的container属性就是null。如果没有子对象,length属性为 0。本章后面的“访问容器中的内容对象”一节将介绍几种不同的访问容器中的对象的方法。

相对和绝对坐标

如您所知,传递给内容对象的构造函数的leftrighttopbottom属性通过指定对象与其容器之间的边距来定义内容对象的位置。因为这些属性表达了点相对于父容器的位置,所以它们被称为相对坐标。比如,当你用值 10 传递left时,并不一定意味着内容对象在绘制时会离屏幕左侧 10 个像素;这意味着无论内容放在哪个容器中,它的左边都是 10 个像素。

一旦内容对象被绘制在屏幕上,它的坐标就被称为绝对坐标,它将点的位置表示为离屏幕边缘的距离。当容器是整个屏幕时,这通常是application对象的情况,容器子对象的相对和绝对坐标是相同的。

当容器移动时,Piu 会调整容器的所有子内容对象的绝对坐标。这使得制作复合用户界面元素的动画更加容易,比如header例子中的标题,因为你的代码只需要移动复合元素的容器,而不是每个单独的内容元素。

添加和删除容器内容

容器的内容不是固定的。正如您可以从application对象中添加和删除对象一样,您可以随时从container对象中添加和删除对象。Container类和所有从它继承的类都有addremove方法,您可以用它们来修改它们的contents数组。Application类是一个继承自Container类的公共类。

您可以随时调用容器的add方法,不管该容器是否是容器层次结构的一部分。例如,不是在创建header对象时将一个contents数组传递给构造函数(如前面的清单 10-27 所示),而是在实例化所有对象之后,在将标题添加到application对象之前,将每个内容对象添加到标题中(参见清单 10-28 )。

const header = new Container(null, {
    top: 0, height: 50, left: 0, right: 0,
    skin: headerSkin
});

header.add(jsLogo);
header.add(headerText);
application.add(header);

Listing 10-28.

无论哪种方式,结果都是一样的:jsLogoheaderTextheader包含,headerapplication对象包含。这就创建了一个三层的包含层次结构,其中application对象是根,header是分支,jsLogoheaderText是叶子。

下面是如何使用remove方法从标题的子列表中取出jsLogo:

header.remove(jsLogo);

方法从容器中移除所有子元素。这在您需要重新构建复合节点的内容时非常有用,比如移动到另一个屏幕时(您将在后面的“应用程序逻辑”一节中看到)。

header.empty();

每个内容对象一个容器

一个内容对象在任何时候只能是一个容器的子对象。您可以在容器中添加和移除对象,次数不限,也可以通过从当前容器中移除对象并将其添加到新容器中,将对象移动到新容器中。但是,您不能同时将相同的内容对象添加到多个容器中。如果您试图将一个容器中已经存在的内容对象添加到另一个容器中,Piu 会抛出一个错误。

这可能看起来很奇怪。您可能认为将同一个对象添加到多个容器中只会创建进入不同容器的相同对象,但事实并非如此。屏幕上显示的每个图形元素都与单个内容对象相关联。

如果这看起来仍然很奇怪,一个真实世界的隐喻可能会帮助你理解它。假设你有两个盒子和一个实物,比如一支笔。你可以把笔放在任何一个盒子里,但不能同时放在两个盒子里。同样的规则也适用于 Piu 中的内容对象和容器。

当然,您总是可以创建相同的内容,并将它们放在不同的容器中。在本章的后面,你将学习一种简单的方法来创建相似或相同的内容,使用模板。

构建响应式布局

图 10-12 所示的屏幕显示了一个由三个按钮组成的导航栏,每个按钮都有一个图标和文本来标识其用途。如果被要求描述这些按钮的位置,大多数人会说,“在屏幕中间有一排均匀间隔的按钮。”很少有人会说,“有一个按钮距离屏幕左侧 20 像素,距离屏幕顶部 74 像素,一个距离屏幕左侧 120 像素,距离屏幕顶部 74 像素,另一个距离屏幕左侧 220 像素,距离屏幕顶部 74 像素。”换句话说,人们最可能描述的是布局规则,而不是每个按钮的坐标。

img/474664_1_En_10_Fig12_HTML.jpg

图 10-12

居中导航栏中的一行按钮

布局规则是描述如何在容器中排列内容对象的简洁方式。布局规则可以独立于当前的容器大小,根据当前的大小进行调整。例如,无论容器(屏幕)的宽度是 320 还是 480 像素,这里显示的布局都可以均匀地分隔按钮。智能调整其父容器大小的布局规则被称为响应布局

如果你有网页设计或编写移动应用的背景,你可能对响应式布局的概念很熟悉。无论浏览器窗口或屏幕的大小如何,好的网页都设计得很好;换句话说,它们对大小的差异做出反应。许多移动应用基于屏幕的方向旋转;也就是说,它们对方向的变化做出反应。

Piu 还具有使您能够创建响应性布局的功能。这些功能通常是有用的,如下面几个例子所示,即使产品的所有型号的屏幕尺寸都相同。

行和列布局

$EXAMPLES/ch10-piu/nav-bar示例显示如图 10-12 所示的导航栏。一个column对象将三个复合按钮元素的图标和标签组合在一起。清单 10-29 显示了最左边按钮的代码。其他两个按钮的代码遵循相同的模式,但每个按钮有不同的皮肤和标签。(为了保持示例简单,每个按钮的行为都被省略了。)

const settingsButton = new Column(null, {
    skin: outlineSkin, width: 80,
    contents: [
        Content(null, {
            top: 5,
            skin: settingsSkin
        }),
        Label(null, {
            top: 0,
            style: textStyle,
            string: "Settings"
        })
    ]
});

Listing 10-29.

Column类用一个布局规则扩展了Container类,以在垂直列中排列其内容。在本例中,content对象的上边距为 5,label对象的上边距为 0。如果你把它们放在一个容器里,它们会重叠;但是,因为它们在一个column对象中,content对象的上边距相对于column对象,而label对象的上边距相对于content对象的下边距。如果添加另一个对象,其上边距将相对于label对象的底部,依此类推。

所有三个按钮的column对象都放在一个row对象中,如清单 10-30 所示。Row类是Container类的另一个子类。

const navBar = new Row(null, {
    left: 0, right: 0,
    contents: [
        Content(null, {left: 0, right: 0}),
        settingsButton,
        Content(null, {left: 0, right: 0}),
        weatherButton,
        Content(null, {left: 0, right: 0}),
        timeButton,
        Content(null, {left: 0, right: 0})
    ]
});

Listing 10-30.

一个row对象水平排列它的内容,并且像一个column对象一样,相对于彼此。在一个row对象的contents数组中,第一项的左边距相对于行的左边,第二项的左边距相对于第一项,依此类推。

您可能想知道为什么清单 10-30 中有content个对象。以下是一些需要注意的重要事项:

  • 它们没有皮肤,因此是透明的。它们表示该行中按钮周围的空白区域。

  • 没有为它们指定宽度;相反,row对象计算按钮周围的空白空间。

  • 它们的leftright边距(和三个按钮一样)都是 0;否则,边距会像row对象一样被计算在内,这通常不是你想要的。

为了进一步理解这一点,让我们先来看看如果从行中删除了content对象,只留下三个按钮,结果会是什么。因为这些按钮都有一个 80 的定义宽度,但是没有左右边距,所以将它们单独放置在navBar行会导致它们被一起推到屏幕左侧的 240 个像素中,如图 10-13 所示。

img/474664_1_En_10_Fig13_HTML.jpg

图 10-13

navBar不带content物体

如果你给每个按钮留出 20 的左边距,你会在 320 x 240 的屏幕上得到想要的布局,如图 10-12 所示。但是现在想象使用不同尺寸的屏幕——比如 480 x 320 图 10-14 显示了这种情况下的结果。

img/474664_1_En_10_Fig14_HTML.jpg

图 10-14

navBar不带content对象,但有页边距,屏幕更大

content对象使布局能够响应不同的屏幕尺寸。由于content对象没有宽度,因此row对象计算出它们的宽度,以实现所需的布局:它计算三个按钮各自占用的宽度——在本例中为 240——并且该行中剩余的可用像素均匀分布在剩余的内容中(导致第一个按钮之前、按钮之间以及最后一个按钮之后的空间量相同)。在图 10-12 的 320 x 240 屏幕上,这达到(320–240)/4,或者每个content对象 20 个像素;在一个 480 x 320 的屏幕上(图 10-15 ),每个像素为 60。

img/474664_1_En_10_Fig15_HTML.jpg

图 10-15

navBar在大屏幕上,适当居中

如果您希望本例中的按钮相距正好 20 像素,但仍在屏幕中央,您可以为两个中间的content对象指定宽度 20。然后,row对象将只计算第一个按钮之前和最后一个按钮之后的空间量。

如果你确定屏幕的大小不会改变或旋转,添加透明的content对象是没有必要的;您可以根据需要定义项目的左边距和右边距。不过,知道你是否在为多种屏幕尺寸设计是一个有用的技巧。

滚动内容

当内容太多而无法一次显示在屏幕上时,一种常见的解决方案是使用滚动来浏览内容。$EXAMPLES/ch10-piu/scrolling-text示例使用滚动来显示太大而不适合 320 x 240 屏幕的内容。图 10-16 显示了最初出现的屏幕。

img/474664_1_En_10_Fig16_HTML.jpg

图 10-16

scrolling-text举例

本例滚动标题、灰色条和样本文本,分别由labelcontenttext对象定义。这些对象放在一个column容器中,垂直放置。该列是一个scroller对象的contents数组中的第一项,如清单 10-31 所示。

const sampleVerticalScroller = new Scroller(null, {
    left: 0, right: 0, top: 0, bottom: 0,
    contents: [
        Column(null, {
            left: 0, right: 0, top: 0,
            contents: [
                sampleHeader,
                grayBar,
                sampleText
            ]
        })
    ],
    active: true,
    Behavior: VerticalScrollerBehavior
});

Listing 10-31.

Scroller类用一个布局规则扩展了Container类,该规则滚动其contents数组中的第一项,同时让其他内容(本例中为 none)遵循默认的容器布局行为。Scroller类可以水平、垂直或两者滚动;本示例垂直滚动。一个scroller物体滚动的方式是由其行为决定的。

本例中的行为VerticalScrollerBehavior(清单 10-32 )使用触摸输入来控制滚动。当您触摸屏幕并上下拖动时,滚动条会上下移动内容。onTouchMoved事件是一个低级事件,当手指在屏幕上移动时触发。一个内容对象可以在一个onTouchBegan事件之后(和在onTouchEnded事件之前,如果有的话)接收许多onTouchMoved事件。

class VerticalScrollerBehavior extends Behavior {
    onTouchBegan(scroller, id, x, y, ticks) {
        this.initialScrollY = scroller.scroll.y;
        this.initialY = y;
        scroller.captureTouch(id, x, y, ticks);
    }
    onTouchMoved(scroller, id, x, y, ticks) {
        const dy = y - this.initialY;
        scroller.scrollTo(0, this.initialScrollY - dy);
    }
}

Listing 10-32.

请注意以下关于此代码的内容:

  • onTouchBegan方法调用scroller对象的captureTouch方法,防止其他内容对象触发与触摸相关的触摸事件。这在这里是不必要的,因为没有其他活动的内容对象来接收触摸事件,但是包含它是因为它使行为更可重用。

  • onTouchMoved方法调用scroller对象的scrollTo方法,根据手指移动垂直滚动内容。最好使用scrollTo而不是改变内容的坐标;scrollTo防止内容移出屏幕,因此您不必编写额外的代码来避免这样做。

  • 没有onTouchEnded方法,因为触摸结束时没有反馈。

内容对象的模板

用户界面经常在许多地方使用相同的元素,有时会有小的变化。例如,一个应用程序的每个屏幕可能使用一个标题,标题中有相同的图标,但有不同的文本,或者导航条中的每个按钮可能有不同的图标和文本,如nav-bar示例所示。为了创建三个按钮中的每一个,nav-bar示例使用了基本相同的代码。Piu 模板是达到相同结果的更简洁有效的方法。模板是使用内容对象的template方法创建的类。像这样在运行时创建类的能力是 Piu 构建的 JavaScript 的一个强大特性。

创建按钮模板类

回想一下,nav-bar示例使用三个column对象创建按钮行,每个对象包含一个content对象(用于图标)和一个label对象。这些column对象的不同之处仅在于content对象的skin属性和label对象的string属性。按钮与不可见的content对象一起被放置在一个row对象中,以使布局响应多种屏幕尺寸。

编写几乎相同的代码来创建三个按钮中的每一个似乎并不不合理,但是想象一下您想要创建十个按钮:您将有超过一百行看起来相似的代码。如果你决定让每个按钮都变宽几个像素,那么单独改变每个width属性将会很乏味并且容易出错。

$EXAMPLES/ch10-piu/nav-bar-template示例为nav-bar按钮创建了一个Button类。它通过调用Column类的静态template方法来做到这一点,如清单 10-33 所示。

const Button = Column.template($ => ({
    skin: outlineSkin,
    width: 80,
    contents: [
        Content(null, {
            top: 5,
            skin: $.skin
        }),
        Label(null, {
            top: 0,
            style: textStyle,
            string: $.string
        })
    ]
}));

Listing 10-33.

这里调用的template方法为Button类创建并返回一个构造函数。这个新类扩展了Column,因为template方法是Column类的一部分。所有 Piu 内容对象都有一个静态的template方法。

即使Button类不是使用class关键字创建的,您仍然可以使用new关键字创建实例,就像在new Button中一样。在开始讨论如何使用Button类之前,让我们先看看这个类的实现。

Column.template的唯一参数是一个返回对象的函数。语法有点不寻常,因为 arrow 函数体是一个值,而不是一系列语句;该值成为函数的返回值。为了用一个简单的例子来说明这一点,下面的代码定义了一个名为test的箭头函数:

let test = () => ({one: 1});
test();    // returns {one: 1}

当调用 arrow 函数时,它返回对象。现在考虑这个例子,它定义了一个接受单个参数的版本test:

let test = $ => ({one: $});
test(1);    // returns {one: 1}

arrow 函数的参数被分配给一个名为$的变量。尽管$是一个不常见的变量名,但它是有效的 JavaScript,并且$变量的行为和其他变量一样。(注意这与用于字符串替换的模板文字中使用的$无关,如第二章所述。)

类似地,在清单 10-33 所示的Button类实现中,Column.template的参数是一个匿名箭头函数,它返回一个对象,该对象的一些属性值取自传入的$变量。当您调用Button构造函数时,如下面代码中的settingsButton所示,您传递一个字典,该字典包含要在使用$变量的模板中替换的属性,这里替换$.skin$.string;构造函数调用在它的实现中指定的 arrow 函数,传递这里显示的字典作为$参数:

const settingsButton = new Button({
    skin: settingsSkin,
    string: "Settings"
});

使用Button模板,每个额外的按钮都是通过简洁调用Button构造函数创建的,如清单 10-34 所示。(创建导航栏的其余代码与nav-bar示例中的相同。)

const weatherButton = new Button({
    skin: sunSkin,
    string: "Weather"
});

const timeButton = new Button({
    skin: clockSkin,
    string: "Time"
});

Listing 10-34.

如您所见,定义模板类有以下优点:

  • 它通过消除定义每个按钮的冗余代码,显著提高了代码的可读性(这也节省了闪存)。

  • 这使得你的代码更容易维护。要更改一个公共属性,比如每列的宽度,您所要做的就是更改模板的该属性。

内容构造函数参数

您可能已经注意到,nav-bar-template示例中的Button构造函数调用看起来与前面示例中的内容对象构造函数不同:对Button构造函数的调用将字典作为第一个参数而不是null传递,并且它们省略了第二个参数(如果存在的话,就是配置对象的字典)。通过更仔细地观察这两个参数(每个 Piu 内容构造器都采用这两个参数),本节将解释这些差异。

实例化数据参数

内容构造器的第一个参数叫做实例化数据的*。这一概念在使用模板时最为相关。例如,前面创建的Button模板类使用作为第一个参数传递的数据来创建字典,从字典中实例化该类(换句话说,字典通常作为第二个参数传递)。*

*实例化数据可以是任何 JavaScript 值或对象。您实例化的类决定了什么数据是有效的。例如,清单 10-33 中定义的Button类模板期望实例化数据是具有skinstring属性的对象。清单 10-35 显示了Button类的另一个实现。

const Button = Column.template($ => ({
    skin: outlineSkin,
    width: 80,
    contents: [
        Label(null, {
            top: 0,
            style: textStyle,
            string: $
        })
    ]
}));

Listing 10-35.

这个Button类没有图标,并期望一个字符串作为$参数传递,如下例所示:

const weatherButton = new Button("Weather");
const timeButton = new Button("Time");

实例化数据还有另一个有趣的属性:它被传递给所创建实例的行为的onCreate方法。例如,清单 10-36 展示了从清单 10-35 实现Button类的另一种方式。

const Button = Column.template($ => ({
    skin: outlineSkin,
    width: 80,
    contents: [
        Label(null, {
            top: 0,
            style: textStyle
        })
    ],
    Behavior: class extends Behavior {
        onCreate(column, $) {
            column.first.string = $;
        }
    }
}));

Listing 10-36.

实例化数据的功能不限于模板;任何内容对象构造器都可以使用它。例如,清单 10-37 创建了一个带有字符串"Hello, World"的标签。

const sampleLabel = new Label("Hello, World", {
    top: 0, bottom: 0, left: 0, right: 0,
    style: textStyle,
    Behavior: class extends Behavior {
        onCreate(label, data) {
            label.string = data;
        }
    }
});

Listing 10-37.

在本章的后面,您将学习实例化数据参数的高级用法:定义内容锚。

内容字典参数

内容构造函数的第二个参数是一个字典,它定义了创建的实例的属性。您在这个内容字典中包含的属性与您正在实例化的内容类的内置属性相关联,例如,实例的皮肤或宽度。除了前面展示的Button模板例子,本章所有例子都定义了内容字典参数;然而,它是可选的,默认为undefined

清单 10-37 展示了使用实例化数据参数和内容字典参数来创建一个label对象。$EXAMPLES/ch10-piu/colored-squares示例演示了如何在调用模板构造函数时使用这两个参数来创建如图 10-17 所示的彩色方块。

img/474664_1_En_10_Fig17_HTML.jpg

图 10-17

colored-squares举例

清单 10-38 显示了创建模板和构建方块的代码。

const Square = Content.template($ => ({
    width: 80, height: 80,
    skin: new Skin({fill: $})
}));

const redSquare = new Square("red", {left: 20, top: 20});
const yellowSquare = new Square("yellow");
const blueSquare = new Square("blue", {right: 20, bottom: 20});

Listing 10-38.

在此示例中,实例化数据是定义正方形填充颜色的字符串。红色和蓝色方块的位置由第二个字典参数定义,而黄色方块省略了第二个参数,因此默认位于其父容器的中心。

访问容器中的内容对象

在目前为止看到的例子中,您已经通过局部变量访问了内容对象,但是您还没有看到当您在局部变量中没有对内容对象的引用时,如何访问内容对象。在许多情况下,您可能需要直接从容器层次结构中访问对象,例如在处理使用模板创建的复合对象时。

您已经了解到一个容器对象包含一个子对象列表,其数量可以从容器的length属性中获得。以下部分介绍了访问容器层次结构中的内容对象的几种方法。

使用firstlastnextprevious

您可以使用容器的first属性检索它的第一个子容器,使用last属性检索它的最后一个子容器。如果一个容器没有子对象,firstlast就是null

每个内容对象都有一个next属性,您可以使用它来访问其容器中的下一个内容对象,如果没有属性,则为null。同样,previous属性返回前面的内容对象(或null)。

使用这些属性是访问容器中内容的简单方法。它们适用于某些情况,但不是所有情况。例如,使用firstnext访问一个名为myContainer的容器的第四个子容器的代码很难阅读,编写起来也很繁琐。

let button = myContainer.first.next.next.next;

下一节将介绍针对这些情况的更好的解决方案。

通过索引和名称访问子项

content方法通过索引提供对容器子对象的访问。索引值从 0 开始,因此您可以访问名为myContainer的容器中的第三个子节点,如下所示:

myContainer.content(2);

firstlastnextprevious属性一样,这种访问子对象的方法很简单,但是当容器中内容的顺序改变时,需要修改代码。或者,您可以使用content方法通过名称访问子对象。您在传递给构造函数的字典中为内容对象定义了一个name属性。

let myContent = new Content(null, {
    name: "foo"
});

如果myContentmyContainer的子节点,您可以如下访问它:

let foo = myContainer.content("foo");

这种方法适用于许多容器层次结构,但是请注意,内容对象必须是容器的直接子对象才能工作。您不能使用content方法来访问容器的孙辈、曾孙辈等等。

使用锚点访问内容

是对作为属性保存在内容对象的实例化数据中的内容对象的引用。锚是访问复杂接口中的内容的最佳方法,这些复杂接口在其包含层次中有许多级别;然而,它们是最难理解的。试图从概念上解释锚往往是令人困惑而非有益的,所以让我们通过一个例子来看看它们。

这个例子演示了使用锚点创建一个动画用户界面的基本方法。当你点击开始按钮时,背景和一个彩色方块在两种不同的颜色之间闪烁。图 10-18 显示了点击开始按钮时屏幕切换的两种状态。

img/474664_1_En_10_Fig18_HTML.jpg

图 10-18

anchors举例

该界面由三个内容对象组成:

  • 开始按钮(一个StartButton类的实例)

  • 一个彩色正方形(AnimatedSquare类的一个实例)

  • 用颜色填充背景的背景对象(MainContainer类的一个实例),如清单 10-39 所示,包含开始按钮和彩色方块

const MainContainer = Container.template($ => ({
    ...
    contents: [
        new StartButton($),
        new AnimatedSquare($)
    ],
    ...
}));

Listing 10-39.

注意,所有三个对象都通过$变量传递了相同的实例化数据。在这个例子中,实例化数据从一个空字典开始。

let instantiatingData = {};
application.add(new MainContainer(instantiatingData));

当内部时钟运行时,即当每个对象的start方法被调用并且对象开始接收onTimeChanged事件时,彩色正方形和背景对象具有每秒改变其填充颜色两次的行为。点击开始按钮负责调用这些对象的start方法;彩色方块和背景对象创建锚点,以便开始按钮可以引用它们来完成此操作。

要为内容对象创建锚点,需要在传递给构造函数的字典中指定一个anchor属性。MainContainer模板将anchor属性设置为字符串"BACKGROUND",如清单 10-40 所示。

const MainContainer = Container.template($ => ({
    ...
    anchor: "BACKGROUND",
    ...
}));

Listing 10-40.

同样,AnimatedSquare模板将anchor属性设置为字符串"SQUARE"(列表 10-41 )。

const AnimatedSquare = Content.template($ => ({
    ...
    anchor: "SQUARE",
    ...
}));

Listing 10-41.

当实例化带有anchor属性的内容对象时,Piu 将实例分配给实例化数据中带有锚点名称的属性。回想一下instantiatingData一开始是一个空字典;如果使用锚,实例化数据必须是一个字典,这样锚就可以添加到其中。在彩色方块和背景对象被实例化后,instantiatingData看起来是这样的:

{
    BACKGROUND: <reference to the background object>,
    SQUARE: <reference to the colored square object>
}

instantiatingDataBACKGROUNDSQUARE属性是背景和彩色正方形对象的锚点。任何可以访问instantiatingData的东西都可以使用这些锚来引用这些对象。在这个例子中, Start 按钮使用锚点来触发背景和方块动画的开始。使用锚点和触发动画的代码都包含在StartButton模板的行为中。

如您所知,传递给内容对象的构造函数的实例化数据被传递给所创建内容的行为的onCreate方法。StartButtonBehaviordata属性中保存对实例化数据的引用,以便它可以在其他方法中使用。

class StartButtonBehavior extends Behavior {
    onCreate(label, data) {
        this.data = data;
    }

然后,StartButtonBehavior在它的onTouchEnded方法(清单 10-42 )中使用它的data属性来访问背景和彩色方块的锚点,这样它就可以调用它们的start方法,这又会导致动画开始。

onTouchEnded(label) {
    ...
    this.data.SQUARE.start();
    this.data.BACKGROUND.start();
}

Listing 10-42.

请注意,当您使用锚点时,容器层次结构中内容对象的级别并不重要。在这个例子中,开始按钮和彩色方块都是背景对象的子对象,但是您可以重新安排容器层次结构——例如,您可以使彩色方块成为application对象的子对象——而不必改变StartButtonBehavior的实现来触发动画。这种灵活性使得锚点在创建可能改变的包容层次结构时非常有用。

定义和触发您自己的事件

您已经看到了几个使用behavior对象响应由 Piu 定义和触发的低级事件的例子。您的应用程序可能需要其他事件,即 Piu 未定义的高级事件;例如,当传感器检测到变化时,连接有传感器的产品可以触发onSensorValueChanged事件,以便应用程序可以更新显示或将变化报告给网络服务。要处理高级别事件,您可以像处理低级别的事件一样,向您的行为添加方法。

通常几个内容对象需要响应一个事件。例如,当传感器值改变时,用户界面中的多个元素可能需要更新。一个对象的事件处理程序可以将事件(它接收的事件或它创建的其他事件)传播到整个容器层次结构中的其他对象。Piu 提供了用于传播事件的delegatedistributebubble方法。

本节展示了如何定义和触发您自己的事件。它还引入了将事件传播到容器层次结构中的一个或多个内容对象的方法。

触发内容对象上的事件

$EXAMPLES/ch10-piu/counter示例将一个计数器存储在一个label对象中,并启用另一个对象,在本例中是一个按钮,使用一个高级increment事件来递增计数器。图 10-19 显示了示例中的步骤,计数器从 0 开始,用户触摸按钮,最后当触摸结束时计数器增加到 1。

img/474664_1_En_10_Fig19_HTML.jpg

图 10-19

counter举例

如清单 10-43 所示,计数器是一个行为名为CounterBehaviorlabel对象。

const counter = new Label(null, {
    top: 70, height: 30, left: 0, right: 0,
    style: textStyle,
    string: "0",
    Behavior: CounterBehavior
});

Listing 10-43.

计数器存储在标签行为的count属性中(列表 10-44 ),并由CounterBehavioronDisplaying事件处理程序初始化为 0。该行为还实现了一个increment事件处理程序,它递增label对象的计数器,并用新值更新其string属性。

class CounterBehavior extends Behavior {
    onDisplaying(label) {
        this.count = 0;
    }
    increment(label) {
        label.string = ++this.count;
    }
}

Listing 10-44.

incrementButton对象(清单 10-45 )也是一个label对象,其行为名为IncrementButtonBehavior

const incrementButton = new Label(null, {
    top: 120, height: 40, left: 140, width: 40,
    style: textStyle,
    string: "+",
    skin: buttonSkin,
    active: true,
    Behavior: IncrementButtonBehavior
});

Listing 10-45.

当点击按钮时,IncrementButtonBehavior(清单 10-46 )通过在onTouchBeganonTouchEnded方法中改变按钮的state属性来提供反馈。onTouchEnded方法也increment事件委托给counter对象。content 对象的delegate方法会立即触发该方法第一个参数中指定的事件。这里的increment事件是在counter对象上触发的。

class IncrementButtonBehavior extends Behavior {
    onTouchBegan(label) {
        label.state = 1;
    }
    onTouchEnded(label) {
        label.state = 0;
        counter.delegate("increment");
    }
}

Listing 10-46.

您可以将附加参数传递给事件处理程序,方法是将它们传递给事件名称后面的delegate方法;例如,onSensorValueChanged事件可以接收新的传感器读数作为事件的一部分。要将counter示例更改为递增任意数字,您可以更改清单 10-44 中的increment方法,以接受一个额外的参数来指定递增的数量,如清单 10-47 所示。

class CounterBehavior extends Behavior {
    ...
    increment(label, delta) {
        this.count += delta;
        label.string = this.count;
    }
}

Listing 10-47.

然后在onTouchEnded方法中将一个数字传递给delegate方法。例如:

counter.delegate("increment", 1); // increments by 1
counter.delegate("increment", 5); // increments by 5

在容器内分发事件

$EXAMPLES/ch10-piu/color-scheme示例提供了在亮暗模式之间改变应用程序外观的按钮。当用户点击按钮时,按钮触发应用程序容器内所有对象的事件。对象通过将其颜色更新为所请求的模式来做出响应。图 10-20 显示界面以亮模式启动,轻击按钮,界面处于暗模式。按钮上方显示的文本表示当前模式。

img/474664_1_En_10_Fig20_HTML.jpg

图 10-20

color-scheme举例

按钮触发名为onModeChanged的事件。每个按钮都是一个ModeButton的实例,一个基于Label的模板,如清单 10-48 所示。

const ModeButton = Label.template($ => ({
    top: 110, height: 40, width: 120,
    skin: buttonSkin,
    active: true,
    Behavior: ModeButtonBehavior
}));

Listing 10-48.

ModeButtonBehavior(清单 10-49 )通过在onTouchBeganonTouchEnded方法中改变按钮的state属性,在点击按钮时提供反馈。通过调用application对象的distribute方法,onTouchEnded方法还在整个application容器中分发事件。distribute方法触发容器中每个内容对象的事件。在对application.distribute的调用中,ModeButtonBehavior传递按钮的名称,在本例中是"Light""Dark",作为指示要更改到的模式的参数。

class ModeButtonBehavior extends Behavior {
    onTouchBegan(label) {
        label.state = 1;
    }
    onTouchEnded(label) {
        label.state = 0;
        application.distribute("onModeChanged", label.string);
    }
}

Listing 10-49.

所有容器对象都有一个distribute方法,它触发容器和容器层次结构中所有向下的内容对象上的指定事件。当事件被传递给容器中的所有对象时,或者当其中一个事件处理程序返回true指示事件已经被完全处理时,事件的分发结束。您可以将distribute方法视为一种向容器内容广播事件的方式。在这个例子中,用行为中的onModeChanged处理程序直接调用几个内容对象上的delegate会很容易;然而,随着应用程序变得越来越复杂,使用distribute方法自动遍历容器中的所有内容变得更加容易。

既然您已经知道了distribute如何触发容器内容上的事件,让我们看看内容对象如何响应这些事件。state属性起着关键的作用。保存按钮和文本字符串的LightDarkScreen容器有一个皮肤,当其state属性为 0 时是白色的,当其state属性为 1 时是黑色的。

const backgroundSkin = new Skin({
    fill: ["white", "black"]
});

文本字符串是一个label对象,它的样式正好相反,当它的state属性为 0 时文本是黑色的,当它的state属性为 1 时文本是白色的。(参见清单 10-50 。)

const textStyle = new Style({
    font: "24px Open Sans",
    color: ["black", "white"],
    top: 10, bottom: 10, left: 10, right: 10
});

Listing 10-50.

LightDarkScreen的代码如清单 10-51 所示。

const LightDarkScreen = new Container(null, {
    top: 0, bottom: 0, left: 0, right: 0,
    skin: backgroundSkin,
    style: textStyle,
    contents: [
        Label(null, {
            top: 50, height: 30, left: 0, right: 0,
            string: "Light",
            Behavior: TextBehavior
        }),
        ModeButton(null, {
            left: 30,
            string: "Dark"
        }),
        ModeButton(null, {
            right: 30,
            string: "Light"
        })
    ],
    Behavior: LightDarkScreenBehavior
});

Listing 10-51.

当收到一个onModeChanged事件时,LightDarkScreen和它包含的label对象都有改变它们的state属性的行为。标签改变它的string属性来反映哪个按钮被点击了。清单 10-52 展示了这些行为。

class LightDarkScreenBehavior extends Behavior {
    onModeChanged(container, mode) {
        container.state = (mode === "Dark")? 1 : 0;
    }
}

class TextBehavior extends Behavior {
    onModeChanged(label, mode) {
        label.state = (mode === "Dark")? 1 : 0;        label.string = mode;
    }
}

Listing 10-52.

使事件在容器层次结构中冒泡

$EXAMPLES/ch10-piu/background-color示例提供了改变屏幕背景颜色的按钮。当用户点击按钮时,它们触发容器层次结构中向上的事件。按钮的父容器横跨整个屏幕,并更新其skin属性以响应事件。图 10-21 显示背景为初始白色状态,轻点按钮为黄色,变为黄色后的背景。

img/474664_1_En_10_Fig21_HTML.jpg

图 10-21

background-color举例

如清单 10-53 所示,按钮是用一个模板创建的,该模板创建一个带有名为ColorButtonBehavior的行为的label对象。

const ColorButton = Label.template($ => ({
    height: 40, left: 10, right: 10,
    skin: buttonSkin,
    active: true,
    Behavior: ColorButtonBehavior
}));

Listing 10-53.

ColorButtonBehavior(清单 10-54 )通过在onTouchBeganonTouchEnded方法中改变按钮的state属性,在点击按钮时提供反馈。onTouchEnded方法还通过调用bubble方法并将其作为事件处理程序的参数传递给按钮的string属性— "Yellow""Red""Blue"使onColorSelected事件在容器层次结构中冒泡

class ColorButtonBehavior extends Behavior {
    onTouchBegan(label) {
        label.state = 1;
    }
    onTouchEnded(label) {
        label.state = 0;
        label.bubble("onColorSelected", label.string);
    }
}

Listing 10-54.

所有内容对象都有一个bubble方法,这使得它们、它们的父容器以及容器层次结构中所有向上的容器对象触发一个指定的事件。当事件已经被传递到所有对象直到application对象时,或者当一个事件处理程序返回true指示事件已经被完全处理时,事件的传播结束。与delegatedistribute方法一样,事件由名称指定,并作为第一个参数传递给bubble方法。

既然您已经知道了如何使用bubble方法来触发事件,那么在探究onColorSelected事件如何在这个特定的容器层次结构中传播的细节之前,让我们来看看这个例子的容器层次结构是如何组织的。

按钮包含在一个row对象中。这一行是名为colorScreencontainer对象的一部分,它被添加到application对象中。如清单 10-55 所示,该行没有与之关联的行为,但是colorScreen引用了一个名为ColorScreenBehavior的行为。

const colorScreen = new Container(null, {
    top: 0, bottom: 0, left: 0, right: 0,
    skin: whiteSkin,
    style: textStyle,
    contents: [
        Row(null, {
            height: 50, width: 320,
            contents: [
                new ColorButton(null, {string: "Red"}),
                new ColorButton(null, {string: "Yellow"}),
                new ColorButton(null, {string: "Blue"})
            ]
        })
    ],
    Behavior: ColorScreenBehavior
});

application.add(colorScreen);

Listing 10-55.

ColorScreenBehavior接收到onColorSelected事件时改变背景颜色;如清单 10-56 所示,新的颜色作为一个参数被传递。每个按钮字符串的第一个字母都是大写的("Red"),但是 CSS 颜色都是小写的,所以事件处理程序使用toLowerCase将字符串转换成全部小写的字母。

class ColorScreenBehavior extends Behavior {
    onColorSelected(container, color) {
        container.skin = new Skin({            fill: color.toLowerCase()        });
    }
}

Listing 10-56.

点击其中一个按钮会发生以下情况:

  1. onColorSelected事件首先在按钮本身上触发。按钮的行为没有对应的onColorSelected方法,所以事件会上升到它的父容器。

  2. 按钮的父容器是row对象。这个对象没有行为,因此也没有onColorSelected方法,所以事件转移到行的父容器。

  3. 该行的父容器是colorScreen容器。这个容器的行为有一个onColorSelected方法,所以当行为触发onColorSelected事件时,这个方法被调用。然后,事件转移到该容器的父容器。

  4. colorScreen容器的父容器是application对象。这个对象没有onColorSelected方法,并且是容器层次结构的根,所以遍历是完整的。

和其他传播事件的例子一样,简单地将事件委托给行为中有相应的onColorSelected方法的所有内容是很容易的。但是在容器层次结构中有许多级别的应用程序可以使用 content objects 的bubble方法来简化传播事件的代码,并在容器层次结构改变时最小化所需的代码更改。

动画

将动画整合到用户界面中可以显著改善用户体验。动画用于有意义的功能目的,例如当用户点击按钮时提供反馈。它们也用于美学目的,给产品一种特殊的感觉——例如,在屏幕之间移动时创建动画过渡。

放松方程式

线性修改内容对象属性的动画通常看起来不自然。缓动方程式是实现感觉更自然的动画或添加视觉样式的常用工具。

Piu 用 Robert Penner 著名的放松方程扩展了 JavaScript Math对象。Piu 中这些函数的名称是不言自明的——例如,bounceEaseInOut在动画开始和结束时创建一个反弹效果。有关彭纳方程的详细信息,请访问 robertpenner.com/easing/

这些缓动方程的 Piu 实现都采用单个参数,即范围[0,1]内的一个数字,并返回应用了缓动函数的范围[0,1]内的一个数字。这些方程式在所有类型的动画中广泛使用。输入值是已经完成的动画的分数;缓动函数将分数调整为另一个值,然后用于计算动画中值的状态。在接下来的章节中,您将会看到这样的例子。

一些缓动方程创建了一个微妙的效果,使动画感觉更自然。例如,四重缓动功能— Math.quadEaseInMath.quadEaseOutMath.quadEaseInOut—在整个动画持续时间内稍微改变速度,以使动画的开始和/或结束不那么突然。其他人创造了一个大胆的效果。例如,反弹缓动功能— Math.bounceEaseInMath.bounceEaseOutMath.bounceEaseInOut—使对象在动画开始和/或结束时反弹。

当然,您并不局限于默认包含的缓解功能;您可以轻松添加自己的缓动方程,以满足您产品的需求。创建你自己的放松方程的细节超出了本书的范围,但是如果你认为这对你的产品是必要的,网上有大量的信息。

动画内容对象

helloworld-ticking示例展示了如何使用内容对象的内置时钟来创建一个简单的动画。创建更复杂的动画,尤其是那些同时独立移动几个界面元素的动画,是很困难的。

这个例子演示了如何在屏幕上创建一个包含多个对象的动画序列。本例中的动画很简单,但是理解代码将为您创建自己的更复杂的动画打下基础。图 10-22 显示了动画中几个点的用户界面。

img/474664_1_En_10_Fig22_HTML.jpg

图 10-22

timeline举例

示例中的接口由一个名为animatedContainer(列表 10-57 )的container对象组成,其中包含一个label对象和一个content对象。

const animatedContainer = new Container(null, {
    top: 0, bottom: 0, left: 0, right: 0, skin: whiteSkin,
    contents: [
        new Label(null, {
            style: textStyle, top: 80, left: 0, right: 0, string: "Hello, World"
        }),
        new Content(null, {
            top: 115, height: 3, left: 0, width: 320, skin: colorfulSkin
        })
    ],
    Behavior: TimelineBehavior
});

Listing 10-57.

动画由TimelineBehavior驱动,即animatedContainer的行为。TimelineBehavior在其onDisplaying事件处理程序中实例化一个timeline对象。Piu 提供了Timeline类来简化和构建实现动画的代码。这个类既可以用于动画单个屏幕中的元素,也可以用于动画屏幕之间的过渡。使用Timeline类通常是组织和实现多个内容对象动画的最佳方式;例如,它很容易处理每个内容对象开始动画的时间交错的情况。Piu Timeline类的 API 基于 GreenSock 的 timeline lite API,这是一个流行的 JavaScript 库,用于制作网页动画。

onDisplaying事件处理程序还初始化reverse属性,该属性用于使时间轴动画向前和向后运行。清单 10-58 显示了相关代码。

class TimelineBehavior extends Behavior {
    onDisplaying(container) {
        let timeline = this.timeline = new Timeline();
        this.reverse = false;
        ...

Listing 10-58.

一个timeline对象由一组补间组成,每个补间描述一个内容对象的一个或多个属性如何从初始值和结束值变化。补间通过时间轴的fromto方法添加到时间轴中,这两个方法基于以下参数定义补间:

  1. target–要制作动画的内容对象

  2. properties–一个字典,其关键字是要制作动画的目标对象的属性

  3. duration–补间的持续时间,以毫秒为单位

  4. easing–*(可选)*用于补间的缓动函数

  5. delay–*(可选)*时间轴中上一个补间完成后,此补间应开始的毫秒数;默认为 0

由时间轴的from方法添加的补间(称为 from-tween— )在duration毫秒内将target对象的属性从properties对象中指定的值缓和为target对象的原始值。清单 10-58 中的onDisplaying方法继续添加以下表格。在这个例子中,label对象从屏幕顶部的y位置移动到距离屏幕顶部 80 像素的原始位置。同时,它的状态从状态 1 变为状态 0,使它从白色变为黑色。注意,这里的标签被访问为container.first,因为它是添加到容器中的第一个内容对象。补间的持续时间为 750 毫秒,并使用quadEaseOut缓动功能。

timeline.from(container.first, {
        y: -container.first.height,
        state: 1
    }, 750, Math.quadEaseOut, 0);

如下面的代码所示,对时间轴的from方法的第二次调用将添加一个补间,以将颜色条从屏幕左边缘的x位置移动到距左边缘 0 像素的原始位置。对from的每次调用都会延长时间轴的动画时长,除非使用delay参数,否则对from的下一次调用所添加的补间动画会从时间轴的末尾开始。为了使两个补间同时运行,本示例将delay属性设置为–750 毫秒,这使得它与第一个补间同时开始。此补间不会更改时间轴的持续时间,因为它与第一个补间同时结束。

timeline.from(container.last, {
        x: -320
    }, 750, Math.linearEase, -750);

由时间轴的to方法添加的补间(称为 to-tween— )在duration毫秒内将target对象的属性从其当前值缓和到在properties对象中指定的目标值。onDisplaying方法继续添加补间动画,如下所示。在本例中,颜色条从其当前状态 0 变为状态 1。这里没有指定delay属性,所以它默认为 0,这使得这个补间在前一个补间完成后立即开始。

timeline.to(container.last, {
        state: 1
    }, 750, Math.linearEase, 0);

添加完所有补间后,时间轴就可以使用了,如下面的代码所示,这些代码是对onDisplaying方法的其余调用。时间轴有一个当前时间,在 0 和时间轴的持续时间之间,表示动画的进度,可以使用它的seekTo方法设置。像duration属性(和一个内容对象的时钟)一样,seekTo以毫秒表示时间。此示例通过使用seekTo将时间轴的当前时间设置为 0,将时间轴倒回到起点。然后,它使用内容对象的时钟(在本例中是容器的时钟)来驱动动画:在将容器的持续时间设置为与时间轴的持续时间相匹配后,它会倒带容器的时钟并开始计时。

timeline.seekTo(0);
container.duration = timeline.duration;
container.time = 0;
container.start();

TimelineBehavior包括两个额外的事件处理程序,onTimeChangedonFinished(列表 10-59 ):

  • 随着时钟滴答作响,onTimeChanged被定期调用。因为时间轴的持续时间等于容器时钟的持续时间,onTimeChanged使用seekTo将时间轴与容器时钟的time属性同步。

  • 当容器的时钟到达其持续时间时,触发onFinished事件。这也意味着动画序列是完整的。在本例中,时间轴到达终点后会反向移动,并无限循环往复。

onTimeChanged(container) {
    let time = container.time;
    if (this.reverse)         time = container.duration - time;
    this.timeline.seekTo(time);
}
onFinished(container) {
    this.reverse = !this.reverse;
    this.timeline.seekTo(0);
    container.time = 0;
    container.start();
}

Listing 10-59.

动画过渡

Piu Transition类提供了另一种实现动画的方法。它最常用于在容器层次结构中用一个内容对象替换另一个内容对象——例如,在屏幕之间移动。本节重点介绍内置的擦拭和梳理过渡,它们是Transition类的子类。与修改内容对象的属性的时间线动画不同,擦拭和梳理过渡是在显示器的像素上操作的纯图形操作。因为它们经过优化,可以最大限度地减少每帧中绘制的像素数量,所以这些过渡可以实现高帧速率,即使在 ESP8266 微控制器上也是如此。你也可以通过子类化Transition类来创建你自己的过渡,但是这超出了本书的范围。

您可以从模块中导入擦拭和梳理过渡类:

import WipeTransition from "piu/WipeTransition";
import CombTransition from "piu/CombTransition";

擦除过渡从屏幕的边缘或角落开始显示新屏幕。此转换的构造函数具有以下参数来控制擦除:

  1. 以毫秒为单位的持续时间

  2. 宽松的等式

  3. 水平方向,如"center""left""right"

  4. 垂直方向,如"middle""top""bottom"

水平和垂直方向决定了过渡开始的位置。例如,如果它们是centertop,过渡从顶部边缘开始;如果他们是rightbottom,过渡从右下角开始。

const wipeFromCenter = new WipeTransition(250,
                            Math.quadEaseOut, "center", "top");
const wipeFromTopRight = new WipeTransition(250,
                          Math.quadEaseOut, "right", "bottom");

梳状过渡通过一系列交错的条显示新屏幕,这些条从屏幕的顶部和底部边缘或屏幕的左侧和右侧边缘出现。梳状转换的构造函数具有以下参数:

  1. 以毫秒为单位的持续时间

  2. 宽松的等式

  3. 方向,如"horizontal""vertical"

  4. 酒吧的数量

如果方向设置为horizontal,条从左右边缘出现;如果设置为vertical,条从顶部和底部边缘出现。

const horizontalComb = new CombTransition(250,                             Math.quadEaseOut, "horizontal", 4);
const verticalComb = new CombTransition(250,                               Math.quadEaseOut, "vertical", 8);

一旦有了一个过渡实例,就调用要过渡的对象的父容器的run方法,将过渡、要过渡的内容对象和要过渡的内容对象作为参数传递。转换异步运行,因此不会阻止代码的执行。当过渡完成时,要从其过渡的内容对象在容器层次结构中被要过渡到的内容对象替换。例如,清单 10-60 中的代码运行wipeFromTopRightTransition转换,用nextScreen替换firstScreen

const firstScreen = new Content(...);
const nextScreen = new Content(...);
const sampleContainer = new Container(null, {
    ...
    contents: [
        firstScreen
    ]
});

sampleContainer.run(wipeFromTopRightTransition, firstScreen,                     nextScreen);

Listing 10-60.

$EXAMPLES/ch10-piu/transitions示例显示了擦拭和梳理过渡的几种变化。它定期在两个屏幕之间切换。

实时绘制图表

有时,使用 Poco 提供的绘图功能来呈现用户界面的某些元素会更方便、更高效,而不是像 Piu 那样创建和更新对象。例如,假设您想要创建一个如图 10-23 所示的条形图,它根据传感器的读数实时更新。

img/474664_1_En_10_Fig23_HTML.jpg

图 10-23

实时更新的条形图

您可以使用 Piu 内容对象,但这不是最有效的实现。您需要跟踪和更新许多内容对象——图表中的每个条形和图表的背景至少有一个content对象,再加上一个或多个label对象作为 y 轴上的标签。每个对象都会占用一些内存,所以你的内存使用量会很快增加。

幸运的是,您不必在 Piu 和 Poco 的方法之间进行选择;您可以通过使用 Piu 的Port类来组合它们。端口是一个内容对象,允许您在 Piu 布局中发出类似于 Poco 的绘制命令,这对于图形等用户界面元素来说非常有用,否则这些用户界面元素将需要许多内容对象。

$EXAMPLES/ch10-piu/graph示例使用单个port对象高效地渲染实时条形图,如图 10-23 所示:

const graph = new Port(null, {
    top: 0, bottom: 0, left: 0, right: 0,
    Behavior: GraphBehavior
});

这个端口的行为,GraphBehavior(列表 10-61 )维护一个样本值列表,并将其绘制到存储在values属性中的数组中。每隔 100 毫秒,onTimeChanged事件处理程序删除列表中的第一个值,并用一个从 0 到 100 的随机数替换它。这些随机数是模拟的传感器读数。在生成一个新值后,onTimeChanged调用端口的invalidate方法,该方法告诉 Piu 需要重画端口。

class GraphBehavior extends Behavior {
    onDisplaying(port) {
        this.values = new Array(20);
        this.values.fill(0);
        port.interval = 100;
        port.start();
    }
    onTimeChanged(port) {
        this.values.shift();
        this.values.push(Math.random() * 100);
        port.invalidate();
    }

Listing 10-61.

invalidate的调用导致port对象触发自身的onDraw事件。请注意,onDraw不是从对invalidate方法的调用中调用的,而是在一小段时间后调用的。如清单 10-62 所示,在这种情况下onDraw事件处理程序用白色填充背景,绘制 y 轴标签和相应的灰色线条,然后为每个随机生成的值绘制一个蓝色条。

onDraw(port, x, y, width, height) {
    port.fillColor(WHITE, x, y, width, height);

    for (let i = 100, yOffset = 0; yOffset < height;
            yOffset += height / 5, i -= 20) {
        port.drawString(i, textStyle, "black",
                        30 - textStyle.measure(i).width,
                        yOffset);
        port.fillColor(GRAY, 35, yOffset + 10, width, 1);
    }

    let xOffset = 35;
    const values = this.values;
    for (let i = 0; i < values.length; i++) {
        let value = values[i];
        let barHeight = (value / 100) * (height - 10);
        port.fillColor(BLUE, xOffset, height - barHeight,
                       12, barHeight);
        xOffset += 14;
    }
}

Listing 10-62.

这个例子使用了由port内容对象提供的两种绘制方法:

  • 它调用drawString按照label对象的方式绘制一行文本,并指定样式和颜色。调用textStyle对象的measure方法来计算标签字符串的宽度,以便精确定位它们。

  • 它调用fillColor以指定的颜色绘制一个矩形。

port对象有几个其他的绘制方法,包括用drawTexture绘制由纹理指定的图像,用drawSkin绘制带有皮肤的矩形,就像任何内容对象一样。关于所有可用于port对象的绘图命令的详细信息,参见可修改 SDK 中的 Piu 文档。

添加屏幕键盘

在许多物联网产品中,有些情况需要用户输入文本,例如,在设置产品时输入 Wi-Fi 密码。如今,这种操作通常是在手机上的配套应用程序中完成的,需要用户安装新的移动应用程序,并遵循复杂、容易出错的流程来配置 Wi-Fi。在包含触摸屏的物联网产品上,用户可以直接在产品上配置 Wi-Fi,并为其他目的输入文本。要做到这一点,你只需要一个屏幕键盘。

挑战在于,当键盘更大时,准确打字更容易,但更大的触摸屏更贵。为了解决这个问题,可修改的 SDK 包括一个提供扩展屏幕键盘的模块,使在小触摸屏上准确输入文本成为可能。在这个键盘上输入一个字符是一个两步的过程:首先你在你想要输入的字符附近轻击(或者在那个字符上或者在那个字符附近);键盘会在您轻按的位置周围展开,然后您轻按您想要的字符。输入完文本后,点击 OK

您可以通过运行$EXAMPLES/ch10-piu/keyboard示例来尝试一下。当该示例启动时,您会看到键盘处于未展开状态(图 10-24 ,在键盘上方的文本字段中有一个闪烁的光标。

img/474664_1_En_10_Fig24_HTML.jpg

图 10-24

未展开的键盘

在图 10-25 中,左图显示了你点击字母 a 或其附近后键盘如何展开,右图显示了你点击字母 g 或其附近后键盘如何展开。

img/474664_1_En_10_Fig25_HTML.jpg

图 10-25

键盘围绕字母 a(左)和 g(右)展开

然后轻按您想要的字符,该字符出现在文本栏中闪烁的光标之前,键盘返回到未展开状态。(注意在展开状态下, OK 按钮变为显示键盘图标;如果你根本不想输入字符,而是想返回到未展开的键盘和 OK 按钮,你可以点击它。)

有两种扩展键盘可供选择:VerticalExpandingKeyboard用于 240 像素宽的屏幕,而HorizontalExpandingKeyboard用于 320 像素宽的屏幕。keyboard示例使用了水平变量,所以它从键盘模块导入了HorizontalExpandingKeyboardKeyboardField对象。

import {HorizontalExpandingKeyboard} from "keyboard";
import {KeyboardField} from "common/keyboard";

这些模块是可修改的 SDK 的一部分,因此您可以看到它们的源代码和完整文档。既然你已经阅读了这一章,模块中的一切看起来都很熟悉;键盘的所有元素都是用你学过的 Piu 类构建的,包括PortTimelineBehavior。本节不描述键盘模块的实现,而只关注如何使用这些模块将键盘合并到您的项目中。

KeyboardContainer模板(清单 10-63 )是开始探索这个例子的好地方。其内容中的第一项是从common/keyboard模块导入的内容对象类KeyboardField的实例。此字段是您键入文本的位置。KeyboardField类有一个响应文本输入和闪烁光标的行为。第二项是容纳键盘的容器,尽管它最初是一个空容器。注意,这两个内容对象都有一个anchor属性,所以它们的锚是在实例化数据中创建的。

const KeyboardContainer = Column.template($ => ({
    left: 0, right: 0, top: 0, bottom: 0,
    contents: [
        KeyboardField($, {
            anchor: "FIELD",
            left: 32, right: 0, top: 0, bottom: 0,
            style: fieldStyle
        }),
        Container($, {
            anchor: "KEYBOARD",
            left: 0, right: 0, bottom: 0, height: 164
        })
    ],
    active: true,
    Behavior: KeyboardContainerBehavior
}));

Listing 10-63.

KeyboardContainerBehavior(清单 10-64 )中,与onDisplayingonTouchEnded事件相关联的方法(你已经熟悉了)都做同样的事情:它们调用addKeyboard方法。

class KeyboardContainerBehavior extends Behavior {
    ...
    onDisplaying(column) {
        this.addKeyboard();
    }
    onTouchEnded(column) {
        this.addKeyboard();
    }
    ...
}

Listing 10-64.

方法addKeyboard(清单 10-65 )检查由data.KEYBOARD引用的container对象是否已经包含一个键盘。如果没有,该方法将根据传入的三个参数向空的container对象添加一个HorizontalExpandingKeyboard实例:

  • 样式是键盘按键上字符的样式。

  • 目标是当一个键被点击时应该接收事件的对象,在本例中是由data.FIELD引用的KeyboardField对象。

  • doTransition参数指定键盘是否应该过渡。如果true,键盘一次一行地过渡进来;如果false,它会立刻出现。

addKeyboard() {
    if (1 !== this.data.KEYBOARD.length) {
        this.data.KEYBOARD.add(HorizontalExpandingKeyboard(     this.data, {
                style: keyboardStyle,
                target: this.data.FIELD,
                doTransition: true
            }
        ));
    }
}

Listing 10-65.

当用户点击 OK 按钮时,键盘将onKeyboardOK事件分配给application容器,容器中包含用户输入的文本字符串。在本例中,KeyboardContainerBehavior通过跟踪输入的字符串并隐藏显示字符串和光标的字段来响应事件。

onKeyboardOK(application, string) {
    trace(`User entered: ${string}\n`);
    this.data.FIELD.visible = false;
}

当用户点击 OK 时,键盘出现滑入过渡,滑出消失。当这些转换中的任何一个完成时,键盘产生一个带有参数的onKeyboardTransitionFinished事件,该参数指示转换是针对键盘的出现还是消失。您的代码可以使用这些事件来执行操作,例如在使用键盘时隐藏用户界面元素。

在这个例子中,onKeyboardTransitionFinished方法(清单 10-66 )通过从容器层次结构中移除键盘来响应键盘的消失,并且该方法通过使键盘上方的文本字段可见来响应键盘的出现。

onKeyboardTransitionFinished(application, out) {
    if (out) {
        let keyboard = this.data.KEYBOARD;
        keyboard.remove(keyboard.first);
    }
    else
        this.data.FIELD.visible = true;
}

Listing 10-66.

请注意,键盘在被转移出来后不必从容器层次结构中移除;您可以不断地将同一个实例移入和移出视图。然而,在许多应用程序中,点击 OK 会触发到另一个屏幕的转换,因此最好将键盘从容器层次结构中移除,以便可以对其进行垃圾收集。

使用模块组织用户界面代码

本章中的每个例子都包含在一个模块中,也就是一个源代码文件中。随着您的应用程序变得越来越复杂——具有多个屏幕、与云服务和其他设备的交互等等——您可能希望将代码划分到多个模块中。将代码分成模块有以下好处:

  • 重用代码更容易,因为不特定于某个产品的代码可以存储在单独的源代码文件中。键盘模块就是一个例子。

  • 当代码被组织在逻辑模块中时,编辑和维护代码更容易。

  • 在团队中分配工作更容易。

本节讨论的$EXAMPLES/ch10-piu/multiple-screens示例展示了一种组织用户界面的常用方法。这是一个简单的应用程序,有两个屏幕:闪屏和主屏幕,如图 10-26 所示。该应用程序首先显示一个动画启动屏幕,然后转换到一个主屏幕,该屏幕上有一个重启按钮和标签。点击重启按钮返回到初始屏幕。在此过程中,该示例展示了构建具有多个模块和屏幕的可维护、高内存效率的应用程序的有用技术。

img/474664_1_En_10_Fig26_HTML.jpg

图 10-26

multiple-screens示例中的闪屏(左)和主屏幕(右)

模块

multiple-screens示例由三个模块组成:

  • example.js–在屏幕间导航的应用程序逻辑

  • 整个应用程序中使用的assets.jstextureskinstyle对象

  • screens.js–应用程序两个屏幕的模板

在这个例子中,assetsscreens模块并不是特别长,因此将它们分开可能看起来很奇怪,因为assets模块导出只有screens模块使用的对象。然而,在较大的应用程序中,这通常是一种有用的分离,因为您只需要修改一个文件就可以更改所有屏幕上使用的颜色和资源。当你在建立一系列相似品牌的产品时,这也很有用;你可以通过创建一个共享的assets文件来定义你的屏幕使用的通用纹理、皮肤和风格,从而给你所有的产品一个一致的外观和感觉。

在本章中你已经看到了许多关于textureskinstyle对象的例子,所以这里不再详细描述assets模块。接下来的部分集中在examplescreens模块以及它们如何交互。

应用程序逻辑

example模块包含所有特定于应用程序的逻辑,在这个应用程序中是在屏幕之间移动的简单逻辑。在启动时,该示例实例化了MainContainer模板(清单 10-67 )并将其添加到application对象中。这个容器是示例用来保存屏幕的。

const MainContainer = Container.template($ => ({
    top: 0, bottom: 0, left: 0, right: 0,
    Behavior: MainContainerBehavior
}));

application.add(new MainContainer({}));

Listing 10-67.

MainContainer的实例最初是空的。它的行为是添加和删除在screens模块中定义的屏幕。如清单 10-68 所示,该行为通过调用带有屏幕名称"SPLASH"switchScreen方法,在onDisplaying事件处理程序中添加第一个屏幕。

class MainContainerBehavior extends Behavior {
    onCreate(container, data) {
        this.data = data;
    }
    onDisplaying(container) {
        this.switchScreen(container, "SPLASH");
    }
    ...
}

Listing 10-68.

行为中的下一个事件处理程序是switchScreen,应用程序每次需要切换到新屏幕时都会调用它。switchScreen方法触发doSwitchScreen事件,以便移动到新屏幕;然而,它没有使用delegate方法触发事件,而是使用了defer方法,后者将事件的交付推迟到事件循环的下一次迭代。deferdelegate的唯一区别是事件传递的时间。

switchScreen(container, nextScreenName) {
    container.defer("doSwitchScreen", nextScreenName);
}

您希望延迟事件交付的一个原因是为了避免栈溢出。微控制器上的栈很小,创建屏幕的代码通常会占用相当多的栈空间。如果您立即切换屏幕,一些栈已经被调用您的行为的事件处理程序的调用所使用。通过延迟事件的传递,您的事件处理程序在几乎空的栈上运行,从而减少了栈的使用峰值。

推迟事件交付的另一个原因是为了减少切换屏幕时的内存使用高峰。由于垃圾收集的工作方式,如果您立即提交doSwitchScreen事件,垃圾收集器会将前一个和下一个屏幕都保存在内存中一小段时间。使用defer可以在实例化下一个屏幕之前先释放上一个屏幕。MainContainerdoSwitchScreen方法(清单 10-69 )就是这么做的,如下:

  1. 它使用empty方法清空当前屏幕。因为这是通过延迟事件完成的,所以与该屏幕关联的对象有资格进行垃圾回收。

  2. 它调用application.purge,释放 Piu 创建的缓存并运行垃圾收集器,从旧屏幕中释放对象使用的内存。

  3. 它实例化并添加下一个屏幕。

doSwitchScreen(container, nextScreenName) {
    container.empty();
    application.purge();
    switch (nextScreenName) {
        case "SPLASH":
            container.add(new SCREENS.SplashScreen(this.data));
            break;
        case "HOME":
            container.add(new SCREENS.HomeScreen(this.data));
            break;
    }
}

Listing 10-69.

这个过程是管理应用程序 RAM 使用的好方法,因为它有助于确保 RAM 中永远不会同时有两个屏幕的对象。将切换屏幕的逻辑放在MainContainer的行为中也是有用的,因为它避免了您必须在每个屏幕模板的行为中重复它;取而代之的是,当到了转到新屏幕的时候,每个屏幕可以简单地委托switchScreen事件。

闪屏

像许多移动和 web 应用程序一样,这个例子在应用程序启动时显示一个简单的闪屏。如清单 10-70 所示,这个屏幕上的标志是通过层叠三个content对象创建的,这使得每一个部分都可以用一个timeline对象单独制作动画。屏幕上的标题是一个简单的label物体。

const SplashScreen = Container.template($ => ({
    top: 0, bottom: 0, left: 0, right: 0,
    skin: ASSETS.backgroundSkin,
    contents: [
        Content($, {
            anchor: "LOGO1",
            top: 30,
            skin: ASSETS.logoSkin1
        }),
        Content($, {
            anchor: "LOGO2",
            top: 30,
            skin: ASSETS.logoSkin2
        }),
        Content($, {
            anchor: "LOGO3",
            top: 30,
            skin: ASSETS.logoSkin3
        }),
        Label($, {
            anchor: "TITLE",
            top: 155,
            style: ASSETS.bigTextStyle,
            string: "lorem ipsum"
        })
    ],
    Behavior: SplashScreenBehavior
}));

Listing 10-70.

像往常一样,时间线是在行为中定义的(清单 10-71 ),由container对象的内部时钟驱动。

class SplashScreenBehavior extends Behavior {
    ...
    onDisplaying(container) {
        let data = this.data;
        let timeline = this.timeline = new Timeline;
        ...
    }
    onTimeChanged(container) {
        this.timeline.seekTo(container.time);
    }
}

Listing 10-71.

当动画完成时,行为的onFinished方法(清单 10-72 )执行以下操作:

  • 它删除屏幕上所有内容对象的锚点。注意,这并没有删除内容对象本身,而只是删除了在data对象中对它们的引用。删除这些引用很重要,因为data对象由MainContainer对象共享,并传递给它创建的所有屏幕;如果引用没有被删除,当在doSwitchScreen方法中调用application.purge时,垃圾收集器将不能释放与内容对象相关的 RAM。

  • 然后它使switchScreen事件冒泡,最终到达MainContainer对象。它将字符串"HOME"作为第二个参数传递,因此MainContainer接下来加载主屏幕。

onFinished(container) {
    let data = this.data;
    // Delete anchors
    delete data.LOGO1;
    delete data.LOGO2;
    delete data.LOGO3;
    delete data.TITLE;
    // Transition to next screen
    container.bubble("switchScreen", "HOME");
}

Listing 10-72.

主屏幕

主屏幕(列表 10-73 )是一个以重启按钮和标签为中心的行。重启按钮和主屏幕分别有名为RestartButtonBehaviorHomeScreenBehavior的行为。

const HomeScreen = Row.template($ => ({
    top: 0, bottom: 0, left: 0, right: 0,
    skin: ASSETS.backgroundSkin,
    contents: [
        Content($, {
            left: 0, right: 0
        }),
        Container($, {
            anchor: "BUTTON",
            skin: ASSETS.buttonBackgroundSkin,
            contents: [
                Content($, {
                    skin: ASSETS.restartArrowSkin
                })
            ],
            active: true,
            Behavior: RestartButtonBehavior
        }),
        Label($, {
            anchor: "TEXT",
            left: 10,
            style: ASSETS.bigTextStyle,
            string: "Restart",
            left: 0, right: 0
        })
    ],
    Behavior: HomeScreenBehavior
}));

Listing 10-73.

HomeScreenBehavior类的onDisplaying事件处理程序激活重启按钮和标签,如清单 10-74 所示。

class HomeScreenBehavior extends Behavior {
    onCreate(container, data) {
        this.data = data;
    }
    onDisplaying(container) {
        let data = this.data;
        let timeline = this.timeline = new Timeline();
        ...
        container.start();
    }
    ...
}

Listing 10-74.

与闪屏不同,主屏幕在动画出现后不会自动切换屏幕。相反,它等待接收一个animateOut事件;其行为的animateOut方法(清单 10-75 )创建一个timeline对象并将transitioningOut属性设置为true

animateOut(container) {
    let data = this.data;
    this.transitioningOut = true;
    let timeline = this.timeline = new Timeline();
    ...
    container.start();
}

Listing 10-75.

onFinished事件在动画结束时被触发,相应的事件处理程序(列表 10-76 )检查transitioningOut属性以决定采取哪一个动作:

  • 如果transitioningOuttrue,按钮和标签的锚点被删除,switchScreen事件被冒泡到MainContainer对象。

  • 如果transitioningOutfalse,则删除timeline属性,使timeline对象符合垃圾回收条件。由于垃圾收集器仅在需要释放 RAM 时运行,并且在输入和输出转换之间没有实例化其他对象,所以垃圾收集器不会运行,所以这里没有必要删除timeline属性。尽管如此,养成删除不再使用的对象的引用的习惯还是有好处的。

onFinished(container) {
    if (this.transitioningOut) {
        let data = this.data;
        // Delete anchors
        delete data.BUTTON;
        delete data.TEXT;
        // Transition to next screen
        container.bubble("switchScreen", "SPLASH");
    }
    else
        delete this.timeline;
}

Listing 10-76.

重启按钮的行为(清单 10-77 )只响应一个事件:onTouchEnded。行为的onTouchEnded方法只是将onAnimateOut事件委托给按钮的容器,这是HomeScreen模板的一个实例。正如你刚才看到的,这触发了动画,并最终导致过渡回闪屏。

class RestartButtonBehavior extends Behavior {
    onTouchEnded(content) {
        content.container.delegate("animateOut");
    }
}

Listing 10-77.

添加更多屏幕

既然您已经知道如何在两个屏幕之间切换,那么添加更多屏幕就很简单了。这些是步骤:

  1. 为新屏幕定义模板。

  2. 将其添加到screens模块的默认导出中。

  3. example模块中,给MainContainerBehaviordoSwitchScreen方法中的switch语句添加一个 case,实例化屏幕模板并添加到MainContainer

  4. 在代码中根据需要触发switchScreen事件,在switch语句中传递您用于新屏幕的名称。

结论

在本章中,您学习了使用 Piu 构建用户界面的基础知识,包括如何添加图形和文本、赋予它们事件驱动的行为以及创建动画。您学习了几种节省 RAM 的技术,比如重用纹理和皮肤,以及删除对未使用对象的引用。您还学习了保存 ROM 的技巧,包括使用模板。根据本章的信息,你可以用便宜的硬件构建漂亮的现代用户界面。

本章介绍了用于构建嵌入式产品用户界面的 Piu 的主要特性。Piu 还有许多其他特性,您可能会发现这些特性在您的产品中很有用——例如,支持为必须支持多种语言的产品有效地本地化文本字符串。有关 Piu 所有特性的详细文档以及使用这些特性的示例链接,请参见可修改 SDK 中的 Piu 文档。*