Skip to content

Creating a watchOS App

Willie edited this page Feb 10, 2020 · 6 revisions

创建 watchOS App

本教程为你提供一个将你已经学到的关于 SwiftUI 的知识应用到自己的产品上的机会,并且不费吹灰之力就可以将 Landmarks app 迁移到 watchOS 上。

首先,给项目添加一个 watchOS target,然后复制为 iOS app 中创建的共享数据和视图。当所有资源都准备好后,你就可以通过自定义 SwiftUI 视图,在 watchOS 上显示详细信息和列表视图。

下载项目文件并按照以下步骤操作,也可以打开已完成的项目自行浏览代码。

  • 预计完成时间:25 分钟
  • 项目文件:下载

1. 添加一个 watchOS Target

要创建 watchOS app,首先要给项目添加一个 watchOS target。

Xcode 会将 watchOS app 的组和文件,以及构建和运行 app 所需的 scheme 添加到项目中。

1.1 选择 File > New > Target,当模版表单显示后,选择 watchOS 标签,选择 Watch App for iOS App 模版后点击 Next

这个模版会给项目添加一个新的 watchOS app,将 iOS app 与它配对。

1.2 在表单的 Product Name 中输入 WatchLandmarks ,将 Language 设置成 Swift ,将 User Interface 设置成 SwiftUI 。勾选 Include Notification Scene 复选框,然后点击 Finish

1.3 Xcode 弹出提示,点击 Activate

这样选择 WatchLandmarks scheme 后,就可以构建和运行你的 watchOS app 了。

Whenever possible, create an independent watchOS app. Independent watchOS apps don’t require an iOS companion app.

1.4 在 WatchLandmarks ExtensionGeneral 标签中,勾选 Supports Running Without iOS App Installation 复选框。

尽可能创建一个独立的 watchOS app。独立的 watchOS app 不需要与 iOS app 配套使用。

2. 在多个 Target 中共享文件

设置了 watchOS target 后,你需要从 iOS target 中共享一些资源。比如重用 Landmark app 中的数据模型,一些资源文件,以及任何不需要修改就可以跨平台显示的视图。

2.1 在项目导航器中,按住 Command 键然后点击选中以下文件:LandmarkRow.swift , Landmark.swift , UserData.swift , Data.swift , Profile.swift , Hike.swift , CircleImage.swift

Landmark.swift , UserData.swift , Data.swift , Profile.swift , Hike.swift 定义了 app 的数据模型。虽然你不会用到所有这些模型,但是需要保证这些文件都编译到了 app 中。 LandmarkRow.swiftCircleImage.swift 是两个不修改就可以显示在 watchOS 中的视图。

2.2 打开 File 检查器,勾选 Target Membership 中的 WatchLandmarks Extension 复选框。

这会让你在上一步中选择的文件在 watchOS app 中可用。

2.3 打开项目导航器,在 Landmark 组中选择 Assets.xcassets ,然后在 File 检查器的 Target Membership 中将它添加到 WatchLandmarks target。

这与你上一步选择到 target 不一样, WatchLandmarks Extension target 包含你的 app 的代码,而 WatchLandmarks target 则管理你的故事板,图标和相关资源。

2.4 在项目导航器中,选择 Resources 文件夹中的所有文件,然后在 File 检查器的 Target Membership 中将它们添加到 WatchLandmarks Extension target。

3. 创建详情视图

现在 iOS target 的资源在 watch app 上已经可用了,你需要创建一个 watch 独有的视图来显示地标详情。为了测试这个视图,你需要给最大和最小 watch 尺寸创建自定义预览,然后给圆形视图做一些修改来适配 watch 显示。

3.1 在项目导航器中,单击 WatchLandmarks Extension 文件夹旁边的显示三角形来显示其内容,然后添加一个新 SwiftUI 视图,命名为 WatchLandmarkDetail

