写更少的 SwiftUI 代码 —— Namespace

写更少的 SwiftUI 代码 —— Namespace

你是否写过这样的代码:DetailView(namespace: namespace, isSource: selected == nil)

或者这样的:DetailView(namespace: Namespace().wrappedValue, isSource: false)

这基本上是每一次写 Hero 动画时必不可少的两个参数。

如果还有更复杂的需求,一个 View 可能会有更多的参数需要传递。

其实,很久之前我就在想,如果把 Namespace 以环境变量的方式注入,那么就可以不用再写 namespace 这个参数了,岂不美哉?

Property Wrapper

Property Wrapper 是 SwiftUI 中的一大利器,它无处不在:@State@Binding@ObservedObject……

借此机会,我也来好好地学习下如何自己写一个 Property Wrapper。

首先,用 @propertyWrapper 来标注他它是一个包装器。

1
2
3
4
@propertyWrapper
struct InheritNamespace {
...
}

由于此时我们想要从环境变量中读取到 Namespace,因此还需要创建 EnvironmentValue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct NamespaceKey: EnvironmentKey {
static var defaultValue: Namespace.ID? = nil
}

extension EnvironmentValues {
var namespace: Namespace.ID? {
get { self[NamespaceKey.self] }
set { self[NamespaceKey.self] = newValue }
}
}

extension View {
func namespace(_ namespace: Namespace.ID?) -> some View {
environment(\.namespace, namespace)
}
}

第二步,读取环境变量中的 Namespace.ID

1
2
3
4
5
@propertyWrapper
struct InheritNamespace: DynamicProperty {
@Environment(\.namespace) private var parentNamespace
...
}

第三步,提供 WrappedValue

Property Wrapper(属性包装器),顾名思义,把一个属性包装起来。

当我们使用 @PropertyWrapper var variable 时,我们希望使用原本的类型,而不是 Property Wrapper 的类型

wrappedValue 是一个计算属性,用于 获取包装下的值 以及 修改某个值

1
2
3
4
5
6
7
8
@propertyWrapper
struct InheritNamespace {
@Environment(\.namespace) private var parentNamespace

var wrappedValue: Namespace.ID? {
parentNamespace
}
}

到此为止,基本上就大功告成了。

但是,这里只能获取到一个 Optional 的值,显然在 matchedGeometryEffect 中使用并不方便,解决方案是,再创建一个独立的 Namespace 作为候补 Namespace

1
2
3
4
5
6
7
8
9
@propertyWrapper
struct InheritNamespace {
@Namespace private var newNamespace
@Environment(\.namespace) private var parentNamespace

var wrappedValue: Namespace.ID {
parentNamespace ?? newNamespace
}
}

此时,尝试在 View 中使用这个 Property Wrapper 会发现报 Runtime Error

Accessing Environment<Optional>’s value outside of being installed on a View. This will always read the default value and will not update.

Reading a Namespace property outside View.body. This will result in identifiers that never match any other identifier.

Dynamic Property

上面的错误提示表明,我们需要在 View 内部才能使用 @Environment@Namespace

这是因为,当 View 刷新时,内容可能会发生变化,此时 Property Wrapper 中的值不能与视图保持同步刷新。

此时,Environment 只会使用 default Value,Namespace 则不会与任何 View 关联。

同时,当我们在 Property Wrapper 中修改 Environment 值时也不会刷新视图(SwiftUI 不知何时刷新/同步数据)

解决方案也很简单,给我们的 Property Wrapper 添加 DynamicProperty Protocol 即可。

1
2
3
4
@propertyWrapper
struct InheritNamespace: DynamicProperty {
...
}

这样,就可以愉快地在自定义的 Property Wrapper 中使用 SwiftUI 的 Property Wrappers 了,Dynamic Property 背后的 update() 会帮助我们处理数据同步,以及在数据变化时刷新视图

如果这部分仍然不理解,可以参考这个教程:https://www.hackingwithswift.com/plus/intermediate-swiftui/creating-a-custom-property-wrapper-using-dynamicproperty

SwiftUI Built-in Property Wrappers

SwiftUI 的源码我们并不能直接看到,但是可以通过 Swift Interface 来分析。

[XcodePath]/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/

基本所有的 SwiftUI 内置 Property Wrappers 都是用了 DynamicProperty,这使得 SwiftUI 的渲染引擎知道视图内的值何时发生了变化,然后触发视图刷新,这是一个很重要的机制,确保了 Property Wrapper 中的值与当前视图的状态始终保持同步

总结

完整代码

使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import SwiftUI

struct ContentView: View {
@Namespace private var showDetail = false
@Namespace private var NAMESPACE

var body: some View {
ZStack {
...

if showDetail {
DetailView()
}
}
.namespace(NAMESPACE)
}
}

struct DetailView: View {
@InheritNamespace private var namespace

var body: some View {
...
}
}

写更少的 SwiftUI 代码 —— Namespace

https://liyanan2004.github.io/inherit-namespace-in-swiftui/

作者

LiYanan

发布于

2023-09-10

更新于

2023-09-10

许可协议

评论