Swift Playgrounds App 3 - 引导任务
晴猫编程
2022年04月23日 14:49
收录于文集
共14篇

Xcode 版本:13.2.1(Swift 5.5.2)

Swift Playgrounds(iPad)版本:4.0.2


上一篇文章实现的的引导演示能够显示文字描述,同时跳转到对应的文件并指示对应的代码,除了这种引导方式,我们在官方的 “开始使用 App” 项目中还能看到通过任务的方式来引导用户,户根据提示完成任务,才能继续接下来的引导内容。

引导任务

这篇文章就介绍一下这种引导方式的实现,但是在开始之前,还是要和上一篇文章一样提醒大家:

引导内容仅在 Swift Playgrounds 中编辑 App 项目时有效,并且因为 Swift Playgrounds App 不像 Playground Book 那样可以还原,所以引导过程是不可逆的,比如完成引导任务后会出现一个绿色的打勾,这个状态永远不会消除。

Swift Playgrounds App 被设计成能够在 iOS 、iPadOS 上运行并允许上架的完整程序格式,我们更应该在 App 内部实现对用户的引导,本文提到的“引导内容”只能让项目在 Swift Playgrounds 的编辑体验上起到锦上添花的效果。

本文所描述的操作演示录屏:

好了,我们开始吧!

初步设置

在项目中创建 Guide 目录以支持引导功能,这需要改变 App 项目的目录结构,详细步骤请看上一篇文章,设置好以后项目的目录结构如下图所示(本例使用由 Xcode 创建的空白 Swift Playgrounds App 项目):

带有引导内容的目录结构

接着在 Finder 中打开项目的 Package.swift 文件 ,将 .executableTarget 的 path 参数设置为 App,即当前程序的路径:

代码块
Swift
自动换行
复制代码
// /Package.swift

    targets: [
        .executableTarget(
            name: "AppModule",
            path: "App"
        )
    ]
复制成功

此时将项目 AirDrop 到 iPad 上运行,如果 Swift Playgrounds 将 Guide 目录隐藏,那么结构的设置就是正确的。

接下来,回到 Xcode,在 Guide.tutorial 文件中设置必要的初始引导结构,同时在第一个 Setp 的 ContentAndMedia 里添加一些内容(本例内容只做中文内容,其他语言的实现方式是一样的,添加对应的本地化文件并编辑即可):

代码块
Swift
自动换行
复制代码
// /Guide/Guide.tutorial:

@GuideBook(title: "MyApp", icon: title.png, background: titleBackground.png, firstFile: ContentView.swift) {
    @Guide {
        @Step(title: "Add Some Views") {
            @ContentAndMedia {
               让我们在 ContentView 里添加一点视图吧!             
            }
        }
    }
}
复制成功

下面在 Localizable.strings 中设置对应的内容:

代码块
Swift
自动换行
复制代码
// /Guide/Resources/zh_CN.lproj/Localizable.strings:

"GuideBook..Guide0..StepAdd Some Views..title" = "添加一些视图";
"GuideBook..Guide0..StepAdd Some Views..LearningCenterContent..Paragraph0" = "让我们在 ContentView 里添加一点视图吧!";
复制成功

此时在 Swift Playgroudns 中打开项目可以看到引导内容显示:

引导页顶部的描述内容

目前为止我们所实现的都是上一篇文章做过的内容,接下来我们要在引导页中添加新的内容——引导任务。

代码准备

我们希望用户能够根据引导,在 ContentView 里添加今年 WWDC 的图片视图和标题,所以需要事先准备一下视图。就像下面图片展示的一样,新建一个 WWDCViews.swift 文件,在里面实现两个视图,同时添加一张 WWDC 的图片。

编写 WWDC 的图片和标题视图

WWDCViews.swift 的代码如下:

代码块
Swift
自动换行
复制代码
// /App/WWDCViews.swift:

import SwiftUI

struct WWDCImage: View {
    var body: some View {
        Image("wwdc22_image")
            .resizable()
            .scaledToFit()
    }
}

struct WWDCTitle: View {
    var body: some View {
        Text("WWDC22")
            .font(.largeTitle)
    }
}

struct WWDCViews_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            WWDCImage()
            WWDCTitle()
        }
    }
}
复制成功

添加引导任务结构和内容

下面我们展示一个简单的引导任务,让用户在 ContentView 里面添加一个 WWDCImage 视图。

先添加引导结构。

