MTGなどの予定が入っている時、そのMTGが始まる30分前くらいから残り時間を気にしだすのでパフォーマンスが上がりますよね(?)
なら次の予定まであと何分何秒残っているのか常に表示しておけば常にパフォーマンス上がるのでは!?と思ってつくりました。

予定データはGoogleカレンダーから取得するのが一番良いんでしょうが、ちょっとめんどくさそうだったのでMacのローカルのカレンダーアプリから予定を取得することにしました。
(カレンダーアプリ上でGoogleカレンダーと同期すればGoogleカレンダー上の予定も間接的に取得できます)

また具体的な予定名を隠したり、0.1秒単位ではなく1秒単位や1分単位でカウントダウンさせることもできます。

 

ソース

GitHub

 

インストール方法

リリースページからMenuBarCountDown.app.zipをダウンロードいただき、解凍して出てきた.appをmacのアプリケーションフォルダに移動させてください。

起動時に下記ダイアログが出た場合は、

お手数ですがFinderを開きMenuBarCountDown.appを右クリック(2本指タップ)して「開く」を選択してください。

 

つかいかた

Googleカレンダーの予定を表示したい場合は、まずローカルのカレンダーアプリに Googleアカウントを追加する必要があります。
カレンダーアプリを開いて左上のメニュー「カレンダー」から「アカウントを追加…」を押下し、Googleアカウントを追加してください。

 

その後はこのアプリを起動してください。カレンダー取得の権限を求めるダイアログが出るので、許可すればあとは勝手にカウントダウンが始まります。

 


以下、詰まったところ紹介

詰まったところその1: なぜかカレンダーから予定データが取得できない

let eventStore: EKEventStore = EKEventStore()
let calendars = eventStore.calendars(for: .event)
// 検索条件作成
let calendar = Calendar.current
var components = calendar.dateComponents([.year, .month, .day], from: Date())
components.hour = 23
components.minute = 59
let endOfDay = calendar.date(from: components)!
// 現時刻から今日の23時59分までのイベントを検索
let predicate = eventStore.predicateForEvents(
    withStart: Date(),
    end: endOfDay,
    calendars: calendars
)
let events = eventStore.events(matching: predicate)

※コード中4行目のCalendarクラスは日時を扱うためのクラスなのでローカルのカレンダーアプリとは別の存在です。ややこしい。

ローカルのカレンダーアプリに登録されている予定は、 eventStore.calendars(for: .event) で全て取得することができます。
ここでは「今日の次の予定」を取得してその予定までの残り時間を表示したいので、検索条件を「予定の開始時刻が現時刻から今日の23時59分までの間」とし、該当する予定があるかどうか検索しています。

let predicate = eventStore.predicateForEvents(withStart: Date(), end: endOfDay, calendars: calendars) で検索条件を作成し、
let events = eventStore.events(matching: predicate) でそれにマッチする予定を取得しています。変数 eventsEKEventクラスの配列となります。

ローカルのカレンダーアプリに何かしら予定が入っていれば events にそれが入ってくるはずなのですが、何故かずっと空の配列で返ってくる現象が起きていました。

A.

entitlementsファイルでCalendarsの ValueをYESに設定する必要がありました。

参考

 

詰まったところその2: Timerを設定してMenubarにカウントダウンを表示させたいがカウントダウンが進まない

// 0.1秒ごとに動くタイマーをセット
self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
    // 0.1秒ごとにMenubarのタイトルの文言を書き換える
    button.title = self.makeTimerStr(targetTime: targetTime, showTitle: showTitle, pattern: pattern)
}

Menubar上に表示する次の予定までのカウントダウンは、単純に表示する文言を0.1秒ごとに書き換えることで実現しています。
ここでは0.1秒ごとに走るタイマーを作成し、その中でMenubarの文言を書き換えています…が、実際に動かしてみたところカウントダウンが動かず時が止まったままになりました。

A. UIに変更をかけるにはメインスレッドでタイマーを起動する

DispatchQueue.main.async {
    guard let button = self.statusBarItem?.button else { return }
    self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
        button.title = self.makeTimerStr(targetTime: targetTime, showTitle: showTitle, pattern: pattern)
    }
}

