第2章 使用SwiftUI构建watchOS app的界面

791 阅读11分钟

​ 在上一章中,我们创建了第一个watchOS app项目,然后我们修改了ContentView.swift的代码,构建并运行了这个app。那么app是怎么启动并找到ContentView来显示的呢?

2.1 watchOS app的启动过程和生命周期

​ 在用模板创建项目时我们说过,『WatchKit App包含你应用的界面(storyboard)及界面所用的资源文件(assets),WatchKit Extension包含你应用的代码』。现在我们回到项目的文件导航栏,可以看到WatchKit App底下有个Interface.storyboard文件,该文件包含一个Hosting Controller Scene场景,而场景下就是一个带"->"表示的Hosting Controller,表明它就是我们app的入口(或者叫主控制器)。通过声明storyboard下的这个控制器的类型为HostingController,就能与WatchKit Extension下的HostingController.swift这个代码文件关联起来。

11.jpg
​ 点击HostingController.swift,它的代码只有以下几行:

import WatchKit
import Foundation
import SwiftUI

class HostingController: WKHostingController<ContentView> {
    override var body: ContentView {
        return ContentView()
    }
}

​ 首先导入WatchKit、Foundation、SwiftUI三个框架,然后是HostingController类的定义,它继承WKHostingController并包含ContentView协议。WKHostingController是SwiftUI框架中的类,前缀WK则是WatchKit的缩写表示。按住Command键然后点击WKHostingController可以查看它的定义:

/// A `WKInterfaceController` which hosts a `View` hierarchy.
open class WKHostingController<Body> : WKInterfaceController where Body : View {

    /// The root `View` of the view hierarchy to display.
    open var body: Body { get }

    /// Invalidates the current `body` and triggers a body update during the
    /// next update cycle.
    public func setNeedsBodyUpdate()

    /// Update `body` immediately, if updates are pending.
    public func updateBodyIfNeeded()

    @objc override dynamic public init()
}

​ 通过阅读以上代码和注释可以知道,WKHostingController可以使用SwiftUI的视图来显示和管理app的主界面。程序必须子类化WKHostingController并重写(override)body属性来提供我们想要显示的SwiftUI视图。而上面的HostingController就是这个子类,body属性返回的正是ContentView的实例。现在,我们总算搞清楚本章最开始的问题了。

​ 接下来,我们再看看WatchKit Extension下最后一个代码文件ExtensionDelegate.swift,它的代码大概有50行左右:

import WatchKit

class ExtensionDelegate: NSObject, WKExtensionDelegate {

    func applicationDidFinishLaunching() {
        // Perform any final initialization of your application.
    }

    func applicationDidBecomeActive() {
        // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
    }

    func applicationWillResignActive() {
        // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
        // Use this method to pause ongoing tasks, disable timers, etc.
    }

    func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
        // Sent when the system needs to launch the application in the background to process tasks. Tasks arrive in a set, so loop through and process each one.
      
      //笔者注:此处省略若干行
		}
}

​ 类似地,通过上述代码,我们知道ExtensionDelegate是NSObject的子类并遵循WKExtensionDelegate代理协议,查看WKExtensionDelegate的定义,会发现它定义了各种可选的(optional)代理方法。实现这些代理方法就可以响应app的各种生命周期事件,例如app的活跃和停止以及后台任务等。WatchKit框架会根据WatchKit Extension下的Info.plist文件中的WKExtensionDelegateClassName对应的类名(默认情况下为ExtensionDelegate),自动为watchOS app实例化一个扩展代理对象。然后,WatchKit将应用程序执行状态的变化报告给这个扩展代理对象。

12.jpg

​ 在watchOS app的生命周期中,通常会有以下几种状态:未运行(Not running)、闲置(Inactive)、活跃(Active)、后台(Background)、停止(Suspended)。各种状态间的切换如下图所示:

13.png

​ A. 当状态从未运行切换到闲置或者后台时,系统会调用扩展代理的applicationDidFinishLaunching()方法;

​ B. 当状态在闲置与活跃间切换时,系统会调用扩展代理的applicationDidBecomeActive()或者applicationWillResignActive()方法;

​ C. 当状态在闲置与后台间切换时,系统会调用扩展代理的applicationWillEnterForeground()或者applicationDidEnterBackground()方法。

​ 这三种状态切换是watchOS app开发中最常见的,需要重点关注。

​ 了解完watchOS app的启动过程和生命周期后,我们要正式开始学习watchOS app开发的首选UI框架SwiftUI了。

2.2 SwiftUI简介