代码块
Swift
自动换行
复制代码
// /Guide/Guide.tutorial:

@GuideBook(title: "MyApp", icon: title.png, background: titleBackground.png, firstFile: ContentView.swift) {
    @Guide {
        @Step(title: "Add Some Views") {
            @ContentAndMedia {
               让我们在 ContentView 里添加一点视图吧!             
            }
            @Task(type: addCode, title: "Add WWDC Image", id: "wwdcImage", file: ContentView.swift)  {
                试着添加一个 WWDC22 的图片吧!
                @Page(id: "showContentViewBody", title: "page1") {
                    ContentView 的视图内容在 body 里设置。
                }
                @Page(id: "addWWDCImage", title: "page2", isAddable: true) {
                    在 VStack 里找个位置加上 WWDC 的图片吧,代码如下:
                    
                    ```
                    WWDCImage()
                    ```
                }
            }   
        }
    }
}
复制成功

新添加的 Task 的 type 属性变成了 addCode,表示这是一个引导任务,要完成以后才能继续后面的引导内容。

第二个 Page 的 isAddable 参数用来确定展示的代码旁边有没有一个 “添加” 按钮让用户一键添加到标记位置,这个参数默认是 true,就是说如果希望添加按钮存在的话写不写都可以,如果不希望存在就必须要写,传入 false。

设置引导内容。

代码块
Swift
自动换行
复制代码
// /Guide/Resources/zh_CN.lproj/Localizable.strings:

"GuideBook..Guide0..StepAdd Some Views..title" = "添加一些视图";
"GuideBook..Guide0..StepAdd Some Views..LearningCenterContent..Paragraph0" = "让我们在 ContentView 里添加一点视图吧!";
"GuideBook..Guide0..StepAdd Some Views..TaskwwdcImage..Paragraph1" = "试着添加一个 WWDC22 的图片吧!";
"GuideBook..Guide0..StepAdd Some Views..TaskwwdcImage..title" =  "添加 WWDC 图片";
"GuideBook..Guide0..StepAdd Some Views..TaskwwdcImage..Pagepage1..Paragraph2" = "ContentView 的视图内容在 body 里设置。";
"GuideBook..Guide0..StepAdd Some Views..TaskwwdcImage..Pagepage2..Paragraph3" = "在 VStack 里找个位置加上 WWDC 的图片吧,代码如下:";
复制成功

在 ContentView.swift 添加第一个 Page 的代码指示和输入标记。

代码块
Swift
自动换行
复制代码
// /App/ContentView.swift:

import SwiftUI

struct ContentView: View {
    /*#-code-walkthrough(showContentViewBody)*/
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
            //#-learning-task(wwdcImage)
        }
    }
    /*#-code-walkthrough(showContentViewBody)*/
}
复制成功

//#-learning-task(wwdcImage) 的位置在 Image 和 Text 的下面,意味着用户点击 “添加” 按钮,提供的代码就会自动输入到这个位置。

此时在 Swift Playgrounds 中运行项目,看到的界面如下。

添加引导任务

我们看到在添加 addCode 类型的 Task 以后,引导页面上显示了一个 “任务” 标题,如果引导结构中没有 addCode 类型的 Task,这个标题的内容会是 “演示” 而不是 “任务”。

在引导任务的最后一个 Page ,“下一页” 按钮是无法点击的,我们需要告诉 Swift Playgrounds 用户通过了任务,才能够让这个按钮可点击,体验接下来的引导内容,这个 “告诉” 的过程比较复杂,下面就来详细讲一下。

任务完成判断

添加对 Guide 的依赖

要判断任务是否完成,需要在 Guide 文件中添加相关的代码,同时在 Package.swift 中给应用添加对 Guide 的依赖。

我们先在 Package.swift 中进行编辑,修改后的文件内容如下:

代码块
Swift
自动换行
复制代码
// / Package.swift

import PackageDescription
import AppleProductTypes

let package = Package(
    name: "MyApp",
    defaultLocalization: "en",
    platforms: [
        .iOS("15.2")
    ],
    products: [
        .iOSApplication(
            name: "MyApp",
            targets: ["AppModule"],
            bundleIdentifier: "cn.edu.sbs.iosclub.MyApp",
            displayVersion: "1.0",
            bundleVersion: "1",
            iconAssetName: "AppIcon",
            accentColorAssetName: "AccentColor",
            supportedDeviceFamilies: [
                .pad,
                .phone
            ],
            supportedInterfaceOrientations: [
                .portrait,
                .landscapeRight,
                .landscapeLeft,
                .portraitUpsideDown(.when(deviceFamilies: [.pad]))
            ]
        )
    ],
    targets: [
        .executableTarget(
            name: "AppModule",
            dependencies: ["Guide"],
            path: "App"
        ),
        .target(
            name: "Guide",
            path: "Guide",
            resources: [
            .process("Guide.tutorial"),
            ]
        )
    ]
)
复制成功