3.2 给 WatchLandmarkDetail 结构体添加 userDatalandmarklandmarkIndex 属性。

这些和你在 处理用户输入 中添加到 LandmarkDetail 结构体中的属性是一样的。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    //
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    //
    
    var body: some View {
        Text("Hello World!")
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        WatchLandmarkDetail()
    }
}

在上一步添加属性后,你会在 Xcode 中得到一个缺少参数的错误。为了修复这个错误,你需要二选一:提供属性的默认值,或传递参数来设置视图的属性。

3.3 在预览中,创建一个用户数据的实例,然后用它给 WatchLandmarkView 结构体的初始化传递一个地标对象。另外还需要将这个用户数据设置成视图的环境对象。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        Text("Hello World!")
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        //
        let userData = UserData()
        return WatchLandmarkDetail(landmark: userData.landmarks[0])
            .environmentObject(userData)
        //
    }
}

3.4 在 WatchLandmarkDetail.swift 中,从 body() 方法里返回一个 CircleImage 视图。

这就是你从 iOS 项目中复用 CircleImage 视图的地方。因为创建了可调整大小的图片, .scaledToFill() 的调用会让圆形的尺寸自动适配显示。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        //
        CircleImage(image: self.landmark.image.resizable())
            .scaledToFill()
        //
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        return WatchLandmarkDetail(landmark: userData.landmarks[0])
            .environmentObject(userData)
    }
}

3.5 给最大 (44mm) 和最小 (38mm) 表盘创建预览。

通过针对最大和最小表盘的测试,你可以看到你的 app 是如何缩放来适配显示的。与往常一样,你应该在所有支持的设备尺寸上测试用户界面。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        CircleImage(image: self.landmark.image.resizable())
            .scaledToFill()
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        //
        return Group {
            WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
                .previewDevice("Apple Watch Series 4 - 44mm")
            
            WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
                .previewDevice("Apple Watch Series 2 - 38mm")
        }
        //
    }
}

圆形图片重新调整大小来适配显示的高度。但不幸,这依然裁剪了圆形的宽度。为了修复这个裁剪问题,你需要把图片嵌入到一个 VStack 中,并且做一些额外的布局修改来让圆形图片适配任何 watch 的宽度。

3.6 把图片嵌入到一个 VStack 中,在图片下面显示地标的名字和它的信息。

如你所见,信息并没有完全适配 watch 的屏幕,但是你可以通过将这个 VStack 放在一个滚动视图中来修复这个问题。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        //
        VStack {
            CircleImage(image: self.landmark.image.resizable())
                .scaledToFill()
            
            Text(self.landmark.name)
                .font(.headline)
                .lineLimit(0)
            
            Toggle(isOn:
            $userData.landmarks[self.landmarkIndex].isFavorite) {
                Text("Favorite")
            }
            
            Divider()
            
            Text(self.landmark.park)
                .font(.caption)
                .bold()
                .lineLimit(0)
            
            Text(self.landmark.state)
                .font(.caption)
        }
        //
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        return Group {
            WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
                .previewDevice("Apple Watch Series 4 - 44mm")
            
            WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
                .previewDevice("Apple Watch Series 2 - 38mm")
        }
    }
}

3.7 将竖直 stack 包装中一个滚动视图中。

这让视图可以滚动,但是带来了另外一个问题:圆形图片展开到了全屏,并且调整了其他 UI 元素来匹配这个图片。你需要调整这个圆形图片的大小来让它和地标名字显示在屏幕上。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        //
        ScrollView {
            VStack {
                CircleImage(image: self.landmark.image.resizable())
                    .scaledToFill()
                
                Text(self.landmark.name)
                    .font(.headline)
                    .lineLimit(0)
                
                Toggle(isOn:
                $userData.landmarks[self.landmarkIndex].isFavorite) {
                    Text("Favorite")
                }
                
                Divider()
                
                Text(self.landmark.park)
                    .font(.caption)
                    .bold()
                    .lineLimit(0)
                
                Text(self.landmark.state)
                    .font(.caption)
            }
        }
        //
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        return Group {
            WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
                .previewDevice("Apple Watch Series 4 - 44mm")
            
            WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
                .previewDevice("Apple Watch Series 2 - 38mm")
        }
    }
}

