対象読者と動作環境
NSPanel を使ったオーバーレイ表示を複数モニタで安定させたい人、CALayer の position だけを動かしてウィンドウ自体は動かさないという実装に興味がある人を対象にしています。取り上げるのはメニューバー常駐アプリ「Neon Pointer」の v1.1.0 です。マルチディスプレイ対応と、SMAppService によるログイン時自動起動を実装しました。
- macOS 14 以上
- Swift 5.0
- Xcode 15 以上(ビルド時のみ)
- XcodeGen(
brew install xcodegen、ビルド時のみ)
リポジトリは Tanishi-z/NeonPointer です。なぜこの構造に作り替えたか(v1.0.0 の設計がどう破綻したか)は姉妹記事の Zenn 版で扱っています。この記事はコードと技術的な仕組みだけに絞ります。
スクリーンごとにオーバーレイを作る
OverlayController は v1.0.0 では単一のパネル・ビュー・トラッカーを持つだけでしたが、v1.1.0 ではスクリーンの数だけ組を持つ構造に変わりました。
@MainActor
final class OverlayController {
/// 1 スクリーンに対応するパネル・ビュー・トラッカーの組。
/// パネルはそのスクリーンの `frame` に固定されたまま動かない。
private struct ScreenOverlay {
let panel: OverlayPanel
let view: NeonCursorView
let tracker: CursorTracker
}
private let settings: SettingsStore
private var overlays: [ScreenOverlay] = []
private var cancellable: AnyCancellable?
private var lastCursorLocation: CGPoint = NSEvent.mouseLocation
private var isStarted = false
/// `NSScreen.screens` の並びに合わせてパネルを作り直す。
/// パネルはスクリーン境界をまたいで移動しないため、モニタ間の
/// リフレッシュレート差・スケール差・非矩形配置のデッドゾーンによる
/// 追従の乱れが起きない。
private func rebuildOverlays() {
tearDownOverlays()
overlays = NSScreen.screens.map { screen in
let view = NeonCursorView(configuration: NeonConfiguration(settings: settings))
let panel = OverlayPanel(contentView: view)
panel.setFrame(screen.frame, display: false)
view.frame = CGRect(origin: .zero, size: screen.frame.size)
let tracker = CursorTracker(hostView: view) { [weak self] location in
self?.move(to: location)
}
return ScreenOverlay(panel: panel, view: view, tracker: tracker)
}
}
private func tearDownOverlays() {
for overlay in overlays {
overlay.tracker.stop()
overlay.panel.orderOut(nil)
}
overlays = []
}
}
NSScreen.screens を map してパネル・ビュー・トラッカーの組を作り、panel.setFrame(screen.frame, display: false) でそのスクリーンの矩形にそのまま固定します。ここで一度置いたパネルの frame は、この後 setFrameOrigin で動かすことはありません。カーソルが動いたときに動くのは、後述するビュー内の CALayer だけです。
モニタの抜き差しに追従する
モニタの接続・切断や解像度変更が起きると NSScreen.screens の内容が変わるので、NSApplication.didChangeScreenParametersNotification を購読して overlays を作り直します。
func start() {
guard !isStarted else { return }
isStarted = true
cancellable = settings.didChange.sink { [weak self] in self?.applySettings() }
NotificationCenter.default.addObserver(
self,
selector: #selector(screenParametersDidChange),
name: NSApplication.didChangeScreenParametersNotification,
object: nil
)
rebuildOverlays()
applySettings()
}
func stop() {
guard isStarted else { return }
isStarted = false
cancellable = nil
NotificationCenter.default.removeObserver(
self,
name: NSApplication.didChangeScreenParametersNotification,
object: nil
)
tearDownOverlays()
}
@objc private func screenParametersDidChange() {
rebuildOverlays()
applySettings()
}
isStarted フラグは、start() / stop() の多重呼び出しでオブザーバーが二重登録されたり、tearDownOverlays() が空の配列に対して無駄に呼ばれたりするのを防ぐガードです。v1.0.0 にはこのガードがなく、呼び出し元の呼び方次第では通知の多重登録が起こり得る作りでした。スクリーンの組み替えという「壊れると気づきにくい」処理を扱うようになったので、ここで明示的に防いでいます。
constrainFrameRect(_:to:) を override してパネル原点の補正を止める
panel.setFrame(screen.frame, ...) でパネルをスクリーンの矩形に固定しても、それだけでは足りません。AppKit は NSWindow / NSPanel の frame に対して、メニューバー帯を避けるなどの制約を自動でかけようとします。複数モニタが縦にずれた配置だと、この補正でパネルの原点がずれ、カーソル追従が飛んで見える不具合が実際に起きました。
final class OverlayPanel: NSPanel {
// このパネルは常に `screen.frame` と同じ大きさ・原点で配置される。
// AppKit の既定の制約(メニューバー帯を避ける等)をかけると、
// 複数モニタが縦にずれた構成で原点が勝手に補正され、カーソル追従が飛んで見える。
override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect {
frameRect
}
}
渡された frameRect をそのまま返すだけの override です。他のプロパティ(ignoresMouseEvents、level、collectionBehavior など)は v1.0.0 から変更していません。
neonLayer.position だけを動かす
ネオンの発光を描く NeonCursorView には、v1.1.0 で neonLayer という CALayer を新設しました。従来のシャドウレイヤー(outerGlowLayer / glowLayer / coreLayer)はすべてこの neonLayer の下にぶら下げます。
private let neonLayer = CALayer()
private let outerGlowLayer = CAShapeLayer()
private let glowLayer = CAShapeLayer()
private let coreLayer = CAShapeLayer()
init(configuration: NeonConfiguration) {
self.configuration = configuration
super.init(frame: .zero)
wantsLayer = true
layer?.masksToBounds = false
neonLayer.masksToBounds = false
neonLayer.bounds = CGRect(origin: .zero, size: configuration.canvasSize)
layer?.addSublayer(neonLayer)
for shapeLayer in shapeLayers {
shapeLayer.masksToBounds = false
shapeLayer.lineCap = .round
shapeLayer.lineJoin = .round
neonLayer.addSublayer(shapeLayer)
}
}
/// カーソルのグローバル座標と、このビューが乗っているスクリーンの原点を渡して
/// ネオン層を移動させる。ウィンドウ自体は動かさない。
func update(cursorLocation: CGPoint, screenOrigin: CGPoint) {
CATransaction.begin()
CATransaction.setDisableActions(true)
neonLayer.position = CGPoint(
x: cursorLocation.x - screenOrigin.x,
y: cursorLocation.y - screenOrigin.y
)
CATransaction.commit()
}
ビュー自体はスクリーン全面に固定されたまま動きません。カーソルのグローバル座標からそのビューが乗っているスクリーンの原点を引いた、スクリーンローカルな座標を neonLayer.position に渡すだけです。CATransaction.setDisableActions(true) で囲んでいるのは v1.0.0 から変わらず、暗黙アニメーションによる追従遅延を防ぐためです。
canvasSize 基準の redraw() と viewDidChangeBackingProperties での contentsScale
v1.0.0 では NeonCursorView 自体がカーソルサイズ程度の小さなビューで、redraw() は bounds(=ビューサイズ)を基準にシェイプの矩形を計算していました。v1.1.0 ではビューがスクリーン全面サイズになったため、この基準は使えません。代わりに NeonConfiguration.canvasSize(ネオンの見た目のサイズ+グローのはみ出し分)を基準にするよう変更しています。
private func redraw() {
CATransaction.begin()
CATransaction.setDisableActions(true)
defer { CATransaction.commit() }
let canvasSize = configuration.canvasSize
neonLayer.bounds = CGRect(origin: .zero, size: canvasSize)
let side = configuration.size
let rect = CGRect(
x: (canvasSize.width - side) / 2,
y: (canvasSize.height - side) / 2,
width: side,
height: side
)
let strokeWidth = configuration.strokeWidth
let path = configuration.shape.path(in: rect, strokeWidth: strokeWidth)
let color = configuration.color
neonLayer.opacity = Float(configuration.opacity)
shapeLayers.forEach { $0.frame = CGRect(origin: .zero, size: canvasSize) }
// 以降のシャドウ・塗りの設定は v1.0.0 と同じ
}
layer?.opacity だった不透明度の適用先も neonLayer.opacity に変わりました。ビュー全体ではなく、ネオンを描いている層だけに不透明度を掛けるためです。多重シャドウの重ね方(shadowRadius を変えた2層+芯のレイヤー)や shadowPath = nil にしている理由自体は v1.0.0 と同じなので、詳細は省略します。
viewDidChangeBackingProperties では、layer だけでなく neonLayer と各シェイプレイヤーの contentsScale もまとめて更新します。
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
let scale = window?.backingScaleFactor ?? 2
layer?.contentsScale = scale
neonLayer.contentsScale = scale
shapeLayers.forEach { $0.contentsScale = scale }
redraw()
}
解像度・スケールが異なる複数モニタをまたいでウィンドウが移動しなくなった一方、各スクリーン用のビューがそれぞれ自分の乗っているディスプレイの backingScaleFactor を正しく反映できるように、レイヤーごとに contentsScale を明示しています。
カーソルが乗っていないスクリーンを isHidden で間引く
スクリーンの数だけ全画面パネルを持つ構造は、その数だけ再描画対象が増えるということでもあります。カーソルが乗っていないスクリーンでも毎フレーム同じ処理をするのは無駄なので、OverlayController.reposition() でグローの外接矩形とパネルの frame が交差するかを見て、交差しないビューを隠します。
private func reposition() {
let configuration = NeonConfiguration(settings: settings)
let half = CGSize(
width: configuration.canvasSize.width / 2,
height: configuration.canvasSize.height / 2
)
let glowRect = CGRect(
x: lastCursorLocation.x - half.width,
y: lastCursorLocation.y - half.height,
width: half.width * 2,
height: half.height * 2
)
for overlay in overlays {
// グローの外接矩形がそのスクリーンに掛からないなら、合成コストを避けるため隠す。
overlay.view.isHidden = !overlay.panel.frame.intersects(glowRect)
overlay.view.update(
cursorLocation: lastCursorLocation,
screenOrigin: overlay.panel.frame.origin
)
}
}
CursorTracker 自体は v1.0.0 と変わらず、NSEvent.mouseLocation をポーリングし、前回と同じ座標なら onMove を呼ばずに抜けます。カーソルが静止している間はどのスクリーンの reposition() も呼ばれないので、この間引きと合わせて、静止時の再描画コストは実質ゼロになります。
SMAppService でログイン時の自動起動を実装する
v1.1.0 ではログイン時の自動起動にも対応しました。ServiceManagement フレームワークの SMAppService.mainApp を使います。
import Foundation
import ServiceManagement
/// ログイン時の自動起動(常駐)を `SMAppService` で管理する。
/// システム設定 > ログイン項目 が唯一の正とみなし、UserDefaults には保存しない。
@MainActor
final class LoginItemManager: ObservableObject {
static let shared = LoginItemManager()
@Published private(set) var isEnabled: Bool
private init() {
isEnabled = SMAppService.mainApp.status == .enabled
}
/// メニューが開かれるたびに呼び、システム設定側での変更を反映する。
func refresh() {
isEnabled = SMAppService.mainApp.status == .enabled
}
func setEnabled(_ enabled: Bool) {
do {
if enabled {
if SMAppService.mainApp.status != .enabled {
try SMAppService.mainApp.register()
}
} else if SMAppService.mainApp.status == .enabled {
try SMAppService.mainApp.unregister()
}
} catch {
// 登録/解除に失敗した場合も、以下で実際のステータスに合わせて表示を戻す。
}
refresh()
}
}
状態は @Published private(set) var isEnabled の1つだけで、UserDefaults には保存しません。SMAppService.mainApp.status を都度読みに行き、それをそのまま真実として扱います。register() / unregister() は失敗しうる(throws)ので do-catch で囲みますが、失敗時に特別なエラーハンドリングはせず、refresh() で実際のステータスに UI を合わせ直すだけにしています。ユーザーがシステム設定側で直接ログイン項目を切り替えた場合にも対応できるよう、SettingsView の .onAppear で毎回 refresh() を呼びます。
struct SettingsView: View {
@EnvironmentObject private var settings: SettingsStore
@ObservedObject private var loginItem = LoginItemManager.shared
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Toggle("ネオンポインタを表示", isOn: $settings.isEnabled)
.toggleStyle(.switch)
.font(.headline)
Toggle("ログイン時に自動的に起動", isOn: Binding(
get: { loginItem.isEnabled },
set: { loginItem.setEnabled($0) }
))
.toggleStyle(.switch)
.font(.callout)
// ...
}
.padding(16)
.frame(width: 300)
.onAppear { loginItem.refresh() }
}
}
トグルの Binding は get で loginItem.isEnabled を読み、set で loginItem.setEnabled(_:) を呼ぶだけです。SettingsStore の他の設定のように値をアプリ側に保持しないので、@Published の変更通知だけで表示が最新のシステム状態に追従します。
project.yml のバージョン表記
MARKETING_VERSION を 1.1.0 に上げています。
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.1.0"
CURRENT_PROJECT_VERSION: "1"
ビルド・配布の手順(XcodeGen での .xcodeproj 生成、make dmg での dmg 作成)自体は v1.0.0 から変わっていません。