DispatchQueue.main.async{} 内に書いた処理はメインスレッドで動くようになります。

SwiftではUIが変更されるようなことはメインスレッドでやるという鉄の掟があるんですが、「UIを変更する処理を動かすタイマー」もメインスレッド上で作る必要があるみたいでした。(考えてみればそりゃそうか)

参考

 

以下、その他中身の説明

主に以下6つのファイルを作りました。

  • MenuBarCountDownApp.swift
  • CalendarManager.swift
  • TimerManager.swift
  • ContentView.swift
  • SettingView.swift
  • PreferencesView.swift

 

このうち、アプリの根幹を成している

  • MenuBarCountDownApp.swift
  • CalendarManager.swift
  • TimerManager.swift

について、ポイントを絞って説明します。

記事中のコードはアップデートによって一部古くなっていますので、実際のコードはGitHubをご覧ください

 

MenuBarCountDownApp.swift

SwiftUIを使うための設定と、AppDelegateがあるファイルです。

applicationDidFinishLaunching()

アプリの起動時に呼ばれるメソッドです。

func applicationDidFinishLaunching(_ notification: Notification) {
        // Dockにアプリを表示しない
        NSApp.setActivationPolicy(.accessory)
        // Notificationの受信設定
        NotificationCenter.default.addObserver(self, selector: #selector(updateTimer), name: .updateTimer, object: nil)

        // ポップオーバーウインドウの設定
        popover.behavior = .transient
        popover.contentViewController = NSHostingController(rootView: ContentView())

        // ステータスバーの設定
        self.statusBarItem = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))
        guard let button = self.statusBarItem.button else { return }
        button.font = NSFont.monospacedDigitSystemFont(ofSize: 14, weight: .regular)
        button.title = "Reading Calendar Data..."
        // アクションの設定
        button.action = #selector(menuButtonAction(sender: ))
        // カレンダーアプリから取得
        CalendarManager.shared.requestCalendarAccess()
    }

 

  • MenuBarの設定
  • MenuBar部がクリックされた時にContentViewを表示する
  • 起動時に動かしたい処理の発火

をやっています。

またカレンダーアプリから予定を取得する前にMenubarに表示する初期値の文言として「Reading Calendar Data..」という文言を表示するようにしています。

menuButtonAction()

MenuBarのタイマー表示部分を押下した時に呼ばれるメソッドです。

    @objc func menuButtonAction(sender: AnyObject) {

        guard let button = self.statusBarItem.button else { return }
        if self.popover.isShown {
            self.popover.performClose(sender)
        } else {
            // ポップアップを表示
            self.popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
            // 他の位置をクリックすると消える
            self.popover.contentViewController?.view.window?.makeKey()
        }
    }

タイマー表示部分をクリックしたときに、

  • popover(ContentView)を表示する
  • 他の部分をクリックした時Viewを非表示にする
    ということをやっています。

    ※画像は開発中のものなのでHello Worldになってますが今は複数の機能を表示するなどの機能があります。

 

CalendarManager.swift

macローカルのカレンダーアプリからイベント(予定)データを取得する処理をまとめているファイルです。

authorizationStatus()

    /// カレンダー取得の権限の状態を確認する
    /// - Returns: 許可されていればtrue
    func authorizationStatus() -> Bool {
        let status = EKEventStore.authorizationStatus(for: .event)

        switch status {
        case .authorized:
            print("Authorized")
            return true
        case .notDetermined:
            print("Not determined")
            return false
        case .restricted:
            print("Restricted")
            return false
        case .denied:
            print("Denied")
            return false
        @unknown default:
            print("Unknown default")
            return false
        }
    }

カレンダーアプリからデータを取得するにはユーザの許可が必要なので、今現在許可されているかどうかを確認します。
(許可されていればtrueを返す)
この部分はXCodeの警告に従ったら勝手に作られたので楽ちんでした。

getCalendarData()