3.8 把 scaleToFill() 改成 scaleToFit()

这会让圆形图片缩放来匹配显示的宽度。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        ScrollView {
            VStack {
                CircleImage(image: self.landmark.image.resizable())
                    //
                    .scaledToFit()
                    //
                
                Text(self.landmark.name)
                    .font(.headline)
                    .lineLimit(0)
                
                Toggle(isOn:
                $userData.landmarks[self.landmarkIndex].isFavorite) {
                    Text("Favorite")
                }
                
                Divider()
                
                Text(self.landmark.park)
                    .font(.caption)
                    .bold()
                    .lineLimit(0)
                
                Text(self.landmark.state)
                    .font(.caption)
            }
        }
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        return Group {
            WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
                .previewDevice("Apple Watch Series 4 - 44mm")
            
            WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
                .previewDevice("Apple Watch Series 2 - 38mm")
        }
    }
}

3.9 添加填充使地标名字在圆形图像下方可见。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        ScrollView {
            VStack {
                CircleImage(image: self.landmark.image.resizable())
                    .scaledToFit()
                
                Text(self.landmark.name)
                    .font(.headline)
                    .lineLimit(0)
                
                Toggle(isOn:
                $userData.landmarks[self.landmarkIndex].isFavorite) {
                    Text("Favorite")
                }
                
                Divider()
                
                Text(self.landmark.park)
                    .font(.caption)
                    .bold()
                    .lineLimit(0)
                
                Text(self.landmark.state)
                    .font(.caption)
            }
            //
            .padding(16)
            //
        }
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        return Group {
            WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
                .previewDevice("Apple Watch Series 4 - 44mm")
            
            WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
                .previewDevice("Apple Watch Series 2 - 38mm")
        }
    }
}

3.10 给返回按钮添加一个标题。

这里将返回按钮的文字设置成来 Landmarks ,但是在本教程后面的部分中,只有添加 LandmarksList 视图后,你才能看到返回按钮。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        ScrollView {
            VStack {
                CircleImage(image: self.landmark.image.resizable())
                    .scaledToFit()
                
                Text(self.landmark.name)
                    .font(.headline)
                    .lineLimit(0)
                
                Toggle(isOn:
                $userData.landmarks[self.landmarkIndex].isFavorite) {
                    Text("Favorite")
                }
                
                Divider()
                
                Text(self.landmark.park)
                    .font(.caption)
                    .bold()
                    .lineLimit(0)
                
                Text(self.landmark.state)
                    .font(.caption)
            }
            .padding(16)
        }
        //
        .navigationBarTitle("Landmarks")
        //
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        return Group {
            WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
                .previewDevice("Apple Watch Series 4 - 44mm")
            
            WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
                .previewDevice("Apple Watch Series 2 - 38mm")
        }
    }
}

4. 添加 watchOS 地图视图

现在你已经创建了基本的详情视图,可以添加地图视图来显示地标的位置了。与 CircleImage 不同,你不能仅仅重用 iOS app 的 MapView 。相对的,你需要创建一个 WKInterfaceObjectRepresentable 结构体来包装 WatchKit 地图。

4.1 给 WatchKit extension 添加一个自定义视图,命名为 WatchMapView

WatchMapView.swift

import SwiftUI

struct WatchMapView: View {
    var body: some View {
        Text("Hello World!")
    }
}

struct WatchMapView_Previews: PreviewProvider {
    static var previews: some View {
        WatchMapView()
    }
}

4.2 在 WatchMapView 结构体中,将 View 改成 WKInterfaceObjectRepresentable

