WWDC Quick Look 💓 By SwiftGGTeam
Use SwiftUI with AppKit

Use SwiftUI with AppKit

元の動画を見る

ハイライト

Shortcuts チームの Ian は、macOS アプリで SwiftUI と AppKit を混合するさまざまなシナリオを体系的に説明するための実践的なケースとして Shortcuts アプリを使用しました。

主要内容

ショートカットが macOS に登場した後、チームは共通の問題に直面しました。Mac アプリにはすでに AppKit ウィンドウ、分割ビュー、コレクション ビュー、メニュー、応答チェーンが備わっていましたが、多くのクロスプラットフォーム インターフェイスはすでに SwiftUI で記述されていました。すべてを書き直すにはコストがかかり、直接埋め込むと状態、レイアウト、フォーカス、パフォーマンスの問題が発生します。

イアンのアプローチは、ショートカットの実際のインターフェイスを分解してそれについて話すことです。左側のサイドバーはSwiftUIですList、外側の窓はまだですNSSplitViewController。ショートカットグリッドの使用NSCollectionView、単一のセルで SwiftUI ショートカット カードをホストします。エディターは SwiftUI View ですが、AppKit メイン メニューの切り取り、コピー、貼り付け、およびカスタム コマンドに応答します。最後に、AppleScript エディタは依然として AppKit ですNSView、SwiftUIレイアウトに入れる必要があります。

このセッションの中心は、Mac アプリ全体を一度に移行する必要がないことです。これにより、一連の縫い目が得られます。NSHostingControllerNSHostingViewObservableObjectNSViewRepresentable、コーディネーター、レスポンダーチェーン、フォーカス。それぞれの継ぎ目は移行シナリオに対応しており、SwiftUI を部分的に採用できます。

詳細

AppKit 分割ビューのホスト SwiftUI サイドバー

(01:29) ショートカット メイン ウィンドウの外側のレイヤーは AppKit 分割ビューで、左側のサイドバーは SwiftUI を使用しますList成し遂げる。選択した項目はサイドバー内に維持され、オプションはSidebarItem列挙表現。

struct SidebarView: View {
    @State private var selectedItem: SidebarItem
    
    var body: some View {
        List(selection: $selectedItem) {
            ...
            Section("Shortcuts") { ... }
            Section("Folders") { ... }
        }
    }
}

enum SidebarItem: Hashable {
    case gallery
    case allShortcuts
    ...
    case folder(Folder)
}

キーポイント:

  • SidebarViewサイドバーインターフェースを独立して記述できる通常のSwiftUI Viewです。
  • @State private var selectedItem現在選択されている位置を保存します。
  • List(selection:)リストの選択を状態にバインドします。
  • Section("Shortcuts")そしてSection("Folders")ショートカットに対応するナビゲーション グループ。
  • SidebarItem: Hashable各ナビゲーション ターゲットをリスト選択値として使用できるようにします。

(01:53) この SwiftUI サイドバーを AppKit に入れます。入り口は次のとおりです。NSHostingController。通常のビューコントローラーと同じように使用されるため、次のように直接ラップできます。NSSplitViewItem

let splitViewController = NSSplitViewController()

let sidebar = NSHostingController(rootView: SidebarView(...))
let splitViewItem = NSSplitViewItem(viewController: sidebar)
splitViewController.addSplitViewItem(splitViewItem)

キーポイント:

  • NSSplitViewController()AppKit は引き続きウィンドウ構造を管理します。
  • NSHostingController(rootView:)SwiftUIを入れるSidebarViewAppKit で使用できるビュー コントローラーになります。
  • NSSplitViewItem(viewController:)マネージド コントローラーを受け取ります。追加のブリッジ層は必要ありません。
  • addSplitViewItemSwiftUI サイドバーを分割ビューに追加します。

共有モデルを使用して選択ステータスを AppKit に渡す

(03:06) サイドバーの選択は当初、SwiftUI 内にのみ存在していました。右側の詳細ページは AppKit の分割ビューによって管理されており、選択された項目を知る必要があります。Ian の解決策は、状態を SwiftUI の外に移動することです。ObservableObject

class SelectionModel: ObservableObject {

    @Published var selectedItem: SidebarItem = .allShortcuts

}

// AppKit Window Controller
cancellable = selectionModel.$selectedItem.sink { newItem in
    // update the NSSplitViewController detail
}

キーポイント:

  • SelectionModel参照型であり、AppKitとSwiftUIで同時に保持できます。
  • ObservableObjectモデルが変更されたときに SwiftUI でビューを更新できるようにします。
  • @Published var selectedItem現在の選択範囲を公開し、結合パブリッシャーを生成します。
  • AppKit ウィンドウ コントローラーのサブスクリプション$selectedItem
  • sinknewItem更新に使用したNSSplitViewController詳細エリア。

