SwiftUI:Introspect

在開發(fā)應(yīng)用時(shí),SwiftUI提高了開發(fā)效率。
SwiftUI大概可以滿足任何現(xiàn)代應(yīng)用程序需求的95%,而剩下的5%則是通過退回到以前的UI框架。
我們有兩種主要的回退方法:

  • SwiftUI的 UIViewRepresentable/NSViewRepresentable
  • SwiftUI Introspect

什么是SwiftUI Introspect

SwiftUI Introspect是一個(gè)開源庫(kù)。它的主要目的是獲取和修改任何SwiftUI視圖的底層UIKit或AppKit元素。

這是可能的,因?yàn)樵S多SwiftUI視圖(仍然)依賴于它們的UIKit,例如:

  • 在macOS中,Button在幕后使用NSButton
  • 在iOS中,TabView在幕后使用UITabBarController

我們很少需要知道這樣的實(shí)現(xiàn)細(xì)節(jié)。然而,知道這一點(diǎn)給了我們另一個(gè)強(qiáng)大的工具,我們可以在需要的時(shí)候使用。這正是SwiftUI Introspect發(fā)揮作用的地方。

SwiftUI Introspect的使用

SwiftUI Introspect在func introspectX(customize: @escaping (Y) -> ()) -> some View模式之后提供了一系列視圖修飾符,其中:

  • X是我們的目標(biāo)視圖
  • Y是底層的UIKit/AppKit視圖/視圖控制器類型

假設(shè)我們想要從ScrollView中移除彈性效果。目前,SwiftUI沒有相應(yīng)的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系統(tǒng)中,用戶可以通過向下滑動(dòng)表單來關(guān)閉表單。在UIKit中,我們可以通過isModalInPresentation UIViewController屬性阻止這種行為,讓我們的應(yīng)用程序邏輯控制表單的顯示。在SwiftUI中,我們還沒有類似的方法。

同樣,我們可以使用Introspect來抓取呈現(xiàn)表UIViewController,并設(shè)置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的一個(gè)小功能缺失,我們不得不在UIKit/AppKit中重新實(shí)現(xiàn)一個(gè)完整的復(fù)雜功能:Introspect是一個(gè)不可思議的時(shí)間節(jié)省器。

我們已經(jīng)看到了它的明顯好處:接下來,讓我們揭開SwiftUI Introspect是如何工作的。

SwiftUI Introspect如何工作的

我們將采用UIKit路徑:除了UI/NS前綴,AppKit的代碼是相同的。

為了清晰起見,本文中所示的代碼進(jìn)行了輕微的調(diào)整。最初的實(shí)現(xiàn)可以在SwiftUI Introspect的存儲(chǔ)庫(kù)中找到。

injection注入

正如上面的例子所示,Introspect為我們提供了各種視圖修飾符。如果我們看看它們的實(shí)現(xiàn),它們都遵循類似的模式。這里有一個(gè)例子:

extension View {
  /// Finds a `UITextView` from a `TextEditor`
  public func introspectTextView(
    customize: @escaping (UITextView) -> ()
  ) -> some View {
    introspect(
      selector: TargetViewSelector.siblingContaining, 
      customize: customize
    )
  }
}

所有這些公共introspectX(customize:)視圖修飾符都是一個(gè)更通用的introspect(selector:customize:)的方便實(shí)現(xiàn):

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
      )
    )
  }
}

這里我們看到另一個(gè)介紹inject(_:)``View試圖修飾符,和第一個(gè)Introspect試圖,UIKitIntrospectionView:

extension View {
  public func inject<SomeView: View>(_ view: SomeView) -> some View {
    overlay(view.frame(width: 0, height: 0))
  }
}

inject(_:)采用我們的原始視圖,并在頂部添加一個(gè)給定視圖的覆蓋層,其框架最小化。

例如,如果我們有以下視圖:

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層次結(jié)構(gòu)中注入一個(gè)IntrospectionUIView
  • 對(duì)UIViewRepresentable具象的updateUIView生命周期事件做出反應(yīng)

這是IntrospectionUIView的定義:

public class IntrospectionUIView: UIView {
  required init() {
    super.init(frame: .zero)
    isHidden = true
    isUserInteractionEnabled = false
  }
}

IntrospectionUIView是一個(gè)最小的、隱藏的、非交互的UIView:它的全部目的是給SwiftUI Introspect一個(gè)進(jìn)入U(xiǎn)IKit層次結(jié)構(gòu)的入口點(diǎn)。

總之,所有的.introspectX(customize:)視圖修改器覆蓋了一個(gè)微小的,不可見的,非交互的視圖在我們的原始視圖之上,確保它不會(huì)影響我們最終的UI。

實(shí)現(xiàn)原理

我們已經(jīng)看到了SwiftUI Introspect是如何獲取UIKit層次結(jié)構(gòu)的。庫(kù)剩下要做的就是找到我們要找的UIKit視圖或視圖控制器。

回到UIKitIntrospectionView的實(shí)現(xiàn)中,神奇的事情發(fā)生在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的例子中,這個(gè)方法主要在兩個(gè)場(chǎng)景中被SwiftUI調(diào)用:

  • 當(dāng)IntrospectionUIView即將被添加到視圖層次結(jié)構(gòu)時(shí)
  • 當(dāng)IntrospectionUIView要從視圖層次結(jié)構(gòu)中移除時(shí)

asyncdispatch有兩個(gè)函數(shù):

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

