译自 Random Lessons from the SwiftUI Digital Lounge
建议横屏阅读代码
本文之前已发布过一次,但那个版本的翻译多有纰漏和文法不通的地方。
这版重新整理了翻译,并且启用了新的排版风格。
今年的 WWDC 有一大亮点是 Digital Lounge 的引入。遗憾的是,好东西往往短暂,须臾之间就已落幕。
很多人因为种种原因无法参与这个频道,这其中包括没有时间,注册失败等等。我个人由于日常安排的缘故也没能跟进它的节奏。频道已经下线,不过我做了笔记。幸运的是,主持人表示频道中被问及的问题和答复可以与大家分享。
鉴于此,我分类并整理了 SwiftUI Digital Lounge 的问题和答复,还对其中的一部分添加了评论。为了方便浏览,我把许多问题的描述都简化到一两行以内的句子,同时也保留了原问题的记录。
如果一个问题与 WWDC 21 介绍的新特性有关,我会给它加上 ♦️ 标记。
对那些我特别感兴趣的问题,我还使用了 ⭐️ 标记,基于下面几个理由:
- Apple 的答复提供了全新的信息
- Apple 的答复证实了某些我们怀疑了很久的东西,并且这些东西没有文档
- Apple 的答复确认了我们已经在社区中使用的模式,而这些模式目前还没有被 Apple 官方使用
- 问题涉及了某些很少被提及但是值得注意的东西
- Apple 的答复提供了某些窥探框架内部工作机制的视角
- 或者只是某个会让我停下来思考的问题 ?
介绍完毕,让我们开始这些问题和答复吧。
译者注:鉴于篇幅过长,译文将分为三篇公众号文章发布。
动画
视图从一个位置到另一个位置的过程可以动画化吗?比如改变父级视图的场景??
当然,你可以去看一看 matchedGeometryEffect()
,这是去年的 SwiftUI 2.0 版本引入的 API,链接:developer.apple.com/documentati…🙂
译者:关于
matchGeometryEffect
modifier,作者也曾写过一个系列文章详细解释过。
文本字体尺寸的变化可以动画化吗?♦️ ?
完整问题: 我们现在有办法可以动画化文本字号的变化吗?因为尺寸和背景颜色现在能够平滑过渡,但是字体会直接跳变,中间没有插值过程。
答复: 这个反馈很棒,我们会对这个需求进行评审。
如果你对现有的解决方案有兴趣,可以去参考一下 Fruta 范例工程里的 AnimatableFontModifier
,那里边用了显式的字号作为可动画数据,使用场景是主视图和详情视图上的配料卡片的平滑过渡效果。这个实现对于 Fruta 来说已经够用,毕竟用例有限。链接:developer.apple.com/documentati…
译者:作者对于进阶动画技术也有一系列博文,其中有一篇特别提到了
AnimatableModifier
( 「Swift花园」也发布过该博客的译文 —— “SwiftUI 动画进阶 – part3: AnimatableModifier”,感兴趣的读者可以查阅)
可以同时使用多个以 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 {
// 更早版本的回滚策略
}
复制代码
这也就是说,确实有一些特性可以向后发布。比如,把对集合的绑定直接传入 List
和 ForEach
,然后取回每个元素的绑定:
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()
,但一些人可能会喜欢 onAppear
和 onDisppear()
的对称性。
**追加的问题:**那么二者有区别吗?
答复(工程师 #1): task()
会在 onDisappear
时取消异步任务,并且不会再触发新的任务。
**答复(工程师 #2):**通常我们会避免废弃 API,除非它们真的有害。之前提到,onAppear
相比 task
有更多的限制,所以我会建议在新代码中使用 task
。但不管怎么说, onAppear
是无害的。
更多文章,欢迎关注公众号「Swift花园」