MacのウィンドウにFoundationの配列を表示するには、要素の型を検証し、各値に対するビューを作成します。NSArrayを[String]にブリッジすると文字列を取得できますが、それらを表示するには、別途AppKitのラベルに変換する必要があります。

この解説は、自分のmacOSアプリに小さなリストを追加する開発者向けです。具体的には、合成データの名前Circle、Square、Triangleの3つを縦に並べて表示します。この例では、コレクションの型、要素の型、レイアウト、オブジェクトのライフタイムを明示します。

コレクションのブリッジングで実際に何が変わるのか?

Appleは、ArrayとNSArrayを、StringとNSStringと同様に、SwiftとFoundationで対応する型として説明しています。通常のSwiftコードではSwiftの値型を使い、その振る舞いが必要な場合にFoundationの参照型へアクセスすることを推奨しています。このタスクでは、入力境界ではFoundationのコレクションを維持し、その後は型指定されたSwift配列を使います。Working with Foundation Types

コレクションの外側の型と要素の型は、異なる問いに答えます。NSArrayは、オブジェクトの順序付きコレクションであることを示します。[String]は、すべての要素をSwiftの文字列として使用できるという具体的な要件を加えます。どちらの宣言も、ビューのコレクションを表すものではありません。Appleのスタック初期化子が明示的に要求するのは[NSView]です。NSArray、init(views:)

段階 この例での型 契約
Foundationの入力 NSArray 渡された合成オブジェクトを含む
検証済みモデル [String] すべての要素が文字列の要件を満たす
表示オブジェクト [NSView] 各要素は新しく作成されたテキストラベル
レイアウトコンテナ NSStackView 設定に従ってビューを配置する

重要なのは、ブリッジングとオブジェクトの生成を区別することです。ブリッジングは、Foundationの文字列とSwiftの文字列など、対応する表現をつなぎます。NSTextFieldオブジェクトを生成すると、それぞれ固有のプロパティとライフタイムを持つ表示オブジェクトが作られます。文字列からラベルへの文書化されたブリッジはありません。サポートされている方法は、文字列ごとにラベル初期化子を呼び出すことです。Array、init(labelWithString:)

項目を取りこぼさずに要素を検証するには?

入力コレクションの要素の契約を確認する必要がある場合は、条件付きキャストを使います。Appleの説明によると、as?はオプショナルの結果を返し、失敗した強制キャストas!は実行時エラーを発生させます。インターフェイスを構築する前に、失敗を明示的に処理してください。Handling Dynamically Typed Methods and Objects in Swift

次の例は、合成入力を使ったFoundationだけの完全な検証です。BridgeCheck.swiftとして保存し、Swiftツールを備えたMacでswift BridgeCheck.swiftを実行してください。アサーションで期待する結果を表しています。

import Foundation

let source: NSArray = [
    NSString(string: "Circle"),
    NSString(string: "Square"),
    NSString(string: "Triangle")
]

let names = source as? [String]
assert(names == ["Circle", "Square", "Triangle"])

let mixed: NSArray = [NSString(string: "Circle"), NSNumber(value: 7)]
assert((mixed as? [String]) == nil)

let empty: NSArray = []
assert((empty as? [String]) == [])

print("Collection checks passed.")

最初のキャストは文字列の配列を要求するため、型指定されたコレクションを取得する際に、必要な要素型も検証します。異なる型が混在する入力はこの契約を満たせません。空の入力は、互換性のない要素を含まないため満たせます。これらは選択した入力契約を確認する例であり、測定結果やアプリケーションの実行結果ではありません。

混在したコレクションを通すためだけにcompactMap { $0 as? String }へ置き換えるのは避けてください。Appleの定義では、compactMapは変換結果のうちnilでないものだけを返します。この例で使うと数値が破棄され、不完全なリストが表示されます。明示的に求められている場合はフィルタリングが適切なこともありますが、ここでは渡されたすべての項目を保持する必要があります。compactMap(_:)

