0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

スクリーンごとにパネルを固定し、レイヤーだけ動かしてマルチディスプレイに対応する実装(+SMAppServiceによるログイン時自動起動)

0
Last updated at Posted at 2026-09-06

対象読者と動作環境

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 から変わっていません。

NeonPointer の関連記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?