漫步SwiftUI数字长廊(上)

译自 Random Lessons from the SwiftUI Digital Lounge

建议横屏阅读代码

本文之前已发布过一次,但那个版本的翻译多有纰漏和文法不通的地方。

这版重新整理了翻译,并且启用了新的排版风格。

今年的 WWDC 有一大亮点是 Digital Lounge 的引入。遗憾的是,好东西往往短暂,须臾之间就已落幕。

很多人因为种种原因无法参与这个频道,这其中包括没有时间,注册失败等等。我个人由于日常安排的缘故也没能跟进它的节奏。频道已经下线,不过我做了笔记。幸运的是,主持人表示频道中被问及的问题和答复可以与大家分享。

鉴于此,我分类并整理了 SwiftUI Digital Lounge 的问题和答复,还对其中的一部分添加了评论。为了方便浏览,我把许多问题的描述都简化到一两行以内的句子,同时也保留了原问题的记录。

如果一个问题与 WWDC 21 介绍的新特性有关,我会给它加上 ♦️ 标记。

对那些我特别感兴趣的问题,我还使用了 ⭐️ 标记,基于下面几个理由:

  1. Apple 的答复提供了全新的信息
  2. Apple 的答复证实了某些我们怀疑了很久的东西,并且这些东西没有文档
  3. Apple 的答复确认了我们已经在社区中使用的模式,而这些模式目前还没有被 Apple 官方使用
  4. 问题涉及了某些很少被提及但是值得注意的东西
  5. Apple 的答复提供了某些窥探框架内部工作机制的视角
  6. 或者只是某个会让我停下来思考的问题 ?

介绍完毕,让我们开始这些问题和答复吧。

译者注:鉴于篇幅过长,译文将分为三篇公众号文章发布。


动画

视图从一个位置到另一个位置的过程可以动画化吗?比如改变父级视图的场景??

当然,你可以去看一看 matchedGeometryEffect() ,这是去年的 SwiftUI 2.0 版本引入的 API,链接:developer.apple.com/documentati…🙂

译者:关于 matchGeometryEffect modifier,作者也曾写过一个系列文章详细解释过。

文本字体尺寸的变化可以动画化吗?♦️ ?

完整问题: 我们现在有办法可以动画化文本字号的变化吗?因为尺寸和背景颜色现在能够平滑过渡,但是字体会直接跳变,中间没有插值过程。

答复: 这个反馈很棒,我们会对这个需求进行评审。

如果你对现有的解决方案有兴趣,可以去参考一下 Fruta 范例工程里的 AnimatableFontModifier,那里边用了显式的字号作为可动画数据,使用场景是主视图和详情视图上的配料卡片的平滑过渡效果。这个实现对于 Fruta 来说已经够用,毕竟用例有限。链接:developer.apple.com/documentati…

译者:作者对于进阶动画技术也有一系列博文,其中有一篇特别提到了 AnimatableModifier ( 「Swift花园」也发布过该博客的译文 —— “SwiftUI 动画进阶 – part3: AnimatableModifier”,感兴趣的读者可以查阅)

wave-text

可以同时使用多个以 animation 方式调度的 TimelineView 吗? ♦️⭐️

**完整问题:**同时使用多个以 animation 频率调度的 TimelineView 这种做法安全吗?或者这样做跟实例化多个 CADisplayLink 是等效的?我是考虑到 CADisplayLink 有尽量复用的最佳实践。

**答复:**当然!为了让你的界面实现你想要的行为,你可以使用任意多的 TimelineView。但要注意,不要让每次时间线内容更新时都有过多的差异。


AppKit/UIKit

我们要如何访问 SwiftUI 之下的 AppKit/UIKit API 呢??

**原问题:**有的时候只有通过访问底层的 UI/NSViewController 或者 UI/NSWindow 才能实现某些事情,这就要求我们采用一些窥探 SwiftUI 表层之下的东西的”花招“。SwiftUI 能提供一些机制让这类操作变得可配置或者更清晰吗?

尽管这个问题没有得到答复。围绕这个问题的对话暗示了(官方)并没有提供此类机制的打算。他们鼓励大家对缺失的特性提出反馈,而不是优先考虑那些窥探底层的花招。

虽然我也希望有这个机制,不过我能够理解为什么我们不会看到它被实现。因为一旦我们采取窥探内部的策略来实现某些功能,我们的代码将会暴露在版本不兼容的风险下,因为苹果很有可能在未来改变视图的内部工作方式。

SwiftUI 里有像 UIView 的 drawHierarchy 那样可以把视图画到图像中的 API 吗?

SwiftUI 并没有支持这个功能的 API,但是借助 UIHostingController,我们可以把 SwiftUI 视图包装起来,然后在 hosting controller 视图上使用 drawHierarchy 实现目标。