在步骤 1 和 2 所示的代码之间来回滚动来查看区别。

WatchMapView.swift

import SwiftUI

//
struct WatchMapView: WKInterfaceObjectRepresentable {
//
    var body: some View {
        Text("Hello World!")
    }
}

struct WatchMapView_Previews: PreviewProvider {
    static var previews: some View {
        WatchMapView()
    }
}

Xcode 会显示编译错误,因为 WatchMapView 还没有遵循 WKInterfaceObjectRepresentable 属性。

4.3 删除 body() 方法,将其替换为 landmark 属性。

每当你创建一个地图视图你都需要给这个属性传递一个值。比如,你可以给预览传递一个地标实例。

WatchMapView.swift

import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
    //
    var landmark: Landmark
    //
}

struct WatchMapView_Previews: PreviewProvider {
    static var previews: some View {
        //
        WatchMapView(landmark: UserData().landmarks[0])
        //
    }
}

4.4 实现 WKInterfaceObjectRepresentable 协议的 makeWKInterfaceObject(context:) 方法。

这个方法会创建 WatchMapView 用来显示的 WatchKit 地图。

WatchMapView.swift

import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
    var landmark: Landmark
    
    //
    func makeWKInterfaceObject(context: WKInterfaceObjectRepresentableContext<WatchMapView>) -> WKInterfaceMap {
        return WKInterfaceMap()
    }
    //
}

struct WatchMapView_Previews: PreviewProvider {
    static var previews: some View {
        WatchMapView(landmark: UserData().landmarks[0])
    }
}

4.5 实现 WKInterfaceObjectRepresentable 协议的 updateWKInterfaceObject(_:, context:) 方法,根据地标坐标设置地图的范围。

现在项目可以成功构建而没有任何错误了。

WatchMapView.swift

import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
    var landmark: Landmark
    
    func makeWKInterfaceObject(context: WKInterfaceObjectRepresentableContext<WatchMapView>) -> WKInterfaceMap {
        return WKInterfaceMap()
    }
    
    //
    func updateWKInterfaceObject(_ map: WKInterfaceMap, context: WKInterfaceObjectRepresentableContext<WatchMapView>) {
        
        let span = MKCoordinateSpan(latitudeDelta: 0.02,
            longitudeDelta: 0.02)
        
        let region = MKCoordinateRegion(
            center: landmark.locationCoordinate,
            span: span)
        
        map.setRegion(region)
    }
    //
}

struct WatchMapView_Previews: PreviewProvider {
    static var previews: some View {
        WatchMapView(landmark: UserData().landmarks[0])
    }
}

4.6 选中 WatchLandmarkView.swift 文件,然后把地图视图添加到竖直 stack 的底部。

代码在地图视图之后添加了一个分割线。.scaledToFit().padding() 修饰符让地图的尺寸很好的匹配了屏幕。

WatchLandmarkDetail.swift

import SwiftUI

struct WatchLandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    
    var body: some View {
        ScrollView {
            VStack {
                CircleImage(image: self.landmark.image.resizable())
                    .scaledToFit()
                
                Text(self.landmark.name)
                    .font(.headline)
                    .lineLimit(0)
                
                Toggle(isOn:
                $userData.landmarks[self.landmarkIndex].isFavorite) {
                    Text("Favorite")
                }
                
                Divider()
                
                Text(self.landmark.park)
                    .font(.caption)
                    .bold()
                    .lineLimit(0)
                
                Text(self.landmark.state)
                    .font(.caption)
                
                //
                Divider()
                
                WatchMapView(landmark: self.landmark)
                    .scaledToFit()
                    .padding()
                //
            }
            .padding(16)
        }
        .navigationBarTitle("Landmarks")
    }
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        let userData = UserData()
        return Group {
            WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
                .previewDevice("Apple Watch Series 4 - 44mm")
            
            WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
                .previewDevice("Apple Watch Series 2 - 38mm")
        }
    }
}

