🦋

SwiftUI: MenuBarExtraのLabelの外観モードを掌握する

に公開

macOSではMenuBarExtraを用いることでメニューバーに独自のメニューを追加できます。
MenuBarExtra(content:label:)APIを用いLabel要素を指定することで、メニューバーに表示するアイコンを好きなものにできますが、これを自在に制御したいです。特にメニューバーはシステム全体の外観モード(ライト/ダーク)とは独立しており、デスクトップ壁紙の色によって外観モードが定まるので、思いのままに実装するのは非常に難しいです。

おさらい

MenuBarExtraの明らかな仕様についておさらいしましょう。

  • SwiftUIではEnvironmentValuescolorSchemeを見ることで現状の外観モードを取得できる
    • しかし、MenuBarExtraのコンテキストでのcolorSchemeを正確に取得することはできない
  • MenuBarExtralabelにはどんなViewでも渡して解釈してくれるわけではない
    • ピュアなTextImageまたはLabelしか受け付けない
    • ImageにModifierをつけてViewになってしまうとそのModifierの効果は反映されない
  • MenuBarExtraの外観モードを固定したい場合はenvironment(\.colorScheme, .dark)のようにすればいい
    • ついでに、MenuBarExtraImageの解像度が粗い場合は.environment(\.displayScale, 2.0)をつける
  • AppKitではNSStatusItemNSStatusBarButtonがメニューバーに表示されるアイコン部分のUI
    • MenuBarExtraも現状内部実装の実態はこれ

もっと特別な仕様

  • macOSのデスクトップにはワークスペースという概念があり、複数のワークスペースに別々の壁紙を指定することができる
    • この場合、メニューバーの外観モードがライトの場合とダークの場合があり得る
    • これに対応するために、メニューバーのアイコン画像を読み込む際にmacOSはどうやらライトモード用とダークモード用のスナップショットを予め取得している
    • これに伴い、NSStatusBarButtoneffectiveAppearanceを観察してみると、アイコン画像の描画要求が来た際に、一瞬でダーク→ライト→ダークのように切り替わっていることがわかる

外観モードに沿ってアイコンを指定したい

モノクロでいい場合

SF Symbolsを使う場合、外観モードに合わせて輪郭縁取り版と中塗り版を切り替えたいということもあるでしょう。その場合シンボル名に.inverseが含まれていたりすれば、自動的に切り替えてくれるっぽいです。


inverseが含まれている例:pianokeys

また、Asset CatalogのImage Setを利用する場合もAppearanceごとのリソースが指定されていれば、外観モードに合わせて自動的に切り替えてくれます。

画像のアルファチャンネルだけを使用してモノクロ表示にしたい場合は.renderingMode(.template)を指定します。ここで重要なのは.renderingMode() ModifierをつけてもImageImageのままであるということです。

Image(.sample)
    .renderingMode(.template)

さらにImageを動的に作りたい場合はImage(size:label:opaque:colorMode:renderer:) APIを使います。

Image(size: CGSize(width: 24, height: 18), renderer: { context in
    let rect = CGRect(x: 1, y: 1, width: 22, height: 16)
    context.fill(Path(rect), with: .color(.black))
})
.renderingMode(.template)

固定の色にしたい場合

MenuBarExtraImageは融通が効かないやつなので、.tint().foregroundStyle()などで色を指定してもダメです。色がついている画像をAsset CatalogのImage Setに登録しておいて、Imageで参照しつつ.renderingMode(.original)を指定します。

Image(.sample)
    .renderingMode(.original)

こちらもImageを動的に作りたい場合はImage(size:label:opaque:colorMode:renderer:) APIを使えば良いです。クロージャーの中で好きな色を指定します。

Image(size: CGSize(width: 24, height: 18), renderer: { context in
    let rect = CGRect(x: 1, y: 1, width: 22, height: 16)
    context.fill(Path(rect), with: .color(.preferredColor))
})
.renderingMode(.original)

外観モードで色を切り替えたい場合

これがMenuBarExtraが登場してから3年間探求し続けて最適解が分かっていなかったやつなのですが、ようやく解を見つけました。

Image(size:label:opaque:colorMode:renderer:) APIはNSStatusBarButtoneffectiveAppearanceが切り替わってもクロージャーの呼び出しがかからないので、頑張ってeffectiveAppearanceの変化をKVOやCombineなどで検知して外観モードをチェックした上で無理やり再描画をかける必要があります。この方法だとワークスペースの切り替え時に、完全に切り替わってからアイコンの色に反映されるまでにどうしても0.4秒ほどタイムラグが発生してしまいます。

検出例(イケてない)
@MainActor @Observable class StatusIconModel {
    private static let statusItem: NSStatusItem = {
        let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        statusItem.isVisible = false
        return statusItem
    }()

    var colorScheme = ColorScheme.light

    func onTask() async {
        guard let button = Self.statusItem.button else { return }
        let publisher = button
            .publisher(for: \.effectiveAppearance, options: .new)
            .debounce(for: 0.01, scheduler: RunLoop.main)
        for await value in publisher.values {
            colorScheme = button.effectiveAppearance.isDark ? .dark : .light
        }
    }
}

extension NSAppearance: @retroactive @unchecked Sendable {
    var isDark: Bool {
        if name == .vibrantDark {
            true
        } else {
            bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
        }
    }
}

struct MenuIconView: View {
    @State var viewModel = StatusIconModel()

    var body: some View {
        Image(size: CGSize(width: 24, height: 18), renderer: { context in
            let rect = CGRect(x: 1, y: 1, width: 22, height: 16)
            context.fill(Path(rect), with: .color(.black))
        })
        .renderingMode(.template)
        .environment(\.colorScheme, viewModel.colorScheme)
    }
}

(これは、スナップショットを撮るためにダークとライトの高速切り替えが起きることに起因しています。colorSchemeが変更されるとSwiftUIのViewの描画更新がかかり、Imageが再計算されますが、そうするとスナップショットのリクエストがかかり、colorSchemeが変更され無限ループに突入するので、それを回避するためにdebounce()などをつける都合上避けられなさそうです。)

ではどうしたらいいのか、NSImage(size:flipped:drawingHandler:) APIを使いましょう!

SwiftUIのImageのAPIにかなり似ていますが、このNSImageのAPIはSwiftUIのViewの再描画サイクルとは独立しているため、スナップショットを要求された時に画像の再レンダリングが可能です。
このAPIを使えば、タイムラグなしに外観モードに合わせた画像の表示が可能です。effectiveAppearanceを監視する必要もありません。

Image(nsImage: NSImage(size: NSSize(width: 26.0, height: 18.0), flipped: false, drawingHandler: { rect in
    NSColor(resource: .preferredColor).setFill()
    NSBezierPath(rect: rect).fill()
    return true
}))

似ているから同様だと思ってSwiftUIのAPIにこだわっていたのがダメでした。
答えはAppKitにあったんですね。

Discussion