Watchアプリのラベルに表示した文字列を横スクロールさせる


## 概要
Watchアプリのラベルに表示した文字列を横スクロールさせたいという要望があったので、WKInterfaceLabelのExtensionで実現した。

## サンプル動画
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">WKInterfaceLabelの自動横スクロールのサンプル。ブログ用。 <a href="https://t.co/sgovtl7JOQ">pic.twitter.com/sgovtl7JOQ</a></p>&mdash; こうちゃん黒猫まみれ (@macneko_ayu) <a href="https://twitter.com/macneko_ayu/status/976772906603524096?ref_src=twsrc%5Etfw">2018年3月22日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 仕様など
* スクロールは、一定時間おきに文字列の左端を削除することで実現
* エンドレス版は、削除した文字列を末尾に追加し、電子掲示板のような動きにした

## 留意点
いくつか留意すべき点がある。

### 表示する文字列がラベルに収まる長さかどうかの判定ができない
UILabelでは、自身のframeのwidthと表示する文字列のwidthを比較して、ラベルに収まるかどうかの判定を行うことが可能。

参照：[UILabelの高さ計算時に気をつけること](https://qiita.com/unkomatch/items/672a146c398f57843dfa)

しかし、WKInterfaceLabelは下記の理由から、UILabelのように判定を行うことができない。

* ラベルに表示している文字列を取得するAPIがない
* ラベルに適用されているUIFontを取得するAPIがない
* ラベルのframeを取得するAPIがない

回避策として、enumなどを用いて各値を保持しておくという案を思いついたが、ラベルごとに異なるであろう設定をもれなく抜き出し、かつ改修があったときに追随していくことは苦痛に思えたので、「常にスクロールする」仕様とすることが無難だろう。

### 処理が重く、CPUの使用率に影響がある
当然のことだが、Xcodeでデバッグした限り、スクロールさせるとCPUの使用率があがる。
試しに15個程度のラベルをスクロールさせてみたが、CPU使用率が50%以上という結果になった。
画面外のラベルはスクロールさせないなど、可能な限りスクロールさせないための処理は必須。

### WKInterfaceLabelはサブクラスが機能しない
スクロール処理とは関係ない話ではあるが、WKInterfaceLabelはサブクラスが機能しない。

>Do not subclass or create instances of this class yourself. Instead, define outlets in your interface controller class and connect them to the corresponding objects in your storyboard file. For example, to refer to a label object in your interface, define a property with the following syntax in your interface controller class:

[公式ドキュメント](https://developer.apple.com/documentation/watchkit/wkinterfacelabel)から抜粋。

Xcode上ではなんのサジェストも行われず、上記のドキュメントの記載に気づかずに実装したときの状態を表にした。

|やったこと|可否|
|---|---|
|サブクラス化|◯|
|Storyboard上のカスタムクラスの設定|◯|
|ビルド|◯|
|サブクラス内のメソッドの呼び出し|×|

一見サブクラス化できるように思えるが、実際は動かないので、かなしい。

## ソースコード

```swift:WKInterfaceLabelExtension.swift

import WatchKit

private var ScrolledTextKey: UInt8 = 0
private var OriginTextKey: UInt8 = 0
private var TimerKey: UInt8 = 0
private var EndlessKey: UInt8 = 0
private var TimerStartedKey: UInt8 = 0

extension WKInterfaceLabel {

    // MARK: - Public method
    
    /// テキストを横方向に自動でスクロールする
    ///
    /// - Parameter text: text
    func setAutoHorizontalScrollText(_ text: String?, isEndless endless: Bool = false) {
        guard let text = text else {
            return
        }
        setEndless(endless)
        setOriginText(NSAttributedString(string: text + "　　　　　　　　　　"))
        initializeText()
        self.startTimer()
    }
    
    /// 自動スクロール開始
    @objc func startTimer() {
        // タイマーの多重起動を防ぐため、スクロール開始済の場合はreturn
        if getTimerStarted() {
            return
        }
        setTimerStarted(true)
        DispatchQueue.main.async {
            let timer = Timer.scheduledTimer(timeInterval: 0.35, target: self, selector: #selector(self.timerUpdate), userInfo: nil, repeats: true)
            self.setTimer(timer)
        }
    }
    
    /// 自動スクロール停止
    func stopTimer() {
        getTimer().invalidate()
        setTimerStarted(false)
        // テキストを初期化
        initializeText()
    }
   
    // MARK: - Private method

    /// ラベルのテキストを更新
    @objc private func timerUpdate() {
        if getScrolledText().length > 1 {
            let scrolledText = NSMutableAttributedString(attributedString: getScrolledText())
            guard let firstCharacter = scrolledText.string.first else {
                return
            }
            // テキストの先頭の文字を削除
            scrolledText.deleteCharacters(in: NSRange(location: 0, length: 1))
            // エンドレスの場合はテキストの末尾に削除した文字を追加する
            if getEndless() {
                scrolledText.append(addAttribute(String(firstCharacter)))
            }
            setScrolledText(scrolledText)
            setAttributedText(getScrolledText())
        } else {
            // タイマーを停止
            stopTimer()
            if !getEndless() {
                // 少し間をおき、スクロール開始
                let timer = Timer(timeInterval: 2, target: self, selector: #selector(startTimer), userInfo: nil, repeats: false)
                RunLoop.main.add(timer, forMode: .commonModes)
            }
        }
    }
    
    /// ラベルのテキストを初期化
    private func initializeText() {
        let attributeText = addAttribute(getOriginText().string)
        setOriginText(attributeText)
        setScrolledText(getOriginText())
        setAttributedText(getScrolledText())
    }
    
    private func addAttribute(_ text: String) -> NSAttributedString {
        // ラベルにLineBreakModeのプロパティが存在しないので、NSAttributedStringで設定する
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.lineBreakMode = .byClipping
        let attributes: [NSAttributedStringKey: Any] = [.paragraphStyle: paragraphStyle]
        return NSAttributedString(string: text, attributes: attributes)
    }
    
    // MARK: - getter/setter
    
    private func setScrolledText(_ text: NSAttributedString) {
        objc_setAssociatedObject(self, &ScrolledTextKey, text, .OBJC_ASSOCIATION_RETAIN)
    }
    
    private func getScrolledText() -> NSAttributedString {
        guard let object = objc_getAssociatedObject(self, &ScrolledTextKey) as? NSAttributedString else {
            return NSAttributedString(string: "")
        }
        return object
    }
    
    private func setOriginText(_ text: NSAttributedString) {
        objc_setAssociatedObject(self, &OriginTextKey, text, .OBJC_ASSOCIATION_RETAIN)
    }
    
    private func getOriginText() -> NSAttributedString {
        guard let object = objc_getAssociatedObject(self, &OriginTextKey) as? NSAttributedString else {
            return NSAttributedString(string: "")
        }
        return object
    }
    
    private func setTimer(_ timer: Timer) {
        objc_setAssociatedObject(self, &TimerKey, timer, .OBJC_ASSOCIATION_RETAIN)
    }
    
    private func getTimer() -> Timer {
        guard let object = objc_getAssociatedObject(self, &TimerKey) as? Timer else {
            return Timer()
        }
        return object
    }

    private func setEndless(_ endless: Bool) {
        objc_setAssociatedObject(self, &EndlessKey, endless, .OBJC_ASSOCIATION_RETAIN)
    }
    
    private func getEndless() -> Bool {
        guard let object = objc_getAssociatedObject(self, &EndlessKey) as? Bool else {
            return false
        }
        return object
    }

    private func setTimerStarted(_ timerStarted: Bool) {
        objc_setAssociatedObject(self, &TimerStartedKey, timerStarted, .OBJC_ASSOCIATION_RETAIN)
    }
    
    private func getTimerStarted() -> Bool {
        guard let object = objc_getAssociatedObject(self, &TimerStartedKey) as? Bool else {
            return false
        }
        return object
    }
}
```

## 使い方
サンプル動画のInterfaceControllerの実装は下記の通り。

```swift:InterfaceController.swift

import WatchKit
import Foundation

class InterfaceController: WKInterfaceController {

    @IBOutlet var notEndlessLabel: WKInterfaceLabel!
    @IBOutlet var endlessLabel: WKInterfaceLabel!

    override func awake(withContext context: Any?) {
        super.awake(withContext: context)
        notEndlessLabel.setAutoHorizontalScrollText("レノくんかわいい。ごんたかわいい。あゆさんかわいい")
        endlessLabel.setAutoHorizontalScrollText("レノくんかわいい。ごんたかわいい。あゆさんかわいい", isEndless: true)
    }
    
    override func willActivate() {
        // This method is called when watch view controller is about to be visible to user
        super.willActivate()
    }
    
    override func didAppear() {
        super.didAppear()
        notEndlessLabel.startTimer()
        endlessLabel.startTimer()
    }
    
    override func didDeactivate() {
        // This method is called when watch view controller is no longer visible
        super.didDeactivate()
        notEndlessLabel.stopTimer()
        endlessLabel.stopTimer()
    }
}
```

iOS App同様に、Watch Appがバックグラウンドに移行したときにスクロールを止める処理をお忘れなく。

## まとめ
Watch Appはいろいろと制限があって、難しい。
あと情報が少ない上に古いものが多くて、つらい。
みなさんもお持ちの情報で公開できるものがあれば、どんなものでも公開していただけるとお互いに幸せになれそう。