SwiftUI 是一种创新、简洁的编程方式,通过 Swift 的强大功能,在所有 Apple 平台上构建用户界面。借助它,您只需一套工具和 API,即可创建面向任何 Apple 设备的用户界面。SwiftUI 采用简单易懂、编写方式自然的声明式 Swift 语法,可无缝支持新的 Xcode 设计工具,让您的代码与设计保持高度同步。SwiftUI 原生支持“动态字体”、“深色模式”、本地化和辅助功能——第一行您写出的 SwiftUI 代码,就已经是您编写过的、功能最强大的 UI 代码。

​ SwiftUI支持iOS 13+、watchOS 6+、tvOS 13+和macOS 10.15+,目前第一个版本虽然对于iPhone和mac应用开发可能还显得不够强大,但作为watchOS app的首选UI框架,比起原始的UIKit已经体现出无比的优势。首先,手表的屏幕较小布局简单;其次,各种界面的交互也足够方便;最后,数据与视图的绑定使得界面的更新变得实时且不易出错。

SwiftUI 采用声明式语法,您只需声明用户界面应具备的功能即可。例如,您可以写明您需要一个由文本栏组成的项目列表,然后描述各个栏位的对齐方式、字体和颜色。您的代码比以往更加简单直观和易于理解,可以节省您的时间和维护工作。

1.png

这种声明式风格甚至适用于动画等复杂的概念。只需几行代码,就能轻松地向几乎任何控件添加动画并选择一系列即时可用的特效。在运行时,系统会处理所有必要的步骤和中断因素,来保证您的代码流畅运行、保持稳定。实现动画效果是如此简单,您还能探索新的方式让 app 更生动出彩。

​ 下面,我们通过实例来讲解SwiftUI构建界面的一些基本操作。

2.3 创建你的第二个watchOS项目

​ 有了第一个项目"👋🍎⌚️‼️"的经验,我们的第二个watchOS项目将创建一个简单的游戏——Emoji成语。游戏的逻辑很简单:一般地,我们的成语会有4个文字(非4字的先不考虑),我们把其中三个通过Emoji显示出来,剩下一个使用"❓"来代替;然后提供4个Emoji选项作为"❓"的备选答案,在用户点击某个选项后返回结果是否正确,然后进入下一个成语,直到全部成语展示完毕;最后显示用户的得分,计分规则是答对1题加10分,答错1题减5分(到0分则不再减),一共10题满分是100分;为了让游戏能有更好的交互性,我们还将提供『求助』和『跳过』功能,求助会直接显示正确答案但只能得5分,而跳过则不显示答案也不会加减分,每个功能限定最多只能用一次。

​ 第一个界面的视图规划大概是下图这个样子,其中黄色区域是我们的成语显示区,绿色区域是我们的备选答案区,红色区域是功能交互区,三个区域间有两条灰色的分隔线。

2.jpg

​ 现在,我们先按第一章的步骤,以EmojiIdioms为名称创建这个项目。然后还是选中WatchKit Extension下的ContentView.swift来编辑我们的界面视图:

3.jpg

​ 可以看到ContentView.swift已经在最开始的地方就导入了SwiftUI。在SwiftUI中,所有的UI组件都可以看成是View,各种各样的View构建成app的界面与交互。所有的View都是struct类型,因为struct能比class更快速地渲染和更新视图。ContentView也是View的一个子类,它有一个类型为some View的变量body,只要把视图的代码写在body内,系统就会自动生成并显示对应的视图,比如这里的文本"Hello, World!"。底下的ContentView_Previews是PreviewProvider类型,是为了让Xcode能在Canvas实时更新我们视图的黑科技,它有一个类型同样为some View的静态变量previews,返回的就是ContentView的实例。如果使用真机或者模拟器运行程序,即使去掉ContentView_Previews也不会影响程序的实际运行。

​ SwiftUI采用的是类似HTML的流式布局方式,方向为从上到下从左到右,默认会居中。点击Xcode右上角的"+"按钮,可以看到目前支持的所有视图类型,包括控件视图、布局视图、绘画以及其它视图。回到开始的视图规划,我们需要1个Text视图来显示成语、4个Button视图来显示备选答案、2个Button视图来处理功能交互,另外还有两个Divider视图来分隔各个区域,最后使用Vertical Stack 和 Horizontal Stack处理各视图的布局。

4.jpg

​ 1)黄色区域,先把文本更新为"1️⃣💎2️⃣❓",其中"❓"就是游戏中这个成语要补充的字:

struct ContentView: View {
    var body: some View {
        Text("1️⃣💎2️⃣❓")
            .font(.title)
    }
}