このパターンは、既存の AppKit アーキテクチャに適しています。SwiftUI ビュー ツリーでは状態がロックされず、AppKit コントローラーは引き続きウィンドウ調整を処理できます。

コレクション ビュー セルで NSHostingView を再利用する

(04:37) コレクションビューとテーブルビューのセルは再利用されます。スクロール中にサブビューの追加と削除を繰り返すと、インターフェイスの速度が低下します。公式の推奨事項は、各セルを 1 回作成することです。NSHostingView、その後はそれを交換するだけですrootView

class ShortcutItemView: NSCollectionViewItem {
    private var hostingView: NSHostingView<ShortcutView>?

    func displayShortcut(_ shortcut: Shortcut) {
        let shortcutView = ShortcutView(shortcut: shortcut)

        if let hostingView = hostingView {
            hostingView.rootView = shortcutView
        } else {
            let newHostingView = NSHostingView(rootView: shortcutView)
            view.addSubview(newHostingView)
            setupConstraints(for: newHostingView)
            self.hostingView = newHostingView
        }
    }
}

キーポイント:

  • ShortcutItemViewまだAppKitNSCollectionViewItem
  • hostingViewSwiftUI マネージド ビューを現在のセル内にキャッシュします。
  • displayShortcut(_:)セルの構成時にデータ ソースによって呼び出されます。
  • ShortcutView(shortcut:)現在のモデルの新しい SwiftUI 記述を生成します。
  • すでにhostingView次の場合にのみ更新しますrootView、マネージド ビュー階層全体の再構築を避けるため。
  • コンテンツの初回表示時に作成されますNSHostingView、セルを追加し、自動レイアウト制約を設定します。

(06:08) セルが画面からロールオフして別のショートカットが表示されると、SwiftUI は元の構造を再利用し、変更された画像、テキスト、背景のみを更新します。この詳細は重要です: 再利用NSHostingViewAppKitのセル再利用とSwiftUIのビュー更新モデルを連携させます。

SwiftUI マネージド ビューを AppKit レイアウトとウィンドウ サイズに参加させる

06:35NSHostingControllerそしてNSHostingView固有のサイズは、SwiftUI ビューの理想的な幅と理想的な高さに基づいて生成されます。SwiftUI は、AppKit Auto Layout で使用される最小サイズと最大サイズの制約も生成します。

これにより、2 つの実際的なルールが生じます。

まず、埋め込みますNSHostingView最後に、AppKit 側のビューと親ビューに制約を追加する必要があります。SwiftUI の内部frameこのようなレイアウト変更により、管理ビューによって課される固定幅などの制約が変更されます。

第二に、もしHostingViewウィンドウの最上位レベルですcontentView, SwiftUI は、コンテンツに基づいてウィンドウの最小サイズと最大サイズを更新します。ウィンドウを水平または垂直に拡大縮小できるかどうかは、SwiftUI のコンテンツによって変わります。

(07:55) モーダル表示では、ホスティング コントローラーを直接使用することもできます。ポップオーバー、シート、モーダル ウィンドウはすべて AppKit API を使用し、コンテンツ サイズは SwiftUI によって提供されます。

viewController.present(NSHostingController(rootView: ...), 
    asPopoverRelativeTo: rect, of: view, 
    preferredEdge: .maxY, behavior: .transient)

キーポイント:

  • NSHostingController(rootView:)ポップオーバーのコンテンツ コントローラーとして直接。
  • asPopoverRelativeTo: rectポップオーバーがどの領域からポップアップするかを指定します。
  • of: view参照ビューを指定します。
  • preferredEdge: .maxY優先するポップアップ方向を指定します。
  • behavior: .transientAppKit の一時的なポップオーバー動作を使用します。

(08:15) シートは短く、ホスティング コントローラーを渡すだけで済みます。presentAsSheet

viewController.presentAsSheet(NSHostingController(rootView: ...))

キーポイント:

  • presentAsSheetAppKitのシート表示方法です。
  • SwiftUI ビューが渡されましたNSHostingControllerシートを入力します。
  • シートのコンテンツのサイズは、ホストされている SwiftUI コンテンツによって決まります。

(08:22) モーダルウィンドウはタイトルを設定してから呼び出すことができますpresentAsModalWindow

let hostingController = NSHostingController(rootView: ModalView())
hostingController.title = "Window Title"
viewController.presentAsModalWindow(hostingController)

キーポイント:

  • ModalView()SwiftUIのコンテンツです。
  • hostingController.titleモーダルウィンドウのウィンドウタイトルになります。
  • presentAsModalWindowインタラクションをブロックするモーダル ウィンドウを表示します。
  • ウィンドウ サイズは SwiftUI コンテンツに適応します。