有三处修改:

  • Package 里添加了一个 defaultLocalization 参数的设置,没有这个设置项目将无法运行。

  • 在 Package 的 targets 参数里加上一个新的 target,指向 Guide 目录,同时 resources 参数里要包含 /Guide 目录下的一些文件,如果不包含,项目打开后会弹出警告,看到警告以后根据情况添加即可。

  • executableTarget 的 path 参数上面添加一个 dependencies 参数,让应用依赖 Guide,如果把 dependencies 设置在 path 之后,程序会报错。

连接任务和判断函数

在 /Guide/Resources 目录里添加一个名为 Assessment.swift 的文件,这个文件里的代码主要是用来判断任务是否完成的函数,内容如下:

代码块
Swift
自动换行
复制代码
//  /Guide/Resources/ Assessment.swift

import Foundation

public var additionViews: [String] = []

func wwdcImage() -> Bool {
    print(additionViews)
    return false
}
复制成功

在 Guide 内声明了一个公开的全局字符串数组和一个全局函数,这个函数必须要返回一个 Bool 值结果 ,我们先让它始终返回 false,并且为了接下来观察方便,让它把数组的内容打印出来。

接下来再在 /Guide/Resources 里创建一个名为 Connection.swift 的文件,这个文件的能够将 Assessment.swift 中的函数和 Swift  Playgrounds 中对应用代码的编辑动作连接起来。

这个文件的代码从官方项目 “开始使用 App” 的 /Guide/Resources/Connection.swift 目录中复制,内容如下:

代码块
Swift
自动换行
复制代码
//  /Guide/Resources/Connection.swift

import Foundation

let taskFunctionByID = ["wwdcImage": wwdcImage]


@_cdecl("Assessment") public dynamic func Assessment(_ payload: [String: Any], _ completion: @escaping ([String: Any]?, NSError?) -> Void) -> Void {
    if let taskIDData = payload["TaskID"] as? Data,
       let taskID = String(data: taskIDData, encoding: .utf8) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {

            var completed = false
            
            if let taskFunction = taskFunctionByID[taskID] {
                completed = taskFunction()
            } else {
                print("Error: Assessment function for taskID '\(taskID)' not found.")
            }
            
            completion([taskID: Data(completed.description.utf8)], nil)
        }
    }
}
复制成功

字典 taskFunctionByID 中的 key 表示 Guide.tutorial 里设置的 Task id,value 是要和 Task 关联的判断函数。

函数 Assessment 能够让用户在进入某个引导任务后,编辑代码时不断运行对应的判断函数,如果直到判断函数返回 true 表示任务成功

下面我们进入到  /App/WWDCViews.swift ,让每个 WWDCImage 和 WWDCTitle 显示时都在 additionViews 里添加内容(需要 import Guide 才能访问到 additionViews)

代码块
Swift
自动换行
复制代码
// /App/WWDCViews.swift

import SwiftUI
import Guide

struct WWDCImage: View {
    var body: some View {
        Image("wwdc22_image")
            .resizable()
            .scaledToFit()
            .onAppear {
                additionViews.append("WWDCImage")
            }
    }
}

struct WWDCTitle: View {
    var body: some View {
        Text("WWDC22")
            .font(.largeTitle)
            .onAppear {
                additionViews.append("WWDCImage")
            }
    }
}

struct WWDCViews_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            WWDCImage()
            WWDCTitle()
        }
    }
}
复制成功

随后我们在 Swift Playgrounds 里打开项目,进入引导任务后在 ContentView 试着编辑一下内容,我们可以通过控制台发现每次预览加载完毕都会触发和引导任务关联的判断函数,如果所使用的 iPad 性能足够强,基本上判断函数是会在编辑的过程不断运行的。

判断函数在编辑时不断运行

隐藏代码和完成通知

下面我们继续在 Guide.tutorial 里添加新的引导内容。