検証に成功したら、mapを使って名前ごとにラベルを1つ作成します。Appleの説明では、mapはシーケンスの各要素に変換を適用し、NSTextField(labelWithString:)はテキストを選択できない、折り返しのない静的ラベルを作成します。このため、短い名前3つの変換に適しています。map(_:)、init(labelWithString:)

Macのウィンドウ全体をどう構築するのか?

この単独で動作する例にはmacOS 14以降と、インストール済みのXcodeコマンドラインツールが必要です。インターフェイスをプログラムで作成するため、ストーリーボード、nib、アセット、追加のソースファイルは不要です。macOS 14という前提条件は、アクティベーションを要求するものの、それを保証しないNSApplication.activate()の使用によるものです。activate()

以下の完全なプログラムをCollectionDemo.swiftとして保存してください。意図的にエントリポイントも含めています。既存のアプリに適用する場合は、2つ目のエントリポイントを追加せず、そのアプリで確立されているエントリポイントとライフサイクルを使ってください。

import Foundation
import AppKit

@MainActor
final class DemoDelegate: NSObject, NSApplicationDelegate {
    var window: NSWindow?

    func applicationDidFinishLaunching(_ notification: Notification) {
        let source: NSArray = [
            NSString(string: "Circle"),
            NSString(string: "Square"),
            NSString(string: "Triangle")
        ]

        guard let names = source as? [String] else {
            print("Expected a collection containing only strings.")
            NSApplication.shared.terminate(nil)
            return
        }

        let labels: [NSView] = names.map { name in
            NSTextField(labelWithString: name)
        }

        let stack = NSStackView(views: labels)
        stack.orientation = .vertical
        stack.alignment = .leading
        stack.spacing = 8
        stack.translatesAutoresizingMaskIntoConstraints = false

        let content = NSView(frame: NSRect(
            x: 0, y: 0, width: 360, height: 180
        ))
        content.addSubview(stack)

        NSLayoutConstraint.activate([
            stack.leadingAnchor.constraint(
                equalTo: content.leadingAnchor, constant: 20
            ),
            stack.topAnchor.constraint(
                equalTo: content.topAnchor, constant: 20
            ),
            stack.trailingAnchor.constraint(
                lessThanOrEqualTo: content.trailingAnchor, constant: -20
            ),
            stack.bottomAnchor.constraint(
                lessThanOrEqualTo: content.bottomAnchor, constant: -20
            )
        ])

        let window = NSWindow(
            contentRect: content.frame,
            styleMask: [.titled, .closable, .miniaturizable],
            backing: .buffered,
            defer: false
        )
        window.title = "Synthetic Shapes"
        window.isReleasedWhenClosed = false
        window.contentView = content
        self.window = window
        window.center()
        window.makeKeyAndOrderFront(nil)
        NSApplication.shared.activate()
    }

    func applicationShouldTerminateAfterLastWindowClosed(
        _ sender: NSApplication
    ) -> Bool {
        true
    }
}

@main
@MainActor
enum CollectionDemo {
    static let appDelegate = DemoDelegate()

    static func main() {
        let app = NSApplication.shared
        app.setActivationPolicy(.regular)
        app.delegate = appDelegate
        app.run()
    }
}

ファイルがあるディレクトリで、ターミナルからコンパイルして起動します。

xcrun swiftc -parse-as-library CollectionDemo.swift -o CollectionDemo
./CollectionDemo

指定した入力では、Synthetic Shapesというタイトルのウィンドウに3つのラベルが元の順序で表示されます。ウィンドウを閉じるとアプリケーションの終了が要求されます。このサンプルは、その動作のためにAppleが用意しているデリゲートメソッドからtrueを返します。applicationShouldTerminateAfterLastWindowClosed(_:)

これらの例は確認しましたが、ここではコンパイルも実行もしていません。調査環境にはSwiftコンパイラもmacOS AppKitもありません。サンプルを組み込む前に、対象のMacでFoundationの検証を実行し、ウィンドウを確認してください。

どのレイアウトとライフタイムの契約を維持する必要があるのか?