(08:45) macOS Ventura には、自動制約を制御するための新しい API があります。最小サイズ、固有サイズ、および最大サイズの制約がデフォルトで作成されます。必要に応じて調整できますsizingOptions

hostingController.sizingOptions = [.minSize, .intrinsicContentSize, .maxSize]

キーポイント:

  • sizingOptionsホスティング コントローラーによってどのサイズ制約が自動的に作成されるかを制御します。
  • .minSize最小サイズに対応します。
  • .intrinsicContentSize理想的なコンテンツサイズに対応します。
  • .maxSize最大サイズに対応します。
  • 外側の AppKit がすでに制約を提供している場合、またはビューを常に柔軟にしたい場合は、これらのオプションを減らすことができます。

SwiftUI View をレスポンシブ チェーンとフォーカス システムに入力させます

(10:47) ショートカットのエディターは SwiftUI View ですが、メイン メニューは AppKit にあります。メニュー項目がトリガーされた後、ファーストレスポンダーからの応答チェーンに沿ってセレクターが渡されます。SwiftUIにおけるファーストレスポンダに相当する概念はフォーカスビューです。

Image(...)
    .focusable()
    .copyable { ... }
    .cuttable { ... }
    .pasteDestination(payloadType: Image.self) { ... }

キーポイント:

  • Image(...)キーボードとメニュー コマンドを受け取る必要がある SwiftUI View です。
  • .focusable()このビューをフォーカスターゲットにします。
  • .copyableデータをペーストボードにコピーする処理を提供します。
  • .cuttableシャー加工を施します。
  • .pasteDestination(payloadType:)受信できるペーストデータの種類を宣言します。

(11:02) SwiftUI は、コピー、切り取り、貼り付けに加えて、矢印キー、Escape、AppKit 標準セレクター、およびカスタム セレクターも処理できます。