ローカルのカレンダーアプリからイベントのデータを取得するメソッドです。
結果はEKEvent(カレンダーのイベントの情報を扱うオブジェクト)の配列で返されます。
検索条件は、現在時刻〜今日の23時59分としています。

    /// ローカルのカレンダーアプリからイベントのデータを取得する
    func getCalendarData() -> [EKEvent] {
        let calendars = eventStore.calendars(for: .event)
        // 検索条件
        let calendar = Calendar.current
        var components = calendar.dateComponents([.year, .month, .day], from: Date())
        components.hour = 23
        components.minute = 59
        let endOfDay = calendar.date(from: components)!

        let predicate = eventStore.predicateForEvents(
            withStart: Date(),
            end: endOfDay,
            calendars: calendars)
        let events = eventStore.events(matching: predicate)
        return events
    }

 

getEventToDisplay()

MenuBarに表示するイベントを1つだけ取り出すメソッドです。

引数にEKEvent(カレンダー上のイベントの情報を扱うクラス)の配列をとり、
配列内にデータがあれば、現時刻からいちばん近い未来にあるEKEventの1つを取り出して返します。
データがなければ定時までのカウントダウンをするために、タイトルが「定時」で開始時間が「19時」のEKEventを作成して返します。

    /// 取得したイベントデータをもとに表示するイベントを返す
    /// - Parameter events: 取得したイベントデータの配列
    /// - Returns: 表示するイベントデータ(開始時間が最も現在時刻に近いイベントとなる)
    func getEventToDisplay(events: [EKEvent]) -> EKEvent{
        for event in events {
            if let eventStartDate = event.startDate {
                // 現時刻より先にあるイベントの開始時刻とタイトルをタイマーにセット
                if Date() < eventStartDate {
                    return event
                }
            }
        }
        // 予定がなければ定時までカウントダウンする
        let calendar = Calendar.current
        let components = calendar.dateComponents([.year, .month, .day], from: Date())
        let onTime = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: calendar.date(from: components)!)!
        // 定時のEKEventオブジェクト作成
        let event = EKEvent(eventStore: eventStore)
        event.title = "定時"
        event.startDate = onTime
        return event   
    }

 

TimerManager.swift

カウントダウン表示するためのタイマーまわりの処理をまとめているファイルです。

makeTimerStr()

開始時刻とイベント名を引数にとり、
{イベント名} まで(orから) XX h YY m ZZ.Z sという文字列を作るメソッド。表示用。

    func makeTimerStr(targetTime :Date, showTitle: String) -> String{
        let now = Date()
        let timeDifference = now.timeIntervalSince(targetTime)
        let timeLeft = timeDifference > 0 ? timeDifference : -timeDifference
        let hoursLeft = Int(timeLeft) / 3600
        let minutesLeft = Int(timeLeft) / 60 % 60
        let secondsLeft = Int(timeLeft) % 60
        let millisecondsLeft = Int(timeLeft * 10) % 10
        if timeDifference > 0 {
            return String(format: "\(showTitle) から %02i h %02i m %02i.%01i s", hoursLeft, minutesLeft, secondsLeft, millisecondsLeft)
        } else {
            return String(format: "\(showTitle) まで %02i h %02i m %02i.%01i s", hoursLeft, minutesLeft, secondsLeft, millisecondsLeft)
        }
    }

timerStart()

開始時刻とイベント名を引数にとり、makeTimerStr()で作った文字列を0.1秒単位でMenuBarに表示し続けることでカウントダウンするメソッド。
ユーザ設定で「予定タイトルを表示する」をOFFにしている場合は、具体的なタイトル名ではなく「次の予定」とぼかした表現にしています。

    func timerStart(targetTime :Date, eventTitle: String){
        timer.invalidate()

        var showTitle = eventTitle
        let isShowTitle = UserDefaults.standard.bool(forKey: "isShowTitle")
        print(isShowTitle)
        if(!isShowTitle){
            showTitle = "次の予定"
        }
        print("timerStart \(showTitle), \(targetTime)")

        DispatchQueue.main.async {
            guard let button = self.statusBarItem?.button else { return }

            self.timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
                button.title = self.makeTimerStr(targetTime: targetTime, showTitle: showTitle)
            }
        }
    }

最後に

ぜひ使ってみてください!!