togo

= ひとりごと to go

zumuya の人による机の上系情報サイト

スペースバーを押したときだけ手のひらツールにする

  • Apple
  • 開発

「スペースバーが押されているあいだだけ手のひらツールに変化1させるのが大変だった」という話を耳にしたので自分も挑戦してみた。もっときれいな方法や間違いがあったら教えてください。

import Cocoa

class CanvasView: NSView
{
    var cursorTrackingArea: NSTrackingArea!

    private func _commonInit()
    {
        layerContentsRedrawPolicy = .onSetNeedsDisplay
        cursorTrackingArea = NSTrackingArea(rect: .zero, options: [.activeInKeyWindow, .cursorUpdate, .inVisibleRect], owner: self, userInfo: nil)
        addTrackingArea(cursorTrackingArea)
    }
    override init(frame: NSRect)
    {
        super.init(frame: frame)
        _commonInit()
    }
    required init?(coder: NSCoder)
    {
        super.init(coder: coder)
        _commonInit()
    }
    deinit
    {
        removeTrackingArea(cursorTrackingArea)
    }

    //MARK: - View Flags

    override var wantsUpdateLayer: Bool { return true }
    override var isOpaque: Bool { return true }

    override var canBecomeKeyView: Bool { return true }
    override var acceptsFirstResponder: Bool { return true }

    //MARK: - Drawing

    override func updateLayer()
    {
        layer?.backgroundColor = NSColor.textBackgroundColor.cgColor
    }

    //MARK: - Hit Test

    override func hitTest(_ pointInSuperview: NSPoint) -> NSView?
    {
        if effectiveTool.interceptsDescendantEvents {
            guard let point = superview?.convert(pointInSuperview, to: self) else {
                return nil
            }
            return (isMousePoint(point, in: bounds) ? self : nil)
        } else {
            return super.hitTest(pointInSuperview)
        }
    }

    //MARK: - Mouse Events

    override func mouseDown(with event: NSEvent)
    {
        window?.makeFirstResponder(self)

        switch effectiveTool {
        case .hand:
            window?.trackEvents(matching: [.leftMouseDragged, .leftMouseUp], timeout: 0.5, mode: .eventTracking, handler: { (event, stopPtr) in
                //check the button state just in case.
                guard ((NSEvent.pressedMouseButtons & (1 << 0)) != 0) else {
                    stopPtr.pointee = true
                    return
                }
                guard let event = event else {
                    return
                }
                switch event.type {
                case .leftMouseUp:
                    stopPtr.pointee = true
                default:
                    //do some work for scroll here.
                    break
                }
            })
        default:
            break
        }
    }

    //MARK: - Keyboard Events

    override func keyDown(with event: NSEvent)
    {
        if (event.keyCode == 49/*space*/) {
            isSpaceBarPressed = true
        } else {
            isSpaceBarPressed = false
            super.keyDown(with: event)
        }
    }
    override func keyUp(with event: NSEvent)
    {
        if (event.keyCode == 49/*space*/) {
            isSpaceBarPressed = false
        } else {
            super.keyUp(with: event)
        }
    }

    //MARK: - Cursor Events

    override func cursorUpdate(with event: NSEvent)
    {
        effectiveTool.cursor.set()
    }

    //MARK: - Tool

    public enum Tool
    {
        case select
        case hand

        var cursor: NSCursor
        {
            switch self {
            case .select:
                return .arrow
            case .hand:
                return .openHand
            }
        }
        var interceptsDescendantEvents: Bool
        {
            switch self {
            case .hand:
                return true
            default:
                return false
            }
        }
    }

    /// Tool selected by user.
    public var tool = Tool.select
    {
        didSet { updateEffectiveTool() }
    }

    /// Actual effective tool which generally equals to `tool` but changes on some conditions.
    public private(set) var effectiveTool = Tool.select
    {
        didSet
        {
            if (oldValue != effectiveTool) {
                window?.invalidateCursorRects(for: self)
            }
        }
    }
    private func updateEffectiveTool()
    {
        if isSpaceBarPressed {
            effectiveTool = .hand
        } else {
            effectiveTool = tool
        }
    }

    //MARK: - Keyboard Flags

    private var isSpaceBarPressed = false
    {
        didSet { updateEffectiveTool() }
    }
}
CanvasView.swift

以下、解説。

ツールの切り替え

スペースバーが押されているあいだだけ手のひらツールに変化させ、それ以外にはユーザの選択したツールが有効になるのがよくあるパターンなので再現してみる。

  1. tool(ユーザが選択するツール)と effectiveTool(実際に有効なツール)を別プロパティとして用意。
  2. keyDown(with:) と keyUp(with:) のタイミングで isSpaceBarPressed(スペースバーが押されているかのフラグ)を操作。
  3. フラグが変更されたらそれらをもとに effectiveTool を更新。

キーボードイベントが受け取れるようにビューがレスポンダチェーンに存在する必要があるはず。

カーソルの変更

Cocoa におけるカーソルの変更は独特なので注意。ビューには cursor みたいなプロパティがなくて直接「現在のカーソル」を NSCursor.set() で切り替えるのだが、好きなタイミングでセットしてもほかの要因ですぐに上書きされてしまう。

ビューの cursorUpdate(with:) をオーバーライドして、状態やポイントされている位置に応じた NSCursor をセットする処理を実装しておきそれが適切なタイミングで呼ばれるようにするのが基本だ。

  1. init(...) で NSTrackingArea を追加しておく。カーソルの更新が目的なので .cursorUpdate オプションは必須。さらに .inVisibleRect を指定すればサイズが変わるたびに追加しなおす必要がなくなる。
  2. するとマウスが触れたり離れたりしたときに cursorUpdate(with:) が呼ばれるようになるので、そこで effectiveTool に応じた NSCursor をセット。
  3. effectiveTool が変更されたときにウインドウの invalidateCursorRects(for:) を呼ぶようにすれば、そこでも cursorUpdate(with:) が呼ばれる2

その他の工夫

  • mouseDragged(with:) を使ってドラッグイベントを捉えようとするとカーソルがウインドウの外に出たときに変化してしまうので trackEvents(matching:timeout:mode:handler:) を使って余計なイベントが割り込んでこないようにしてみた。下手な実装をするとループから抜け出せないことがあるので注意3
  • hitTest(_:) をオーバーライドして、手のひらツールのときは子孫ビューにマウスイベントが行かないようにする。
子孫ビュー上でも問題なし

  1. 広く見かける UI だけど、現在の Apple 純正ソフトウェアで使えるのは Keynote ぐらい? ↩︎

  2. このメソッドがこういう作用をすることは明記されていないので、cursorUpdate(with:) に window?.currentEvent などを渡して直接呼ぶ方がいいかも。今回は super を呼ばないから予期せぬ副作用もなさそうだし。 ↩︎

  3. 経験あり ↩︎

Share

リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。