當(dāng)SwiftUI觸發(fā)updateUIView(_:context)這個(gè)方法時(shí),UIKitIntrospectionView調(diào)用selector方法我們從最初的便利修飾符實(shí)現(xiàn)中繼承過來的方法:
selector有一個(gè)(IntrospectionUIView) -> TargetViewType?方法簽名。它接受IntrospectIntrospectionUIView的視圖作為輸入,并返回一個(gè)可選的TargetViewType,這是我們想要達(dá)到的原始視圖或視圖控制器類型的通用表示。

如果搜索成功,我們就調(diào)用customize,這是我們?cè)谝晥D上應(yīng)用Introspect的視圖修改器時(shí)傳遞或定義的方法,從而對(duì)底層的UIKit/AppKit視圖或視圖控制器進(jìn)行更改。

回到我們的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是一個(gè)Swift的enum類型,使它成為一個(gè)靜態(tài)方法的容器,意味著可以直接調(diào)用,所有的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)
  }

  ...
}

第一步是找到一個(gè)視圖的持有者(宿主):
SwiftUI將每個(gè)UIViewRepresentable視圖包裝在一個(gè)宿主視圖中,與PlatformViewHost<PlatformViewRepresentableAdaptor<IntrospectionUIView>>有關(guān)的,然后封裝到一個(gè)類型為_UIHostingView的“托管視圖”中,表示一個(gè)能夠托管SwiftUI視圖的UIView

為了獲得視圖持有者,Introspect從另一個(gè)無(wú)Introspect enum中使用findViewHost(from:)靜態(tài)方法:

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
  }

  ...
}

這個(gè)方法從我們的IntrospectionUIView開始,遞歸地查詢每個(gè)superview父視圖,直到找到一個(gè)視圖持有者:如果我們找不到視圖宿主,我們的IntrospectionUIView還不是屏幕層次結(jié)構(gòu)的一部分,我們的查找會(huì)立即停止。

一旦我們有了視圖的宿主,我們有了尋找目標(biāo)視圖的起點(diǎn),這就是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
  }

  ...
}

這個(gè)新的靜態(tài)方法接受所有viewHost的父視圖的子視圖(也就是viewHost的兄弟視圖),過濾在viewHost之前的子視圖,然后遞歸地搜索我們的目標(biāo)視圖(作為type參數(shù)傳遞),從最近的到最遠(yuǎn)的兄弟視圖,通過最終的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
  }

  ...
}

這個(gè)方法,通過傳遞我們的目標(biāo)視圖和一個(gè)我們的viewHost兄弟調(diào)用,將遍歷每個(gè)兄弟完整子樹視圖層次結(jié)構(gòu),尋找我們的目標(biāo)視圖,并返回第一個(gè)匹配的對(duì)象,如果有的話。

分析

既然我們已經(jīng)揭示了SwiftUI Introspect的所有內(nèi)部工作原理,那么回答常見的問題就容易多了:

它使用安全嗎?

只要我們不做太大膽的事,是安全的。重要的是要明白我們并不擁有底層的AppKit/UIKit視圖,而SwiftUI擁有。通過Introspect應(yīng)用的更改應(yīng)該可以工作,但是SwiftUI可能會(huì)在不通知的情況下隨意覆蓋它們。

這是未來的趨勢(shì)嗎?

不。隨著SwiftUI的發(fā)展,當(dāng)新的操作系統(tǒng)版本出現(xiàn)時(shí),情況可能會(huì)發(fā)生變化。當(dāng)這種情況發(fā)生時(shí),庫(kù)會(huì)更新新的補(bǔ)丁,但是我們的用戶需要在看到修復(fù)之前更新應(yīng)用程序。

我們應(yīng)該使用它嗎?

答案可能是肯定的。任何讀過這篇文章的人都完全了解庫(kù)是如何工作的:如果有什么東西壞了,我們應(yīng)該知道去哪里找并找到解決辦法。

SwiftUI Introspect的亮點(diǎn)在哪里?

向后兼容性。例如,讓我們想象一下,iOS15 List引入了下拉刷新的功能:我們知道SwiftUI Introspect允許我們?cè)趇OS13和14中添加列表下拉刷新(Introspect方法設(shè)置下拉刷新)。到那時(shí),我們可以使用Introspect針對(duì)舊的操作系統(tǒng)版本,并使用新的SwiftUI方式針對(duì)iOS15或更高版本。

這樣做可以保證不會(huì)出現(xiàn)問題,因?yàn)樾碌牟僮飨到y(tǒng)版本將使用SwiftUI的“原生”方法,只有過去的iOS版本才會(huì)使用Introspect。

什么時(shí)候不用SwiftUI Introspect?

當(dāng)我們想要完全控制一個(gè)視圖,并且無(wú)法承受與新OS版本的沖突時(shí):如果這是我們的情況,使用UIViewRepresentable/NSViewRepresentable會(huì)更安全、更有前瞻性。當(dāng)然,我們應(yīng)該總是盡可能地先找到一個(gè)“純粹的”SwiftUI方法,只有當(dāng)我們確信這是不可能的時(shí)候,才去尋找替代方法。

結(jié)論

SwiftUI Introspect是為數(shù)不多的可能是任何SwiftUI應(yīng)用程序必須擁有的庫(kù)之一。它的執(zhí)行優(yōu)雅、安全,它的優(yōu)點(diǎn)遠(yuǎn)遠(yuǎn)大于將其作為依賴項(xiàng)添加的缺點(diǎn)。

當(dāng)向我們的項(xiàng)目添加一個(gè)依賴項(xiàng)時(shí),我們應(yīng)該盡可能地理解這個(gè)依賴項(xiàng)是做什么的,我希望這篇文章能幫助你做到這一點(diǎn)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容