代码块
Swift
自动换行
复制代码
//  /Guide/Guide.tutorial 
......
@Task(type: addCode, title: "Add WWDC Title", id: "wwdcTitle", file: ContentView.swift)  {
                再添加一个 WWDC22 的标题吧!
                @Page(id: "addWWDCTitle", title: "page0") {
                    使用 WWDCTitle() 将 WWDC 的标题添加到合适的位置吧,如果没有思路,可以点击下一页看看解决方案。
                }
                @Page(id: "addWWDCTitleAnswer", title: "page1", isAddable: false, isHidden: true) {
                    ```
                    VStack {
                        Image(systemName: "globe")
                            .imageScale(.large)
                            .foregroundColor(.accentColor)
                        Text("Hello, world!")
                        WWDCImage()
                        WWDCTitle()
                        
                    }
                    ```
                }
            }
            @SuccessMessage(message: "") { 
                恭喜你,成功在 ContentView 里添加了 WWDC22 的图片和标题,希望你能够做出优秀的作品!
            }
......
复制成功

有两个新内容:

1. 第二个 Page 里有一个 isHidden 参数,如果 Page 里显示代码,那么当 isHidden 为 true 时 Page 会将代码隐藏,用户可以点击 “显示解决方案” 按钮来显示代码。

隐藏代码的 Page

2. SuccessMessage 一般放在当前引导页的最后一个 Task 后面,用于展示完成任务后给用户展示的内容。

SuccessMessage

在本地化文件中设置引导内容:

代码块
Swift
自动换行
复制代码
// /Guide/Resources/zh_CN.lproj/Localizable.strings:

......
"GuideBook..Guide0..StepAdd Some Views..TaskwwdcTitle..Paragraph4" = "再添加一个 WWDC22 的标题吧!";
"GuideBook..Guide0..StepAdd Some Views..TaskwwdcTitle..title" = "添加 WWDC 标题";
"GuideBook..Guide0..StepAdd Some Views..TaskwwdcTitle..Pagepage0..Paragraph5" = "使用 WWDCTitle() 将 WWDC 的标题添加到合适的位置吧,如果没有思路,可以点击下一页看看解决方案。";
"GuideBook..Guide0..StepAdd Some Views..SuccessMessage..Paragraph6" = "恭喜你,成功在 ContentView 里添加了 WWDC22 的图片和标题,希望你能够做出优秀的作品!";
复制成功

设置关联函数:

代码块
Swift
自动换行
复制代码
// /Guide/Resources/Assessment.swift:

......
func wwdcTitle() -> Bool {
    var count = 0;
    additionViews.forEach {
        count += ($0 == "WWDCTitle" ? 1 : 0)
    }
    return (count > 0 ? true : false)
}
复制成功

代码块
Swift
自动换行
复制代码
// /Guide/Resources/Connection.swift:
......
let taskFunctionByID = [
    "wwdcImage": wwdcImage,
    "wwdcTitle": wwdcTitle
]
......
复制成功

代码块
Swift
自动换行
复制代码
// /App/ContentView.swift:

import SwiftUI

struct ContentView: View {
    /*#-code-walkthrough(showContentViewBody)*/
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
            //#-learning-task(wwdcImage)
            //#-learning-task(wwdcTitle)
        }
    }
    /*#-code-walkthrough(showContentViewBody)*/
}
复制成功

此时用 Swift Playrounds 打开,就可以体验新的任务了,我们会发现还没有进行到的 Task 是无法点击的,要把之前的 Task 通过才行。

需要完成前面的 Task 解锁

在需要的引导内容都设置好以后,不要忘记编译 Localizable.strings 文件进行替换,同时备份一份未编译的 Localizable.strings 文件一以便将来修改。

总结

引导任务的实现关键在于判断逻辑的设计,需要找到一个 App 和 Guide 都能够访问、修改的对象作为媒介,本例使用的是 additionViews 数组,我们可以根据情况来设计这个媒介。

官方的项目中,只有 “开始使用 App” 用到了引导任务,我们按照任务的指示添加对应的 SwiftUI 结构来完成任务,判断的逻辑就是每次编辑都通过 UIKit 遍历预览中所有的视图结构,然后通过 UIAccessibilityTraits 和 UIAccessibilityContainerType 获取容器、控件的信息描述,根据描述结果来判断任务是否完成。详细内容可以在 “开始使用 App” 的 /Guide/Resources/Assessment.swift 查看。

以上就是本文全部内容,如有疏漏或错误之处,欢迎私信交流 。