6 月初,苹果在 WWDC 2019 大会上发布了 SwiftUI。这是一个单一的“跨平台”“声明性”框架,可以用来构建 tvOS、macOS、watchOS 和 iOS/iPad OS 平台的 UI。而本文要介绍的SwiftWebUI则能将 SwiftUI 带到 Web 平台上。
免责声明:记住这是一个娱乐项目!可别把它用到生产环境里哦。它可以帮助你深入了解 SwiftUI 及其内部的工作机制。
SwiftWebUI
那么 SwiftWebUI 到底是什么?简单来说,你可以用它编写能显示在浏览器中的SwiftUI视图。详情请查看:
import SwiftWebUI
struct MainPage: View {
@State var counter = 0
func countUp() { counter += 1 }
var body: some View {
VStack {
Text("🥑🍞 #\(counter)")
.padding(.all)
.background(.green, cornerRadius: 12)
.foregroundColor(.white)
.tapAction(self.countUp)
}
}
}
复制代码
结果如下:
SwiftWebUI 和其他的一些方案不太一样,它不仅会将 SwiftUI 视图呈现为 HTML,还会在浏览器和 Swift 服务器中托管的代码之间建立一个连接,由此可以实现交互操作——按钮、选择器、步进器、列表、导航,所有这些都能用!
换句话说:SwiftWebUI 是 SwiftUI API 在浏览器上的(接近完整)的实现。
再强调一遍免责声明:记住这是一个娱乐项目!可别把它用到生产环境里哦。它可以帮助你深入了解 SwiftUI 及其内部的工作机制。
一次学习,随处使用
SwiftUI 的宗旨不是“一次编写,随处运行”,而是“一次学习,随处使用”。可别指望随手抓起一个 iOS 平台上写得好好的 SwiftUI 应用,然后把代码扔进 SwiftWebUI 项目,就能原样在浏览器上完美呈现出来。这不是它们的设计目标。
这里的关键是在各个平台复用同一套代码逻辑和框架。本文所关注的就是复用到 Web 平台上。
接下来我们开始深入研究,并编写一个简单的 SwiftWebUI 应用。本着“一次学习,随处使用”的精神,首先要看一遍这两个 WWDC 讲座:SwiftUI简介和SwiftUI要点。还有一个讲座内容比较深,超出了本文涉及的范畴(并且讲座中涉及的概念大都被 SwiftWebUI 支持了):经过SwiftUI的数据流。
系统需求
macOS Catalina
目前,SwiftWebUI 需要运行在 macOS Catalina 系统上。还好大家可以很容易地在单独的 APFS 卷上安装 Catalina。另外 SwiftUI 需要调用大量 Swift 5.1 中的新功能,所以还得安装 Xcode 11 才行。
为什么需要 Catalina? SwiftUI 使用新的 Swift 5.1 运行时功能(例如不透明的结果类型)。这些功能在 Mojave 附带的 Swift 5 运行时中不可用。 (另一个原因是使用仅在 Catalina 中可用的 Combine,尽管可以使用 OpenCombine 修复该部分)
tuxOS
SwiftWebUI 现在可以在 Linux 上使用 OpenCombine 运行(也可以在没有它的情况下运行,但有些东西不起作用,例如 NavigationView)。
需要 Swift 5.1 快照。我们还提供了一个包含 5.1 快照的 Docker 镜像:
helje5 / swift。
Mojave 系统
想在 Mojave 与 Xcode 11 的组合上运行也能做到。你需要创建一个 iOS 13 模拟器项目,在里面运行所有内容。
第一个应用
创建 SwiftWebUI 项目
启动 Xcode 11,选择“File> New> Project …”或按 Cmd-Shift-N:
选择“macOS / Command Line Tool”项目模板:
取个好名字,这里用“AvocadoToast”:
然后我们将 SwiftWebUI 添加为 Swift Package Manager 依赖项。该选项隐藏在“File / Swift Packages”菜单组中:
输入https://github.com/SwiftWebUI/SwiftWebUI.git作为包的 URL
在“Branch”中选 master,始终获取最新更新(你也可以使用 revision 或 develop 分支):
最后将 SwiftWebUI 库添加到你的 tool target:
搞定了。现在你就有了一个可以 import SwiftWebUI 的工具项目。(Xcode 可能需要花些时间来获取和构建依赖项。)
SwiftWebUI Hello World
开始使用 SwiftWebUI 吧。打开 main.swift 文件并将其内容替换为:
import SwiftWebUI
SwiftWebUI.serve(Text("Holy Cow!"))
复制代码
在 Xcode 中编译并运行应用,打开 Safari 并访问http://localhost:1337/
背后发生了什么事情呢?首先是导入 SwiftWebUI 模块(可别搞错了,导入 macOS SwiftUI 就不对了😀)
然后我们调用 SwiftWebUI.serve,它要么接受一个闭包返回一个视图,要么直接出一个视图,这里就是一个Text视图(又名“UILabel”,可以显示普通或格式化的文本)。
幕后
在程序内部,serve函数创建了一个非常简单的SwiftNIOHTTP 服务器,侦听端口 1337。当浏览器访问该服务器时,它会创建一个会话并将我们的(Text)视图传递给该会话。
最后 SwiftWebUI 在服务器上从视图中创建一个“Shadow DOM”,将其呈现为 HTML 并将结果发送到服务器。这个“Shadow DOM”(和搭配的状态对象)存储在这个会话中。
这里就是 SwiftWebUI 应用与 watchOS 或 iOS SwiftUI 应用之间的区别所在。一个 SwiftWebUI 应用不仅服务一个用户,而是为一组用户提供服务。
添加一些交互
接下来我们给代码做些改进。在项目中创建一个新的 Swift 文件并调用 MainPage.swift。然后为其添加一个简单的 SwiftUI 视图定义:
import SwiftWebUI
struct MainPage: View {
var body: some View {
Text("Holy Cow!")
}
}
复制代码
根据我们的自定义视图来调整一下 main.swift:
SwiftWebUI.serve(MainPage())
复制代码
然后就不用管 main.swift 了,所有工作都能在自定义的视图中完成。下面添加一些交互:
struct MainPage: View {
@State var counter = 3
func countUp() { counter += 1 }
var body: some View {
Text("Count is: \(counter)")
.tapAction(self.countUp)
}
}
复制代码
我们的视图得到了一个名为counter的持久状态变量(具体介绍见前面第一个 wwdc 讲座)。还有一个小函数来触发计数器。
然后我们使用SwiftUI tapAction修饰符将事件处理程序附加到 Text 上。最后我们在标签中显示当前值:
幕后
这里程序又是怎么工作的呢?当浏览器点击我们的端点时,SwiftWebUI 在其中创建了会话和我们的“Shadow DOM”。然后它将描述我们视图的 HTML 发送到浏览器。然后 tapAction 向 HTML 添加了一个 onclick 处理程序。SwiftWebUI 还向浏览器发送(少量,没那么多!)JavaScript,负责处理点击操作并将其转发到我们的 Swift 服务器。
然后轮到 SwiftUI 上场了。SwiftWebUI 将 click 事件与我们的“Shadow DOM”中的事件处理程序相关联并调用 countUp 函数。该函数修改了计数器状态变量,使视图的呈现无效。接着 SwiftWebUI 对“Shadow DOM”中的更改执行 diff 命令。然后这些更改被发送回浏览器。
这些“更改”被作为 JSON 数组发送出去,我们页面中的小 JavaScript 程序可以处理这些数组。如果整个子树发生了变化(例如,如果用户跳转到一个全新的视图),则更改可以是更大的 HTML 片段,应用于 innerHTML 或 outerHTML。
但通常情况下这些更改都不大,诸如 add class、set HTML attribute 等(比如浏览器 DOM 调整之类)。
吐司面包 Avocado Toast
基础打得很牢固。下面该引入更多的交互了。下面的内容是基于“SwiftUI 要点”讲座中演示 SwiftUI 用的“Avocado Toast 应用”。这个应用是关于美味的吐司面包的。
我们写的 HTML/CSS 样式不是很完美也不够漂亮。你也知道我们不是网页设计师,需要大家帮助。欢迎提交贡献!
完整应用下载链接:https://github.com/SwiftWebUI/AvocadoToast。
吐司面包订单
讲座中的相关内容大约从 6 分钟开始,我们把其中的代码添加到新的 OrderForm.swift 文件中:
struct Order {
var includeSalt = false
var includeRedPepperFlakes = false
var quantity = 0
}
struct OrderForm: View {
@State private var order = Order()
func submitOrder() {}
var body: some View {
VStack {
Text("Avocado Toast").font(.title)
Toggle(isOn: $order.includeSalt) {
Text("Include Salt")
}
Toggle(isOn: $order.includeRedPepperFlakes) {
Text("Include Red Pepper Flakes")
}
Stepper(value: $order.quantity, in: 1...10) {
Text("Quantity: \(order.quantity)")
}
Button(action: submitOrder) {
Text("Order")
}
}
}
}
复制代码
测试一下,在 main.swift 中将 SwiftWebUI.serve()指向新的 OrderForm 视图。
浏览器中是这个样子:
SemanticUI(https://semantic-ui.com/)用来在 SwiftWebUI 中设置一些样式。这一步并不是非用它不可,它只是用来做一些好看的小部件的。
注意:这里只使用 CSS/fonts,不用 JavaScript 组件。
插点内容:SwiftUI 布局
在 SwiftUI 要点讲座中大约 16 分钟的时候,他们会讲 SwiftUI 布局和视图修饰符排序:
var body: some View {
HStack {
Text("🥑🍞")
.background(.green, cornerRadius: 12)
.padding(.all)
Text(" => ")
Text("🥑🍞")
.padding(.all)
.background(.green, cornerRadius: 12)
}
}
复制代码
结果如下,请注意修饰符的排序:
SwiftWebUI 试图复制常见的 SwiftUI 布局,但还没有完全成功。毕竟它必须考虑浏览器提供的布局系统。欢迎 flexbox 专家提供帮助!
吐司面包订单历史
再回来看应用。讲座 19 分 50 秒开始介绍列表(https://developer.apple.com/documentation/swiftui/list)视图,用于显示牛油果土司面包的订单历史记录。它在 Web 端长成这个样子:
List 视图遍历已完成订单的数组,为每个订单创建一个子视图(OrderCell),并传入列表中的当前项。
以下是我们使用的代码:
struct OrderHistory: View {
let previousOrders : [ CompletedOrder ]
var body: some View {
List(previousOrders) { order in
OrderCell(order: order)
}
}
}
struct OrderCell: View {
let order : CompletedOrder
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(order.summary)
Text(order.purchaseDate)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if order.includeSalt {
SaltIcon()
}
else {}
if order.includeRedPepperFlakes {
RedPepperFlakesIcon()
}
else {}
}
}
}
struct SaltIcon: View {
let body = Text("🧂")
}
struct RedPepperFlakesIcon: View {
let body = Text("🌶")
}
// Model
struct CompletedOrder: Identifiable {
var id : Int
var summary : String
var purchaseDate : String
var includeSalt = false
var includeRedPepperFlakes = false
}
复制代码
SwiftWebUI 列表视图效率非常低,它总是呈现整个子集。没有单元复用,啥都没有。在 Web 应用中有多种方法可以处理这种情况,例如使用分页或更多客户端逻辑。
讲座中用到的示例数据如下:
let previousOrders : [ CompletedOrder ] = [
.init(id: 1, summary: "Rye with Almond Butter", purchaseDate: "2019-05-30"),
.init(id: 2, summary: "Multi-Grain with Hummus", purchaseDate: "2019-06-02",
includeRedPepperFlakes: true),
.init(id: 3, summary: "Sourdough with Chutney", purchaseDate: "2019-06-08",
includeSalt: true, includeRedPepperFlakes: true),
.init(id: 4, summary: "Rye with Peanut Butter", purchaseDate: "2019-06-09"),
.init(id: 5, summary: "Wheat with Tapenade", purchaseDate: "2019-06-12"),
.init(id: 6, summary: "Sourdough with Vegemite", purchaseDate: "2019-06-14",
includeSalt: true),
.init(id: 7, summary: "Wheat with Féroce", purchaseDate: "2019-06-31"),
.init(id: 8, summary: "Rhy with Honey", purchaseDate: "2019-07-03"),
.init(id: 9, summary: "Multigrain Toast", purchaseDate: "2019-07-04",
includeSalt: true),
.init(id: 10, summary: "Sourdough with Chutney", purchaseDate: "2019-07-06")
]
复制代码
吐司面包选择器
讲座第 43 分钟开始讲解 Picker 控件及它与枚举一起使用的方法。首先是各种吐司选项的枚举:
enum AvocadoStyle {
case sliced, mashed
}
enum BreadType: CaseIterable, Hashable, Identifiable {
case wheat, white, rhy
var name: String { return "\(self)".capitalized }
}
enum Spread: CaseIterable, Hashable, Identifiable {
case none, almondButter, peanutButter, honey
case almou, tapenade, hummus, mayonnaise
case kyopolou, adjvar, pindjur
case vegemite, chutney, cannedCheese, feroce
case kartoffelkase, tartarSauce
var name: String {
return "\(self)".map { $0.isUppercase ? " \($0)" : "\($0)" }
.joined().capitalized
}
}
复制代码
可以把它们加入 Order 结构中:
struct Order {
var includeSalt = false
var includeRedPepperFlakes = false
var quantity = 0
var avocadoStyle = AvocadoStyle.sliced
var spread = Spread.none
var breadType = BreadType.wheat
}
复制代码
然后使用不同的 Picker 类型显示它们。循环枚举值的代码非常简洁:
Form {
Section(header: Text("Avocado Toast").font(.title)) {
Picker(selection: $order.breadType, label: Text("Bread")) {
ForEach(BreadType.allCases) { breadType in
Text(breadType.name).tag(breadType)
}
}
.pickerStyle(.radioGroup)
Picker(selection: $order.avocadoStyle, label: Text("Avocado")) {
Text("Sliced").tag(AvocadoStyle.sliced)
Text("Mashed").tag(AvocadoStyle.mashed)
}
.pickerStyle(.radioGroup)
Picker(selection: $order.spread, label: Text("Spread")) {
ForEach(Spread.allCases) { spread in
Text(spread.name).tag(spread) // there is no .name?!
}
}
}
}
复制代码
结果:
这里也需要改进一下 CSS……
吐司面包“最终版”应用
其实我们的结果和原版略有不同,也不是什么完整的版本。它看起来没那么完美,但毕竟这只是一个演示嘛
HTML 和 SemanticUI
SwiftWebUI 中的对应 UIViewRepresentable(https://developer.apple.com/documentation/swiftui/uiviewrepresentable)的等效组件负责发出原始 HTML。
这里提供了两种变体,HTML 按原样输出字符串,或者通过 HTML 转义内容:
struct MyHTMLView: View {
var body: some View {
VStack {
HTML("<blink>Blinken Lights</blink>")
HTML("42 > 1337", escape: true)
}
}
}
复制代码
一般来说,你可以使用此原语构建任何 HTML。
HTMLContainer 的级别更高一些。例如,这是我们 Stepper 控件的实现:
var body: some View {
HStack {
HTMLContainer(classes: [ "ui", "icon", "buttons", "small" ]) {
Button(self.decrement) {
HTMLContainer("i", classes: [ "minus", "icon" ], body: {EmptyView()})
}
Button(self.increment) {
HTMLContainer("i", classes: [ "plus", "icon" ], body: {EmptyView()})
}
}
label
}
}
复制代码
HTMLContainer 是“反应性的”,即如果类、样式或属性发生变化(而不是重新呈现所有内容),它将发出常规 DOM 更改。
SemanticUI
SwiftWebUI 还有一些预设的 SemanticUI 控件:
VStack {
SUILabel(Image(systemName: "mail")) { Text("42") }
HStack {
SUILabel(Image(...)) { Text("Joe") } ...
}
HStack {
SUILabel(Image(...)) { Text("Joe") } ...
}
HStack {
SUILabel(Image(...), Color("blue"),
detail: Text("Friend"))
{
Text("Veronika")
} ...
}
}
复制代码
呈现为:
请注意,SwiftWebUI 还支持一些 SFSymbols 图像名称(通过 Image(systemName:))。这些都是基于 SemanticUI 对 Font Awesome 的支持的(https://semantic-ui.com/elements/icon.html)。
还有 SUISegment、SUIFlag 和 SUICARD:
SUICards {
SUICard(Image.unsplash(size: UXSize(width: 200, height: 200),
"Zebra", "Animal"),
Text("Some Zebra"),
meta: Text("Roaming the world since 1976"))
{
Text("A striped animal.")
}
SUICard(Image.unsplash(size: UXSize(width: 200, height: 200),
"Cow", "Animal"),
Text("Some Cow"),
meta: Text("Milk it"))
{
Text("Holy cow!.")
}
}
复制代码
呈现为:
添加此类视图非常简单,非常有趣。可以使用 SwiftUI 视图快速构建相当复杂和美观的布局。
Image.unsplash 使用 Unsplash API(http://source.unsplash.com)构建图像查询。只需输入一些参数,诸如图像尺寸和可选范围即可。
注意:有时 Unsplash 服务不怎么好用。
总结
上面就是我们的演示了,希望你能喜欢!但要再次重复免责声明:记住这是一个娱乐项目!可别把它用到生产环境里哦。它可以帮助你深入了解 SwiftUI 及其内部的工作机制。
我们认为它是一个很好的玩具,可能也是一个有价值的工具。
查看原文:http://www.alwaysrightinstitute.com/swiftwebui/
评论