跳到主要内容

【macOS】状态栏歌词

身为一个运行在 macOS 上的音乐软件,如果没有状态栏歌词那一定是不完整的。

现在 Flutter 没有提供一个开箱即用的解决方案,一通搜索之后,Adding a Menu Bar extra to my Flutter macOS app 这篇文章算是打通了我的任督二脉。

显示图标

首先需要准备(16x16、32x32、48x48)三种尺寸的纯白色图标,创建一个 ImageSet,依次将三种尺寸的图标拖进去,并修改右侧导航栏的 Render As Template Image

文章里引用的设计规范是说画布大小是 48x48 时,图标大小设为 32x32 比较合适,但我照这样去做,最终效果却比其他 APP 的图标小了一大截,所以最后我把图标大小设置为 44x44,看起来就正常多了。

接下来修改 AppDelegate.swift,以便在应用启动时向状态栏添加自定义的图标。

AppDelegate.swift
import Cocoa
import FlutterMacOS

@NSApplicationMain
class AppDelegate: FlutterAppDelegate {
var statusBar: NSStatusItem?
var popover = NSPopover()
var flutterViewController: FlutterViewController?

override func applicationDidFinishLaunching(_ aNotification: Notification) {
statusBar = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))
if let button = statusBar?.button {
button.image = NSImage(named: "MenuBarExtraAppIcon")
button.action = #selector(togglePopover(_:))
}
}

@objc func togglePopover(_ sender: AnyObject) {
print("clicked!")
}
}

重新启动应用,就可以看到刚刚创建的图标出现在系统状态栏了。

显示文字

开头提到的那篇文章到这里就开始介绍怎么与 Flutter 端通信了,而我要实现的效果是同时显示歌词和应用图标,当鼠标移上去后显示歌曲控制按钮,所以现在的效果还远远无法满足我的需求。

又是一通搜索之后发现,可以直接定义按钮的文字:

AppDelegate.swift
import Cocoa
import FlutterMacOS

@NSApplicationMain
class AppDelegate: FlutterAppDelegate {
var statusBar: NSStatusItem?
var popover = NSPopover()
var flutterViewController: FlutterViewController?

override func applicationDidFinishLaunching(_ aNotification: Notification) {
statusBar = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))
if let button = statusBar?.button {
button.title = "A ha"
button.image = NSImage(named: "MenuBarExtraAppIcon")
button.action = #selector(togglePopover(_:))
}
}

@objc func togglePopover(_ sender: AnyObject) {
print("clicked!")
}
}

显示效果如下:

我一看,这还不是我要的效果啊……我想要歌词在左边,图标在右边,现在的样子,一看就是一个定义了图标和文字的按钮,就没办法一个应用同时定义多个按钮吗?

于是我回看这段代码,一个 NSStatusItem 类型的变量,为什么会被定义成 statusBar 这个变量名呢?

到苹果官网一查,发现 statusBar 这个变量就是通过 statusBar = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength)) 这段代码得到的,而 statusItem() 方法是返回一个新创建的状态栏项目!!!

说真的,这个方法名,我咋看咋像是获取某个对象的一个属性来着,创建,属实是我没想到的。

既然知道了如何创建和移除状态栏项目,那么接下来就试试看:

AppDelegate.swift
import Cocoa
import FlutterMacOS

@NSApplicationMain
class AppDelegate: FlutterAppDelegate {
var appIconInStatusBar: NSStatusItem?
var lyricsInStatusBar: NSStatusItem?
var popover = NSPopover()
var flutterViewController: FlutterViewController?


override func applicationWillFinishLaunching(_ notification: Notification) {
let flutterViewController = NSApplication.shared.windows.first?.contentViewController as? FlutterViewController
self.flutterViewController = flutterViewController
}

override func applicationDidFinishLaunching(_ aNotification: Notification) {
// 在状态栏创建 APP 图标
appIconInStatusBar = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))
if let button = appIconInStatusBar?.button {
button.image = NSImage(named: "MenuBarExtraAppIcon")
button.action = #selector(togglePopover(_:))
}
// 在状态栏绘制文字
lyricsInStatusBar = NSStatusBar.system.statusItem(withLength: 300)
if let button = lyricsInStatusBar?.button {
button.title = "歌词显示"
}
}

@objc func togglePopover(_ sender: AnyObject) {
print("clicked!")
}
}

显示效果如下:

同样的,如果调整歌词和图标的创建顺序,那么显示的相对位置也会发生改变:

显示的问题解决了,接下来,只需要处理好文字对齐、鼠标移上去的事件监听、状态栏显示项目的切换、以及与 Flutter 端的通信,状态栏歌词的功能应该就算开发完成了。

苹果的设计规范有提到,状态栏项目是否显示,应该交由用户决定。

后续

文字对齐问题

设置了 button 的 alignment 但并不生效,看 QQ 音乐 也是居中显示的,难道是不支持左对齐吗?