struct ShortcutsEditorView: View {
    var body: some View {
        ScrollView { ... }
            .onMoveCommand { moveSelection(direction: $0) }
            .onExitCommand { cancelOperations() }
            .onCommand(#selector(NSResponder.selectAll(_:)) { selectAllActions() }
            .onCommand(#selector(moveActionUp(_:)) { moveSelectedAction(.up) }
            .onCommand(#selector(moveActionDown(_:)) { moveSelectedAction(.down) }
    }
}

キーポイント:

  • ScrollView { ... }ショートカットエディタ本体です。
  • .onMoveCommand矢印キーの移動選択を処理します。
  • .onExitCommandEscape などの終了コマンドを処理します。
  • #selector(NSResponder.selectAll(_:))AppKit の select all 標準コマンドを受け入れます。
  • #selector(moveActionUp(_:))そして#selector(moveActionDown(_:))ショートカットのカスタム メニュー コマンドを受け取ります。
  • コード スニペットは公式ビデオからのものです。この例では、コマンド マウントの場所に焦点を当て、特定の実装といくつかの終了記号を省略しています。

(11:28) キーボード ナビゲーションをテストするときは、キーボード システム設定でフル キーボード ナビゲーションをオンまたはオフにします。多くのコントロールは、この設定がオンの場合にのみフォーカス可能です。

SwiftUI で AppKit NSView をホストする

(12:37) 移行の方向を逆にすることもできます。Shortcuts の AppleScript エディターは、SwiftUI エディター レイアウトに配置される AppKit コントロールです。SwiftUI が提供するNSViewRepresentableそしてNSViewControllerRepresentable、AppKit ビューまたはビュー コントローラーを SwiftUI ビューとして記述するために使用されます。

(15:18) 元の AppKit コントロールには独自のプロパティとデリゲートがあります。

class ScriptEditorView: NSView {
    var sourceCode: String
    var isEditable: Bool
    weak var delegate: ScriptEditorViewDelegate?
}

protocol ScriptEditorViewDelegate: AnyObject {
    func sourceCodeDidChange(in view: ScriptEditorView) -> Void
}

キーポイント:

  • ScriptEditorView継承元NSView、それが既存の AppKit コントロールであることを示します。
  • sourceCodeエディターのテキストを保存します。
  • isEditable編集を許可するかどうかを制御します。
  • delegateAppKit 側の変更を通知する責任を負います。
  • sourceCodeDidChangeソースコードが変更されたときに呼び出されます。

(15:40) SwiftUI コンテナはソース コードのステータスを@Stateの場合、「コンパイル」ボタンは同じステータスを読み取ります。

struct ScriptEditorContainerView: View {
    @State var sourceCode: String = ""

    var body: some View {
        VStack {
            CompileButton { compile(code: sourceCode) }
            Divider()
            ScriptEditorRepresentable(sourceCode: $sourceCode)
        }
    }
}

キーポイント:

  • @State var sourceCodeSwiftUI 側の単一の状態ソースです。
  • CompileButtonハンドラーは現在のsourceCodeコードをコンパイルします。
  • Divider()ボタンとエディターを分離します。
  • ScriptEditorRepresentable(sourceCode: $sourceCode)バインディングを AppKit ラッパーに渡します。

16:13NSViewRepresentableAppKit ビューの作成、更新、調整を担当します。コーディネーターはデリゲート コールバックを受信し、SwiftUI バインディングを書き戻します。

struct ScriptEditorRepresentable: NSViewRepresentable {
    @Binding var sourceCode: String

    func makeNSView(context: Context) -> ScriptEditorView {
        let scriptEditor = ScriptEditorView(frame: .zero)
        scriptEditor.delegate = context.coordinator
        return scriptEditor
    }

    func updateNSView(_ nsView: ScriptEditorView, context: Context) {
        if sourceCode != scriptEditor.sourceCode {
            scriptEditor.sourceCode = sourceCode
        }
        scriptEditor.isEditable = context.environment.isEnabled
        // Make sure coordinator has a reference to the current value 
        // of the binding:
        context.coordinator.representable = self
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(representable: self)
    }
}

class Coordinator: NSObject, ScriptEditorViewDelegate {
    var representable: ScriptEditorRepresentable

    init(representable: ScriptEditorRepresentable) { ... }

    func sourceCodeDidChange(in view: ScriptEditorView) {
        representable.sourceCode = view.sourceCode
    }
}

キーポイント:

  • @Binding var sourceCodeラッパーに SwiftUI コンテナの状態を読み書きさせます。
  • makeNSView作成するScriptEditorView、1 回限りのセットアップに適しています。
  • scriptEditor.delegate = context.coordinatorAppKit デリゲート イベントをコーディネーターに引き渡します。
  • updateNSViewSwiftUI の状態または環境が変化したときに呼び出されます。
  • if sourceCode != scriptEditor.sourceCodeソース コードを設定すると余分な作業が発生するため、プロパティを繰り返し設定しないようにしてください。
  • context.environment.isEnabledAppKitへのマッピングisEditable
  • context.coordinator.representable = selfコーディネーターのバインディングを最新の値に保ちます。
  • sourceCodeDidChangeAppKitの編集結果をSwiftUIに書き戻します。

公式クリップではupdateNSViewパラメータ名はnsView、という状態で登場しました。scriptEditor。ビデオ クリップの元のテキストはここに保持されます。実際のプロジェクトでは、現在のメソッドで取得した AppKit ビュー インスタンスを使用する必要があります。

重要ポイント

  • ハイブリッド Mac サイドバーを作成: AppKit ウィンドウと分割ビューを維持し、サイドバーのみを SwiftUI に変更します。List。実行する価値がある理由: ナビゲーション構造には明確な境界があるため、多くの場合、ナビゲーション構造を最初に移行するのが最適です。開始方法: 使用するNSHostingController(rootView:)SwiftUI サイドバーをラップし、共有選択状態を内部に配置します。ObservableObject

  • 複雑なセルを SwiftUI に移行:NSCollectionViewItemまたはNSTableCellView社内向けNSHostingViewカードインターフェースを表示します。実行する価値がある理由: SwiftUI でのカード レイアウトの作成はより簡単で、AppKit は引き続き高パフォーマンスのリスト コンテナーを担当します。開始方法: セルごとに 1 つをキャッシュしますNSHostingViewを交換するだけです。rootView

  • Mac メニュー コマンドを SwiftUI エディターに接続: SwiftUI エディターがすべて選択、矢印キー、エスケープ、およびカスタム メニュー項目に応答できるようにします。価値がある理由: Mac ユーザーは、メニュー バーとキーボード ショートカットが利用できることを期待しています。開始方法: インタラクティブなビューを追加する.focusable()、次に使用します.onCommand.onMoveCommand.onExitCommandコマンドを受信します。

  • 既存の AppKit コントロールを SwiftUI ページに配置: スクリプト エディター、画像エディター、複雑なテキスト コントロールなどを成熟した状態に保ちます。NSView。実行する価値がある理由: 一部のコントロールには長年にわたり動作と委任エコシステムがあり、書き換えのリスクが高くなります。開始方法: 作成するNSViewRepresentable、使用makeNSView初期化するには、次を使用しますupdateNSView状態と環境を同期するには、コーディネーターを使用してデリゲートを接続します。

関連セッション

コメント

GitHub Issues · utterances