LoginSignup
2
9

macOSメニューバー常駐アプリ入門

Last updated at Posted at 2023-07-29

SwiftUIを使えば、簡単にメニューバー常駐アプリが作れるらしい。

完成作品

image.png

クリップボードの内容の文字数か単語数を表示するシンプルなmacOSのユーティリティアプリ。
例えば大学生がレポートを書くときにコピーするだけで文字数を調べられて便利。

作ったのは以下3ファイルだけ。

  • AppDelegate
  • LetterCounterApp
  • Counter

実装

まずは、クリップボードの変化を取得したい。ただ、変化を通知してくれる仕組みは無いようで、
今回はTimerで一定時間ごとに監視してNotificationCenterにpostしてみた。
NotificationCenterに登録するために、iOS14以降では使われていない(?)AppDelegateに今回は復活してもらう。

AppDelegate.swift
//  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とやりとりする。

LetterCounterApp.swift
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に(?)伝える。

Counter.swift
import SwiftUI

class Counter: ObservableObject {
    @Published var characters: Int = 0
    @Published var words: Int = 0

    enum Kind {
        case words, characters
    }
}

簡単なカウンター

学んだこと

App protocolの必須メソッドであるbodySceneの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から非表示にする↓

全般を参考に↓

2
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
9