5. 创建一个跨平台的列表视图

对于地标列表,你可以重用 iOS app 中的行视图,但是每个平台需要展示其自身的详情视图。为此,你需要将明确定义详情视图的 LandmarkList 视图转换为范型列表类型,

5.1 在工具栏中,选中 Landmarks scheme

Xcode 现在会构建和运行 app 的 iOS 版本。在把列表移动到 watchOS app 之前,你需要确认任何对 LandmarkList 视图对修改在 iOS app 中依然生效。

5.2 选中 LandmarkList.swift 然后修改类型的声明将其变成范型类型。

LandmarksList.swift

import SwiftUI

//
struct LandmarkList<DetailView: View>: View {
//
    @EnvironmentObject private var userData: UserData
    
    var body: some View {
        List {
            Toggle(isOn: $userData.showFavoritesOnly) {
                Text("Show Favorites Only")
            }
            
            ForEach(userData.landmarks) { landmark in
                if !self.userData.showFavoritesOnly || landmark.isFavorite {
                    NavigationLink(
                    destination: LandmarkDetail(landmark: landmark).environmentObject(self.userData)) {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
        }
        .navigationBarTitle(Text("Landmarks"))
    }
}

struct LandmarksList_Previews: PreviewProvider {
    static var previews: some View {
        ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
            LandmarkList()
                .previewDevice(PreviewDevice(rawValue: deviceName))
                .previewDisplayName(deviceName)
        }
        .environmentObject(UserData())
    }
}

添加范型声明会让你无论何时创建一个 LandmarkList 结构体实例时都会出现 Generic parameter could not be inferred 错误。接下来的几步会修复这些错误。

5.3 添加一个创建详情视图的闭包属性。

LandmarksList.swift

import SwiftUI

struct LandmarkList<DetailView: View>: View {
    @EnvironmentObject private var userData: UserData
    
    //
    let detailViewProducer: (Landmark) -> DetailView
    //
    
    var body: some View {
        List {
            Toggle(isOn: $userData.showFavoritesOnly) {
                Text("Show Favorites Only")
            }
            
            ForEach(userData.landmarks) { landmark in
                if !self.userData.showFavoritesOnly || landmark.isFavorite {
                    NavigationLink(
                    destination: LandmarkDetail(landmark: landmark).environmentObject(self.userData)) {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
        }
        .navigationBarTitle(Text("Landmarks"))
    }
}

struct LandmarksList_Previews: PreviewProvider {
    static var previews: some View {
        ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
            LandmarkList()
                .previewDevice(PreviewDevice(rawValue: deviceName))
                .previewDisplayName(deviceName)
        }
        .environmentObject(UserData())
    }
}

5.4 使用 detailViewProducer 属性给地标创建详情视图。

LandmarksList.swift

import SwiftUI

struct LandmarkList<DetailView: View>: View {
    @EnvironmentObject private var userData: UserData
    
    let detailViewProducer: (Landmark) -> DetailView
    
    var body: some View {
        List {
            Toggle(isOn: $userData.showFavoritesOnly) {
                Text("Show Favorites Only")
            }
            
            ForEach(userData.landmarks) { landmark in
                if !self.userData.showFavoritesOnly || landmark.isFavorite {
                    NavigationLink(
                    //
                    destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
                    //
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
        }
        .navigationBarTitle(Text("Landmarks"))
    }
}

struct LandmarksList_Previews: PreviewProvider {
    static var previews: some View {
        ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
            LandmarkList()
                .previewDevice(PreviewDevice(rawValue: deviceName))
                .previewDisplayName(deviceName)
        }
        .environmentObject(UserData())
    }
}

当你创建了一个 LandmarkList 的实例后,你还需要提供一个给地标创建详情视图的闭包。

5.5 选中 Home.swift ,在 CategoryHome 结构体的 body() 方法中添加一个闭包来创建 LandmarkDetail 视图。

Xcode 会根据闭包的返回类型来推断 LandmarkList 结构体的类型。

Home.swift

import SwiftUI

struct CategoryHome: View {
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarkData,
            by: { $0.category.rawValue }
        )
    }

    var featured: [Landmark] {
        landmarkData.filter { $0.isFeatured }
    }
    
    @State var showingProfile = false
    
    var profileButton: some View {
        Button(action: { self.showingProfile.toggle() }) {
            Image(systemName: "person.crop.circle")
                .imageScale(.large)
                .accessibility(label: Text("User Profile"))
                .padding()
        }
    }

    var body: some View {
        NavigationView {
            List {
                FeaturedLandmarks(landmarks: featured)
                    .scaledToFill()
                    .frame(height: CGFloat(200))
                    .clipped()
                    .listRowInsets(EdgeInsets())
                
                ForEach(categories.keys.sorted(), id: \.self) { key in
                    CategoryRow(categoryName: key, items: self.categories[key]!)
                }
                .listRowInsets(EdgeInsets())
                
                //
                NavigationLink(destination: LandmarkList { LandmarkDetail(landmark: $0) }) {
                //
                    Text("See All")
                }
            }
            .navigationBarTitle(Text("Featured"))
            .navigationBarItems(trailing: profileButton)
            .sheet(isPresented: $showingProfile) {
                ProfileHost()
            }
        }
    }
}

struct FeaturedLandmarks: View {
    var landmarks: [Landmark]
    var body: some View {
        landmarks[0].image.resizable()
    }
}

// swiftlint:disable type_name
struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
            .environmentObject(UserData())
    }
}

5.6 在 LandmarkList.swift 中,给预览添加类似的代码。

在这里,你需要使用条件编译来根据 Xcode 的当前 scheme 来定义详细视图。Landmark app 现在可以按预期在 iOS 上构建并运行了。

LandmarksList.swift

import SwiftUI

struct LandmarkList<DetailView: View>: View {
    @EnvironmentObject private var userData: UserData
    
    let detailViewProducer: (Landmark) -> DetailView
    
    var body: some View {
        List {
            Toggle(isOn: $userData.showFavoritesOnly) {
                Text("Show Favorites Only")
            }
            
            ForEach(userData.landmarks) { landmark in
                if !self.userData.showFavoritesOnly || landmark.isFavorite {
                    NavigationLink(
                    destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
        }
        .navigationBarTitle(Text("Landmarks"))
    }
}

#if os(watchOS)
typealias PreviewDetailView = WatchLandmarkDetail
#else
typealias PreviewDetailView = LandmarkDetail
#endif

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList { PreviewDetailView(landmark: $0) }
            .environmentObject(UserData())
    }
}

6. 添加地标列表

现在你已经更新了 LandmarksList 视图让其能在两个平台上都工作,可以将它添加到 watchOS app 中了。

6.1 在文件检查器中,把 LandmarksList.swift 添加到 WatchLandmarks Extension target 中。

你现在可以中你的 watchOS app 的代码中使用 LandmarkList 视图了。

6.2 在工具栏中,将 scheme 改为 Watch Landmarks

6.3 打开 LandmarkList.swift 的同时,恢复预览。

现在预览会显示 watchOS 的列表视图。

LandmarksList.swift

import SwiftUI

struct LandmarkList<DetailView: View>: View {
    @EnvironmentObject private var userData: UserData
    
    let detailViewProducer: (Landmark) -> DetailView
    
    var body: some View {
        List {
            Toggle(isOn: $userData.showFavoritesOnly) {
                Text("Show Favorites Only")
            }
            
            ForEach(userData.landmarks) { landmark in
                if !self.userData.showFavoritesOnly || landmark.isFavorite {
                    NavigationLink(
                    destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
        }
        .navigationBarTitle(Text("Landmarks"))
    }
}

#if os(watchOS)
typealias PreviewDetailView = WatchLandmarkDetail
#else
typealias PreviewDetailView = LandmarkDetail
#endif

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList { PreviewDetailView(landmark: $0) }
            .environmentObject(UserData())
    }
}

watchOS app 的根是显示默认 Hello World! 消息的 ContentView

6.4 修改 ContentView 来让它显示列表视图。

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        LandmarkList { WatchLandmarkDetail(landmark: $0) }
            .environmentObject(UserData())
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList { WatchLandmarkDetail(landmark: $0) }
            .environmentObject(UserData())
    }
}

6.5 在模拟器中构建并运行 watchOS app。

通过在列表中滚动地标,点击视图的地标详情,并将其标记为收藏来测试 watchOS app 的行为。点击返回按钮回到列表,然后打开 Favorite 开关来查看收藏的地标。

7. 创建自定义通知界面

你的 Landmarks 的 watchOS 版本差不多要完成了。在这最后一节,你需要创建一个通知界面来显示地标信息,当你在某一个收藏的地标附近时,就会收到这个通知。

注意

这一节只包含当你收到通知后如何显示,并不描述如何设置或发送通知。

7.1 打开 NotificationView.swift 并创建一个视图来显示地标的信息,标题和消息。

因为任何通知的值都可以为 nil,所以预览会显示通知的两个版本。第一个仅显示当没有数据时的默认值,第二个显示你提供的标题,信息,和位置。

NotificationView.swift

import SwiftUI

struct NotificationView: View {
    
    //
    let title: String?
    let message: String?
    let landmark: Landmark?
    
    init(title: String? = nil,
         message: String? = nil,
         landmark: Landmark? = nil) {
        self.title = title
        self.message = message
        self.landmark = landmark
    }
    //
    
    var body: some View {
        //
        VStack {
            
            if landmark != nil {
                CircleImage(image: landmark!.image.resizable())
                    .scaledToFit()
            }
            
            Text(title ?? "Unknown Landmark")
                .font(.headline)
                .lineLimit(0)
            
            Divider()
            
            Text(message ?? "You are within 5 miles of one of your favorite landmarks.")
                .font(.caption)
                .lineLimit(0)
        }
        //
    }
}

struct NotificationView_Previews: PreviewProvider {
    //
    //
    static var previews: some View {
        //
        Group {
            NotificationView()
            
            NotificationView(title: "Turtle Rock",
                             message: "You are within 5 miles of Turtle Rock.",
                             landmark: UserData().landmarks[0])
        }
        .previewLayout(.sizeThatFits)
        //
    }
}

7.2 打开 NotificationController 并添加 landmarktitle ,和 message 属性。

这些数据存储发送进来的通知的相关值。

NotificationController.swift

import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
    //
    var landmark: Landmark?
    var title: String?
    var message: String?
    //
    
    override var body: NotificationView {
        NotificationView()
    }
    
    override func willActivate() {
        // This method is called when watch view controller is about to be visible to user
        super.willActivate()
    }
    
    override func didDeactivate() {
        // This method is called when watch view controller is no longer visible
        super.didDeactivate()
    }
    
    override func didReceive(_ notification: UNNotification) {
        // This method is called when a notification needs to be presented.
        // Implement it if you use a dynamic notification interface.
        // Populate your dynamic notification interface as quickly as possible.
    }
}

7.3 更新 body() 方法来使用这些属性。

此方法会实例化你之前创建的通知视图。

NotificationController.swift

import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
    var landmark: Landmark?
    var title: String?
    var message: String?
    
