SwiftUI:Introspect

在開發應用時,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 }
    }
  }
}

scroll.gif

在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 }
      }
  }
}
sheet.gif

其他的例子:

想象一下,由于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)
    }
  }
}

UIKitIntrospectionViewIntrospect到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要從視圖層次結構中移除時

asyncdispatch有兩個函數:

  1. 如果方法被調用時,視圖將被添加到視圖層次結構,我們需要等待當前runloop周期完成之前,我們的觀點是說(到視圖層次),那時,也只有到那時,我們就可以開始尋找我們的目標視圖
  2. 如果在視圖即將從視圖層次結構中刪除時調用該方法,則等待runloop循環完成可確保視圖已被刪除(從而使搜索失敗)

當SwiftUI觸發updateUIView(_:context)這個方法時,UIKitIntrospectionView調用selector方法我們從最初的便利修飾符實現中繼承過來的方法:
selector有一個(IntrospectionUIView) -> TargetViewType?方法簽名。它接受IntrospectIntrospectionUIView的視圖作為輸入,并返回一個可選的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應用程序必須擁有的庫之一。它的執行優雅、安全,它的優點遠遠大于將其作為依賴項添加的缺點。

當向我們的項目添加一個依賴項時,我們應該盡可能地理解這個依賴項是做什么的,我希望這篇文章能幫助你做到這一點。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容