macOS のメニューバーの常駐アイコンからポップする popover の見た目をカスタマイズする(by swift)

# メニューバー常駐型アプリケーションを実装する

macOS のメニューバー常駐型アプリケーションを作成しようと考えた時の知見を公開しておきます．

## 結論

[iOS and OS X Development: Cocoa Popup window in the Status bar](http://blog.shpakovski.com/2011/07/cocoa-popup-window-in-status-bar.html) を参考に，所々修正を加え，以下のようなものを作りました．

![screenshot.gif](https://qiita-image-store.s3.amazonaws.com/0/64850/7d3c3329-08db-dc7a-7a2e-950ebaef6820.gif)

最終的な成果物は参考サイト先のものと同様で，参考サイト先のコードは現在も動くのですが，今回はそれを Swift で再実装しました．また，deprecated なAPIの使用を控えました．

## 目指すアプリケーション

メニューバー常駐型のアプリケーションです．Dropbox のように，アイコンをクリックするとパネルがポップするものを考えています．

![dropbox.png](https://qiita-image-store.s3.amazonaws.com/0/64850/a62e9636-b00d-bfb2-5f45-f654d1f46c71.png)

実装の仕方の話に入る前に，ガイドラインを確認しておきます．上記のように，メニューバー上にアイコンを作成するアプリケーションに関しては公式からガイドラインが出ています．ガイドラインには，以下のような記述があります．

> - メニューバーの表示/非表示はシステム設定で切り替え可能であるため，アイコンが表示される保証はない．そのため，アイコンに依存したアプリケーションはよろしくない
> - メニューバーアイコンからはメニューを表示させるべきであり， popover を作成すべきでない([macOS Human Interface Guidelines: Content Views](https://developer.apple.com/library/content/documentation/UserExperience/Conceptual/OSXHIGuidelines/ControlsView.html#//apple_ref/doc/uid/20000957-CH52-SW2))

> [macOS Human Interface Guidelines: Menu Bar Menus](https://developer.apple.com/library/content/documentation/UserExperience/Conceptual/OSXHIGuidelines/MenuBarMenus.html#//apple_ref/doc/uid/20000957-CH29-SW12)

ガイドラインによれば，**メニューバー上のアイコンからpopoverを作成することは好ましくない** ようです．しかし，今回は個人的に使用するアプリケーションの実装を目的としているため，とりあえずこの警告は無視して話を進めていきます．

## 実現のためにどうするか
### NSPopover による実装

macOS アプリケーション開発で上記のようにメニューバーにアイコンを配置するアプリケーションを作成したい場合は，[`NSPopover`](https://developer.apple.com/reference/appkit/nspopover) を使用するのがスタンダードのようです．とても親切なチュートリアルを見つけたので，こちらを参考にすれば問題なく実装できると思います．
> [OS X Tutorial: Menus and Popovers in Menu Bar Apps](https://www.raywenderlich.com/98178/os-x-tutorial-menus-popovers-menu-bar-apps)

### NSPopover の問題点

上記のチュートリアルで十分ではないかとも思われるのですが，NSPopover を使用すると，インタフェースがユーザの環境に依存して変化してしまうという問題点があります．
macOS ではシステムの設定で Dark モードにしていると，メニューバーが黒くなります．この時，NSPopover で表示される popover も同時に黒くなってしまいます．
Stackoverflow にも質問が飛んでいましたが([cocoa - How to change NSPopover background color include triangle part? - Stack Overflow](http://stackoverflow.com/questions/19978620/how-to-change-nspopover-background-color-include-triangle-part))，popover の背景色を独立して変更する方法はないようです．

白と黒では明度が真逆であるため，ボタンを画像として用意した場合には，各々に適した2種類の画像を用意しておくか，どちらの背景色にも適した色調の画像にする必要があります．これと同様の事象については公式のガイドラインでも一応触れられています．

> Use a template image to represent a menu bar extra. In General system preferences, users can change the menu bar (and Dock) to a dark appearance. If you don’t use a template image to represent your menu bar extras, they might not look good in both menu bar appearances.
> [macOS Human Interface Guidelines: Menu Bar Menus](https://developer.apple.com/library/content/documentation/UserExperience/Conceptual/OSXHIGuidelines/MenuBarMenus.html#//apple_ref/doc/uid/20000957-CH29-SW12)

この問題に対して，自分の思いつく解決策は以下の2つです．

1. Dark mode かそうでないかを判定し，適したUI部品を配置する
2. NSPopover 以外の方法を考える

ユーザ環境に依存せずUIを統一したかったので，今回は 2 の方法を考えていきます．

### NSBezierPath による実装

[`NSBezierPath`](https://developer.apple.com/reference/appkit/nsbezierpath) オブジェクトを使用すると，直線と曲線を組み合わせて図形を作成し，内部を塗りつぶすといった操作が可能になります．これを利用して，NSPopover を模した NSWindow を実装できます．実際にこれを実現している方がおり，ソースコードも公開されています．
> [iOS and OS X Development: Cocoa Popup window in the Status bar](http://blog.shpakovski.com/2011/07/cocoa-popup-window-in-status-bar.html)

しかし，上記サイトから辿ることのできるソースは Objective-C で記述されている上に，[drawStatusBarBackgroundInRect:withHighlight:](https://developer.apple.com/reference/appkit/nsstatusitem/1532509-drawstatusbarbackgroundinrect?l_3)等，Deprecated なメソッドがちらほら見かけられます．そこで，今回は上記で紹介されているコードを参考に，同様の処理を Swift で記述していこうと思います．

## 実装

メニューバーに配置されるアイテムは，`NSStatusItem` オブジェクトとして実装します．[参考サイト](http://blog.shpakovski.com/2011/07/cocoa-popup-window-in-status-bar.html)では，`NSStatusItem` をメニューバーに配置する役割を `MenubarController` に課し，表示内容の詳細を `StatusItemView` に記述します．さらに，アイテム押下時にポップするウインドウをパネルと呼称し，その描画は `PanelController` に任せています．そして，それら2つ(アイテムとパネル)各々の動作を，`ApplicationDelegate` で結びつけています．今回の実装も，基本的にこの方針に沿っていきます．

### 事前準備

Xcode プロジェクトの作成，画像の準備，アイコンをメニューバーに表示するための画像に対する設定，plistの設定等，メニューバー常駐型アプリケーション作成のためには色々な設定が必要となるようですが，今回は割愛します．詳しくは，既に紹介した以下のチュートリアルに記述されています．
> [OS X Tutorial: Menus and Popovers in Menu Bar Apps](https://www.raywenderlich.com/98178/os-x-tutorial-menus-popovers-menu-bar-apps)

### メニューバー上のアイコンの表示

まずは，メニューバー上にアイコンを表示できるようにします．リファレンス([NSStatusItem - AppKit | Apple Developer Documentation](https://developer.apple.com/reference/appkit/nsstatusitem))を参考にすると，以下のようなことがわかります．

- `NSStatusItem` は生成時に自動的にステータスバーに追加される
- 描画や振る舞いは `button` プロパティを使用して行う

[参考サイト](http://blog.shpakovski.com/2011/07/cocoa-popup-window-in-status-bar.html)では，アイテムの描画のために `NSView` を実装し，そのプロパティとして画像等を持たせ，描画の際には `NSView` に `NSImage` を貼り付けるといった処理を行っていました．これらの処理については，単純な `button` プロパティへの代入に置き換えられます．view の生成，保持と実際の描画を分けるために，Controller と View に分割し，以下のように実装しました．

``` swift
class MenubarController: NSObject {
    let view: StatusItemView

    override init() {
        self.view = StatusItemView()
    }
}
```

``` swift
class StatusItemView: NSObject {
    let WIDTH: CGFloat = 24.4
    let statusItem: NSStatusItem

    override init() {
        self.statusItem = NSStatusBar.system().statusItem(withLength: WIDTH)

        if let button = statusItem.button {
            // アイコン押下時，AppDelegate の togglePopover メソッドを呼び出す
            button.action = #selector(AppDelegate.togglePopover)
            button.image = NSImage(named: "StatusBarButtonImage")
        }
    }
}
```

### ポップするパネルの表示

まず，パネルのUIの基礎部分となる `.xib` ファイルを作成します．今回は，背景となる `background view` と，UIコンポーネントを配置する `base view` の2枚を重ねた xib ファイルを用意します．後に，`background view` のフレームサイズに合わせた `NSWindow` を生成し，そこにこれらを貼り付けることでパネルを作成することになります．

![view.png](https://qiita-image-store.s3.amazonaws.com/0/64850/a7e1fad5-d281-51c3-fd45-1db7bc6ca84a.png)

`background view` は背景，すなわち `NSPopover` のような見た目を形作るために利用します．そのため，`NSView` ではなく，以下のような `PanelBackground` クラスを実装し，そのオブジェクトとして設定します．この`PanelBackground`の処理内容は，[参考サイト](http://blog.shpakovski.com/2011/07/cocoa-popup-window-in-status-bar.html)先のコードをほぼそのまま Swift に書き直したものです．

``` swift
class PanelBackground: NSView {
    // 各種定数
    let LINE_THICKNESS: CGFloat = 1.0
    let CORNER_RADIUS:  CGFloat = 6.0
    let ARROW_WIDTH:    CGFloat = 10.0
    let ARROW_HEIGHT:   CGFloat = 10.0
    let FILL_OPACITY:   CGFloat = 0.9
    let STROKE_OPACITY: CGFloat = 0.0

    // パネルの三角形部分の位置
    var arrowX_: CGFloat = 10.0

    func setArrowX(value: CGFloat) {
        arrowX_ = value
        // 以下のメソッドを呼び出すことで，再描画のために `draw` メソッドが呼び出される
        self.needsDisplay = true
    }

    /*
      下記のような順番で直線を描画していく
      ⑧  ⑨  ①  ②
       ┏━━^-━┓
     ⑦ ┃     ┃③
       ┗━━━━━┛
      ⑥   ⑤   ④
     */
    override func draw(_ dirtyRect: NSRect) {
        let contentRect = NSInsetRect(self.bounds, LINE_THICKNESS, LINE_THICKNESS)

        // パスの初期化
        let path = NSBezierPath()

        // ①
        path.move(to: NSMakePoint(arrowX_, contentRect.maxY))
        path.line(to: NSMakePoint(arrowX_ + ARROW_WIDTH / 2,
                                  contentRect.maxY - ARROW_HEIGHT))
        path.line(to: NSMakePoint(contentRect.maxX - CORNER_RADIUS,
                                  contentRect.maxY - ARROW_HEIGHT))

        // ②
        let topRightCorner = NSMakePoint(contentRect.maxX,
                                         contentRect.maxY - ARROW_HEIGHT)
        path.curve(to: NSMakePoint(contentRect.maxX,
                                   contentRect.maxY - ARROW_HEIGHT - CORNER_RADIUS),
                   controlPoint1: topRightCorner,
                   controlPoint2: topRightCorner)

        // ③
        path.line(to: NSMakePoint(contentRect.maxX,
                                  contentRect.minY + CORNER_RADIUS))

        // ④
        let bottomRightCorner = NSMakePoint(contentRect.maxX,
                                            contentRect.minY)
        path.curve(to: NSMakePoint(contentRect.maxX - CORNER_RADIUS,
                                   contentRect.minY),
                   controlPoint1: bottomRightCorner,
                   controlPoint2: bottomRightCorner)

        // ⑤
        path.line(to: NSMakePoint(contentRect.minX + CORNER_RADIUS,
                                  contentRect.minY))

        // ⑥
        path.curve(to: NSMakePoint(contentRect.minX,
                                   contentRect.minY + CORNER_RADIUS),
                   controlPoint1: contentRect.origin,
                   controlPoint2: contentRect.origin)

        // ⑦
        path.line(to: NSMakePoint(contentRect.minX,
                                  contentRect.maxY - ARROW_HEIGHT - CORNER_RADIUS))

        // ⑧
        let topLeftCorner = NSMakePoint(contentRect.minX,
                                        contentRect.maxY - ARROW_HEIGHT)
        path.curve(to: NSMakePoint(contentRect.minX + CORNER_RADIUS,
                                   contentRect.maxY - ARROW_HEIGHT),
                   controlPoint1: topLeftCorner,
                   controlPoint2: topLeftCorner)

        // ⑨
        path.line(to: NSMakePoint(arrowX_ - ARROW_WIDTH / 2,
                                  contentRect.maxY - ARROW_HEIGHT))

        path.close()

        // パス内の塗りつぶし
        NSColor(deviceWhite: 1, alpha: FILL_OPACITY).setFill()
        path.fill()

        NSGraphicsContext.saveGraphicsState()

        let clip = NSBezierPath(rect: self.bounds)
        clip.append(path)
        clip.addClip()

        path.lineWidth = LINE_THICKNESS * 2
        NSColor.black.setStroke()
        path.stroke()

        NSGraphicsContext.restoreGraphicsState()
    }
}
```

用意した `.xib` ファイルをロードする `PanelBase` ビューを用意します．

``` swift
class PanelBase: NSView {
    @IBOutlet var backgroundView: PanelBackground!
    @IBOutlet var baseView: NSView!

    convenience init() {
        // 適当なフレームサイズで初期化する
        self.init(frame: CGRect(x: 0, y: 0, width: 0, height: 0))

        // `PanelBase.xib` をロード
        Bundle.main.loadNibNamed("PanelBase", owner: self, topLevelObjects: nil)
        // フレームサイズを background view に合わせる
        self.frame = self.backgroundView.frame
        // 各ビューを貼り付ける
        addSubview(self.backgroundView)
        addSubview(self.baseView)
    }
}
```

ビューを呼び出すコントローラを用意します．

``` swift
class PanelBaseController: NSViewController {
    override func loadView() {
        self.view = PanelBase()
    }
}
```

これで，`PanelBaseController` を呼び出せば，`NSPopover` ライクなUIを表示できるようになったはずです．

### パネルの表示/非表示処理

実際にパネルの表示/非表示を切り替える処理部分を実装します．同時に，先ほど作成したビューを貼り付けるためのウインドウを生成します．

ここで，パネルはアイコンの直下に表示する必要がありますが，`PanelController` はアイコンの位置を知りません．そのため，`MenubarController`からアイコンの位置(`NSRect`)を取得するメソッドが必要になります．これは外部のクラス(後述)に実装し，Delegate パターンで処理を呼び出します．このためのプロトコルとして，以下のような`PanelControllerDelegate`を定義します．

``` swift
protocol PanelControllerDelegate {
    func statusItemViewRectForPanelController() -> NSRect
}

class PanelController: NSWindowController, NSWindowDelegate {
    var delegate: PanelControllerDelegate?
    ...
```

ウインドウを生成する`PanelController`を実装します．このコントローラの初期化処理では，上記のプロトコルを実装したクラスのオブジェクトの受け取り&格納と，パネル用ウインドウの生成と設定を行います．`backgroundColor`を`clear`，かつ`hasShadow`を`false`にすると，ウインドウの背景を透明化できます．さらに，`styleMask`を`borderless`にするとアプリケーションのタイトルバーを非表示にできます．これによりパネルの見た目が`NSPopover`に近くなります．

``` swift
convenience init(delegate: PanelControllerDelegate?) {
    let panel = NSWindow(contentViewController: PanelBaseController())
    self.init(window: panel)

    panel.hasShadow = false
    panel.styleMask = [.borderless]
    panel.acceptsMouseMovedEvents = true
    panel.isOpaque = false
    panel.backgroundColor = .clear

    self.delegate = delegate
    self.baseView = panel.contentView as? PanelBase
}
```

次に，パネルの表示/非表示のためのメソッドを実装します．表示の際，パネルがスクリーンを飛び出してしまうことがあるので，そのような場合には位置の調整が必要となります．また，シンプルな表示/非表示では寂しかったので，簡単なフェードイン/フェードアウトのアニメーションを付加しました．

``` swift
func openPanel() {
    let panel = self.window!

    // 画面サイズを取得
    let screenRect = NSScreen.screens()!.first!.frame
    // アイコンの位置を取得
    let statusRect = self.delegate!.statusItemViewRectForPanelController()
    // アイコンの位置に合わせて，パネルの位置の初期化
    var panelRect = self.window!.frame
    panelRect.origin.x = statusRect.minX
    panelRect.origin.y = statusRect.midY - panelRect.height

    if panelRect.maxX > screenRect.maxX {
        // パネルが画面外に飛び出す時は，パネルとパネル上の三角形の位置を調整する
        self.baseView?.backgroundView.setArrowX(value: ARROW_X + (panelRect.maxX - screenRect.maxX + RIGHT_MARGIN))
        panelRect.origin.x = screenRect.maxX - panelRect.width - RIGHT_MARGIN
    } else {
        self.baseView?.backgroundView.setArrowX(value: ARROW_X)
    }

    // パネルの位置を設定
    panel.setFrame(panelRect, display: true, animate: true)

    // 表示アニメーション
    // alpha を 0 に設定し，1 まで変化させる
    self.window?.alphaValue = 0
    self.window?.makeKeyAndOrderFront(nil)
    NSAnimationContext.runAnimationGroup({ (context) -> Void in
        // アニメーションの速さ: 0.3 sec
        context.duration = 0.3
        self.window?.animator().alphaValue = 1
        }, completionHandler: nil
    )
    // アプリケーションにフォーカスを合わせる
    NSApp.activate(ignoringOtherApps: true)
}

func closePanel() {
    // 非表示アニメーション
    // alpha を 1 から 0 にする
    NSAnimationContext.runAnimationGroup({ (context) -> Void in
        context.duration = 0.3
        self.window?.animator().alphaValue = 0
        }, completionHandler: {
            // ウインドウを非表示にする
            self.window?.orderOut(self)
        }
    )
}
```

### アイコン押下とパネルの表示/非表示処理の連携

メニューバー上のアイコンが押下された時にパネルがポップする必要があります．この連携処理は `AppDelegate` に記述します．また，`PanelController` 内で使用する delegate 用メソッドもここで実装し，`AppDelegate`自身を`PanelController`に渡します．この delegate は，[参考サイト](http://blog.shpakovski.com/2011/07/cocoa-popup-window-in-status-bar.html)のソースコードの手法をそのまま流用しました．

``` swift
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate, PanelControllerDelegate {

    @IBOutlet weak var window: NSWindow!
    var menubarController: MenubarController!
    var panelController: PanelController!

    func applicationDidFinishLaunching(_ Notification: Notification) {
        self.menubarController = MenubarController()
        // AppDelegate 自身をわたす
        self.panelController = PanelController(delegate: self)
    }

    // アイコン押下時に呼び出されるメソッド
    func togglePopover() {
        // self.panelController.window = パネル が表示されているか否か
        if self.panelController.window!.isVisible {
            self.panelController.closePanel()
        } else {
            self.panelController.openPanel()
        }
    }

    // アプリケーションからフォーカスが失われたら，パネルを閉じる
    func applicationDidResignActive(_ notification: Notification) {
        self.panelController.closePanel()
    }

    // アイコンの描画位置を取得するためのメソッド
    // panelController 内で使用する
    func statusItemViewRectForPanelController() -> NSRect {
        return self.menubarController.view.getRect()
    }
}
```

必要な実装は以上です．

## まとめ

最終的には冒頭で示した通り，以下のようなものができました．

![screenshot.gif](https://qiita-image-store.s3.amazonaws.com/0/64850/7d3c3329-08db-dc7a-7a2e-950ebaef6820.gif)

ソースコードも公開しています．コントローラに処理描き過ぎていたり，アクセス修飾子がなかったりお粗末ですが，ベースとしてはとりあえず良いかなという気持ちです．

>[tasuwo/Popover: Custom popover window appearing from the icon in the macOS status bar](https://github.com/tasuwo/Popover)