- マルチディスプレイ時の問題点
- NSWindow
- スクロールビュー
- ウィンドウのアクティブ化
- CGRect の型について
- 🔹 ((CGRect) -> Void)
- SwiftUIのViewで矩形描写
- エントリに複数View用意して呼ぶ
- SCShareableContent (キャプチャするウィンドウフィルタ)
- アクティブウィンドウの取得のあれこれ
- 毎回ウィンドウの位置を MainView の座標にリセットする
- ウィンドウの完全終了
- ウィンドウのリサイズ
- キャプチャウィンドウ(失敗)
- プロパティ情報
- 🟦 .onDrop の構造を理解しよう
- 翻訳機能が来た時のメモ
- NSViewRepresentable
- ウィンドウイベントの取得
- この点はリサイズハンドルか判定
- NSView でキーを拾う例
- NSTextView 作成例
- Main Thread only メモ
マルチディスプレイ時の問題点
① マルチディスプレイの座標体系は “左上原点ではない”
macOS はこういう座標体系になっている:
|
1 2 |
原点(0,0) はメインディスプレイ 左下 サブディスプレイは 0 より上や左に来る |
例:
|
1 2 3 |
メイン: (0,0)-(1920x1080) サブ1: (1920,0)-(3840x1080) サブ2: (-1600, 200)-(0,1280) |
→ ディスプレイの frame は絶対座標(グローバル座標)なので、
macOS が再配置すると値が変動し得る。
② “NSScreen.frame” と “SCDisplay.frame” は100%一致しない
Apple内部でも:
NSScreen = Cocoa系座標
SCDisplay = ScreenCaptureKitの独自座標
となっていて、
- UIスケール
- Retina / 非Retina
- Rotation
- メニューバーの高さ
などで微妙にズレる。
実際、Appleのフォーラムでも同じ例が大量に報告されている。
③ macOS 15 ではディスプレイの位置変更が “遅延適用” されることがある
Mission Control で画面構成が変わったあとなど:
- NSScreen がすぐ更新される
- ScreenCaptureKit が数百ms遅れて更新される
という差分が発生する。
→ 座標比較だとズレることがある。
NSWindow
最小
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import Cocoa class MyWindowController: NSWindowController { convenience init() { let window = NSWindow( contentRect: NSRect(x: 100, y: 100, width: 400, height: 300), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false ) self.init(window: window) window.title = "カスタムウィンドウ" } } // 呼び出し例 エントリに書く @main class AppDelegate: NSObject, NSApplicationDelegate { var windowController: MyWindowController? func applicationDidFinishLaunching(_ notification: Notification) { windowController = MyWindowController() windowController?.showWindow(nil) } } |
矩形描写するView
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
import Cocoa class SelectionView: NSView { var startPoint: NSPoint? var currentPoint: NSPoint? var onComplete: ((CGRect) -> Void)? // クリックイベント override func mouseDown(with event: NSEvent) { startPoint = event.locationInWindow } // ドラッグイベント override func mouseDragged(with event: NSEvent) { currentPoint = event.locationInWindow needsDisplay = true } // クリック終了イベント override func mouseUp(with event: NSEvent) { guard let start = startPoint else { return } let end = event.locationInWindow let rect = CGRect( x: min(start.x, end.x), y: min(start.y, end.y), width: abs(start.x - end.x), height: abs(start.y - end.y) ) onComplete?(rect) window?.close() } // 描写内容 override func draw(_ dirtyRect: NSRect) { //背景色 NSColor(calibratedWhite: 0, alpha: 0.2).setFill() bounds.fill() //矩形位置 if let start = startPoint, let current = currentPoint { let selectionRect = CGRect( x: min(start.x, current.x), y: min(start.y, current.y), width: abs(start.x - current.x), height: abs(start.y - current.y) ) //描写物 NSColor.blue.setStroke() let path = NSBezierPath(rect: selectionRect) path.lineWidth = 2 path.stroke() } } } |
画面全体を覆う透明ウィンドウ。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import Cocoa class SelectionWindow: NSWindow { init() { let screenRect = NSScreen.main!.frame // 原初クラスの初期化 super.init( contentRect: screenRect, styleMask: [.borderless], backing: .buffered, defer: false ) isOpaque = false backgroundColor = NSColor.clear ignoresMouseEvents = false level = .screenSaver // 最前面 makeKeyAndOrderFront(nil) let selView = SelectionView(frame: screenRect) contentView = selView } // 完了イベント func onComplete(_ handler: @escaping (CGRect) -> Void) { (contentView as? SelectionView)?.onComplete = handler } } |
ウィンドウの非表示
|
1 2 3 4 5 6 7 8 9 10 |
NSApp.hide(nil) mainWindow.isHidden = true //ウィンドウキーを指定して隠す func hideMainWindow() { if let window = NSApp.windows.first(where: { $0.title != "SelectionOverlay" }) { window.orderOut(nil) } } |
ウィンドウの表示
|
1 2 3 4 5 6 7 8 9 10 |
NSApp.unhide(nil) mainWindow.isHidden = false //ウィンドウキーを指定して表示する func hideMainWindow() { if let window = NSApp.windows.first(where: { $0.title != "SelectionOverlay" }) { window.orderOut(nil) } } |
|
1 2 3 4 5 |
表示非表示をメインでやれとIDEに怒られたら DispatchQueue.main.async { //ここで表示非表示を行う } |
自分を閉じる
|
1 2 3 |
private func closeThisWindow() { NSApp.keyWindow?.close() } |
おまけ
WindowLevel を .statusBar や .floating にする
.screenSaver は強力だが OS の hide/unhide と衝突しがちなので、
以下のレベルでも十分最前面になる:
例
|
1 2 3 4 5 |
window.level = .modalPanel window.level = .floating window.level = .statusBar |
おすすめは .statusBar
常に最前面で、NSApp.hide()にも巻き込まれにくい。
スクロールビュー
方法1: SwiftUIのScrollViewを使用(シンプル)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
struct ContentView: View { @State private var selectedColor: Color = .black let cgImage: CGImage @Bindable var TSO: ToolSettingsObject var body: some View { ScrollView([.horizontal, .vertical]) { CanvasView(TSO: TSO, cg_image: cgImage) .frame( width: CGFloat(cgImage.width), height: CGFloat(cgImage.height) ) } .safeAreaInset(edge: .bottom) { ToolbarView(selectedColor: $selectedColor, ...) } } } |
方法2: NSScrollViewを使用(より細かい制御)
NSScrollViewでラップしたNSViewRepresentableを作成:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
import SwiftUI import AppKit // スクロール対応のCanvasView struct ScrollableCanvasView: NSViewRepresentable { @Bindable var TSO: ToolSettingsObject let cg_image: CGImage func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true scrollView.autohidesScrollers = true scrollView.backgroundColor = .windowBackgroundColor // キャンバスビューを作成 let canvasView = CanvasNSView() canvasView.setImage(cg_image) canvasView.TSO = TSO // キャンバスのサイズを画像サイズに設定 let imageSize = CGSize( width: CGFloat(cg_image.width), height: CGFloat(cg_image.height) ) canvasView.frame = CGRect(origin: .zero, size: imageSize) // スクロールビューのドキュメントビューとして設定 scrollView.documentView = canvasView return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let canvasView = scrollView.documentView as? CanvasNSView else { return } canvasView.setImage(cg_image) canvasView.TSO = TSO // 画像サイズが変わった場合はフレームを更新 let imageSize = CGSize( width: CGFloat(cg_image.width), height: CGFloat(cg_image.height) ) canvasView.frame = CGRect(origin: .zero, size: imageSize) } } // 使用例 struct ContentView: View { @State private var selectedColor: Color = .black @State private var TSO = ToolSettingsObject() let cgImage: CGImage // 実際の画像 var body: some View { ScrollableCanvasView(TSO: TSO, cg_image: cgImage) .safeAreaInset(edge: .bottom) { ToolbarView( selectedColor: $selectedColor, penSize: .constant(2.0), selectedTool: .constant(.pen) ) } } } // ToolSettingsObjectのサンプル定義 @Observable class ToolSettingsObject { var selectedColor: NSColor = .black var penSize: CGFloat = 2.0 var selectedTool: String = "pen" } // ToolbarViewのサンプル struct ToolbarView: View { @Binding var selectedColor: Color @Binding var penSize: Double @Binding var selectedTool: String var body: some View { HStack { ColorPicker("", selection: $selectedColor) Slider(value: $penSize, in: 1...20) } .padding() .background(.ultraThinMaterial) } } |
方法3: CanvasNSView自体にスクロール機能を組み込む
既存のCanvasNSViewを修正する場合:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
final class CanvasNSView: NSView, NSTextViewDelegate { // ... 既存のコード ... // キャンバスの実際のサイズを返す override var intrinsicContentSize: NSSize { guard let image = drawing_image else { return NSSize(width: 800, height: 600) // デフォルト } return NSSize(width: image.width, height: image.height) } func setImage(_ cg_image: CGImage) { // ... 既存のコード ... // サイズ変更を通知 invalidateIntrinsicContentSize() // スクロールビューに対してサイズを通知 if let scrollView = enclosingScrollView { scrollView.documentView?.frame.size = CGSize( width: cg_image.width, height: cg_image.height ) } } } |
おすすめの実装
方法2のNSScrollViewを使う方法がおすすめです。理由:
- ✅ スクロールバーの表示/非表示を細かく制御できる
- ✅ ズーム機能も追加しやすい
- ✅ macOSネイティブのスクロール動作
- ✅ 大きな画像でもパフォーマンスが良い
ズーム機能の追加(オプション)
スクロールビューにズーム機能も追加できます:
|
1 2 3 4 |
scrollView.allowsMagnification = true scrollView.minMagnification = 0.1 scrollView.maxMagnification = 5.0 scrollView.magnification = 1.0 |
ウィンドウのアクティブ化
AppKit (NSWindow) の場合
NSApplicationのインスタンスと、アクティブにしたいウィンドウのNSWindowインスタンスが必要です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import AppKit func activateWindow(window: NSWindow) { // 1. まずアプリケーション自体をアクティブにします NSApplication.shared.activate(ignoringOtherApps: true) // 2. 次に、対象のウィンドウをキーウィンドウ/メインウィンドウにして最前面に表示します window.makeKeyAndOrderFront(nil) // アプリケーションが最小化されている場合でも最前面に表示するには、 // NSApp.windows.first?.orderFrontRegardless() を使用する方法もあります。 // この場合、ウィンドウにフォーカスが当たらないことがあります。 // window.orderFrontRegardless() } // 使用例(例えばAppDelegateやViewController内でウィンドウへの参照がある場合) // if let myWindow = self.window { // activateWindow(window: myWindow) // } |
SwiftUIの場合
SwiftUIでは、直接的なウィンドウのアクティベーションメソッドは提供されていませんが、NSApplication.shared.activate(ignoringOtherApps: true)を呼び出してアプリケーションをアクティブ状態にできます。
特定のウィンドウを表示または最前面に移動したい場合は、@Environment(.openWindow)アクションを使用し、SwiftUIに管理されているウィンドウを識別子(Hashableな値)で指定する方法があります。すでに開いているウィンドウであれば、それが再利用されて前面に表示されます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import SwiftUI import AppKit struct ContentView: View { @Environment(\.openWindow) private var openWindow var body: some View { VStack { Button("アプリケーションとウィンドウをアクティブにする") { // まずアプリをアクティブにする NSApplication.shared.activate(ignoringOtherApps: true) // 特定のウィンドウ識別子を持つウィンドウを前面に // (SwiftUIがウィンドウ管理をしている場合) // openWindow(id: "mySpecificWindow") } } } } |
CGRect の型について
→ 四角形を表す構造体。位置(x,y)+大きさ(width,height)。
Swift の標準型で、UIKit/CoreGraphics でよく使う。
|
1 2 3 4 5 6 7 |
struct CGRect { var origin: CGPoint // x, y var size: CGSize // width, height } //例 let rect = CGRect(x: 10, y: 20, width: 100, height: 200) |
absの意味
absはCGRectのプロパティではなく、Swiftの標準ライブラリに含まれる絶対値を計算する関数です。
CGRectの文脈では、矩形の幅や高さが負の値になった場合に、その絶対値(正の値)を取得したい場合などに利用されます。例えば、幅が-100であればabs(-100)は100を返します。
|
1 2 |
let rect = CGRect(x: 0, y: 0, width: -100, height: 50) let absoluteWidth = abs(rect.size.width) // 結果: 100.0 |
minの意味
minは、CGRectのプロパティ名の一部(例: minX, minY)として使用されます。これらは、矩形の各座標軸における最小値を返します。
- minX: 矩形のX座標の最小値。
- minY: 矩形のY座標の最小値。
これらの値は、矩形の位置とサイズに基づいて自動的に計算される「get-only」プロパティ(読み取り専用)です。
|
1 2 3 |
let rect = CGRect(x: 10, y: 20, width: 100, height: 50) let minimumX = rect.minX // 結果: 10.0 let minimumY = rect.minY // 結果: 20.0 |
var onComplete: ((CGRect) -> Void)? は何を意味する?
これは 「CGRect を引数に取り、返り値が Void(=なし)のクロージャを格納できる変数」 という意味。
分解すると:
🔹 ((CGRect) -> Void)
これは 関数の型
- 引数:
CGRect - 戻り値:
Void(=戻り値なし)
つまりこんなものを入れられる:
|
1 2 3 |
if let 変数 = ウィンドウクラスとか { rect in print("完了!結果は \(rect)") } |
簡単に言うと onComplete(結果内容) で呼び出し元の rect に 結果内容 が入る。
処理が終わって rect に反映させたくなったら使うと便利。
SwiftUIのViewで矩形描写
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
struct SelectionView: View { @State private var startPoint: CGPoint? = nil @State private var currentPoint: CGPoint? = nil var body: some View { ZStack { // 選択中の矩形を描画 if let start = startPoint, let current = currentPoint { Rectangle() .stroke(Color.blue, lineWidth: 2) .background(Color.blue.opacity(0.2)) .frame( width: abs(current.x - start.x), height: abs(current.y - start.y) ) .position( x: (current.x + start.x) / 2, y: (current.y + start.y) / 2 ) } } .contentShape(Rectangle()) // → 画面全体でジェスチャー受ける .gesture( DragGesture(minimumDistance: 0) .onChanged { value in if startPoint == nil { startPoint = value.startLocation } currentPoint = value.location } .onEnded { value in let endPoint = value.location let rect = CGRect( x: min(startPoint!.x, endPoint.x), y: min(startPoint!.y, endPoint.y), width: abs(endPoint.x - startPoint!.x), height: abs(endPoint.y - startPoint!.y) ) print("選択された範囲: \(rect)") // 完了後リセット startPoint = nil currentPoint = nil } ) } } |
🔍 動作説明
✔ .onChanged
- 最初のドラッグ開始時に
startPointを記録 - ドラッグ中は
currentPointを更新 - この2点から矩形が描画される
✔ .onEnded
- 最終位置を使って
CGRectを確定 - OCR する範囲として渡すのに最適
- あとは
onComplete(rect)のようにコールバックに渡せばOK
エントリに複数View用意して呼ぶ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import SwiftUI import AppKit @main struct TestWindowApp: App { @State private var capture_str: String? = nil @State private var refreshID = UUID() @State private var isMainWindowHidden: Bool? = false var body: some Scene { WindowGroup { //↓呼びたいView--------------------------- MainView(refreshID: $refreshID, capture_str: $capture_str, isMainWindowHidden: $isMainWindowHidden) .frame(minWidth: 400, minHeight: 300) } // 全画面透明ウィンドウ(範囲選択用) Window("SelectionOverlay", id: refreshID.uuidString) { SelectionOverlayView(is_hide: $isMainWindowHidden) { resultText in capture_str = resultText } .onDisappear{ refreshID = UUID() } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import SwiftUI import AppKit struct MainView: View { @Binding var refreshID: UUID @Binding var capture_str: String? @Binding var isMainWindowHidden: Bool? @Environment(\.openWindow) private var openWindow var body: some View { VStack(spacing: 20) { Button("範囲選択開始") { if isMainWindowHidden == true { isMainWindowHidden = false } hideMainWindow() openWindow(id: refreshID.uuidString) //呼び出し----------------- } if isMainWindowHidden == true && capture_str != nil { Text("OCR 結果") .font(.headline) TextEditor(text: Binding<String>($capture_str)!) .frame(height: 200) .border(Color.gray) .onAppear { showMainWindow() } } } .padding(40) } } |
SCShareableContent (キャプチャするウィンドウフィルタ)
🔍 excludingDesktopWindows(false, onScreenWindowsOnly: true) の意味
Apple公式ドキュメントに基づいて分解すると:
|
1 2 3 4 |
SCShareableContent.excludingDesktopWindows( _ exclude: Bool, onScreenWindowsOnly: Bool ) |
🟦 第1引数:excludeDesktopWindows
● false → デスクトップウィンドウも含める
つまり:
- 壁紙
- アイコン
- Finder のデスクトップレイヤ
…などもキャプチャ対象として扱う。
● true → デスクトップ(壁紙など)は含まない
ゲームキャプチャなどで有効。
🟩 第2引数:onScreenWindowsOnly
● true → 現在画面に表示されているウィンドウだけ
隠れているウィンドウ(最小化・背面)は対象外。
例:
- 違うデスクトップにあるウィンドウ
- minimize されたウィンドウ
- 画面外にオフセットされたウィンドウ
などは除外。
● false → 表示されていないウィンドウも含める
アプリを丸ごとキャプチャしたい時に使う。
| excludeDesktopWindows | onScreenWindowsOnly | 用途 |
|---|---|---|
| false | true | 選択範囲SSR(あなたのアプリ)/一般的なスクショ |
| true | true | ゲーム配信(壁紙をキャプチャしたくない) |
| false | false | 全ウィンドウ一括キャプチャ/画面外のアプリ画面取り込み |
| true | false | 特殊用途(めったに使わない) |
📌 注意点
ScreenCaptureKit のフィルタは
キャプチャ結果に直結するのでめちゃくちゃ重要。
- excludeDesktopWindows = true だと
→ 壁紙やアイコンがキャプチャされず背景が真っ黒になる - onScreenWindowsOnly = false だと
→ 他のDesktop Space のウィンドウまで全部混ざる
などの副作用がある。
アクティブウィンドウの取得のあれこれ
最前面のウィンドウの SCDisplay の取得
macOS では「最前面のウィンドウ」を
CGWindowListCopyWindowInfo が返してくれる。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import Cocoa func getActiveWindowInfo() -> [String: Any]? { let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements) guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]], let info = windowList.first else { return nil } return info } |
使い方
|
1 2 3 4 5 |
if let info = getActiveWindowInfo() { print("アプリ名:", info[kCGWindowOwnerName as String] ?? "") print("ウィンドウ名:", info[kCGWindowName as String] ?? "") print("Bounds:", info[kCGWindowBounds as String] ?? "") } |
アクティブウィンドウの CGRect(画面上の位置)を取得kCGWindowBounds の中に入ってる:
|
1 2 3 4 5 |
if let info = getActiveWindowInfo(), let boundsDict = info[kCGWindowBounds as String] as? [String: Any], let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary) { print("アクティブウィンドウの位置:", bounds) } |
そのウィンドウが乗っているディスプレイを取得
macOS はウィンドウ → ディスプレイの判定が簡単。
|
1 2 3 4 5 |
func displayForWindow(_ rect: CGRect) -> NSScreen? { return NSScreen.screens.first { screen in screen.frame.intersects(rect) } } |
使い方
|
1 2 3 4 5 6 7 |
if let info = getActiveWindowInfo(), let boundsDict = info[kCGWindowBounds as String] as? [String: Any], let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary), let screen = displayForWindow(bounds) { print("ウィンドウはこのディスプレイ:", screen) } |
ScreenCaptureKit の「表示対象ディスプレイ」に設定する
現在のアクティブウィンドウのある画面を
SCShareableContent の display として使いたい時
これは ディスプレイを正しく特定して、その screen に対応する SCDisplay を検索する という流れになる。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) // SCDisplay を選択する if let info = getActiveWindowInfo(), let boundsDict = info[kCGWindowBounds as String] as? [String: Any], let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary), let targetScreen = displayForWindow(bounds) { // NSRect を SCDisplay とマッチング let display = content.displays.first { scDisplay in scDisplay.frame == targetScreen.frame } if let display = display { print("このディスプレイをキャプチャ:", display) } } |
📌 さらに便利なユーティリティ関数を作るとこうなる
|
1 2 3 4 5 6 7 8 |
func getActiveSCDisplay(for content: SCShareableContent) -> SCDisplay? { guard let info = getActiveWindowInfo(), let boundsDict = info[kCGWindowBounds as String] as? [String: Any], let windowRect = CGRect(dictionaryRepresentation: boundsDict as CFDictionary), let screen = displayForWindow(windowRect) else { return nil } return content.displays.first { $0.frame == screen.frame } } |
|
1 2 3 4 |
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) if let targetDisplay = getActiveSCDisplay(for: content) { print("アクティブディスプレイ:", targetDisplay) } |
上記をまとめたコード
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import Cocoa func getActiveWindowInfo() -> [String: Any]? { let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements) guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]], let info = windowList.first else { return nil } return info } func displayForWindow(_ rect: CGRect) -> NSScreen? { return NSScreen.screens.first { screen in screen.frame.intersects(rect) } } func getActiveSCDisplay(for content: SCShareableContent) -> SCDisplay? { guard let info = getActiveWindowInfo(), let boundsDict = info[kCGWindowBounds as String] as? [String: Any], let windowRect = CGRect(dictionaryRepresentation: boundsDict as CFDictionary), let screen = displayForWindow(windowRect) else { return nil } return content.displays.first { $0.frame == screen.frame } } |
アクティブウィンドウがある SCDisplay の取得
前提、プロセスIDの取得
|
1 2 |
let frontmostApp = NSWorkspace.shared.frontmostApplication let pid = frontmostApp.processIdentifier |
🧩 ステップ①
プロセスIDから「そのアプリの一番上のウィンドウ」を CGWindowList で取得
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
func windowRectOfProcess(pid: pid_t) -> CGRect? { let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { return nil } for info in windowList { if let ownerPID = info[kCGWindowOwnerPID as String] as? pid_t, ownerPID == pid, let boundsDict = info[kCGWindowBounds as String] as? [String: Any], let rect = CGRect(dictionaryRepresentation: boundsDict as CFDictionary) { return rect // 最前面ウィンドウの位置 } } return nil } |
🧩 ステップ②
そのウィンドウの位置から、属するディスプレイ(NSScreen)を取得
|
1 2 3 4 5 |
func screenForWindowRect(_ rect: CGRect) -> NSScreen? { NSScreen.screens.first { screen in screen.frame.intersects(rect) } } |
🧩 ステップ③
NSScreen と SCDisplay をマッチングする
ScreenCaptureKit は frame を使ってディスプレイを判定できる。
|
1 2 3 4 5 |
func scDisplayForScreen(_ screen: NSScreen, content: SCShareableContent) -> SCDisplay? { return content.displays.first { sc in sc.frame == screen.frame } } |
🧩 ステップ④
ぜんぶ合わせた「PID → SCDisplay」関数
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func scDisplayFromPID(_ pid: pid_t) async throws -> SCDisplay? { guard let content = try? await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) else { return nil } // 1. PID のウィンドウ位置 guard let windowRect = windowRectOfProcess(pid: pid) else { return nil } // 2. どの NSScreen にまたがっているか guard let nsScreen = screenForWindowRect(windowRect) else { return nil } // 3. NSScreen → SCDisplay return scDisplayForScreen(nsScreen, content: content) } |
マウスカーソルがある SCDisplay の取得
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/// マウスカーソルの位置からアクティブディスプレイを取得 func getActiveDisplay(from displays: [SCDisplay]) -> SCDisplay? { // 現在のマウス位置を取得 let mouseLocation = NSEvent.mouseLocation // マウス位置を含むディスプレイを探す for display in displays { let displayFrame = display.frame if displayFrame.contains(mouseLocation) { return display } } // 見つからない場合はメインディスプレイを返す return displays.first(where: { $0.displayID == CGMainDisplayID() }) } |
指定したディスプレイの SCDisplay を取得する
メインディスプレイを取得
|
1 2 3 4 5 6 7 |
func getMainDisplay() async throws -> SCDisplay { let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true ) return content.displays.first(where: { $0.displayID == CGMainDisplayID() })! } |
セカンダリディスプレイを取得(メイン以外の最初のディスプレイ)
|
1 2 3 4 5 6 7 8 9 |
func getSecondaryDisplay() async throws -> SCDisplay? { let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true ) let mainDisplayID = CGMainDisplayID() return content.displays.first(where: { $0.displayID != mainDisplayID }) } |
DisplayIDを指定してディスプレイを取得
|
1 2 3 4 5 6 7 |
func getDisplay(by displayID: CGDirectDisplayID) async throws -> SCDisplay? { let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true ) return content.displays.first(where: { $0.displayID == displayID }) } |
インデックスでディスプレイを取得(0がメインとは限らない)
|
1 2 3 4 5 6 7 8 9 10 11 |
func getDisplay(at index: Int) async throws -> SCDisplay? { let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true ) guard index >= 0 && index < content.displays.count else { return nil } return content.displays[index] } |
SCDisplay、NSScreenをディスプレイIDで一致させる
🟦 注意点
❗ NSScreen の displayID は private API
(value(forKey:) を使う時点で非公開の可能性)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import CoreGraphics // CGDirectDisplayID のために必要 func getActiveWindowDisplay() async throws -> SCDisplay? { guard let screen = NSScreen.main else { return nil } guard let screenID = screen.value(forKey: "displayID") as? UInt32 else { return nil } let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true ) for display in content.displays { if display.displayID == screenID { return display } } return nil } |
接続ディスプレイの取得とそのログ吐き出し
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/// 全てのディスプレイを取得してログ出力 func listAllDisplays() async throws { let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: true ) print("=== 利用可能なディスプレイ一覧 ===") for (index, display) in content.displays.enumerated() { print("[\(index)] DisplayID: \(display.displayID)") print(" Frame: \(display.frame)") print(" Size: \(display.width) x \(display.height)") print(" Main: \(display.displayID == CGMainDisplayID())") print("---") } } |
毎回ウィンドウの位置を MainView の座標にリセットする
「SelectionOverlay ウィンドウを MainView と同じ位置・同じ画面に出したいが、
2回目以降は前回の位置のまま残り続ける」
これは SwiftUI の Window Scene は“同じ id のウィンドウを再利用する” という仕様のため。
つまり:
|
1 |
id: "selectionOverlay" |
なら、最初に作られたウィンドウインスタンスが
ずっと生き続ける(位置やサイズも保持する)。
だから、
- 1回目 → MainView の画面に正しく出る
- 2回目 → 前回閉じた場所にそのまま再生成される(正しくは「再表示」)
となっている。
「毎回決まった場所に出す」には、
WindowScene の NSWindow を捕まえて位置を指定する。
SwiftUI の .windowStyle や .windowLevel では位置制御ができないので
AppKit でやる必要がある。
🔧 手順
① SelectionOverlay の Scene に .windowPosition(.center) を追加
(※ただしこれはセンター基準で、MainView の画面ではない)
|
1 2 3 4 |
Window("SelectionOverlay", id: "selectionOverlay") { SelectionOverlayView(...) } .windowPosition(.center) |
ただしこれだけだと「MainView のあるディスプレイ」にはならない。
② MainView のある画面位置に手動で配置
SelectionOverlayView の onAppear に次を追加:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
.onAppear { DispatchQueue.main.async { if let win = NSApp.windows.first(where: { $0.title == "SelectionOverlay" }) { // MainView 直前のウィンドウを取得 if let mainWin = NSApp.windows.first(where: { $0.title == "MainWindowTitle" }) { // MainView と同じ画面に移動 win.setFrameOrigin(mainWin.frame.origin) } } } } |
※ MainView のタイトルは必要なら設定:
|
1 2 3 |
WindowGroup("MainWindowTitle") { MainView(...) } |
これで 毎回 MainView のある画面に出る。
ウィンドウの完全終了
Debug ビルドの .app を Dock から再表示したあと
ボタンを押すとウィンドウが増えてしまう
これは SwiftUI の WindowScene の仕様と
閉じたウィンドウとプログラムで生成したウィンドウが“別物扱いになる”問題が原因。
結論から言うと——
🚨 結論
ウィンドウを「赤い×ボタンで閉じた時」に、SwiftUI の WindowScene は “閉じられたウィンドウを記憶” してしまう。
そして
- Dock からアプリを再度アクティブにすると
→ MainWindowScene が自動再生成される - あなたが
openWindow(id:)を呼ぶと
→ SelectionOverlayScene が新規生成される
結果として ウィンドウが増える。
💡 解決策(ベスト3)
✅ 方法1(ベスト)
赤×ボタンで閉じた時に「完全終了」させる
これは最もシンプルで正しい対処。
macOS の常駐アプリでないなら正解。
|
1 2 3 |
.onDisappear { NSApp.terminate(nil) } |
または MainView の window delegate に:
|
1 2 3 |
func windowWillClose(_ notification: Notification) { NSApp.terminate(nil) } |
✅ 方法2
ウィンドウを × で閉じさせない(常駐アプリ方式)
× を押しても閉じず「非表示」にする。
|
1 |
.windowStyle(.hiddenTitleBar) |
または window.delegate で:
|
1 2 3 4 |
func windowShouldClose(_ sender: NSWindow) -> Bool { sender.orderOut(nil) // 非表示 return false // 本当に閉じるのを禁止 } |
✅ 方法3
MainWindowScene を自動再生成させない
SwiftUI は Dock をクリックするたびに WindowScene を再作成する。
これを止めるには:
Main ウィンドウを閉じた時に “永久に破棄” する
|
1 2 3 |
.onDisappear { NSApp.setActivationPolicy(.accessory) // Dock から消える } |
または
|
1 2 |
.windowResizability(.contentSize) .handlesExternalEvents(preferring: Set([]), allowing: Set(["*"])) |
しかし、これは設定が少し複雑なので、方法1 or 方法2 が最適。
ウィンドウのリサイズ
現在フォーカス中のウィンドウをリサイズする
|
1 2 3 4 5 6 7 |
func resizeWindow(width: CGFloat, height: CGFloat) { if let window = NSApplication.shared.keyWindow { var frame = window.frame frame.size = CGSize(width: width, height: height) window.setFrame(frame, display: true, animate: true) } } |
特定のウィンドウだけリサイズ
|
1 2 3 4 5 |
extension NSApplication { func window(withId id: String) -> NSWindow? { return self.windows.first { $0.identifier?.rawValue == id } } } |
ウィンドウを常に前面にする
|
1 2 3 4 5 |
private func setAlwaysOnTop(_ enabled: Bool) { if let window = NSApplication.shared.windows.first { window.level = enabled ? .floating : .normal } } |
キャプチャウィンドウ(失敗)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 |
import SwiftUI import AppKit @main struct TestWindowApp: App { @State private var capture_str: String? = nil @State private var refreshID = UUID() @State private var isMainWindowHidden: Bool? = false var body: some Scene { WindowGroup { MainView(refreshID: $refreshID, capture_str: $capture_str, isMainWindowHidden: $isMainWindowHidden) .frame(minWidth: 400, minHeight: 300) } // 全画面透明ウィンドウ(範囲選択用) Window("SelectionOverlay", id: refreshID.uuidString) { SelectionOverlayView(is_hide: $isMainWindowHidden) { resultText in capture_str = resultText } .onDisappear{ refreshID = UUID() } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) } } import SwiftUI import AppKit struct MainView: View { @Binding var refreshID: UUID @Binding var capture_str: String? @Binding var isMainWindowHidden: Bool? @Environment(\.openWindow) private var openWindow var body: some View { VStack(spacing: 20) { Button("範囲選択開始") { if isMainWindowHidden == true { isMainWindowHidden = false } hideMainWindow() openWindow(id: refreshID.uuidString) } if isMainWindowHidden == true && capture_str != nil { Text("OCR 結果") .font(.headline) TextEditor(text: Binding<String>($capture_str)!) .frame(height: 200) .border(Color.gray) .onAppear { showMainWindow() } } } .padding(40) } } struct SelectionOverlayView: View { @Binding var is_hide: Bool? var onComplete: (String) -> Void @State private var startPoint: CGPoint? = nil @State private var currentPoint: CGPoint? = nil var body: some View { ZStack { Color.white.opacity(0.3) //.clear if let start = startPoint, let current = currentPoint { selectionRect(from: start, to: current) } } .ignoresSafeArea() .background(TransparentBackground()) // AppKitで透明化 .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in if startPoint == nil { startPoint = value.startLocation } currentPoint = value.location } .onEnded { value in //-------------------------------------------------------------------------- //キャプチャ機能 let rect = makeRect(start: startPoint!, end: value.location) Task { if let image = try? await captureScreenRegion(rect: rect) { recognizeText(from: image) { resultText in onComplete(resultText) } } } //-------------------------------------------------------------------------- // 次の runloop で閉じる(クラッシュ回避) DispatchQueue.main.async { startPoint = nil currentPoint = nil is_hide = true closeThisWindow() } } ) } // 矩形描画 @ViewBuilder private func selectionRect(from start: CGPoint, to end: CGPoint) -> some View { let rect = makeRect(start: start, end: end) Rectangle() .stroke(Color.blue, lineWidth: 2) .background(Color.blue.opacity(0.2)) .frame(width: rect.width, height: rect.height) .position(x: rect.midX, y: rect.midY) } // CGRect 生成 private func makeRect(start: CGPoint, end: CGPoint) -> CGRect { CGRect( x: min(start.x, end.x), y: min(start.y, end.y), width: abs(end.x - start.x), height: abs(end.y - start.y) ) } // 自分のウィンドウを閉じる private func closeThisWindow() { NSApp.keyWindow?.close() } } struct TransparentBackground: NSViewRepresentable { func makeNSView(context: Context) -> NSView { let view = NSView() let screenFrame = NSScreen.main?.frame ?? .zero DispatchQueue.main.async { if let window = view.window { window.setFrame(screenFrame, display: true) window.isOpaque = false window.backgroundColor = .clear window.level = .screenSaver // 最前面にする window.hasShadow = false window.center() } } return view } func updateNSView(_ nsView: NSView, context: Context) {} } func hideMainWindow() { if let window = NSApp.windows.first(where: { $0.title != "SelectionOverlay" }) { window.orderOut(nil) } } func showMainWindow() { if let window = NSApp.windows.first(where: { $0.title != "SelectionOverlay" }) { window.makeKeyAndOrderFront(nil) } } |
プロパティ情報
NSWindow
ウィンドウの状態:
window.title– ウィンドウのタイトルwindow.isVisible– ウィンドウが表示されているかwindow.isKeyWindow– キーウィンドウ(入力フォーカスがある)かwindow.isMainWindow– メインウィンドウかwindow.isMiniaturized– 最小化されているかwindow.isZoomed– ズーム(最大化)されているかwindow.level– ウィンドウレベル(前面表示の優先度)window.alphaValue– 透明度(0.0〜1.0)window.isOpaque– 不透明かどうか
ウィンドウの属性:
window.styleMask– ウィンドウスタイル(タイトルバー、リサイズ可能など)window.backgroundColor– 背景色window.hasShadow– 影があるかwindow.isMovable– 移動可能かwindow.isMovableByWindowBackground– 背景ドラッグで移動可能か
コンテンツ関連:
window.contentView– コンテンツビューwindow.contentViewController– コンテンツビューコントローラwindow.toolbar– ツールバー
ディスプレイ関連:
window.screen– ウィンドウが表示されているNSScreenwindow.backingScaleFactor– Retinaスケール係数(1.0または2.0など)
その他:
window.windowNumber– ウィンドウ番号(システム内の一意識別子)window.orderingMode– ウィンドウの並び順モードwindow.collectionBehavior– Spacesやフルスクリーンの動作
SCDisplay
ディスプレイの基本情報:
activeDisplay.displayID– ディスプレイの一意識別子(CGDirectDisplayID)activeDisplay.width– ディスプレイの幅(ピクセル)activeDisplay.height– ディスプレイの高さ(ピクセル)
その他の情報:
activeDisplay.frame– ディスプレイのフレーム(CGRect)※座標も含むため参考程度
CGDirectDisplayID
ディスプレイの基本情報:
CGDisplayBounds(displayID)– ディスプレイの位置とサイズ(CGRect)CGDisplayPixelsWide(displayID)– 幅(ピクセル)CGDisplayPixelsHigh(displayID)– 高さ(ピクセル)CGDisplayScreenSize(displayID)– 物理サイズ(ミリメートル)
ディスプレイの状態:
CGDisplayIsActive(displayID)– アクティブかどうかCGDisplayIsAsleep(displayID)– スリープ中かどうかCGDisplayIsBuiltin(displayID)– 内蔵ディスプレイかどうかCGDisplayIsMain(displayID)– メインディスプレイかどうかCGDisplayIsOnline(displayID)– オンラインかどうかCGDisplayIsStereo(displayID)– ステレオディスプレイかどうか
色・モード情報:
CGDisplayCopyColorSpace(displayID)– カラースペースCGDisplayModeGetWidth(mode)/CGDisplayModeGetHeight(mode)– 現在の解像度CGDisplayModeGetRefreshRate(mode)– リフレッシュレート
回転情報:
CGDisplayRotation(displayID)– 回転角度(0, 90, 180, 270度)
ミラーリング情報:
CGDisplayIsInMirrorSet(displayID)– ミラーリングセットに含まれているかCGDisplayMirrorsDisplay(displayID)– ミラーリングしているディスプレイID
NSScreen
基本情報:
screen.localizedName– ディスプレイ名(例: “Built-in Retina Display”)screen.depth– 色深度screen.deviceDescription– デバイス情報の辞書
座標・サイズ情報:
screen.frame– スクリーン全体のフレーム(CGRect)screen.visibleFrame– メニューバーやDockを除いた表示可能領域screen.backingScaleFactor– Retinaスケール係数(1.0, 2.0など)
色関連:
screen.colorSpace– カラースペース(NSColorSpace?)screen.maximumExtendedDynamicRangeColorComponentValue– HDRの最大輝度値screen.maximumPotentialExtendedDynamicRangeColorComponentValue– HDRの潜在的最大輝度値screen.maximumReferenceExtendedDynamicRangeColorComponentValue– HDR参照最大値
リフレッシュレート(macOS 12+):
screen.maximumFramesPerSecond– 最大フレームレート(Hz)screen.minimumRefreshInterval– 最小リフレッシュ間隔screen.maximumRefreshInterval– 最大リフレッシュ間隔screen.displayUpdateGranularity– 更新の粒度
ディスプレイ設定:
screen.auxillaryTopLeftArea– 左上の補助領域screen.auxillaryTopRightArea– 右上の補助領域
静的プロパティ:
NSScreen.main– メインスクリーン(メニューバーがある画面)NSScreen.screens– 接続されている全スクリーンの配列NSScreen.deepest– 最も色深度が高いスクリーン
🟦 .onDrop の構造を理解しよう
SwittUlの
.onDrop はこういう形:
|
1 2 3 4 5 |
.onDrop( of: [UTType], // 受け入れるデータタイプ isTargeted: Binding<Bool>?,// ドラッグが乗ってる間の状態(ハイライトなど) perform: ([NSItemProvider]) -> Bool // 何か落とされた時の処理 ) |
✔ of: [.fileURL]
受け入れるデータの種類を指定する配列。
・fileURL→「ファイルのURLをドロップできます」
・PDF だけ許可したいなら.pdfも使える
例:
|
1 |
.onDrop(of: [.pdf], isTargeted: nil, perform: handleDrop(providers:)) |
✔ isTargeted: nil
ドラッグ中にホバーしているかどうかを状態で受け取りたいときに使います。
本来は「ドラッグが乗ってる間 true にするための State」。
視覚的にハイライトしたいときに使う。
例:
|
1 2 3 4 5 6 7 |
@State private var isHovering = false .onDrop( of: [.fileURL], isTargeted: $isHovering, perform: handleDrop ) |
isHovering が true/false になるので背景色を変える…などが可能。
✔ perform: handleDrop(providers:) の正体
([NSItemProvider]) -> Bool という関数を渡す必要がある。
ドラッグ&ドロップされたアイテム(ファイルなど)が
NSItemProvider の配列として渡される。
よく使うテンプレ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
func handleDrop(providers: [NSItemProvider]) -> Bool { // 最初の provider を探す if let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.pdf.identifier) }) { provider.loadItem(forTypeIdentifier: UTType.pdf.identifier, options: nil) { (item, error) in if let url = item as? URL { // ⚡ PDFのURLゲット! DispatchQueue.main.async { self.pdfURL = url } } } return true } return false } |
✔ .onDrop(of: [...]) に渡す三要素まとめ
| 引数 | 意味 | 例 |
|---|---|---|
of: | ドロップ可能なデータタイプ | [.fileURL], [.pdf] |
isTargeted: | ドラッグが乗ってるかどうか | nil, $isHovering |
perform: | 実処理(ファイル取り込み) | handleDrop |
勘違いしてた事:
Viewに .onDrop はついているが SwiftUI の View にファイルを落としても意味ない。
あくまで SwiftUI の View 外のウィンドウにファイルを落とさないと .onDrop は反応しない。(テキストエディタにファイルを落としてたlol)
URLでファイルを判定する
.pathExtension.lowercased()
|
1 2 3 4 5 6 7 8 9 10 |
// PDFファイルかチェック guard url.pathExtension.lowercased() == "pdf" else { print("PDFファイルではありません: \(url.pathExtension)") DispatchQueue.main.async { self.is_processing = false // エラーメッセージを表示する場合 self.ocr_text = "エラー: PDFファイルをドロップしてください" } return } |
より堅牢な方法: PDFDocumentの生成でチェック(PDFのみ)
|
1 |
guard let pdf = PDFDocument(url: url) else { return } |
NSItemProvider について
PDF処理の実行
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
processPdf(url: url) ``` 変換に成功したURLを使ってPDF処理を実行 ## 図解 ``` ドロップされたファイル ↓ provider.loadItem(非同期取得) ↓ item (Any?型) ↓ as? Data data (Data型) - 例: <66696c65 3a2f2f2f...> ↓ String(data:encoding:) path (String型) - 例: "file:///Users/name/Desktop/sample.pdf" ↓ URL(string:) url (URL型) - 例: URL(fileURLWithPath: "...") ↓ processPdf(url: url) |
なぜこんな複雑な変換が必要?
NSItemProviderの仕様: データをAny?型で返すため- ファイルパスの形式:
Data→ 文字列 → URLの順で変換する必要がある - 安全性: 各ステップで失敗する可能性があるため、
guard letで確認
翻訳機能が来た時のメモ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
import SwiftUI import Translate struct TranslatorView: View { @State private var sourceLanguage: TranslateLanguage = .english @State private var targetLanguage: TranslateLanguage = .japanese @State private var inputText: String = "" @State private var outputText: String = "" @State private var isTranslating = false var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { // ▼ ソース言語 Picker("From", selection: $sourceLanguage) { ForEach(TranslateLanguage.allLanguages, id: \.self) { lang in Text(lang.localizedName).tag(lang) } } .frame(width: 140) Image(systemName: "arrow.right") // ▼ ターゲット言語 Picker("To", selection: $targetLanguage) { ForEach(TranslateLanguage.allLanguages, id: \.self) { lang in Text(lang.localizedName).tag(lang) } } .frame(width: 140) } TextEditor(text: $inputText) .border(Color.gray) .frame(height: 120) Button { Task { await translate() } } label: { if isTranslating { ProgressView() } else { Text("翻訳") } } .padding(.vertical, 8) TextEditor(text: $outputText) .border(Color.gray) .frame(height: 120) } .padding() } // MARK: - 翻訳処理 @MainActor func translate() async { isTranslating = true defer { isTranslating = false } do { let translator = Translator( sourceLanguage: sourceLanguage, targetLanguage: targetLanguage ) let result = try await translator.translate(inputText) outputText = result.targetText } catch { outputText = "エラー: \(error.localizedDescription)" } } } // MARK: - 利便性:言語一覧取得 extension TranslateLanguage { /// 利用可能なすべての言語 static var allLanguages: [TranslateLanguage] { [ .arabic, .chinese, .dutch, .english, .french, .german, .hindi, .indonesian, .italian, .japanese, .korean, .polish, .portuguese, .russian, .spanish, .thai, .turkish, .vietnamese ] } /// プルダウン表示用のローカライズ名 var localizedName: String { switch self { case .japanese: return "Japanese(日本語)" case .english: return "English(英語)" case .chinese: return "Chinese(中国語)" case .korean: return "Korean(韓国語)" case .french: return "French(フランス語)" case .german: return "German(ドイツ語)" case .italian: return "Italian(イタリア語)" case .spanish: return "Spanish(スペイン語)" case .portuguese: return "Portugese(ポルトガル語)" case .russian: return "Russian(ロシア語)" case .indonesian: return "Indonesian(インドネシア語)" case .thai: return "Thai(タイ語)" case .turkish: return "Turkish(トルコ語)" case .polish: return "Polish(ポーランド語)" case .vietnamese: return "Vietnamese(ベトナム語)" case .arabic: return "Arabic(アラビア語)" case .hindi: return "Hindi(ヒンディー語)" case .dutch: return "Dutch(オランダ語)" @unknown default: return "Unknown" } } } |
NSViewRepresentable
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
// NSEventを監視するためのRepresentable struct ShortcutRecorderNSView: NSViewRepresentable { @Binding var isRecording: Bool var onShortcutRecorded: (String, NSEvent.ModifierFlags) -> Void class Coordinator: NSObject { var parent: ShortcutRecorderNSView var monitor: Any? init(_ parent: ShortcutRecorderNSView) { self.parent = parent } func startMonitoring() { // ローカルイベントモニターでキーイベントをキャプチャ monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in if self.parent.isRecording { let key = event.charactersIgnoringModifiers?.lowercased() ?? "" // ESCキーでキャンセル if event.keyCode == 53 { DispatchQueue.main.async { self.parent.isRecording = false } return nil } // 通常のキーの場合は記録 if !key.isEmpty { self.parent.onShortcutRecorded(key, event.modifierFlags) DispatchQueue.main.async { self.parent.isRecording = false } return nil // イベントを消費 } } return event } } func stopMonitoring() { if let monitor = monitor { NSEvent.removeMonitor(monitor) self.monitor = nil } } } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSView { let view = NSView() context.coordinator.startMonitoring() return view } func updateNSView(_ nsView: NSView, context: Context) {} static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.stopMonitoring() } } |
NSViewRepresentable は、SwiftUIアプリケーション内でAppKitフレームワークのNSViewを利用できるようにするプロトコルです。macOS向けのSwiftUI開発において重要な役割を果たします。
これは、iOSにおけるUIViewRepresentableのmacOS版と考えてください。
主な目的と機能
- AppKitビューの統合: SwiftUIにはまだ組み込まれていない、またはSwiftUIで再現するのが難しい高度な機能を持つ既存のAppKitビュー(例:カスタム描画ビュー、特定のテキストエディタ、地図ビューなど)を、そのままSwiftUIビュー階層内に配置できるようになります [1, 3]。
- 相互運用性: SwiftUIの新しい宣言的なパラダイムと、AppKitの古い命令的なパラダイムの橋渡しをします [1]。
実装に必要な主なメソッド
NSViewRepresentableプロトコルに準拠するには、主に以下のメソッドを実装する必要があります。
makeNSView(context:)
AppKitのビュー(NSViewのサブクラス)のインスタンスを作成し、初期設定を行うためのメソッドです。SwiftUIビューが表示される準備ができたときに一度だけ呼び出されます。
|
1 2 3 4 5 |
func makeNSView(context: Context) -> NSView { let view = NSView() context.coordinator.startMonitoring() // ← ここでイベント監視開始! return view } |
最初に1回だけ呼ばれる
効果:
- 実際のNSViewを作成してSwiftUIに渡す
startMonitoring()でキーイベントの監視を開始- このビューがSwiftUIの階層に追加される
updateNSView(_:context:):
SwiftUIの状態(@Stateや@Bindingなどで管理されているデータ)が変更されたときに呼び出されます。このメソッドを使って、基となるAppKitビューのプロパティを最新のSwiftUIの状態に合わせて更新します。
|
1 |
func updateNSView(_ nsView: NSView, context: Context) {} |
親の状態が変わる度に呼ばれる
効果:
- 今回は空だが、通常は
@Bindingやvarが変わった時にNSViewを更新する - 例:
isRecordingが変わった時に何かしたい場合はここに書く
makeCoordinator() (任意):
- AppKitビューから発生するイベント(デリゲートメソッドなど)を処理し、SwiftUI側に伝えるための「コーディネーター」オブジェクトを作成します。これにより、両方向のデータフロー(SwiftUIからAppKit、AppKitからSwiftUI)が可能になります。
|
1 2 3 |
func makeCoordinator() -> Coordinator { Coordinator(self) // 親(自分自身)への参照を持つCoordinatorを作成 } |
最初に1回だけ呼ばれる
効果:
- SwiftUIとAppKit(NSView)の橋渡し役を作成
parentで親のプロパティ(@Bindingなど)にアクセスできる- 状態を保持し続ける(ビューが再描画されても同じインスタンスが使われる)
要するに、既存のmacOSネイティブ機能や複雑なカスタムビューをSwiftUIプロジェクトに組み込むための標準的な方法がNSViewRepresentableです。
dismantleNSView(_:coordinator:)
ビューが破棄される時に1回呼ばれる。
効果:
- イベントモニターを解除(超重要!)
- 解除しないとメモリリークが発生する
- ビューが消えてもモニターが残り続けてしまう
|
1 2 3 |
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.stopMonitoring() // ← メモリリーク防止! } |
全体の流れ
- SwiftUIがNSViewRepresentableを作成
↓ - makeCoordinator() → Coordinatorインスタンス作成(1回のみ)
↓ - makeNSView() → NSView作成 & イベント監視開始(1回のみ)
↓ - updateNSView() → 状態変更時に呼ばれる
↓ - dismantleNSView() → ビュー破棄時にイベント監視停止(クリーンアップ)
なぜCoordinatorが必要?
|
1 2 3 |
class Coordinator: NSObject { var parent: ShortcutRecorderNSView // ← SwiftUIの@Bindingにアクセスするため var monitor: Any? // ← イベントモニターを保持 |
理由:
NSEvent.addLocalMonitorForEventsのクロージャ内で@Bindingを更新したい- SwiftUIの構造体は値型なので、直接参照できない
- Coordinatorがクラス(参照型)なので、状態を保持できる
これでself.parent.isRecordingのように親の状態を変更できます!
別の概要説明
(エヌエスビューリプレゼンタブル)とは、SwiftUIでmacOSアプリを開発する際に、従来のAppKitフレームワークのNSView(macOSネイティブのUI部品)をSwiftUIのビューとして利用・統合するための「橋渡し役」となるプロトコルです。SwiftUIとAppKitのハイブリッド開発(共存)を可能にし、既存のNSView資産を活用したり、SwiftUIでは提供されていない高度なmacOS固有のUI機能を使いたい場合に必須となります。
具体的に何をするものか?
- AppKitの
NSViewをラップ(包む):NSViewをSwiftUIのViewとして扱えるようにします。 - データ連携:
NSViewとSwiftUIの間でデータの受け渡し(バインディング)を管理します。 - ライフサイクル管理:
NSViewの作成、更新、破棄といったライフサイクルをSwiftUIの仕組みに適合させます。
なぜ使うのか?
- レガシー資産の活用: 既存のmacOSアプリで使われている
NSView(例えばカスタム描画用のNSViewや複雑なテキスト表示コンポーネントなど)を、新しいSwiftUIアプリに組み込みたい場合。 - macOS固有機能の利用: SwiftUIにはない、macOS特有の高度なUI機能やパフォーマンスが必要な場合に、AppKitの
NSViewを直接利用するため。 - 段階的な移行: SwiftUIへの移行をスムーズにするため、一部の画面やコンポーネントだけをSwiftUIにし、他はAppKitのまま残すハイブリッド構成で利用する。
使用例(簡単なイメージ)
SwiftUIでMyNSViewWrapper()のようにNSViewRepresentableを実装した構造体(ビュー)を記述すると、その中でmakeNSViewメソッドでNSViewインスタンスを作成し、updateNSViewメソッドでSwiftUIからのデータに基づいてNSViewを更新する、といったコードを書きます。
まとめると、「SwiftUI」と「macOSのネイティブUI(AppKitのNSView)」を繋ぐための公式なアダプター(変換器)がNSViewRepresentableです
ウィンドウイベントの取得
NSEvent.addLocalMonitorForEvents
NSNSEvent.addLocalMonitorForEvents(matching:handler:) は、macOS アプリケーション内で発生する特定のイベント(キーボード入力、マウス操作など)を、本来のイベントハンドラにディスパッチされる前に監視・処理するためのメソッドです。
使い方と特徴
- 目的: アプリケーション内のイベントフローを傍受し、イベントをログに記録したり、修正したり、破棄したりすることができます。
- ローカル監視:
addGlobalMonitorForEventsとは異なり、このメソッドは自分のアプリケーションに送られるイベントのみを対象とします。 - 戻り値: ハンドラ(クロージャ)は監視対象の
NSEventオブジェクトを受け取り、NSEventまたはnilを返す必要があります。 - 戻り値(モニターオブジェクト): このメソッドは、後でイベント監視を停止するために使用する不透明なイベントハンドラオブジェクト (
Any?) を返します。監視を停止するには、このオブジェクトをNSEvent.removeMonitor()に渡します。
実装例
NSViewController の viewDidLoad でキーボードイベント (.keyDown) を監視する例です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import Cocoa class ViewController: NSViewController { var eventMonitor: Any? // イベントモニターを保持するプロパティ override func viewDidLoad() { super.viewDidLoad() // .keyDown イベントのローカル監視を開始 eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] (event) -> NSEvent? in // ここでイベントを処理する print("キーダウンイベントを捕捉しました: \(event.characters ?? "")") // 例: 特定のキー(例: Escapeキー)が押されたら nil を返してイベントを消費(停止)する if event.keyCode == 53 { // 53はEscapeキーのキーコード print("Escapeキーが押されました。イベントを停止します。") return nil } // イベントを通常の処理フローに渡す場合は event を返す return event } } override func viewWillDisappear() { super.viewWillDisappear() // ビューが非表示になる前にモニターを削除し、イベント監視を停止する if let monitor = eventMonitor { NSEvent.removeMonitor(monitor) eventMonitor = nil } } } |
注意点
- モニターの削除: メモリリークや意図しない動作を防ぐため、イベント監視が不要になったら必ず
NSEvent.removeMonitor(_:)を呼び出して停止する必要があります。 - イベントタイプ:
matchingパラメータには、NSEvent.EventTypeMask で定義されている様々なイベントタイプ(例:.mouseMoved,.leftMouseDown,.flagsChangedなど)を指定できます。 - Responder Chain との違い: 通常のイベント処理は Responder Chain に沿って行われますが、ローカルモニターはその前にイベントを捕捉します。そのため、
keyDown(with:)などの標準的なレスポンダメソッドよりも先に処理されます。
この点はリサイズハンドルか判定
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
private let resizeHandleSize: CGFloat = 12 private func isInResizeHandle( point: CGPoint, layer: CAShapeLayer ) -> Bool { let frame = layer.frame let handleRect = CGRect( x: frame.maxX - resizeHandleSize, y: frame.maxY - resizeHandleSize, width: resizeHandleSize, height: resizeHandleSize ) return handleRect.contains(point) } |
NSView でキーを拾う例
NSTextView をサブクラス化
|
1 2 3 4 5 6 7 8 9 10 11 12 |
final class EditingTextView: NSTextView { var onCommit: (() -> Void)? override func keyDown(with event: NSEvent) { if event.keyCode == 36 { // Enter onCommit?() return } super.keyDown(with: event) } } |
生成時に差し替える
|
1 2 3 4 |
let textView = EditingTextView(frame: frame) textView.onCommit = { [weak self] in self?.endTextEditing() } |
NSTextView 作成例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
private func beginTextEditing(at point: CGPoint) { endTextEditing(commit: false) let textView = NSTextView(frame: CGRect( x: point.x, y: point.y, width: 200, height: 50 )) textView.font = NSFont.systemFont(ofSize: 18) textView.backgroundColor = .clear textView.isRichText = false textView.drawsBackground = true textView.delegate = self addSubview(textView) window?.makeFirstResponder(textView) editingTextView = textView } |
確定処理(Enter / フォーカスアウト)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private func endTextEditing(commit: Bool = true) { guard let textView = editingTextView else { return } let text = textView.string let frame = textView.frame textView.removeFromSuperview() editingTextView = nil if commit && !text.isEmpty { addTextLayer(text: text, frame: frame) } } |
CATextLayer を作成
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
private func addTextLayer(text: String, frame: CGRect) { let layer = CATextLayer() layer.string = text layer.font = NSFont.systemFont(ofSize: 18) layer.fontSize = 18 layer.foregroundColor = NSColor.labelColor.cgColor layer.frame = frame layer.contentsScale = window?.backingScaleFactor ?? 2.0 layer.alignmentMode = .left layer.isWrapped = true textLayerRoot.addSublayer(layer) textLayers.append(layer) registerUndoForTextLayer(layer) } |
Undo / Redo(テキスト)
|
1 2 3 4 5 6 |
private func registerUndoForTextLayer(_ layer: CATextLayer) { undoManager?.registerUndo(withTarget: self) { target in target.removeTextLayer(layer) } undoManager?.setActionName("テキスト追加") } |
|
1 2 3 4 5 6 7 8 |
private func removeTextLayer(_ layer: CATextLayer) { layer.removeFromSuperlayer() textLayers.removeAll { $0 === layer } undoManager?.registerUndo(withTarget: self) { target in target.restoreTextLayer(layer) } } |
|
1 2 3 4 5 6 7 8 |
private func restoreTextLayer(_ layer: CATextLayer) { textLayerRoot.addSublayer(layer) textLayers.append(layer) undoManager?.registerUndo(withTarget: self) { target in target.removeTextLayer(layer) } } |
Shift 押し判定を取得する?未確認
|
1 |
let isShiftPressed = event.modifierFlags.contains(.shift) |
Main Thread only メモ
| 項目 | ルール |
|---|---|
| NSSavePanel | Main Thread only |
| NSOpenPanel | Main Thread only |
| NSWindow / NSView | Main Thread only |
| CGContext 描画 | Background OK |
コメント