作者: Cyandev, iOS 和 MacOS 开发者,目前就职于字节跳动
0x00 前言
众所周知,SwiftUI 的开发模式与 React、Flutter 非常相似,即都是声明式 UI,由数据驱动(产生)视图,视图也会与数据自动保持同步,框架层会帮你处理“绑定”的问题。
在声明式 UI 中不存在命令式地让一个视图变成 xxx 样子的方法,所有视图的属性都必须映射到一个状态上,那么这就涉及到一个问题:数据该如何传递。父子组件可以直接通过 State
和 Binding
来传递数据,就像这样:
struct RootView: View { @State var input: String = "" var body: some View { VStack { TextField("Email", text: $input) // $input: Binding<String> Text("Your input is \"\(input)\"") } } }
这里这个 input
被称为 “Source of Truth”,$input
则用于获取其对应的 Binding
,以便让子视图可以修改这同一个值。Binding
可以让我们轻松实现父子组件的通信,而不需像在 React 里那样手动传递 callback 来修改父组件中的状态。
更多 React 与 SwiftUI 的对比可以参考我之前写过的一个文章:https://github.com/unixzii/swiftui-for-react-devs\[1\]
0x10 数据传递的问题
简单的父子组件通信不能满足一般 apps 的需求,因为有时几个层级很深的视图可能要同时访问某个全局的状态,并且保持同步。使用属性逐层传递这个 “Source of Truth” 可以达到我们的目的但十分麻烦:
这也就是为什么 React 会提供 Context[2] 机制(Flutter 中则是 [InheritedWidget](https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html "InheritedWidget")
部件),它们会极大简化状态的传递过程:
父视图只需要用特定的 “ID” 标识这个状态,其所有子树视图便可以通过相同的 “ID” 获取到这个状态,省去了显式传值的麻烦。
0x20 被低估的 Environment
SwiftUI 也提供了这样的便利,即 Environment。通常我们会创建一个对象让其实现 ObservableObject
协议,然后这样将其 attach 到某个视图:
`class MyContext: ObservableObject { /* ... */ }
// In the view that provides the context:
var body: some View {
Group {
// ...
}
.environmentObject(MyContext())
}
`
然后在任何一个子视图中就可以使用 @EnvironmentObject
来获取这个对象了,并且由于对象实现了 ObservableObject
,当其发生变化时所有依赖视图也会进行更新。
实际上 SwiftUI 中大量使用了 Environment 来实现视图的常规特性,如 disabled()
、redacted(reason:)
这些 modifiers 都是通过 Environment 实现的,这也就是为什么当父视图设置了 redacted 之后,整个子视图都会变成 placeholder。
由于 SwiftUI 中很多 modifier extension 方法都是 inlinable 的,我们就可以在 SwiftUI 的 swiftinterface 文件 (位于 /path/to/Your-Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-ios.swiftinterface) 中看到其实现,下面是 disabled()
的实现:
`@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
@inlinable public func disabled(_ disabled: Swift.Bool) -> some SwiftUI.View {
return modifier(_EnvironmentKeyTransformModifier(
keyPath: .isEnabled, transform: { $0 = $0 && !disabled }))
}
}
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct _EnvironmentKeyTransformModifier
public var keyPath: Swift.WritableKeyPath<SwiftUI.EnvironmentValues, Value>
public var transform: (inout Value) -> Swift.Void
@inlinable public init(keyPath: Swift.WritableKeyPath<SwiftUI.EnvironmentValues, Value>, transform: @escaping (inout Value) -> Swift.Void) {
self.keyPath = keyPath
self.transform = transform
}
public static func _makeInputs(modifier: SwiftUI._GraphValue<SwiftUI._EnvironmentKeyTransformModifier
public typealias Body = Swift.Never
}
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
@inlinable public func transformEnvironment
return modifier(_EnvironmentKeyTransformModifier(
keyPath: keyPath, transform: transform))
}
}
`
可以看到 disabled()
本质就是应用了一个 modifier,而这个 modifier 正是 transformEnvironment(:transform:)
的实现。
0x21 environment(_:_:)
这里又引出了 environment(_:_:)
和 transformEnvironment(_:transform:)
两个 modifiers 了,它是 Environment 机制的核心。我们来看它们的第一个参数,是 EnvironmentValues
的 key path,这个就是我们所说的 “ID” 了,它可以标识一个要广播的 context,子视图使用相同的 key path 构造 @Environment
即可获取到对应的值。
要使用 environment(_:_:)
来声明 context 需要我们声明新的 EnvironmentKey
并扩展 EnvironmentValues
,例如:
`private struct EnabledEnvironmentKey: EnvironmentKey {
static let defaultValue: Bool = true
}
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension EnvironmentValues {
public var isEnabled: Swift.Bool {
get { self[EnabledEnvironmentKey.self] }
set { self[EnabledEnvironmentKey.self] = newValue }
}
}
`
这些做完之后我们就可以这样来应用它们了:
MyView() .environment(\.isEnabled, false)
0x22 transformEnvironment(_:transform:)
但是现在有个问题,如果我的某个中间子视图也设置了 environment 怎么办?最内层的视图拿到的值是什么呢?例如下面的代码:
`struct InnermostView: View {
@Environment(.isEnabled) var isEnabled
var body: some View {
Text("(isEnabled ? 1 : 0)")
}
}
struct RootView: View {
var body: some View {
ZStack {
ZStack {
InnermostView()
}
.environment(.isEnabled, true)
}
.environment(.isEnabled, false)
}
}
`
我们会发现最后的输出是 1,这说明内层的值覆盖了外层的值。但这往往不是我们希望的,我们希望父视图的 isEnabled
为 false
时能强制禁用掉所有子视图,这也符合其定义。这就需要引出 transformEnvironment(_:transform:)
了,我们这样修改代码:
struct RootView: View { var body: some View { ZStack { ZStack { InnermostView() } .transformEnvironment(\.isEnabled) { $0 = $0 && true } } .transformEnvironment(\.isEnabled) { $0 = $0 && false } } }
transformEnvironment(_:transform:)
的第二个参数是一个闭包,它的参数为上一层 environment 的值,在这个闭包中我们可以基于上一层的值计算出一个新的值。这样我们每层做 $0 = $0 && value
的操作就相当于最终的 isEnabled
是所有层的值与起来的结果。我们再次运行,输出 0,现在就符合预期了。
到这里可以回顾下上文给出的 disabled()
modifier 实现,是不是恍然大悟呢。当你创建自定义视图时,其实并不需要把所有属性列在构造器中,巧妙地使用 Environment 会让你的视图 API 更简洁,同时也易于与其运行的环境融合,毕竟你肯定不想自己造 isEnabled
这样的轮子,是吧。
最后悄悄再看一下 environmentObject()
的实现:
`@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
@inlinable public func environmentObject
return environment(T.environmentStore, object)
}
}
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension ObservableObject {
@usableFromInline
internal static var environmentStore: Swift.WritableKeyPath<SwiftUI.EnvironmentValues, Self?> {
get
}
}
`
其实也是 Environment 的变种应用。
到这里 Environment 这个 part 就结束了,请记住这个 transform 的概念,因为下面我们会继续用到。
0x30 “不为人知”的 Preference
说到 Preference,大家可能会一脸懵,这玩意跟 UserDefaults
是啥关系?答案是:**没关系。半毛钱关系都没!**让我们先来看看文档是怎么说的:
听懂掌声。
... 所以我们还是用一个实际的例子来讲讲它的用处吧,先看下面这张图:
这是一个很常见的交互,选中的 tab 下方会有一个红点,这个红点居中于 item 下方。你可能会说这很简单呀,用 overlay()
就轻松搞定了,但我如果让你加入切换滑动动画呢。整个视图里小红点必须位于同级才可以做滑动动画,所以我给出该视图的一个大致框架:
var body: some View { HStack(alignment: .center, spacing: 16) { ForEach(self.data, id: \.self) { element in self.content(element) } } .overlay( RoundedRectangle(cornerRadius: 3) .fill(Color.red) .frame(width: 6, height: 6) .position(???) .offset(x: 0, y: ???) ) }
通过这个结构,我们可以使用动画来修改小红点的 position 和 offset,但是它们的值怎么计算呢?这个问题其实也等同于如何获取选中 item 视图的 frame。
我们知道 GeometryReader
可以实现相关的操作,结合 coordinateSpace(name:)
和 frame(in:)
items 就可以知道自己在父视图里的位置了:
`private let coordinateSpaceName = UUID().uuidString
var body: some View {
HStack(alignment: .center, spacing: 16) {
ForEach(self.data, id: .self) { element in
self.content(element)
.background(
GeometryReader() { (innerGeometry) -> AnyView in
let frame = innerGeometry.frame(in: .named(self.coordinateSpaceName))
// Then?
}
)
}
}
.coordinateSpace(name: self.coordinateSpaceName)
.overlay(
RoundedRectangle(cornerRadius: 3)
.fill(Color.red)
.frame(width: 6, height: 6)
.position(???)
.offset(x: 0, y: ???)
)
}
`
现在我们可以拿到 frame 了,但是如何让父视图得到这个值呢?
我们先来看看 Flutter 是如何解决的,在 Flutter 中如果我们想让一个视图撑满一个 Row
或者 Column
,可以将视图包裹在 Expanded
中:
看下 Expanded
的实现可以发现其是一个 ParentDataWidget[3],它也是用于建立父子组件通信的,只不过是子到父的单向通信,即子视图暴露一些父视图需要的属性,如自身大小、是否需要填满等等。它的整个工作流程大致如下:
这套机制能完美解决我们的问题,而 SwiftUI 也提供了这项能力,它就叫 Preference!与 Environment 类似,使用前需要声明 PreferenceKey
。按照我们的需求,我们需要收集所有 item 视图的 frame,所以可以这样实现:
struct KeyedItemPreferenceKey<Key: Hashable>: PreferenceKey { typealias Value = [Key: CGRect] static var defaultValue: Value { return [:] } static func reduce(value: inout Value, nextValue: () -> Value) { for (index, center) in nextValue() { value[index] = center } } }
这个 key 表明该属性的类型是一个字典(以 item index 为下标存储所有 item 的 frame),然后默认值是空字典。接下来有个比较重要的方法,reduce(value:nextValue:)
,它用于组合所有同 key 的值。因为我们一个视图下所有的子视图都有一个 preference 值(即 [index: frame]),而同个 key 只会有一个值,所以要按一定逻辑进行收敛。我们这里就简单将所有字典进行 merge,将它们变成一个字典给到父视图。
接下来我们开始接收子视图传来的 preference:
`@State
private var itemPreferences = Data.Index: CGRect
var body: some View {
HStack(alignment: .center, spacing: 16) {
ForEach(self.data, id: .self) { element in
self.content(element)
.background(
GeometryReader() { (innerGeometry) -> AnyView in
let index = self.data.firstIndex(of: element)!
let frame = innerGeometry.frame(in: .named(self.coordinateSpaceName))
return AnyView(GeometryReader() { _ in EmptyView() }
.preference(key: KeyedItemPreferenceKey<Data.Index>.self,
value: [index: frame]))
}
)
}
}
.coordinateSpace(name: self.coordinateSpaceName)
.onPreferenceChange(KeyedItemPreferenceKey<Data.Index>.self) {
self.itemPreferences = $0
}
.overlay(
RoundedRectangle(cornerRadius: 3)
.fill(Color.red)
.frame(width: 6, height: 6)
.position(preferencesOfSelectedItem.center)
.offset(x: 0, y: preferencesOfSelectedItem.height / 2 + 8)
)
}
`
使用 onPreferenceChange(:perform:)
来响应 preference 的变化,这里只需将其存储到 State 中即可。
到这里我们就实现了我们想要的效果。但其实目前的实现有点违反 Single Source of Truth 的理念,我们记录的 frame 和视图真实的 frame 是两份,不过机制上能保证同步罢了。有没有什么方法可以不用额外存储呢?答案是肯定的,SwiftUI 提供了 overlayPreferenceValue(::)
和 backgroundPreferenceValue(::)
两个 API 可以让我们用 preference 的值来构造 overlay 和 background。我们可以用它把代码再优化一下:
`// @State
// private var itemPreferences = Data.Index: CGRect
var body: some View {
HStack(alignment: .center, spacing: 16) {
// ... Same
}
.coordinateSpace(name: self.coordinateSpaceName)
// .onPreferenceChange(KeyedItemPreferenceKey<Data.Index>.self) {
// self.itemPreferences = $0
// }
.overlayPreferenceValue(KeyedItemPreferenceKey<Data.Index>.self) {
let preferencesOfSelectedItem = $0[selectedIndex]!
RoundedRectangle(cornerRadius: 3)
.fill(Color.red)
.frame(width: 6, height: 6)
.position(preferencesOfSelectedItem.center)
.offset(x: 0, y: preferencesOfSelectedItem.height / 2 + 8)
}
}
`
运行一下可以发现效果是一模一样的。
例子说完了,我们这里也可以思考一下系统提供的哪些 API 是使用了 Preference 机制。简单调试后我发现 navigationTitle()
这类 modifiers 是 Preference 实现的,虽然不是内联方法,没有体现在 swiftinterface 中,但是打印被它修饰的 view 的类型可以看到:
`NavigationView<
ModifiedContent<
Text,
TransactionalPreferenceTransformModifier
>
`
而 transformPreference(::)
(preference(::)
的闭包变换版本)的实现是:
`@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct _PreferenceTransformModifier
public var transform: (inout Key.Value) -> Swift.Void
public typealias Body = Swift.Never
@inlinable public init(key _: Key.Type = Key.self, transform: @escaping (inout Key.Value) -> Swift.Void) {
self.transform = transform
}
public static func _makeView(modifier: SwiftUI._GraphValue<SwiftUI._PreferenceTransformModifier
}
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
@inlinable public func transformPreference
return modifier(_PreferenceTransformModifier
}
}
`
因此可以断定 navigationTitle()
是 transformPreference(::)
的变种实现的。
这种子视图向上管理的模式其实在前端里也比较常见,例如 React 中声明式设置文档标题的库 react-document-title[4] 也使用了一个类似的实现 react-side-effect[5]。
0x40 总结
最后我们来总结一下
Environment:指父视图给其下面所有子视图提供的环境,通常业务数据需要采用这种方式进行传递,从而使代码更加容易维护。React、Flutter 均提供了类似的机制,较为常用。
Preference:指子视图声明的偏好,父视图可以获取到子视图的偏好,从而可以调整自己的一些行为。可以用于实现比较复杂的布局,因为父视图可能需要根据子视图的大小位置决定布局行为。Flutter 中提供了类似的机制,但不支持跨层传递。
此外,两者均支持 transform,从而自定义多层之间的覆盖行为。
两个组成 SwiftUI Data Flow 的重要成员,本文就暂时介绍到这里。通过文中两个 API 的对比,大家也一定能看出 SwiftUI 中 API 设计的一些套路,希望会给大家以后使用 SwiftUI 提供新的思路。
推荐阅读
✨ 详解 WWDC 20 SwiftUI 的重大改变及核心优势
WWDC20 10041 - What's new in SwiftUI
WWDC20 10048 - 在 SwiftUI 中创建复杂功能
关注我们
我们是「老司机技术周报」,每周会发布一份关于 iOS 的周报,也会定期分享一些和 iOS 相关的技术。欢迎关注。
关注有礼,关注【老司机技术周报】,回复「2020」,领取学习大礼包。
参考资料
[1]
https://github.com/unixzii/swiftui-for-react-devs: https://github.com/unixzii/swiftui-for-react-devs
[2]
Context: https://reactjs.org/docs/context.html
[3]
ParentDataWidget: https://github.com/flutter/flutter/blob/8874f21e79/packages/flutter/lib/src/widgets/basic.dart#L4516
[4]
react-document-title: https://github.com/gaearon/react-document-title
[5]
react-side-effect: https://github.com/gaearon/react-side-effect
[6]
https://reactjs.org/docs/context.html: https://reactjs.org/docs/context.html
[7]
https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html: https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html
[8]
https://api.flutter.dev/flutter/widgets/ParentDataWidget-class.html: https://api.flutter.dev/flutter/widgets/ParentDataWidget-class.html
[9]
https://swiftwithmajid.com/2020/05/20/fitting-and-filling-view-in-swiftui/: https://swiftwithmajid.com/2020/05/20/fitting-and-filling-view-in-swiftui/
[10]
https://medium.com/swiftify/swift-5-1-module-format-stability-best-time-migrate-objective-c-frameworks-a0434f5352a3: https://medium.com/swiftify/swift-5-1-module-format-stability-best-time-migrate-objective-c-frameworks-a0434f5352a3
[11]
https://developer.apple.com/documentation/swiftui/state-and-data-flow: https://developer.apple.com/documentation/swiftui/state-and-data-flow
[12]
https://swiftwithmajid.com/2020/03/18/anchor-preferences-in-swiftui/: https://swiftwithmajid.com/2020/03/18/anchor-preferences-in-swiftui/
[13]
https://github.com/gaearon/react-side-effect: https://github.com/gaearon/react-side-effect
本文分享自微信公众号 - 老司机技术周报(LSJCoding)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。