[Xcode 8.1] サンプルコードからTouchBarのAPIを理解する#5 NSTouchBar CatalogのCustom Viewの構造と処理

2016.11.16

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

本記事の目標:Custom Viewのサンプルを理解する

こんにちは!モバイルアプリサービス部の加藤潤です。
前回に引き続きNSTouchBar Catalogを理解していきます。
今回はサンプルアプリの左側に表示されるメニューの内、Custom Viewがどういう構造で成り立っているかを見ていきます。

実行結果

まず先にアプリとタッチバーの実行結果をご覧ください。

Custom View

custom_view

最初は「Using Touch Events」が選択された状態となっており、バーの部分が青色になっています。
左右にドラッグすると、何やら座標らしき数値が変わります。
「Using Gesture Recognizers」を選択するとバーの色がグレーとなり、こちらも左右にドラッグすると座標らしき数値が変わります。

Storyboardの構造

CustomViewViewController.storyboardの中がどういう構造になっているかを見てみましょう。

customviewviewcontroller_storyboard

アプリのビューがあり、「Using Touch Events」と「Using Gesture Recognizers」を選択した時のイベントとアクションメソッドの紐付けはされていますが、タッチバーがどこにも見当たりません。Storyboardでタッチバーを定義していないということはコードで定義しているのでしょう。

ソースコード

CustomViewViewController.swift

というわけでコードを見てみましょう。 CustomViewViewController.swiftは以下のようになっています。

import Cocoa

fileprivate extension NSTouchBarCustomizationIdentifier {
    static let customViewBar = NSTouchBarCustomizationIdentifier("com.TouchBarCatalog.customViewBar")
}

fileprivate extension NSTouchBarItemIdentifier {
    static let touchEvent = NSTouchBarItemIdentifier("com.TouchBarCatalog.TouchBarItem.touchEvent")
    static let panGR = NSTouchBarItemIdentifier("com.TouchBarCatalog.TouchBarItem.panGR")
}

fileprivate enum InteractionTypeButtonTag: Int {
    case touchEvent = 1000, panGR = 1001
}

class CustomViewViewController: NSViewController {

    @IBOutlet weak var feedbackLabel: NSTextField!

    var selectedItemIdentifier: NSTouchBarItemIdentifier = .touchEvent

    // MARK: NSTouchBar

    override func makeTouchBar() -> NSTouchBar? {
        let touchBar = NSTouchBar()
        touchBar.delegate = self
        touchBar.customizationIdentifier = .customViewBar
        touchBar.defaultItemIdentifiers = [selectedItemIdentifier]
        touchBar.customizationAllowedItemIdentifiers = [selectedItemIdentifier]

        return touchBar
    }

    // MARK: Action Functions

    @IBAction func choiceAction(_ sender: AnyObject) {
        guard let button = sender as? NSButton,
            let choice = InteractionTypeButtonTag(rawValue:button.tag) else { return }

        switch choice {
        case .touchEvent:
            selectedItemIdentifier = .touchEvent

        case .panGR:
            selectedItemIdentifier = .panGR
        }

        touchBar = nil
    }

    // MARK: Gesture Recognizer

    func panGestureHandler(_ sender: NSGestureRecognizer?) {
        guard let currentItem = self.touchBar?.item(forIdentifier: selectedItemIdentifier),
            let itemView = currentItem.view, let panGR = sender else { return }

        var feedbackStr = "Pan Gesture: "
        let state = sender!.state

        switch state {
        case .began:
            feedbackStr += "Began"

        case .changed:
            feedbackStr += "Changed"

        case .ended:
            feedbackStr += "Ended"

        default:
            break
        }

        let location = panGR.location(in: itemView)
        feedbackStr += String(format: " {x = %3.2f}", location.x)

        feedbackLabel.stringValue = feedbackStr;
    }

    deinit {
        feedbackLabel.unbind(NSValueBinding)
    }
}

// MARK: NSTouchBarDelegate

extension CustomViewViewController: NSTouchBarDelegate {