    override var body: NotificationView {
        //
        NotificationView(title: title,
            message: message,
            landmark: landmark)
        //
    }
    
    override func willActivate() {
        // This method is called when watch view controller is about to be visible to user
        super.willActivate()
    }
    
    override func didDeactivate() {
        // This method is called when watch view controller is no longer visible
        super.didDeactivate()
    }
    
    override func didReceive(_ notification: UNNotification) {
        // This method is called when a notification needs to be presented.
        // Implement it if you use a dynamic notification interface.
        // Populate your dynamic notification interface as quickly as possible.
    }
}

7.4 定义 LandmarkIndexKey

你需要使用这个键从通知中提取额地标的索引。

NotificationController.swift

import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
    var landmark: Landmark?
    var title: String?
    var message: String?
    
    //
    let landmarkIndexKey = "landmarkIndex"
    //
    
    override var body: NotificationView {
        NotificationView(title: title,
            message: message,
            landmark: landmark)
    }
    
    override func willActivate() {
        // This method is called when watch view controller is about to be visible to user
        super.willActivate()
    }
    
    override func didDeactivate() {
        // This method is called when watch view controller is no longer visible
        super.didDeactivate()
    }
    
    override func didReceive(_ notification: UNNotification) {
        // This method is called when a notification needs to be presented.
        // Implement it if you use a dynamic notification interface.
        // Populate your dynamic notification interface as quickly as possible.
    }
}