不过,如果通过自定义 View 设置 NSTextField 的话,是可以设置文字对齐方式的。

鼠标移上去的事件监听

本以为鼠标移上去的事件监听就是一个 onMouseEntered 就搞定的事情,结果想简单了。在 Mac 上要想监听这样的事件,需要设置视图的 NSTrackingArea 才行。

下面的代码是通过自定义 View 实现的,直接粘到项目里就可以:

CustomStatusItemView.swift

import Cocoa

class CustomStatusItemView: NSView {

private var trackingArea: NSTrackingArea?

var onMouseEntered: (() -> Void)?

var onMouseExited: (() -> Void)?


override init(frame: CGRect) {
super.init(frame: frame)

if let window = self.window {
window.ignoresMouseEvents = false
window.acceptsMouseMovedEvents = true
}

wantsLayer = true
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func updateTrackingAreas() {
if let existingTrackingArea = trackingArea {
removeTrackingArea(existingTrackingArea)
}

super.updateTrackingAreas()

let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .inVisibleRect, .activeAlways]
trackingArea = NSTrackingArea(rect: CGRect.zero, options: options, owner: self, userInfo: nil)
addTrackingArea(trackingArea!)
}


override func mouseEntered(with event: NSEvent) {
print("enter")
onMouseEntered?()
}

override func mouseExited(with event: NSEvent) {
print("out")
onMouseExited?()
}
}

用法也很简单:

StatusBarLyricsController.swift
import Cocoa
import FlutterMacOS

class StatusBarLyricsController: NSObject {

var appIconStatusItem: NSStatusItem?
var lyricsStatusItem: NSStatusItem?
var playStatusItem: NSStatusItem?
var pauseStatusItem: NSStatusItem?
var skipPreviousStatusItem: NSStatusItem?
var skipNextStatusItem: NSStatusItem?
var starStatusItem: NSStatusItem?
var unStarStatusItem: NSStatusItem?

var flutterViewController: FlutterViewController?

init(flutterViewController: FlutterViewController?) {
self.flutterViewController = flutterViewController
}

func createAppIconItem() {
// 在状态栏创建应用图标
appIconStatusItem = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))
if let button = appIconStatusItem?.button {

button.image = NSImage(named: "MenuBarExtraAppIcon")
button.action = #selector(showAppWindow(_:))
let appIconView = CustomStatusItemView(frame: button.frame)
button.addSubview(appIconView)
}
createLyricsStatusItem()
}


func createLyricsStatusItem() {
// 在状态栏绘制文字
lyricsStatusItem = NSStatusBar.system.statusItem(withLength: 300)
if let button = lyricsStatusItem?.button {
button.title = "音流"

let lyricsArea = CustomStatusItemView(frame: button.frame)
lyricsArea.onMouseEntered = {
self.removeLyricsStatusItem()
self.createPlayControlItems()
}
button.addSubview(lyricsArea)
}
}

func removeLyricsStatusItem() {
// 移除歌词视图
if let lyricsButton = lyricsStatusItem?.button {
NSStatusBar.system.removeStatusItem(lyricsStatusItem!)
}
}

func createPlayControlItems() {
// 创建播放控制按钮组
playStatusItem = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength))
if let playButton = playStatusItem?.button {
playButton.image = NSImage(named: "MenuBarExtraAppIcon")

let playArea = CustomStatusItemView(frame: playButton.frame)
playArea.onMouseExited = {
self.removePlayControlItems()
self.createLyricsStatusItem()
}
playButton.addSubview(playArea)
}
}

func removePlayControlItems() {
// 移除播放控制按钮组
if let playButton = playStatusItem?.button {
NSStatusBar.system.removeStatusItem(playStatusItem!)
}
}


@objc func showAppWindow(_ sender: AnyObject) {
let event = NSApp.currentEvent!
if event.type == NSEvent.EventType.rightMouseUp {
NSApp.activate(ignoringOtherApps: true)
} else if event.type == NSEvent.EventType.mouseEntered {
print("enterd")
} else {
print("left")
}

}

}

这样做之后,就可以同时监听鼠标的移入移出和点击事件了,也能做到歌词和控制按钮的切换了。

但还存在一个小问题,歌词视图的宽度是要大于控制按钮组的视图的,当鼠标移入歌词视图,可以切换到控制按钮视图,但当鼠标从原有位置移出,则不会有任何变化,因为不在控制按钮视图的监控范围内。

要解决这个问题,就要把控制按钮视图的宽度设为和歌词视图一致,这样的话就不需要那么多 NSStatusItem 了,而是把所有按钮都塞到一个 NSStatusItem 的自定义视图中。

调整切换方案

经过测试,切换时如果先移除再添加,就可能导致两个状态栏项目被其他 APP 的状态栏项目给分离开(之前我还以为系统会自动按应用分组呢)。

因此就不能动移除项目的心思了,只能想想怎么在子视图中切换项目,并且点击效果要改成单个按钮的点击效果而不能是整个状态栏项目的点击效果。