スタック初期化子の文書化されたデフォルト設定は重要です。横向きのスタックを作成し、渡されたビューを先頭側のグラビティ領域に配置し、スタックのautoresizing maskの制約変換を無効にします。この例では、向きを縦に、配置を先頭揃えに明示的に変更しています。配列のキャストでは、こうしたレイアウト設定は決まりません。init(views:)、alignment

スタックは、内部に含まれるビューの配置を管理します。スタックと、それを囲むコンテンツビューとの間の制約によって、その配置をウィンドウ内のどこに置くかを指定します。ここでは、先頭と上端の制約で20ポイントの余白を設け、末尾と下端には不等式制約を使うことで、短いリストがコンテンツ領域全体を埋めなくてもよいようにしています。AppleはスタックビューがAuto Layoutを使うことを説明しており、autoresizing maskの制約変換を無効にすると独自に制約を指定できるとしています。NSStackView、translatesAutoresizingMaskIntoConstraints

これは、短いラベル3つを表示する固定サイズのデモです。テキストが長い場合、行数が多い場合、またはサイズ変更可能なインターフェイスが必要な場合は、サンプルを拡張する前に、折り返しやスクロールなどのレイアウト方法を別途決める必要があります。大きなコレクションのブリッジングに成功しても、表示領域に収まるとは限りません。

ライフタイムも型変換とは別の問題です。Appleはアプリケーションのデリゲートプロパティをweakとして宣言しています。そのため、静的なappDelegateプロパティが明示的な強参照の保持者になります。デリゲートはウィンドウも保持し、ウィンドウではAppleがSwiftおよびARCのクライアント向けに説明している方法に従って、isReleasedWhenClosedをfalseに設定します。delegate、isReleasedWhenClosed

インターフェイスの構築はapplicationDidFinishLaunchingで行います。Appleはここを、メインループの開始後、イベント処理の前に追加の初期化を行う場所として説明しています。プログラムのエントリポイントではrun()を呼び出して、そのイベントループを開始します。変換処理を既存のAppKitアプリに移すときも、こうした役割分担を維持してください。applicationDidFinishLaunching(_:)、run()

ラベルも、生成時点のスナップショットを表しています。この例では、各ラベルのテキストを一度だけ設定します。その後で元のコレクションが変わった場合、ラベルを更新するか、配置済みビューを再構築する明示的なコードが必要です。ブリッジングやマッピング処理によって、継続的な連動が確立されるわけではありません。

コンテナと要素の取り違えをすばやく見つけるには?

次の手順で、どの契約に問題があったかを特定します。型検証と表示、ライフタイムの確認を分けているため、レイアウトの問題を理由に、成功したブリッジを書き直す必要はありません。

症状 確認する点 サポートされている次の手順
[String]へのキャストがnilを返す すべての入力要素が文字列の要件を満たしているか? コレクションを明示的に拒否するか、意図した変換方針を定める。
配列全体をStringにキャストしようとしている 変換先で必要なのは単一の値か、コレクションか? [String]を検証し、タスクで本当に必要な場合に限って要素を選ぶ。
スタック初期化子が引数を受け付けない 引数は[String]ではなく[NSView]か? 先に各文字列をラベルに変換する。
ラベルが横向きに並ぶ スタックの向きを設定したか? このリストには.verticalを設定する。
ビューの位置がずれる、または制約が競合する 親ビューの制約とautoresizing maskの設定は整合しているか? 入力キャストとは切り分けてレイアウトを調べる。
デリゲートのコールバックが届かない 強参照するオブジェクトがデリゲートを保持しているか? weakプロパティへの代入後も、デリゲートが生存するよう保持する。
後からモデルを変更しても表示に反映されない 明示的な更新コードがあるか? ラベルの値または配置済みビューを明示的に更新する。

このタスクで確実な手順は、Foundationの入力を一度検証し、その結果得られた型指定モデルを保持し、値ごとにラベルを1つ作成して、スタックとアプリのライフタイムを明示的に設定することです。各手順の契約が明確なので、Macのインターフェイスが大きくなっても問題を診断しやすくなります。

参考資料