tvOSでmovieアプリのような「さらに表示」がついている選択可能なtext viewを作る

# 概要
tvOSアプリでは、Viewに収まりきらないテキストをフォーカス可能にし、別画面へpresentさせて全文表示を行うというシンプルなUIが多用されています。色んな所で使えるものの作らなければならない部分も多いため、実装パターンとして残しておこうと思います。

具体的には以下のような仕上がりになり、Buttonの下にあるViewがそれにあたります。極力標準アプリに近い形のデザインを目指して作りました。
![focus.gif](https://qiita-image-store.s3.amazonaws.com/0/29477/c7a57c7c-d4f5-4cab-7b1a-edede11d3cf3.gif)

# 実装
## Viewをフォーカス可能にして、ジェスチャーへ合わせてパララックスできるようにする
ここの部分に関しては手前味噌ですが、会社のブログで書いたこちらの記事がそのまま使えます。

- [2017年こそtvOSアプリ開発を始めたいエンジニア向け、UI実装ノウハウ集](https://techblog.yahoo.co.jp/advent-calendar-2016/tvosapp-tips/)

iOSでパララックス効果を与えるにはiOS7から登場した[UIMotionEffect](https://developer.apple.com/reference/uikit/uimotioneffect)を使いますね。tvOSではそれがそのまま、siri remoteを操作したときのエフェクトに利用できます。

サンプルコードでは、後で利用しやすいようにMotionEffectオブジェクトを提供するためだけの構造体を用意します。

```swift
struct FocusEffect {
    let motionEffectGroup: UIMotionEffectGroup = {
        // Y軸方向に動かせるMotionEffectを作成
        // 移動量のパラメータは適当
        let yTilt = UIInterpolatingMotionEffect(keyPath: "center.y", type: .tiltAlongVerticalAxis)
        
        yTilt.maximumRelativeValue = 7
        yTilt.minimumRelativeValue = -7
        

        // 移動に合わせて手前と奥へ回転するMotionEffectを作成
        let verticalTiltEffect = UIInterpolatingMotionEffect(keyPath: "layer.transform", type: .tiltAlongVerticalAxis)

        let tiltAngle = CGFloat(2 * M_PI / 180)

        var minY = CATransform3DIdentity
        minY.m34 = 1.0 / 500
        minY = CATransform3DRotate(minY, -tiltAngle, 1, 0, 0)
        
        var maxY = CATransform3DIdentity
        maxY.m34 = minY.m34
        maxY = CATransform3DRotate(maxY, tiltAngle, 1, 0, 0)

        verticalTiltEffect.minimumRelativeValue = NSValue(caTransform3D: maxY)
        verticalTiltEffect.maximumRelativeValue = NSValue(caTransform3D: minY)
        
        // ２つのMotionEffectを組み合わせてsiri remoteの操作によるパララックス効果を与える
        let motionEffectGroup = UIMotionEffectGroup()
        motionEffectGroup.motionEffects = [yTilt, verticalTiltEffect]
        return motionEffectGroup
    }()
}
```

## フォーカスされた時にふわっと浮き出るアニメーションを行う
標準アプリのこのUIをよくよく眺めると、フォーカス時にはブラーのかかったViewが浮き出て、フォーカスを外すとそれが消えるという動きだとわかります。iOSでブラー効果を手軽につけたければ[UIVisualEffectView](https://developer.apple.com/reference/uikit/uivisualeffectview)ですね。それに対して影をつけて、[CATransform](https://developer.apple.com/reference/quartzcore/core_animation_functions)でスケールさせればできそうです。

まずは影とScaleさせる部分を作ります。ここでは好きなViewに対して適用できるよう、プロトコルエクステンションで実装しましょう。

```swift
protocol FocusEffectable: class {
    // 先ほど作成したFocusEffectオブジェクトを持たせる制約を与える
    var focusEffect: FocusEffect { get set }
    // フォーカス時にどのくらいViewを拡大するか
    var activatedScale: CGAffineTransform { get }
    // 影をつける
    func activateShadow(isActivated: Bool)
    // フォーカス時のUIを表示非表示させる
    func activateEffect(isActivated: Bool)
}

extension FocusEffectable where Self: UIView {
    
    var activatedScale: CGAffineTransform {
        return CGAffineTransform(scaleX: 1.05, y: 1.05)
    }
    
    func activateShadow(isActivated: Bool) {
        if isActivated {
            layer.masksToBounds = false
            layer.shadowColor = UIColor.black.cgColor
            layer.shadowRadius = 30
            layer.shadowOffset = CGSize(width: 0, height: 20)
            layer.shadowOpacity = 0.3
        } else {
            layer.masksToBounds = false
            layer.shadowColor = UIColor.clear.cgColor
            layer.shadowRadius = 0
            layer.shadowOffset = CGSize(width: 0, height: 0)
            layer.shadowOpacity = 0
        }
    }
    
    // 拡大・影・MotionEffectを設定する。whereでUIView制約を与えているため、Viewのプロパティやメソッドを呼べる
    func activateEffect(isActivated: Bool) {
        if isActivated {
            transform = activatedScale
            activateShadow(isActivated: true)
            addMotionEffect(focusEffect.motionEffectGroup)
        } else {
            transform = .identity
            activateShadow(isActivated: false)
            removeMotionEffect(focusEffect.motionEffectGroup)
        }
    }
}
```

続いてUIVisualEffectへ作ったProtocolExtensionを適用します。tvOSならではの部分が```didUpdateFocus```メソッドで、Viewにフォーカスが当たる・外れるとこのイベントが呼ばれるため、そこへon, offの処理を書きます。UIVisualEffectは```effect```プロパティがnilになるとブラー効果がなくなるため、それを活用したアニメーションを作ります。

またsiri remoteでクリックをした時の処理は``pressesBegan``` & ```pressesEnd```へ書きます(iOSのtouchesBegin, touchesEndみたいなもの)。そのイベントを使って押し込み時にも目立ったアニメーションがされるようにします。

```swift
class VisualEffectView: UIVisualEffectView, FocusEffectable {

    // ProtocolExtensionが提供してくれていないため、FocusEffectのオブジェクトを自分で設定
    var focusEffect: FocusEffect = FocusEffect()

    override func awakeFromNib() {
        super.awakeFromNib()
        effect = nil
    }
    
    // これがtrueを返すようにしないとそもそもFocusを当てることができない
    override var canBecomeFocused: Bool {
        return true
    }
    
    // didUpdateFocusを継承して、フォーカス時にアニメーションされてほしいプロパティをcoordinator.addCoordinatedAnimations()内でいじる
    override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
        super.didUpdateFocus(in: context, with: coordinator)
        if let item = context.nextFocusedItem as? VisualEffectView, item == self {
            coordinator.addCoordinatedAnimations({ [weak self] in
                self?.activateEffect(isActivated: true)
                self?.effect = UIBlurEffect(style: .light)
            }, completion: {})
        } else if let item = context.previouslyFocusedItem as? VisualEffectView, item == self {
            coordinator.addCoordinatedAnimations({ [weak self] in
                self?.activateEffect(isActivated: false)
                self?.effect = nil
            }, completion: {})
        }
    }
    
    // プレスした時に奥へ引っ込む
    override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
        UIView.animate(withDuration: 0.2, animations: { [weak self] in
            self?.transform = .identity
        }) { (finish) in }
    }
    

    // プレスをやめたときに手前に出る
    override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
        UIView.animate(withDuration: 0.2, animations: { [weak self, activatedScale = activatedScale] in
            self?.transform = activatedScale
        }) { (finish) in }
    }
}
```

## Text Viewテキストの末尾へ省略記号と「さらに表示」を表示させる

続いてUIVisualEffectの上へ配置するテキスト部分を作ります。ここでは[UITextView](https://developer.apple.com/reference/uikit/uitextview)を使うのが一番ラクに要件を満たせます。

右端の領域に「さらに表示」おいてテキストを回り込ませるには[TextKit](https://developer.apple.com/library/content/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/CustomTextProcessing/CustomTextProcessing.html)の[NSTextContainer](https://developer.apple.com/reference/appkit/nstextcontainer)にexclusivePathとしてUIBezierPathを渡してやればできるでしょう。UITextViewはそれ自体がTextKitを使って作られているので、そいつ自身が持っているtextContainerプロパティをいじれば大丈夫です。
行制限をして省略文字を表示させるにはUITextView自身のプロパティとtextContainerの値をいじればできます。

```swift
class TextView: UITextView {
    
    // 「さらに表示」部分はUILabelで作成
    lazy var moreLabel: UILabel = {
        let label = UILabel()
        label.text = "さらに表示"
        label.textColor = .gray
        label.font = self.font
        label.textAlignment = .center
        return label
    }()

    override func awakeFromNib() {
        super.awakeFromNib()
        
        // 省略文字表示
        textContainer.lineBreakMode = .byTruncatingTail
        // UITextViewでフォーカスできるようにするにはこの辺の値をコードでいじるしかない (バグ(?)が原因でstoryboardでいじっても反映されない)
        isScrollEnabled = false
        isUserInteractionEnabled = true
        isSelectable = true
        textContainer.maximumNumberOfLines = 5
        textContainerInset = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)

        // ここではAutoresizingを使う
        translatesAutoresizingMaskIntoConstraints = true

        addSubview(moreLabel)
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        sizeToFit()
        moreLabel.sizeToFit()
        do {
            let x = bounds.width - moreLabel.bounds.width - textContainerInset.right
            let y = bounds.height - moreLabel.bounds.height - textContainerInset.bottom
            moreLabel.frame.origin = CGPoint(x: x, y: y)
        }
        // ラベルのframeの位置ではテキストを折り返す。
        let exclusivePath = UIBezierPath(rect: moreLabel.frame)
        textContainer.exclusionPaths = [exclusivePath]
    }
}
```


## ViewController上で配置する
以上の作成した、UIパーツの実装全体はこちらに置いています。

https://gist.github.com/kazuhiro4949/6ba1e46494dbb46d79f0e1f2a381f692

これをStoryboard等で配置して、ViewControllerに持たれば概要に載せたようなUIになります。
具体的にはさっき作ったVisualEffectのcontentViewへ、同じくさっき作ったTextViewを置く形です。

<img width="1136" alt="スクリーンショット 2017-03-28 0.50.30.png" src="https://qiita-image-store.s3.amazonaws.com/0/29477/4df685ef-380d-9569-eba5-f835a31e008d.png">

レイアウトは都合上Autosizingで配置しています。

```swift
import UIKit

class ViewController: UIViewController {
    @IBOutlet weak var textView: UITextView!

    override func viewDidLoad() {
        super.viewDidLoad()
        textView.frame.size.width = 800

    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}
```

あとはプレスした時にpresent(_:)を呼べば、よく見るあのUIとして利用できます。

# まとめ
- よく見るUIのわりに、毎回自分でパーツを用意しなければならない
- 標準ライブラリのクラスを思ったより色々使う(と楽に作れる)
- iOSでもそのまま使いまわせる。UIMotionEffectを除いたうえで、「さらに表示」部分にUITapGestureRecognizerでも渡して領域を伸ばすアニメーションを行えばよい。