​ 2)绿色区域,备选按钮不能跟上面的文本直接合在body中显示,需要先创建一个Vertical Stack包含起来,按住Command键然后点击Text可以调出快捷菜单,选择"Embed in VStack",类似地4个备选按钮也要通过Horizontal Stack包含起来:

17.png
struct ContentView: View {
    var body: some View {
        VStack {
            Text("1️⃣💎2️⃣❓")
                .font(.title)
            
            HStack {
                Button(action: {}) { Text("🐶") }
                Button(action: {}) { Text("🐞") }
                Button(action: {}) { Text("🐦") }
                Button(action: {}) { Text("🐟") }
            }
        }
    }
}

​ Button中的action是点击后的回调处理,这个我们留到后面再补充。目前是4个备选项,如果下一版本更新到8个甚至更多,上面这种写法显然太不优雅了。还好,SwiftUI为我们提供了ForEach枚举,来处理这种循环的需求:

struct ContentView: View {
    let options = ["🐶", "🐞", "🐦", "🐟"]
    
    var body: some View {
        VStack {
            Text("1️⃣💎2️⃣❓")
                .font(.title)
            
            HStack {
                ForEach(0..<options.count) { index in
                    Button(action: {}) { Text(self.options[index]) }
                }
            }
        }
    }
}

​ 从预览中可以看到当前视图的效果,到目前为止还算比较符合我们的规划的,可以看到视图垂直方向上由之前的"1️⃣💎2️⃣❓"居中,变成了文本与备选按钮一起居中了,这都是SwiftUI在背后帮我们自动处理好的。

5.jpg

​ 3)红色区域,同样地把2个按钮通过Horizontal Stack包含起来就可以了,按钮的点击处理也是放到后面。最后,在三个区域间添上Divider分隔线。此时,刷新预览界面,你会发现最上面的"1️⃣💎2️⃣❓"有部分竟然超出屏幕外显示不全了,而Text与第一个分隔线的间距却比期望中的要大。

6.jpg

​ 这时,我们可以手动设置Text的padding属性,来调整它与Divider的位置关系。同时我们美化了备选按钮的背景色的形状,再更换了功能按钮的样式(并利用Spacer视图帮助布局),最后还显式地设置了各区域的高度来适应不同的屏幕分辨率,最终的代码如下。

struct ContentView: View {
    let options = ["🐶", "🐞", "🐦", "🐟"]
    
    var body: some View {
        VStack {
            Text("1️⃣💎2️⃣❓")
                .font(.title)
                .padding(.bottom, -15)
                .frame(height: 30)
            
            Divider()
            
            HStack {
                ForEach(0..<options.count) { index in
                    Button(action: {}) { Text(self.options[index]) }
                        .background(Color.green)
                        .clipShape(Circle())
                }
            }
            .frame(height: 35)
            
            Divider()
            
            HStack {
                Spacer()
                Button(action: { }) { Text("🆘").font(.largeTitle) }
                    .buttonStyle(PlainButtonStyle())
                Spacer()
                Button(action: { }) { Text("⏭").font(.largeTitle) }
                    .buttonStyle(PlainButtonStyle())
                Spacer()
            }
            .frame(height: 35)
        }
    }
}

​ 上面的font、padding、frame、background、clipShape、buttonStyle这些都是View 的 Modifier(修饰器),它们在View声明之后通过方法调用的方式,作用于原来的View并生成一个新的版本。需要注意的是,SwiftUI 的 Modifier 所造成的布局影响是严格按照顺序执行的,比如上面Button的background和clipShape如果顺序换一下,将会看到方形的绿色背景按钮。所有可用的Modifier可以通过点击Xcode右上角的"+"按钮并切换到第二栏中查看:

16.jpg

​ 在38mm到44mm各个模拟器上都运行一下,现在都可以正常显示了。此外,我们也可以通过previewDevice修饰器指定显示预览的设备。

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
//        .previewDevice("Apple Watch Series 3 - 38mm")
        .previewDevice("Apple Watch Series 5 - 44mm")
    }
}

7.png9.jpg10.jpg8.jpg

​ 至此,我们仅使用不到40行代码,便初步绘制出之前规划的视图了。SwiftUI在布局方面的简洁与便利可见一斑。当然这只是一个静态视图,游戏还不能真正玩起来。

​ 下一章,我们将详细讲解SwiftUI中的数据流并完成我们的游戏逻辑,敬请期待。

参考内容:

  1. developer.apple.com/documentati…

  2. developer.apple.com/cn/xcode/sw…

  3. developer.apple.com/documentati…

  4. SwiftUI on watchOS:developer.apple.com/videos/play…