SwiftUI: MenuBarExtraのLabelの外観モードを掌握する
macOSではMenuBarExtra
を用いることでメニューバーに独自のメニューを追加できます。
MenuBarExtra(content:label:)
APIを用いLabel要素を指定することで、メニューバーに表示するアイコンを好きなものにできますが、これを自在に制御したいです。特にメニューバーはシステム全体の外観モード(ライト/ダーク)とは独立しており、デスクトップ壁紙の色によって外観モードが定まるので、思いのままに実装するのは非常に難しいです。
おさらい
MenuBarExtraの明らかな仕様についておさらいしましょう。
- SwiftUIでは
EnvironmentValues
のcolorScheme
を見ることで現状の外観モードを取得できる- しかし、
MenuBarExtra
のコンテキストでのcolorScheme
を正確に取得することはできない
- しかし、
-
MenuBarExtra
のlabel
にはどんなViewでも渡して解釈してくれるわけではない- ピュアな
Text
とImage
またはLabel
しか受け付けない -
Image
にModifierをつけてViewになってしまうとそのModifierの効果は反映されない
- ピュアな
-
MenuBarExtra
の外観モードを固定したい場合はenvironment(\.colorScheme, .dark)
のようにすればいい- ついでに、
MenuBarExtra
のImage
の解像度が粗い場合は.environment(\.displayScale, 2.0)
をつける
- ついでに、
- AppKitでは
NSStatusItem
のNSStatusBarButton
がメニューバーに表示されるアイコン部分のUI-
MenuBarExtra
も現状内部実装の実態はこれ
-
もっと特別な仕様
- macOSのデスクトップにはワークスペースという概念があり、複数のワークスペースに別々の壁紙を指定することができる
- この場合、メニューバーの外観モードがライトの場合とダークの場合があり得る
- これに対応するために、メニューバーのアイコン画像を読み込む際にmacOSはどうやらライトモード用とダークモード用のスナップショットを予め取得している
- これに伴い、
NSStatusBarButton
のeffectiveAppearance
を観察してみると、アイコン画像の描画要求が来た際に、一瞬でダーク→ライト→ダーク
のように切り替わっていることがわかる
外観モードに沿ってアイコンを指定したい
モノクロでいい場合
SF Symbolsを使う場合、外観モードに合わせて輪郭縁取り版と中塗り版を切り替えたいということもあるでしょう。その場合シンボル名に.inverse
が含まれていたりすれば、自動的に切り替えてくれるっぽいです。
inverseが含まれている例:pianokeys
また、Asset CatalogのImage Setを利用する場合もAppearanceごとのリソースが指定されていれば、外観モードに合わせて自動的に切り替えてくれます。
画像のアルファチャンネルだけを使用してモノクロ表示にしたい場合は.renderingMode(.template)
を指定します。ここで重要なのは.renderingMode()
ModifierをつけてもImage
はImage
のままであるということです。
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)
固定の色にしたい場合
MenuBarExtra
のImage
は融通が効かないやつなので、.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はNSStatusBarButton
のeffectiveAppearance
が切り替わってもクロージャーの呼び出しがかからないので、頑張って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