7.5 更新 didReceive(_:) 方法从推送中解析数据。

这个方法会更新控制器的属性。调用这个方法后,系统会使控制器的 body 属性无效,从而更新导航视图。然后系统会在 Apple Watch 上显示通知。

NotificationController.swift

import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
    var landmark: Landmark?
    var title: String?
    var message: String?
    
    let landmarkIndexKey = "landmarkIndex"
    
    override var body: NotificationView {
        NotificationView(title: title,
            message: message,
            landmark: landmark)
    }
    
    override func willActivate() {
        // This method is called when watch view controller is about to be visible to user
        super.willActivate()
    }
    
    override func didDeactivate() {
        // This method is called when watch view controller is no longer visible
        super.didDeactivate()
    }
    
    override func didReceive(_ notification: UNNotification) {
        //
        let userData = UserData()
        
        let notificationData =
            notification.request.content.userInfo as? [String: Any]
        
        let aps = notificationData?["aps"] as? [String: Any]
        let alert = aps?["alert"] as? [String: Any]
        
        title = alert?["title"] as? String
        message = alert?["body"] as? String
        
        if let index = notificationData?[landmarkIndexKey] as? Int {
            landmark = userData.landmarks[index]
        }
        //
    }
}

当 Apple Watch 收到通知后,它会创建通知分类关联当通知控制器。你需要打开并编辑 app 当故事板来给你当通知控制器设置分类。