如何控制 UIViewRepresentable 的理想尺寸?

**原问题:**我要如何控制一个 UIViewRepresentable 视图的理想尺寸呢?在获取被包装视图的自动尺寸时,我遇到过不少麻烦,特别是当被包装视图是 UIStackView 的时候。关于获取合适的自动尺寸有没有推荐的方式?好让我不需要过度依赖 fixedSize

**答复:**你可以尝试为你的视图实现 intrinsicContentSize

UIHostingController 可以和 AnyView 一起使用吗?⭐️

**原问题:**我有一个框架,它会通过 UIHostingController 传出一个 SwiftUI 视图给主 app。这个视图在内部处理了它用到的所有东西,因此所有的类型都是 internal 的。唯一的 public 方法是对外给出 UIHostingController。 为了实现隔离维护,我是这样做的:return UIHostingController(rootView: AnyView(SchedulesView(store: store)))。这算是 AnyView 的一种正确用法吗?

**答复:**是的,这个用法没问题,尤其是当 AnyView 被用于视图层级的最基础层而不是用于实现动态性。

不过也有别的方式,你可以封装精确类型的 hosting controller,比如返回一个向上转换或者自定义的协议类型:

  • UIViewController 的类型而不是实际的 UIHostingController<..> 类型返回

  • 创建一个客户端期望返回类型的精确 API,然后返回那个类型

或者,你还可以借助一个容器 UIViewController 来包裹你的 hosting controller,这种做法带来的额外好处是,调用模块可以移除对 SwiftUI 的依赖。

为什么 UIViewRepresentable 会在 makeUIView 之后和 dismantleUIView 之前更新一次?

更新函数可能因为多种原因被调用。当 UIView 存在时,它至少会被调用一次,并且在 UIView 被废弃之前可能调用多次。所以你不能依赖这个更新调用的频率。

**追加的问题:**我实现了一个 UIViewDiffableRepresentable ,它遵循 Hashable 协议,会在有效的更新之后检查各项属性,以防止开销很重的逻辑触发多余的 updateUIView 调用。这个做法是否优化过度了?有没有更合理的做法?

**答复:**这么做的确过度优化了。框架只有在 representable 结构外的属性实际改变时才会调用 updateUIView,你可以把更新放心地托付给它。

创建 UIViewRepresentable 的时候,让 Coordinator 持有通过 updateUIView() 传入的 UIView 是一种危险的行为吗? ⭐️

这样做是安全的,你的 Coordinator 会在任何视图构建之前就被创建 —— 所以在 makeUIView 里你可以让 Coordinator 持有视图的引用。

在 SwiftUI 2 中有没有可以转换旧的 AppDelegate/SceneDelegate 生命周期的方法?

是的,你可以在 App 中使用 UIApplicationDelegateAdaptor 属性包装器,比如 UIApplicationDelegateAdaptor var myDelegate: MyAppDelegate

SwiftUI 会为你实例化一个 UIApplicationDelegate 并且按照 AppDelegate 的方式来回调它的方法。此外,你还可以通过 configurationForConnectingSceneSession 来返回自定义的 scene delegate,SwiftUI 也会实例化并且按照 SceneDelegate 的方式回调它的方法。

向后兼容性

能说一说现有的 SwiftUI 代码要继承新版本特性有什么方法吗?我想要用一套代码同时支持 iOS 14 和 iOS 15。?

大部分新特性不能向后发布到更早的系统版本。你可以用下面的方式来检查某个特性是否可用:

if #available(iOS 15, *) {
    ...
} else {
    // 更早版本的回滚策略
}
复制代码

这也就是说,确实有一些特性可以向后发布。比如,把对集合的绑定直接传入 ListForEach,然后取回每个元素的绑定:

ForEach($elements) { $element in
    ... 
}
复制代码

这个特性可以向后发布到支持 SwiftUI 的早期版本。

WWDC21 还提到了令一个向后发布的特性,那就是 enum-like 风格。

“解密 SwiftUI” 中提到用 @ViewBuilder 来消除对 AnyView 的使用,这个方法是否只能在 iOS 15 上使用?♦️

不是,事实上这个方法可以向后发布到任何支持 SwiftUI 的版本。

编程策略

SwiftUI 中是否存在只能用 AnyView 而不能用其他替代方案构造视图的场景?⭐️

关于能不能使用 AnyView 的问题有不少。如果你能避免使用 AnyView,我们会建议你这么做,比方说采用 @ViewBuilder 或者泛型来传递视图。

不过,我们之所以会提供 AnyView,是因为我们明白,的确存在某些场景是其他方式无法解决的,或者权衡之下 AnyView 是可行的。

这里有一个小规则:假如被包装的视图很少改变或者几乎不改变,那么 AnyView 肯定没问题。但如果把 AnyView 用在那些会在不同的状态之间来回切换的场景,则可能会带来性能问题。这是因为为了管理这个过程,SwiftUI 需要承担额外的负荷。

