カスタマイズ可能なショートカットキー
|
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 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
import SwiftUI import AppKit // ショートカット設定を保存する構造体 struct KeyboardShortcut: Codable, Equatable { var key: String var modifiers: EventModifiers enum CodingKeys: String, CodingKey { case key, modifiers } init(key: String, modifiers: EventModifiers) { self.key = key self.modifiers = modifiers } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: .key) let rawValue = try container.decode(EventModifiers.RawValue.self, forKey: .modifiers) modifiers = EventModifiers(rawValue: rawValue) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(key, forKey: .key) try container.encode(modifiers.rawValue, forKey: .modifiers) } var displayString: String { var parts: [String] = [] if modifiers.contains(.command) { parts.append("⌘") } if modifiers.contains(.shift) { parts.append("⇧") } if modifiers.contains(.option) { parts.append("⌥") } if modifiers.contains(.control) { parts.append("⌃") } parts.append(key.uppercased()) return parts.joined() } } // ショートカット設定を管理するViewModel @Observable class ShortcutManager { var shortcuts: [String: KeyboardShortcut] = [:] init() { loadShortcuts() } func setShortcut(_ shortcut: KeyboardShortcut, for action: String) { shortcuts[action] = shortcut saveShortcuts() } func getShortcut(for action: String) -> KeyboardShortcut? { return shortcuts[action] } private func saveShortcuts() { if let encoded = try? JSONEncoder().encode(shortcuts) { UserDefaults.standard.set(encoded, forKey: "customShortcuts") } } private func loadShortcuts() { if let data = UserDefaults.standard.data(forKey: "customShortcuts"), let decoded = try? JSONDecoder().decode([String: KeyboardShortcut].self, from: data) { shortcuts = decoded } else { // デフォルトのショートカット shortcuts = [ "newDocument": KeyboardShortcut(key: "n", modifiers: .command), "saveDocument": KeyboardShortcut(key: "s", modifiers: .command), "search": KeyboardShortcut(key: "f", modifiers: .command) ] } } } struct ContentView: View { @State private var shortcutManager = ShortcutManager() @State private var showSettings = false var body: some View { VStack(spacing: 20) { Text("ショートカットキーのテスト") .font(.title) // 各アクションのボタンとショートカット表示 ActionButton( title: "新規作成", action: "newDocument", shortcutManager: shortcutManager, onAction: { print("新規作成が実行されました") } ) ActionButton( title: "保存", action: "saveDocument", shortcutManager: shortcutManager, onAction: { print("保存が実行されました") } ) ActionButton( title: "検索", action: "search", shortcutManager: shortcutManager, onAction: { print("検索が実行されました") } ) Divider() Button("ショートカット設定") { showSettings = true } } .padding() .frame(width: 400, height: 300) .sheet(isPresented: $showSettings) { ShortcutSettingsView(shortcutManager: shortcutManager) } } } // アクション実行ボタン struct ActionButton: View { let title: String let action: String let shortcutManager: ShortcutManager let onAction: () -> Void var body: some View { Button(action: onAction) { HStack { Text(title) Spacer() if let shortcut = shortcutManager.getShortcut(for: action) { Text(shortcut.displayString) .font(.caption) .foregroundColor(.secondary) } } .frame(width: 300) } .keyboardShortcut( KeyEquivalent(Character(shortcutManager.getShortcut(for: action)?.key ?? "a")), modifiers: shortcutManager.getShortcut(for: action)?.modifiers ?? [] ) } } // ショートカット設定画面 struct ShortcutSettingsView: View { @Bindable var shortcutManager: ShortcutManager @Environment(\.dismiss) var dismiss var body: some View { VStack(alignment: .leading, spacing: 20) { Text("ショートカットキー設定") .font(.title2) .fontWeight(.semibold) Text("各項目をクリックして、使いたいキーの組み合わせを押してください") .font(.caption) .foregroundColor(.secondary) Divider() ShortcutRow( title: "新規作成", action: "newDocument", shortcutManager: shortcutManager ) ShortcutRow( title: "保存", action: "saveDocument", shortcutManager: shortcutManager ) ShortcutRow( title: "検索", action: "search", shortcutManager: shortcutManager ) Spacer() HStack { Spacer() Button("閉じる") { dismiss() } } } .padding() .frame(width: 500, height: 400) } } // ショートカット設定行 struct ShortcutRow: View { let title: String let action: String @Bindable var shortcutManager: ShortcutManager @State private var isRecording = false var body: some View { HStack { Text(title) .frame(width: 150, alignment: .leading) ShortcutRecorderView( shortcut: Binding( get: { shortcutManager.getShortcut(for: action) ?? KeyboardShortcut(key: "a", modifiers: .command) }, set: { shortcutManager.setShortcut($0, for: action) } ), isRecording: $isRecording ) } } } // 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() } } // ショートカット記録ビュー struct ShortcutRecorderView: View { @Binding var shortcut: KeyboardShortcut @Binding var isRecording: Bool var body: some View { ZStack { Button(action: { isRecording = true }) { HStack { Text(isRecording ? "キーを押してください..." : shortcut.displayString) .frame(maxWidth: .infinity) if isRecording { Text("ESCでキャンセル") .font(.caption2) .foregroundColor(.secondary) } } .padding(8) .frame(width: 280) .background(isRecording ? Color.blue.opacity(0.1) : Color.gray.opacity(0.1)) .cornerRadius(6) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(isRecording ? Color.blue : Color.clear, lineWidth: 2) ) } .buttonStyle(.plain) // NSEventモニター(非表示) ShortcutRecorderNSView(isRecording: $isRecording) { key, modifierFlags in var modifiers: EventModifiers = [] if modifierFlags.contains(.command) { modifiers.insert(.command) } if modifierFlags.contains(.shift) { modifiers.insert(.shift) } if modifierFlags.contains(.option) { modifiers.insert(.option) } if modifierFlags.contains(.control) { modifiers.insert(.control) } shortcut = KeyboardShortcut(key: key, modifiers: modifiers) } .frame(width: 0, height: 0) .opacity(0) } } } #Preview { ContentView() } |
1. なぜ初期化(init)が二つあるのか?
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 通常の初期化 init(key: String, modifiers: EventModifiers) { self.key = key self.modifiers = modifiers } // JSONデコード用の初期化 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: .key) let rawValue = try container.decode(EventModifiers.RawValue.self, forKey: .modifiers) modifiers = EventModifiers(rawValue: rawValue) } |
理由:
- 1つ目:コード内で普通に使うための初期化(例:
KeyboardShortcut(key: "s", modifiers: .command)) - 2つ目:JSONからデコードする時に自動的に呼ばれる初期化
Codableプロトコルに準拠すると、init(from:)はJSONDecoderが自動的に呼び出します。EventModifiersはそのままではJSON化できないので、rawValue(Int値)に変換して保存・復元する必要があります。
2. loadShortcuts()の流れ
|
1 2 |
if let data = UserDefaults.standard.data(forKey: "customShortcuts"), let decoded = try? JSONDecoder().decode([String: KeyboardShortcut].self, from: data) { |
ステップバイステップ:
UserDefaults.standard.data(forKey: "customShortcuts"),(カンマ)で続けているJSONDecoder().decode([String: KeyboardShortcut].self, from: data)- 両方成功すれば
保存時の流れ(参考):
|
1 2 3 |
if let encoded = try? JSONEncoder().encode(shortcuts) { UserDefaults.standard.set(encoded, forKey: "customShortcuts") } |
encode()の時はencode(to encoder:)メソッドが自動的に呼ばれるEventModifiers→rawValue(Int)に変換してJSON化
要するに、CodableプロトコルとJSONEncoder/Decoderが連携して、自動的にシリアライズ・デシリアライズしてくれる仕組みです!
NSViewRepresentableの初期化と各メソッドの役割
NSViewRepresentableのライフサイクル
|
1 2 3 |
struct ShortcutRecorderNSView: NSViewRepresentable { @Binding var isRecording: Bool var onShortcutRecorded: (String, NSEvent.ModifierFlags) -> Void |
この構造体の初期化:
- SwiftUIから呼ばれる時、自動的に初期化される
- 例:
ShortcutRecorderNSView(isRecording: $isRecording) { key, flags in ... }
各メソッドが呼ばれるタイミングと効果
1. makeCoordinator() – 最初に1回だけ
|
1 2 3 |
func makeCoordinator() -> Coordinator { Coordinator(self) // 親(自分自身)への参照を持つCoordinatorを作成 } |
効果:
- SwiftUIとAppKit(NSView)の橋渡し役を作成
parentで親のプロパティ(@Bindingなど)にアクセスできる- 状態を保持し続ける(ビューが再描画されても同じインスタンスが使われる)
2. makeNSView(context:) – 最初に1回だけ
|
1 2 3 4 5 |
func makeNSView(context: Context) -> NSView { let view = NSView() context.coordinator.startMonitoring() // ← ここでイベント監視開始! return view } |
効果:
- 実際のNSViewを作成してSwiftUIに渡す
startMonitoring()でキーイベントの監視を開始- このビューがSwiftUIの階層に追加される
3. updateNSView(_:context:) – 親の状態が変わる度に
|
1 |
func updateNSView(_ nsView: NSView, context: Context) {} |
効果:
- 今回は空だが、通常は
@Bindingやvarが変わった時にNSViewを更新する - 例:
isRecordingが変わった時に何かしたい場合はここに書く
4. dismantleNSView(_:coordinator:) – ビューが破棄される時に1回
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.stopMonitoring() // ← メモリリーク防止! } ``` **効果:** - イベントモニターを解除(**超重要!**) - 解除しないとメモリリークが発生する - ビューが消えてもモニターが残り続けてしまう ## 全体の流れ ``` 1. SwiftUIがShortcutRecorderNSViewを作成 ↓ 2. makeCoordinator() → Coordinatorインスタンス作成(1回のみ) ↓ 3. makeNSView() → NSView作成 & イベント監視開始(1回のみ) ↓ 4. updateNSView() → 状態変更時に呼ばれる(今回は何もしない) ↓ 5. dismantleNSView() → ビュー破棄時にイベント監視停止(クリーンアップ) |
なぜCoordinatorが必要?
|
1 2 3 |
class Coordinator: NSObject { var parent: ShortcutRecorderNSView // ← SwiftUIの@Bindingにアクセスするため var monitor: Any? // ← イベントモニターを保持 |
理由:
NSEvent.addLocalMonitorForEventsのクロージャ内で@Bindingを更新したい- SwiftUIの構造体は値型なので、直接参照できない
- Coordinatorがクラス(参照型)なので、状態を保持できる
これでself.parent.isRecordingのように親の状態を変更できます!
カスタムバインディング
通常のBindingとの違い
通常のBinding(直接プロパティをバインド)
|
1 2 3 |
@State private var name: String = "太郎" TextField("名前", text: $name) // $nameで直接バインディング |
カスタムBinding(今回のケース)
|
1 2 3 4 5 6 |
ShortcutRecorderView( shortcut: Binding( get: { /* 値を取得する処理 */ }, set: { /* 値を設定する処理 */ } ) ) |
get と set の動作
get – 値を読み取る時に呼ばれる
|
1 2 3 |
get: { shortcutManager.getShortcut(for: action) ?? KeyboardShortcut(key: "a", modifiers: .command) } |
いつ呼ばれる?
ShortcutRecorderViewがshortcutの値を読む時- 画面に表示する時(例:
shortcut.displayStringを表示)
何をしている?
shortcutManagerから指定したactionのショートカットを取得- 無ければデフォルト値(⌘A)を返す
set – 値を書き込む時に呼ばれる
|
1 2 3 |
set: { shortcutManager.setShortcut($0, for: action) } |
いつ呼ばれる?
ShortcutRecorderView内でshortcutに新しい値を代入する時- 例:
shortcut = KeyboardShortcut(key: "n", modifiers: .command)
何をしている?
$0は新しく設定された値(新しいKeyboardShortcut)- それを
shortcutManagerに保存
実際の動作フロー
|
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 |
// ユーザーがCommand+Nを押した場合 1. ShortcutRecorderView内で: shortcut = KeyboardShortcut(key: "n", modifiers: .command) ↓ 2. set クロージャが呼ばれる: shortcutManager.setShortcut( KeyboardShortcut(key: "n", modifiers: .command), // $0 for: "newDocument" // action ) ↓ 3. shortcutManager内部で保存され、UserDefaultsに永続化 4. 画面が再描画される時: ↓ 5. get クロージャが呼ばれる: shortcutManager.getShortcut(for: "newDocument") // → KeyboardShortcut(key: "n", modifiers: .command) が返る ↓ 6. ShortcutRecorderViewに表示:⌘N |
なぜこの方法が必要だったのか?
問題:直接バインドできない
|
1 2 3 4 |
// これは動かない! ShortcutRecorderView( shortcut: $shortcutManager.shortcuts["newDocument"] // ❌ 辞書のキーに直接$を使えない ) |
解決策:カスタムBindingで橋渡し
|
1 2 3 4 5 |
// カスタムBindingで辞書アクセスをラップ Binding( get: { shortcutManager.shortcuts["newDocument"] }, // 辞書から取得 set: { shortcutManager.shortcuts["newDocument"] = $0 } // 辞書に保存 ) |
他の使用例
例1:計算プロパティのバインディング
|
1 2 3 4 5 6 7 8 9 10 |
@State private var celsius: Double = 0 var fahrenheitBinding: Binding<Double> { Binding( get: { celsius * 9/5 + 32 }, // 摂氏→華氏に変換 set: { celsius = ($0 - 32) * 5/9 } // 華氏→摂氏に変換 ) } TextField("華氏", value: fahrenheitBinding, format: .number) |
例2:フォーマット変換
|
1 2 3 4 5 6 7 8 9 10 11 12 |
@State private var price: Int = 1000 var priceStringBinding: Binding<String> { Binding( get: { "¥\(price)" }, // Int → "¥1000" set: { if let value = Int($0.replacingOccurrences(of: "¥", with: "")) { price = value // "¥1000" → Int } } ) } |
まとめ
get: 子ビューが値を読む時の処理set: 子ビューが値を書く時の処理- 用途: 直接バインドできない値や、変換が必要な場合に使う
今回のケースでは、辞書から特定のキーの値を取り出して、それを@Bindingとして渡すために使っています!
コメント