対象読者と動作環境
SwiftUI で macOS の常駐ユーティリティを作りたい人、NSPanel でのオーバーレイ表示や CAShapeLayer での描画に興味がある人を対象にしています。取り上げるのはメニューバー常駐アプリ「Neon Pointer」です。マウスカーソルの周りにネオン風の半透明シェイプ(円・リング・十字・四角)を重ねて表示します。
- macOS 14 以上
- Swift 5.0
- Xcode 15 以上(ビルド時のみ)
- XcodeGen(
brew install xcodegen、ビルド時のみ)
リポジトリは Tanishi-z/NeonPointer です。設計判断(なぜアクセシビリティ権限を避けたか、なぜ邪魔をしない設計にしたか)は姉妹記事の Zenn 版で扱っています。この記事はコードと技術的な仕組みだけに絞ります。マルチディスプレイ対応やログイン時自動起動など v1.1.0 での変更は別記事(本記事末尾の関連記事)にまとめています。
クリック透過・全スペース常駐の NSPanel
オーバーレイは NSPanel のサブクラス OverlayPanel で実装しています。
final class OverlayPanel: NSPanel {
init(contentView: NSView) {
super.init(
contentRect: CGRect(origin: .zero, size: contentView.frame.size),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
isOpaque = false
backgroundColor = .clear
hasShadow = false
ignoresMouseEvents = true
isMovable = false
isMovableByWindowBackground = false
hidesOnDeactivate = false
isReleasedWhenClosed = false
isFloatingPanel = true
level = .screenSaver
collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
animationBehavior = .none
self.contentView = contentView
}
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
override var acceptsFirstResponder: Bool { false }
}
各プロパティが何を防いでいるかをまとめます。
| プロパティ | 防いでいること |
|---|---|
isOpaque = false / backgroundColor = .clear
|
パネルの背景が不透明な矩形として描画されるのを防ぐ。ネオンの形以外を透明にする |
hasShadow = false |
AppKit がパネルの矩形に対してつけるウィンドウシャドウを防ぐ。四角い影がネオンの周りに出るのを防ぐ |
ignoresMouseEvents = true |
パネルがマウスイベントを奪うのを防ぐ。クリックやドラッグは背後のウィンドウにそのまま通る |
level = .screenSaver |
通常ウィンドウの背後に隠れるのを防ぐ。常に最前面に出す |
collectionBehavior の .canJoinAllSpaces
|
スペース(デスクトップ)を切り替えるとオーバーレイが消えるのを防ぐ |
同 .stationary
|
Mission Control 操作でパネルが動いてしまうのを防ぐ |
同 .fullScreenAuxiliary
|
フルスクリーンアプリの上にオーバーレイが出ないのを防ぐ |
同 .ignoresCycle
|
`Cmd+`` のウィンドウ切り替えにパネルが混ざるのを防ぐ |
animationBehavior = .none |
パネルを表示するたびに AppKit がフェードなどの既定アニメーションを付けるのを防ぐ。カーソル追従の遅延要因になる |
canBecomeKey / canBecomeMain / acceptsFirstResponder を false
|
パネルがキーボードフォーカスやアプリのメインウィンドウを奪うのを防ぐ |
styleMask に .nonactivatingPanel を指定しているのもポイントです。これがないと orderFrontRegardless() でパネルを出したときにアプリがアクティブ化され、フォーカスしていた別アプリの入力先が奪われます。
CADisplayLink でのカーソル追従
カーソル座標のポーリングは CursorTracker が担当します。
/// ディスプレイのリフレッシュに合わせてカーソル座標をポーリングする。
/// `NSEvent.mouseLocation` を読むだけなのでアクセシビリティ権限は不要。
@MainActor
final class CursorTracker {
private weak var hostView: NSView?
private var displayLink: CADisplayLink?
private var lastLocation: CGPoint?
private let onMove: (CGPoint) -> Void
init(hostView: NSView, onMove: @escaping (CGPoint) -> Void) {
self.hostView = hostView
self.onMove = onMove
}
var isRunning: Bool { displayLink != nil }
func start() {
guard displayLink == nil, let hostView else { return }
let link = hostView.displayLink(target: self, selector: #selector(tick))
link.add(to: .main, forMode: .common)
displayLink = link
lastLocation = nil
tick()
}
func stop() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func tick() {
let location = NSEvent.mouseLocation
guard location != lastLocation else { return }
lastLocation = location
onMove(location)
}
deinit {
displayLink?.invalidate()
}
}
hostView.displayLink(target:selector:) は macOS 14 で追加された NSView の API です。旧来の CVDisplayLink を直接扱う方法に代わるもので、NSView / NSWindow / NSScreen から CADisplayLink を取得できます。ビューが乗っているディスプレイのリフレッシュレートに自動で追従し、ビューが画面に出ていないときは自動的に一時停止します。.add(to: .main, forMode: .common) は、メニュー操作やドラッグ中に発生する RunLoop のモード変更(.eventTracking など)でも配信を止めないための指定です。
毎フレーム読んでいるのは NSEvent.mouseLocation だけです。これはグローバルなカーソル座標をイベント経由ではなくその場で問い合わせる API で、アクセシビリティ権限(AXIsProcessTrusted)や CGEventTap を必要としません。他アプリのキー入力やクリックの中身を監視するわけではなく、座標を読むだけなので権限ダイアログが出ません。
tick() では lastLocation と比較して同座標なら onMove を呼ばずに抜けます。カーソルが静止している間は、後述するオーバーレイ側の再描画・レイヤー移動処理を一切呼ばないための間引きです。座標計算とレイヤー移動は無駄な負荷になるので、動いていないフレームでは何もしません。
CAShapeLayer の多重シャドウでネオンを作る
発光の描画は NeonCursorView が担当します。outerGlowLayer / glowLayer / coreLayer の3枚の CAShapeLayer を、カーソル位置に追従するコンテナ層 neonLayer の下にまとめて重ねます(neonLayer の追従の仕組みは v1.1.0 のマルチディスプレイ対応記事で扱います。この記事では発光の描画そのものに絞ります)。
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) }
// 半径違いの影を重ねて、単層では出しきれないネオン管の滲みを作る。
for (glow, spread) in [(outerGlowLayer, 2.0), (glowLayer, 0.8)] {
glow.path = path
glow.shadowColor = color.cgColor
glow.shadowOpacity = 1
glow.shadowRadius = configuration.glowRadius * spread
glow.shadowOffset = .zero
// shadowPath を指定すると常に塗りつぶし形状の影になり、線形状のグローが崩れる。
glow.shadowPath = nil
}
let coreColor = color.lightened(by: 0.35)
if configuration.shape.isStroked {
for glow in [outerGlowLayer, glowLayer] {
glow.fillColor = nil
glow.strokeColor = color.cgColor
glow.lineWidth = strokeWidth
}
coreLayer.path = path
coreLayer.fillColor = nil
coreLayer.strokeColor = coreColor.cgColor
coreLayer.lineWidth = max(1, strokeWidth * 0.4)
} else {
for glow in [outerGlowLayer, glowLayer] {
glow.fillColor = color.cgColor
glow.strokeColor = nil
glow.lineWidth = 0
}
let coreInset = side * 0.22
let corePath = configuration.shape.path(
in: rect.insetBy(dx: coreInset, dy: coreInset),
strokeWidth: 0
)
coreLayer.path = corePath
coreLayer.fillColor = coreColor.nsColor.withAlphaComponent(0.85).cgColor
coreLayer.strokeColor = nil
coreLayer.lineWidth = 0
}
}
outerGlowLayer は spread 2.0、glowLayer は 0.8 で shadowRadius(configuration.glowRadius * spread)を変えて重ねています。半径の大きい影1枚だけだと発光の中心が薄くぼやけて輪郭が消えます。半径の違う影を2枚重ねることで、外側の広いにじみと内側の濃い光を両立させています。coreLayer はネオン管の芯にあたる、色を白側に寄せた明るいレイヤーです。
glow.shadowPath = nil にしているのは実装中に実際にハマった箇所です。コメントに残したとおり、shadowPath を指定すると CAShapeLayer はその影を常に塗りつぶし形状として描画します。リングや十字のような線形状(isStroked が true のシェイプ)に shadowPath を渡すと、線の内側まで塗りつぶされた影になり、線のグローが崩れます。shadowPath を nil のままにすると、影は path と fillColor / strokeColor の実際の見た目(線なら線の形)に沿って生成されます。パフォーマンス上は shadowPath を明示したほうが有利ですが、ここでは正しい見た目を優先して外しています。
isStroked(円・四角は false、リング・十字は true)で分岐しているのは塗りつぶし系と線形状系で fillColor / strokeColor の使い方が逆になるからです。塗りつぶし系はグロー層を fillColor で塗り、芯は一回り小さいパスを塗りつぶします。線形状系はグロー層を strokeColor で線描きし、芯は同じパスをより細い線幅(strokeWidth * 0.4)で線描きします。
芯の色は color.lightened(by: 0.35) で作っています。ネオン管は中心に近いほど白く飽和して見えるので、設定した本来の色をそのまま芯にも使うと発光の階調がつきません。RGB各成分を白側に35%寄せることで、外側のグローは設定色、芯は白みがかった明るい色という2段階の発光を作っています。
CATransaction.setDisableActions(true) で暗黙アニメーションを止める
redraw() の冒頭は次のようになっています。
CATransaction.begin()
CATransaction.setDisableActions(true)
defer { CATransaction.commit() }
CALayer はプロパティを変更すると、既定で暗黙アニメーション(implicit animation)がかかります。path や shadowRadius を直接書き換えても、変更前の値から変更後の値へ数百ミリ秒かけてアニメーションしてしまい、カーソルが動くたびに形状がふわっと遅れて追従します。CATransaction.setDisableActions(true) で囲んだブロック内の変更は、このアニメーションなしで即座に反映されます。カーソル追従アプリではこの即時性が必須なので、redraw() の全体をこのトランザクションでくるんでいます。
設定変更を即時反映させる仕組み
設定画面の変更をオーバーレイへ即座に伝えるために、SettingsStore に専用の Publisher を用意しています。
/// 値が確定した後に発火する。`objectWillChange` は変更前に発火するうえ、
/// スケジューラを挟むとイベントトラッキング中に配送が止まるため別に用意する。
let didChange = PassthroughSubject<Void, Never>()
ObservableObject の @Published は変更を検知すると自動的に objectWillChange を発火しますが、これをそのまま使わなかった理由がコメントのとおり2つあります。1つ目は objectWillChange が値が変わる前に発火することです。購読側で最新値を読もうとすると、まだ古い値しか手に入りません。2つ目は Combine の既定スケジューラがイベントの配送に RunLoop のデフォルトモードを使うことです。設定画面のスライダーをドラッグしている間(NSEvent のトラッキングループが動いている間)は .default モードのイベント配送が止まるため、objectWillChange を購読していると値がリアルタイムで反映されず、ドラッグを離した瞬間にまとめて反映されるような挙動になります。didChange は各プロパティの didSet が呼ぶ persist(_:forKey:) の中で send() する専用の PassthroughSubject で、この2つの問題を避けています。
受け取り側の OverlayController.applySettings() も、遅延レイアウトに任せず即時反映させています。マルチディスプレイ対応後はスクリーンごとのビューを持つので、全 overlay に対してこの反映処理をループします(マルチディスプレイ対応そのものは v1.1.0 の別記事で扱います)。
for overlay in overlays {
overlay.view.configuration = configuration
// イベントトラッキング中でも即時に反映させるため、遅延レイアウトに任せない。
overlay.view.needsLayout = true
overlay.view.layoutSubtreeIfNeeded()
}
needsLayout = true だけを立てると、実際のレイアウト処理は次の描画サイクルまで遅延します。通常のイベントループでは問題になりませんが、スライダーをドラッグしている間はトラッキングループがメインの描画サイクルを止めるため、layoutSubtreeIfNeeded() を呼ばないとドラッグ中にオーバーレイの見た目が更新されません。layoutSubtreeIfNeeded() を明示的に呼ぶことで、その場でレイアウトを確定させています。
XcodeGen + Makefile でのビルドと dmg 作成
このプロジェクトは .xcodeproj を直接リポジトリに含めず、project.yml から XcodeGen で生成します。
name: NeonPointer
options:
bundleIdPrefix: com.local
deploymentTarget:
macOS: "14.0"
createIntermediateGroups: true
generateEmptyDirectories: true
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.1.0"
...
.xcodeproj はバイナリに近い XML で差分レビューがしづらいため、YAML の project.yml を正として xcodegen generate のたびに再生成します。LSUIElement: true を Info.plist のプロパティに含めているため、Dock アイコンやアプリスイッチャーに出ないメニューバー常駐アプリとしてビルドされます。配布は GitHub Releases の dmg を主導線にしていますが、ここで説明する手順は自分でソースからビルドする場合のものです。
ビルドの流れは scripts/build.sh にまとまっています。
echo "==> Xcode プロジェクトを生成"
xcodegen generate
echo "==> Release ビルド"
xcodebuild \
-project "$APP_NAME.xcodeproj" \
-scheme "$APP_NAME" \
-configuration Release \
-derivedDataPath "$BUILD_DIR" \
CODE_SIGNING_ALLOWED=NO \
build
xcodegen generate → xcodebuild の Release ビルド → dist/ への配置 → codesign という順に進みます。CODE_SIGNING_ALLOWED=NO でビルド時の自動署名を止め、codesign --force --deep --sign "$SIGN_IDENTITY" で署名を明示的に後付けします。SIGN_IDENTITY を環境変数で渡さない場合は -(ad-hoc 署名)になります。
配布用の dmg 作成は scripts/make-dmg.sh が担当します。
# hdiutil create -srcfolder は一時ボリュームのマウントを伴い環境によっては失敗するため、
# マウント不要な makehybrid + convert を使う。
echo "==> ディスクイメージ作成"
rm -f "$RAW_DMG" "$DMG"
hdiutil makehybrid -hfs -hfs-volume-name "$VOL_NAME" -o "$RAW_DMG" "$STAGING" -quiet
echo "==> 圧縮"
hdiutil convert "$RAW_DMG" -format UDZO -o "$DMG" -quiet
dmg 作成の定番は hdiutil create -srcfolder ですが、これは内部で一時的なディスクイメージをマウントする処理を伴います。CI やサンドボックスされた環境ではこのマウントに失敗することがあります。hdiutil makehybrid はマウントせずにフォルダから直接 HFS+ イメージを生成でき、その後 hdiutil convert で圧縮形式(UDZO)に変換します。マウントの手順そのものが不要なので、マウントが失敗しうる環境でも安定して dmg を作れます。