Child 和 Parent 的 body ,哪一个会先被计算?⭐️

**原问题:**在“解密 SwiftUI” 的 Dependency Graph 部分,视频提到了两个视图,它们都依赖于相同的依赖项,需要生成新的 body。假如其中一个是另一个的子视图,那么哪个视图的 body 会被先计算 呢?

**答复:**父视图会先生成 body,然后递归遍历它的所有子视图。

如果不想借助 AnyView,我们要怎样传递一个视图给 ViewModifier ?⭐️ ?

**原问题:**我创建了一个给视图添加自定义模态 overlay 的 ViewModifier,效果类似 sheet。有没有办法以构造器参数的方式把一个视图传给这个 ViewModifier,而不用求助于 AnyView ?我希望能把 overlay 的实际内容直接传入构造器。

**答复:**你可以通过实现你自己的带泛型参数的 ViewModifier 来实现这个目标,比如:struct MyModifier<C: View>: ViewModifier { ... },然后里面声明一个类似 var content: C 这样的属性:

这里提供一个完整的例子:

struct ExampleView: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 10)
            .fill(.green)
            .frame(width: 200, height: 80)
            .modifier(MyHoverModifier(hoverView: Text("hello!").font(.largeTitle)))
    }
}

struct MyHoverModifier<C: View>: ViewModifier {
    @State var isHovering = false
    var hoverView: C
    
    func body(content: Content) -> some View {
        content
            .overlay(self.hoverView.opacity(isHovering ? 1.0 : 0.0))
            .onHover {
                isHovering = $0
            }
    }
}
复制代码

Group 和 ViewBuilder 该怎么选?

原问题: Group { if whatever { … } else { … } }ViewBuilder.buildBlock(pageInfo == nil ? ViewBuilder.buildEither(first: EmptyView()) : ViewBuilder.buildEither(second: renderPage) )。这两种写法都能实现条件化构建视图,哪一种更好呢?

**答复:**这种场景下用 Group 更好。

**追加的问题:**这是出于可读性的考量?还是说一者的性能会优于另一者?

**答复:**主要是出于可读性 —— 通常我们不建议直接调用 result builder 的实现,而是让编译器去处理它们。

我们可以用 .id() 来保持视图的等价性吗?⭐️

**原问题:**如果我们在条件化构建中给视图应用了相同的 id,SwiftUI 会将它们视为相同的视图吗?

var body: some View {
    if isTrue {

        Text("Hello")
            .id(viewID)

    } else {

        Text("World")
            .id(viewID)

    }
}
复制代码

**答复:**不,它们会是两个不同的视图。

这是因为 body 是一个隐式的 ViewBuilder。如果你并不是用 ViewBuilder,比如上面的代码是放在另外一个普通的属性里,那么它们将会是相同的视图。或者,你也可以这样写:

var body: some View {
    Text(isTrue ? "Hello" : "World").id(viewID)
}
复制代码

**追加的问题:**除了 body 之外,还有其他隐式 ViewBuilder 的例子吗?

**答复:**是的,例如 ViewModifier 的 body 函数,view style 的 makeBody, preview providers 等等,有很多。

**追加的问题:**所以我们应当在 view builder 尽量避免条件化吗?

**答复:**当然不是。条件化存在是有原因的,只是应该避免过度使用。

有没有一些场景我们应该优先使用 Hashable 而不是 Identifiable ?

如果你只需要识别一个单一值,那么 Identifiable 就是为此而生的,这意味着只有 id 属性要求是 Hashable,而不是整个类型。

对于样式,我要怎么实现条件化?♦️

**原问题:**我们如何条件化设置不同的 modifier,比如说列表的样式?

List {

   ...

}.listStyle(isIpad ? .sidebar : .insets)
复制代码

**答复:**SwiftUI 里的样式是静态的,不允许在运行时改变,上面的场景里分支语句会更合适。不过,你首先应该考虑是否真的有必要改变样式 —— 单一样式通常是正确的选择。

假如你正在寻找某种动态机制,可以向我们提出反馈。

对 SwiftUI 视图条件化应用 modifier 有没有某种最佳实践?

**原问题:**给 SwiftUI 视图条件化应用 modifier 有没有最佳实践呢?我自己实现了一个 .if modifier,当状态变化时整个视图都会刷新 ?

**答复:**可以考虑采用惰性 modifier,如果哪个 modifier 缺少惰性版本,请反馈给我们。

在使用新的 SwiftUI Table 视图时,我可以把 10 个以上的 TableColumn 以 Group 的方式组织起来吗?♦️ ⭐️ ?

是的,你当然可以这么做,就像我们对视图那那样做!

**追加的问题:**那我是否可以理解为: @ViewBuilder 里不再有对象数量的限制了?