    func touchBar(_ touchBar: NSTouchBar, makeItemForIdentifier identifier: NSTouchBarItemIdentifier) -> NSTouchBarItem? {

        switch identifier {
        case NSTouchBarItemIdentifier.touchEvent:
            let canvasView = CanvasView()
            canvasView.wantsLayer = true
            canvasView.layer?.backgroundColor = NSColor.blue.cgColor
            canvasView.allowedTouchTypes = .direct

            feedbackLabel.unbind(NSValueBinding)
            feedbackLabel.bind(NSValueBinding, to: canvasView, withKeyPath: #keyPath(CanvasView.trackingLocationString))

            let custom = NSCustomTouchBarItem(identifier: identifier)
            custom.view = canvasView

            return custom

        case NSTouchBarItemIdentifier.panGR:
            let view = NSView()
            view.wantsLayer = true
            view.layer?.backgroundColor = NSColor.gray.cgColor

            let panGestureRecognizer = NSPanGestureRecognizer()
            panGestureRecognizer.target = self
            panGestureRecognizer.action = #selector(panGestureHandler(_:))
            panGestureRecognizer.allowedTouchTypes = .direct
            view.addGestureRecognizer(panGestureRecognizer)

            let custom = NSCustomTouchBarItem(identifier: identifier)
            custom.view = view
            return custom

        default:
            return nil
        }
    }
}

24〜32行目

makeTouchBar()をオーバーライドしてタッチバーを生成して返しています。defaultItemIdentifiersにはプロパティで保持しているselectedItemIdentifierを設定しています。selectedItemIdentifierの初期値はtouchEventとなっています。 NSTouchBarItemIdentifierとして他にpanGRも定義されているので、タッチバーアイテムとしてはタッチイベント用とパンジェスチャー用の2つありますね。

87行目以降

NSTouchBarDelegatetouchBar(_:makeItemForIdentifier:)を実装し、パラメータで渡ってくるNSTouchBarItemIdentifierに対応したNSTouchBarItem(のサブクラス)を返しています。
touchEventpanGRによって分岐していますが、どちらもNSCustomTouchBarItemを生成して返している点は同じです。
NSCustomTouchBarItemはviewプロパティに任意のNSViewを設定することができるためカスタムビューを表示したい場合に使えます。
touchEventの場合は後述するNSViewのサブクラスであるCanvasViewをviewプロパティに設定しています。
panGRの場合はNSPanGestureRecognizerを割り当てたNSViewをviewプロパティに設定しています。 ちなみに、このmakeTouchBar()のオーバーライドとNSTouchBarDelegateについてはPart.1でも出てきました。

53〜78行目

NSPanGestureRecognizerのハンドラーです。ここではタッチバーアイテムのViewの中のx座標を取得してNSTextFieldに出力しています。

36〜49行目

「Using Touch Events」または「Using Gesture Recognizers」を選択した時のアクションメソッドです。どちらが選択されたかを取得してselectedItemIdentifierプロパティを更新しています。

1つの疑問

さて、ここで1つ疑問が出て来ます。先ほど、makeTouchBar()の中でdefaultItemIdentifiersにプロパティで保持しているselectedItemIdentifierを設定しているとお伝えしました。ということはつまり、更新したselectedItemIdentifierを反映するためにはもう一度makeTouchBar()が呼ばれる必要がありますね。でもどうやってもう一度呼ばれるようにしているのでしょうか?

その答えは48行目のtouchBar = nilです。
Instance Property - touchBarに以下の説明があります。

If you have not explicitly provided an NSTouchBar object for a responder by setting this property, the system sends the makeTouchBar() message to the responder to create the default bar. This property is archived.

このプロパティが設定されていない場合はシステムがmakeTouchBar()を呼ぶようです。
「Using Touch Events」または「Using Gesture Recognizers」を選択した時にタッチバーのアイテムが切り替わっていたのはそういうからくりでした。

CanvasView.swift

参考までに上述したCanvasViewのソースコードも載せておきます。
こちらはtouchesBegan(with:)などでタッチイベントをハンドリングして自身のビュー内のx座標を出力しています。

class CanvasView: NSView {

    // This is to keep the current tracking touch (figure). 
    // The Touch Bar supports multiple touches but since it has only 30-point height,
    // it is reasonable to only track one touch.
    var trackingTouchIdentity: AnyObject?

    // Marked as dynamic for this property to support KVO.
    dynamic var trackingLocationString = ""

    // NSView by default doesn't accept first responder, so override this to allow it.
    override var acceptsFirstResponder: Bool { return true }

    override func touchesBegan(with event: NSEvent) {
        // trackingTouchIdentity != nil:
        // We are already tracking a touch, so ignore this new touch.
        if trackingTouchIdentity == nil {
            if let touch = event.touches(matching: .began, in: self).first, touch.type == .direct {
                trackingTouchIdentity = touch.identity
                let location = touch.location(in: self)
                trackingLocationString = String(format: "Began at: {x = %3.2f}", location.x)
            }
        }

        super.touchesBegan(with: event)
    }

    override func touchesMoved(with event: NSEvent) {
        if let trackingTouchIdentity = self.trackingTouchIdentity {
            if let trackingTouch = event.touches(matching: .moved, in: self).filter({
                $0.type == .direct && $0.identity.isEqual(trackingTouchIdentity)}).first {

                let location = trackingTouch.location(in: self)
                trackingLocationString = String(format: "Moved at: {x = %3.2f}", location.x)
            }
        }

        super.touchesMoved(with: event)
    }

    override func touchesEnded(with event: NSEvent) {
        if let trackingTouchIdentity = self.trackingTouchIdentity {
            if let trackingTouch = event.touches(matching: .ended, in: self).filter({
                $0.type == .direct && $0.identity.isEqual(trackingTouchIdentity)}).first {

                self.trackingTouchIdentity = nil
                let location = trackingTouch.location(in: self)
                trackingLocationString = String(format: "Ended at: {x = %3.2f}", location.x)
            }
        }

        super.touchesEnded(with: event)
    }

    override func touchesCancelled(with event: NSEvent) {

        if let trackingTouchIdentity = self.trackingTouchIdentity {
            if let trackingTouch = event.touches(matching: .cancelled, in: self).filter({
                $0.type == .direct && $0.identity.isEqual(trackingTouchIdentity)}).first {

                self.trackingTouchIdentity = nil
                let location = trackingTouch.location(in: self)
                trackingLocationString = String(format: "Canceled at: {x = %3.2f}", location.x)

                // The touch will be cancelled, so roll back to the status before touchBegan
                //...
            }
        }

        super.touchesCancelled(with: event)
    }
}

まとめ

今回は前回と異なり、タッチバーをStoryboardで定義せず、コードで定義する方法を学びました。 やはりコードで定義する方が「その時々のアプリのコンテキストに応じたタッチバーを表示する」という観点ですと色々と融通がきくのではないかと思います。

今回のポイントはズバリ!

makeTouchBar()でタッチバーを再生成するためにはtouchBarプロパティにnilを設定する」

です。

参考