7.6 这项目导航器中,选中 Watch Landmarks 文件夹,打开 Interface 故事板。在故事板中选择指向静态通知界面控制器的箭头。

7.7 在 Attributes 检查器中,将 Notification CategoryName 设置成 LandmarkNear

配置测试载荷来使用 LandmarkNear 分类,并传递通知控制器期望的数据。

7.8 选择 PushNotificationPayload.apns 文件,然后更新 titlebodycategorylandmarkIndex 属性。确认将分类设置成了 LandmarkNear 。另外,删除在教程中任何没有用到的键,比如 subtitle WatchKit Simulator Actions ,以及 customKey

载荷文件会模拟从服务发来的远程通知数据。

PushNotificationPayload.apns

{
    "aps": {
        "alert": {
            "body": "You are within 5 miles of Silver Salmon Creek."
            "title": "Silver Salmon Creek",
        },
        "category": "LandmarkNear",
        "thread-id": "5280"
    },
    
    "landmarkIndex": 1
}

7.9 选择 Landmarks-Watch (Notification) scheme,然后构建并运行你的 app。

当你第一次运行通知 scheme,系统会请求发送通知的权限。选择 Allow 。之后模拟器会显示一个可滚动的通知,它包括:用于标记 Landmarks app 为发送方的框格,通知视图以及用于通知操作的按钮。