Posted at

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

More than 1 year has passed since last update.


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

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


結論

iOS and OS X Development: Cocoa Popup window in the Status bar を参考に,所々修正を加え,以下のようなものを作りました.

screenshot.gif

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


目指すアプリケーション

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

dropbox.png

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



  • メニューバーの表示/非表示はシステム設定で切り替え可能であるため,アイコンが表示される保証はない.そのため,アイコンに依存したアプリケーションはよろしくない

  • メニューバーアイコンからはメニューを表示させるべきであり, popover を作成すべきでない(macOS Human Interface Guidelines: Content Views)

macOS Human Interface Guidelines: Menu Bar Menus


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


実現のためにどうするか


NSPopover による実装

macOS アプリケーション開発で上記のようにメニューバーにアイコンを配置するアプリケーションを作成したい場合は,NSPopover を使用するのがスタンダードのようです.とても親切なチュートリアルを見つけたので,こちらを参考にすれば問題なく実装できると思います.


OS X Tutorial: Menus and Popovers in Menu Bar Apps



NSPopover の問題点

上記のチュートリアルで十分ではないかとも思われるのですが,NSPopover を使用すると,インタフェースがユーザの環境に依存して変化してしまうという問題点があります.

macOS ではシステムの設定で Dark モードにしていると,メニューバーが黒くなります.この時,NSPopover で表示される popover も同時に黒くなってしまいます.

Stackoverflow にも質問が飛んでいましたが(cocoa - How to change NSPopover background color include triangle part? - Stack Overflow),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


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


  1. Dark mode かそうでないかを判定し,適したUI部品を配置する

  2. NSPopover 以外の方法を考える

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


NSBezierPath による実装

NSBezierPath オブジェクトを使用すると,直線と曲線を組み合わせて図形を作成し,内部を塗りつぶすといった操作が可能になります.これを利用して,NSPopover を模した NSWindow を実装できます.実際にこれを実現している方がおり,ソースコードも公開されています.


iOS and OS X Development: Cocoa Popup window in the Status bar


しかし,上記サイトから辿ることのできるソースは Objective-C で記述されている上に,drawStatusBarBackgroundInRect:withHighlight:等,Deprecated なメソッドがちらほら見かけられます.そこで,今回は上記で紹介されているコードを参考に,同様の処理を Swift で記述していこうと思います.


実装

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


事前準備

Xcode プロジェクトの作成,画像の準備,アイコンをメニューバーに表示するための画像に対する設定,plistの設定等,メニューバー常駐型アプリケーション作成のためには色々な設定が必要となるようですが,今回は割愛します.詳しくは,既に紹介した以下のチュートリアルに記述されています.


OS X Tutorial: Menus and Popovers in Menu Bar Apps



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

まずは,メニューバー上にアイコンを表示できるようにします.リファレンス(NSStatusItem - AppKit | Apple Developer Documentation)を参考にすると,以下のようなことがわかります.



  • NSStatusItem は生成時に自動的にステータスバーに追加される

  • 描画や振る舞いは button プロパティを使用して行う

参考サイトでは,アイテムの描画のために NSView を実装し,そのプロパティとして画像等を持たせ,描画の際には NSViewNSImage を貼り付けるといった処理を行っていました.これらの処理については,単純な button プロパティへの代入に置き換えられます.view の生成,保持と実際の描画を分けるために,Controller と View に分割し,以下のように実装しました.

class MenubarController: NSObject {

let view: StatusItemView

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

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

background view は背景,すなわち NSPopover のような見た目を形作るために利用します.そのため,NSView ではなく,以下のような PanelBackground クラスを実装し,そのオブジェクトとして設定します.このPanelBackgroundの処理内容は,参考サイト先のコードをほぼそのまま 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 ビューを用意します.

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)
}
}

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

class PanelBaseController: NSViewController {

override func loadView() {
self.view = PanelBase()
}
}

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


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

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

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

protocol PanelControllerDelegate {

func statusItemViewRectForPanelController() -> NSRect
}

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

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

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
}

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

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 は,参考サイトのソースコードの手法をそのまま流用しました.

@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

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


tasuwo/Popover: Custom popover window appearing from the icon in the macOS status bar