SwiftUIを使えば、簡単にメニューバー常駐アプリが作れるらしい。
完成作品
クリップボードの内容の文字数か単語数を表示するシンプルなmacOSのユーティリティアプリ。
例えば大学生がレポートを書くときにコピーするだけで文字数を調べられて便利。
作ったのは以下3ファイルだけ。
- AppDelegate
- LetterCounterApp
- Counter
実装
まずは、クリップボードの変化を取得したい。ただ、変化を通知してくれる仕組みは無いようで、
今回はTimerで一定時間ごとに監視してNotificationCenterにpostしてみた。
NotificationCenterに登録するために、iOS14以降では使われていない(?)AppDelegateに今回は復活してもらう。
// Created by Federico Vitale on 22/05/2019.
// Copyright © 2019 Federico Vitale. All rights reserved.
// Modified by Kotaro Matsumoto on 29/07/2023
import Cocoa
class AppDelegate: NSObject, NSApplicationDelegate {
var timer: Timer!
let pasteboard: NSPasteboard = .general
var lastChangeCount: Int = 0
func applicationDidFinishLaunching(_ aNotification: Notification) {
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
if self.lastChangeCount != self.pasteboard.changeCount {
self.lastChangeCount = self.pasteboard.changeCount
NotificationCenter.default.post(name: .NSPasteboardDidChange, object: self.pasteboard)
}
}
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
timer.invalidate()
}
}
extension NSNotification.Name {
public static let NSPasteboardDidChange: NSNotification.Name = .init(rawValue: "pasteboardDidChangeNotification")
}
新規ファイルでAppDelegateを作成。
AppDelegateは事実上Appのルートオブジェクトで、UIApplicationと一緒に働き、systemとやりとりする。
import SwiftUI
import NaturalLanguage
@main
struct LetterCounterApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var counter = Counter()
@State private var menuBarIcon = Counter.Kind.words
var body: some Scene {
MenuBarExtra {
Button("\(counter.characters) Characters") {
menuBarIcon = .characters
}
Button("\(counter.words) Words") {
menuBarIcon = .words
}
Divider()
Button("Quit") {
NSApplication.shared.terminate(nil)
}.keyboardShortcut("q")
} label: {
let icon: String = switch menuBarIcon {
case .words: "\(counter.words)W"
case .characters: "\(counter.characters)C"
}
Text("\(icon)")
.onReceive(NotificationCenter.default.publisher(for: .NSPasteboardDidChange)){ notification in
guard let pb = notification.object as? NSPasteboard else { return }
guard let items = pb.pasteboardItems else { return }
guard let item = items.first?.string(forType: .string) else { return } // you should handle multiple types
counter.characters = item.count
let tokenizer = NLTokenizer(unit: .word)
tokenizer.string = item
let range = item.startIndex..<item.endIndex
let tokenArray = tokenizer.tokens(for: range)
counter.words = tokenArray.count
}
}
}
}
App
は@main
attributeに続けて書くことで、ここがアプリの入り口だとSwiftUIに教える。
App
protocolはmain()
のデフォルト実装を提供する。
main()
はsystemがアプリを起動するために呼び出す。
@NSApplicationDelegateAdaptor
というattributeに続けてAppDelegateを宣言することで、AppDelegateをこのアプリは実装していることをSwiftUIに(?)伝える。
import SwiftUI
class Counter: ObservableObject {
@Published var characters: Int = 0
@Published var words: Int = 0
enum Kind {
case words, characters
}
}
簡単なカウンター
学んだこと
App protocolの必須メソッドであるbody
はScene
のOpaque Result Typeを返すComputed Property。
Scene
はviewヒエラルキーのコンテナ。viewヒエラルキーのルートviewと、systemに管理されたlifecycleを持つ。
View protocolに似ているね。
Sceneについて詳しくは
今回詰まったこと
MenuBarExtraのアイコンを状態に基づいて変更したかったが、どこでNotificationCenterからの通知に基づいてstateを変更するかが問題だった。
最初はinit<S>(S, content: () -> Content)
で
MenuBarExtra("\(count)") {
SomeView()
.onReceive(...)...
}
としていたが、なぜかこれではonReceiveが呼ばれなかった。
実装コードのようにinit(content: () -> Content, label: () -> Label)
でlabelの中でonReceiveを使うときちんと呼ばれた。
賢い人なんでか教えて。
参考:
起動中Dockから非表示にする↓
全般を参考に↓