答复:@ViewBuilder 今年没有变化,它可以构建的元素数量仍然是有限制的。但是 Group 和嵌套 builder 可以帮助你将很多视图组合起来。

经过测试,我发现 ForEach 和控制流语句对 TableColumnBuilder 不起作用。这真遗憾,因为我能够预见,有许多场景会有这种需求。对于这个问题,我已经提出了反馈: FB9189673 (ForEach) 和 FB9189678 (控制流)。

在 UIHostingController 中使用 Core Data 要怎么回避 AnyView 呢?⭐️ ?

**原问题:**在 SwiftUI 中使用 UIHostingController 和 Core Data 时,我们要如何避免因为 environment modifier 导致视图类型变化而不得不使用 AnyView 的问题呢?代码如下:

import UIKit
import SwiftUI
import CoreData

struct MyView: View {
    var body: some View {
        Text("!")
    }
}
class MyHostingController: UIHostingController<MyView> {
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // custom stuff here
    }
}
class TestViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let persistentContainer = NSPersistentContainer(name: "MyStore")
        let rootView = MyView().environment(\.managedObjectContext, persistentContainer.viewContext)
        let hostingController = MyHostingController(rootView: rootView) // this will not work anymore because type has changed with the environment modifier
        // more stuff
    }
}
复制代码

**答复:**这个问题很棒!有一个解决方案,不过里面的方括号会多到瞎… 在 class MyHostingController: UIHostingController 中,MyView 其实并不是我们的目标类型,你需要的是 MyView().environment(.managedObjectContext, persistentContainer.viewContext) 的类型,它的完整形式是 ModifiedContent<MyView,...>... 部分太长了,这里省略)。通常我的做法是拷贝这个类型,然后声明一个顶级类型别名:typealias MyModifiedView = ModifiedContent<MyView, ...>,其中右边的类型是从错误消息中复制的。这样一来,你就可以把代码写成:class MyHostingController: UIHostingController 了。

答复推荐的解决方案在之前是可以工作的。但由于现在 swiftc 已经不再报告具体类型,而是报告 some View,所以这个办法行不通了。

有一个变通的办法是先打印出具体类型:

let rootView = MyView().environment(\.managedObjectContext, persistentContainer.viewContext)
print("\(type(of: rootView))")
复制代码

但这样做还不够,你还得强制转换类型,否则无法通过编译:

rootView as! MyModifiedView
复制代码

完整的代码如下:

import UIKit
import SwiftUI
import CoreData

typealias MyModifiedView = ModifiedContent<MyView, _EnvironmentKeyWritingModifier<NSManagedObjectContext>>

struct MyView: View {
    var body: some View {
        Text("!")
    }
}

class MyHostingController: UIHostingController<MyModifiedView> {
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // custom stuff here
    }
}

class TestViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let persistentContainer = NSPersistentContainer(name: "MyStore")
        let rootView = MyView().environment(\.managedObjectContext, persistentContainer.viewContext)
        let hostingController = MyHostingController(rootView: rootView as! MyModifiedView)
        // more stuff
    }
}
复制代码

假如 SE-309 生效并且应用于 View,届时一个 View 和一个 AnyView 在视图等价性这一点上还会有实质性的区别吗?♦️ ⭐️

原问题: SE-309 使得我们可以把关联类型纳入枚举的 existential type,假设它会涵盖 View 类型,那么到那个时候,一个 View 的 existential 和 AnyView 在视图等价性上还有区别吗?

**答复:**我喜欢这个提案!对于实现的细节我无法评价,但相较于 existential,AnyView 擦除了更多信息,所以 existential 仍然会是区分的边界。

现在我们有了 task,那么是否还有应该使用 onAppear 而非 task 的场景吗? ♦️ ⭐️

答复(工程师 #1): onAppear() 仍然可以使用。对于之前的代码可以不需要更新。我认为 task() 提供了更宽泛的解决方案,即便对于耗时很短的同步任务来说也是(适用的),因为它让你可以在将来需要的时候升级到异步的任务。

**追加的问题:**这么说,新代码你总是会使用 task(),还是说 onAppear() 仍然有它自己适用的地方?我可能会认为 onAppear 类似于被弃用了?

**答复(工程师 #1):**我个人会始终采用 task(),但一些人可能会喜欢 onAppearonDisppear() 的对称性。

**追加的问题:**那么二者有区别吗?

答复(工程师 #1): task() 会在 onDisappear 时取消异步任务,并且不会再触发新的任务。

**答复(工程师 #2):**通常我们会避免废弃 API,除非它们真的有害。之前提到,onAppear 相比 task 有更多的限制,所以我会建议在新代码中使用 task。但不管怎么说, onAppear 是无害的。


更多文章,欢迎关注公众号「Swift花园」

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享