在開發應用時,SwiftUI提高了開發效率。
SwiftUI大概可以滿足任何現代應用程序需求的95%,而剩下的5%則是通過退回到以前的UI框架。
我們有兩種主要的回退方法:
- SwiftUI的
UIViewRepresentable
/NSViewRepresentable
- SwiftUI Introspect
什么是SwiftUI Introspect
SwiftUI Introspect是一個開源庫。它的主要目的是獲取和修改任何SwiftUI視圖的底層UIKit或AppKit元素。
這是可能的,因為許多SwiftUI視圖(仍然)依賴于它們的UIKit,例如:
- 在macOS中,
Button
在幕后使用NSButton
- 在iOS中,
TabView
在幕后使用UITabBarController
我們很少需要知道這樣的實現細節。然而,知道這一點給了我們另一個強大的工具,我們可以在需要的時候使用。這正是SwiftUI Introspect
發揮作用的地方。
SwiftUI Introspect的使用
SwiftUI Introspect在func introspectX(customize: @escaping (Y) -> ()) -> some View
模式之后提供了一系列視圖修飾符,其中:
-
X
是我們的目標視圖 -
Y
是底層的UIKit/AppKit視圖/視圖控制器類型
假設我們想要從ScrollView
中移除彈性效果。目前,SwiftUI沒有相應的API或修飾符允許我們這樣做。
ScrollView
在底層使用UIKit的UIScrollView
。我們能使用Introspect的func introspectScrollView(customize: @escaping (UIScrollView) -> ()) -> some View
方法獲取底層的UIScrollView
,并禁用彈性效果:
import Introspect
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
VStack {
Color.red.frame(height: 300)
Color.green.frame(height: 300)
Color.blue.frame(height: 300)
}
.introspectScrollView { $0.bounces = false }
}
}
}
在iOS系統中,用戶可以通過向下滑動表單來關閉表單。在UIKit中,我們可以通過isModalInPresentation
UIViewController
屬性阻止這種行為,讓我們的應用程序邏輯控制表單的顯示。在SwiftUI中,我們還沒有類似的方法。
同樣,我們可以使用Introspect來抓取呈現表UIViewController
,并設置isModalInPresentation
屬性:
import Introspect
import SwiftUI
struct ContentView: View {
@State var showingSheet = false
var body: some View {
Button("Show sheet") { showingSheet.toggle() }
.sheet(isPresented: $showingSheet) {
Button("Dismiss sheet") { showingSheet.toggle() }
.introspectViewController { $0.isModalInPresentation = true }
}
}
}
其他的例子:
想象一下,由于SwiftUI的一個小功能缺失,我們不得不在UIKit/AppKit中重新實現一個完整的復雜功能:Introspect是一個不可思議的時間節省器。
我們已經看到了它的明顯好處:接下來,讓我們揭開SwiftUI Introspect是如何工作的。
SwiftUI Introspect如何工作的
我們將采用UIKit路徑:除了UI/NS前綴,AppKit的代碼是相同的。
為了清晰起見,本文中所示的代碼進行了輕微的調整。最初的實現可以在SwiftUI Introspect的存儲庫中找到。
injection注入
正如上面的例子所示,Introspect為我們提供了各種視圖修飾符。如果我們看看它們的實現,它們都遵循類似的模式。這里有一個例子:
extension View {
/// Finds a `UITextView` from a `TextEditor`
public func introspectTextView(
customize: @escaping (UITextView) -> ()
) -> some View {
introspect(
selector: TargetViewSelector.siblingContaining,
customize: customize
)
}
}
所有這些公共introspectX(customize:)
視圖修飾符都是一個更通用的introspect(selector:customize:)
的方便實現:
extension View {
/// Finds a `TargetView` from a `SwiftUI.View`
public func introspect<TargetView: UIView>(
selector: @escaping (IntrospectionUIView) -> TargetView?,
customize: @escaping (TargetView) -> ()
) -> some View {
inject(
UIKitIntrospectionView(
selector: selector,
customize: customize
)
)
}
}
這里我們看到另一個介紹inject(_:)``View
試圖修飾符,和第一個Introspect
試圖,UIKitIntrospectionView
:
extension View {
public func inject<SomeView: View>(_ view: SomeView) -> some View {
overlay(view.frame(width: 0, height: 0))
}
}
inject(_:)
采用我們的原始視圖,并在頂部添加一個給定視圖的覆蓋層,其框架最小化。
例如,如果我們有以下視圖:
TextView(...)
.introspectTextView { ... }
最后的視圖將是:
TextView(...)
.overlay(UIKitIntrospectionView(...).frame(width: 0, height: 0))
接下來讓我們看看UIKitIntrospectionView
:
public struct UIKitIntrospectionView<TargetViewType: UIView>: UIViewRepresentable {
let selector: (IntrospectionUIView) -> TargetViewType?
let customize: (TargetViewType) -> Void
public func makeUIView(
context: UIViewRepresentableContext<UIKitIntrospectionView>
) -> IntrospectionUIView {
let view = IntrospectionUIView()
view.accessibilityLabel = "IntrospectionUIView<\(TargetViewType.self)>"
return view
}
public func updateUIView(
_ uiView: IntrospectionUIView,
context: UIViewRepresentableContext<UIKitIntrospectionView>
) {
DispatchQueue.main.async {
guard let targetView = self.selector(uiView) else { return }
self.customize(targetView)
}
}
}
UIKitIntrospectionView
是Introspect
到UIKit的橋梁,它做兩件事:
- 在
UIView
層次結構中注入一個IntrospectionUIView
- 對
UIViewRepresentable
具象的updateUIView
生命周期事件做出反應
這是IntrospectionUIView
的定義:
public class IntrospectionUIView: UIView {
required init() {
super.init(frame: .zero)
isHidden = true
isUserInteractionEnabled = false
}
}
IntrospectionUIView
是一個最小的、隱藏的、非交互的UIView
:它的全部目的是給SwiftUI Introspect一個進入UIKit層次結構的入口點。
總之,所有的.introspectX(customize:)
視圖修改器覆蓋了一個微小的,不可見的,非交互的視圖在我們的原始視圖之上,確保它不會影響我們最終的UI。
實現原理
我們已經看到了SwiftUI Introspect是如何獲取UIKit層次結構的。庫剩下要做的就是找到我們要找的UIKit視圖或視圖控制器。
回到UIKitIntrospectionView
的實現中,神奇的事情發生在updateUIView(_:context)
中,這是UIViewRepresentable
生命周期方法:
public struct UIKitIntrospectionView<TargetViewType: UIView>: UIViewRepresentable {
let selector: (IntrospectionUIView) -> TargetViewType?
let customize: (TargetViewType) -> Void
...
public func updateUIView(
_ uiView: IntrospectionUIView,
context: UIViewRepresentableContext<UIKitIntrospectionView>
) {
DispatchQueue.main.async {
guard let targetView = self.selector(uiView) else { return }
self.customize(targetView)
}
}
}
在UIKitIntrospectionView
的例子中,這個方法主要在兩個場景中被SwiftUI調用:
- 當
IntrospectionUIView
即將被添加到視圖層次結構時 - 當
IntrospectionUIView
要從視圖層次結構中移除時
async
dispatch有兩個函數:
- 如果方法被調用時,視圖將被添加到視圖層次結構,我們需要等待當前runloop周期完成之前,我們的觀點是說(到視圖層次),那時,也只有到那時,我們就可以開始尋找我們的目標視圖
- 如果在視圖即將從視圖層次結構中刪除時調用該方法,則等待runloop循環完成可確保視圖已被刪除(從而使搜索失敗)
當SwiftUI觸發updateUIView(_:context)
這個方法時,UIKitIntrospectionView
調用selector
方法我們從最初的便利修飾符實現中繼承過來的方法:
selector
有一個(IntrospectionUIView) -> TargetViewType?
方法簽名。它接受Introspect
的IntrospectionUIView
的視圖作為輸入,并返回一個可選的TargetViewType
,這是我們想要達到的原始視圖或視圖控制器類型的通用表示。
如果搜索成功,我們就調用customize
,這是我們在視圖上應用Introspect的視圖修改器時傳遞或定義的方法,從而對底層的UIKit/AppKit視圖或視圖控制器進行更改。
回到我們的introspectTextView(customize:)
示例,我們通過TargetViewSelector.siblingContaining
來傳遞selector
選擇器:
extension View {
/// Finds a `UITextView` from a `TextEditor`
public func introspectTextView(
customize: @escaping (UITextView) -> ()
) -> some View {
introspect(
selector: TargetViewSelector.siblingContaining,
customize: customize
)
}
}
TargetViewSelector
是一個Swift的enum
類型,使它成為一個靜態方法的容器,意味著可以直接調用,所有的TargetViewSelector
方法都或多或少的遵循相同的模式,像我們的siblingContaing(from:)
:
public enum TargetViewSelector {
public static func siblingContaining<TargetView: UIView>(from entry: UIView) -> TargetView? {
guard let viewHost = Introspect.findViewHost(from: entry) else {
return nil
}
return Introspect.previousSibling(containing: TargetView.self, from: viewHost)
}
...
}
第一步是找到一個視圖的持有者(宿主):
SwiftUI將每個UIViewRepresentable
視圖包裝在一個宿主視圖中,與PlatformViewHost<PlatformViewRepresentableAdaptor<IntrospectionUIView>>
有關的,然后封裝到一個類型為_UIHostingView
的“托管視圖”中,表示一個能夠托管SwiftUI視圖的UIView
。
為了獲得視圖持有者,Introspect從另一個無Introspect
enum
中使用findViewHost(from:)
靜態方法:
enum Introspect {
public static func findViewHost(from entry: UIView) -> UIView? {
var superview = entry.superview
while let s = superview {
if NSStringFromClass(type(of: s)).contains("ViewHost") {
return s
}
superview = s.superview
}
return nil
}
...
}
這個方法從我們的IntrospectionUIView
開始,遞歸地查詢每個superview
父視圖,直到找到一個視圖持有者:如果我們找不到視圖宿主,我們的IntrospectionUIView
還不是屏幕層次結構的一部分,我們的查找會立即停止。
一旦我們有了視圖的宿主,我們有了尋找目標視圖的起點,這就是TargetViewSelector.siblingContaing
做的通過下面的Introspect.previousSibling(containing: TargetView.self, from: viewHost)
命令:
enum Introspect {
public static func previousSibling<AnyViewType: UIView>(
containing type: AnyViewType.Type,
from entry: UIView
) -> AnyViewType? {
guard let superview = entry.superview,
let entryIndex = superview.subviews.firstIndex(of: entry),
entryIndex > 0
else {
return nil
}
for subview in superview.subviews[0..<entryIndex].reversed() {
if let typed = findChild(ofType: type, in: subview) {
return typed
}
}
return nil
}
...
}
這個新的靜態方法接受所有viewHost
的父視圖的子視圖(也就是viewHost
的兄弟視圖),過濾在viewHost
之前的子視圖,然后遞歸地搜索我們的目標視圖(作為type
參數傳遞),從最近的到最遠的兄弟視圖,通過最終的findChild(ofType:in:)
方法:
enum Introspect {
public static func findChild<AnyViewType: UIView>(
ofType type: AnyViewType.Type,
in root: UIView
) -> AnyViewType? {
for subview in root.subviews {
if let typed = subview as? AnyViewType {
return typed
} else if let typed = findChild(ofType: type, in: subview) {
return typed
}
}
return nil
}
...
}
這個方法,通過傳遞我們的目標視圖和一個我們的viewHost
兄弟調用,將遍歷每個兄弟完整子樹視圖層次結構,尋找我們的目標視圖,并返回第一個匹配的對象,如果有的話。
分析
既然我們已經揭示了SwiftUI Introspect的所有內部工作原理,那么回答常見的問題就容易多了:
它使用安全嗎?
只要我們不做太大膽的事,是安全的。重要的是要明白我們并不擁有底層的AppKit/UIKit視圖,而SwiftUI擁有。通過Introspect應用的更改應該可以工作,但是SwiftUI可能會在不通知的情況下隨意覆蓋它們。
這是未來的趨勢嗎?
不。隨著SwiftUI的發展,當新的操作系統版本出現時,情況可能會發生變化。當這種情況發生時,庫會更新新的補丁,但是我們的用戶需要在看到修復之前更新應用程序。
我們應該使用它嗎?
答案可能是肯定的。任何讀過這篇文章的人都完全了解庫是如何工作的:如果有什么東西壞了,我們應該知道去哪里找并找到解決辦法。
SwiftUI Introspect的亮點在哪里?
向后兼容性。例如,讓我們想象一下,iOS15 List引入了下拉刷新的功能:我們知道SwiftUI Introspect允許我們在iOS13和14中添加列表下拉刷新(Introspect方法設置下拉刷新)。到那時,我們可以使用Introspect針對舊的操作系統版本,并使用新的SwiftUI方式針對iOS15或更高版本。
這樣做可以保證不會出現問題,因為新的操作系統版本將使用SwiftUI的“原生”方法,只有過去的iOS版本才會使用Introspect。
什么時候不用SwiftUI Introspect?
當我們想要完全控制一個視圖,并且無法承受與新OS版本的沖突時:如果這是我們的情況,使用UIViewRepresentable/NSViewRepresentable
會更安全、更有前瞻性。當然,我們應該總是盡可能地先找到一個“純粹的”SwiftUI方法,只有當我們確信這是不可能的時候,才去尋找替代方法。
結論
SwiftUI Introspect是為數不多的可能是任何SwiftUI應用程序必須擁有的庫之一。它的執行優雅、安全,它的優點遠遠大于將其作為依賴項添加的缺點。
當向我們的項目添加一個依賴項時,我們應該盡可能地理解這個依賴項是做什么的,我希望這篇文章能幫助你做到這一點。