LoginSignup
9
6

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-03-27

概要

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

具体的には以下のような仕上がりになり、Buttonの下にあるViewがそれにあたります。極力標準アプリに近い形のデザインを目指して作りました。
focus.gif

実装

Viewをフォーカス可能にして、ジェスチャーへ合わせてパララックスできるようにする

ここの部分に関しては手前味噌ですが、会社のブログで書いたこちらの記事がそのまま使えます。

iOSでパララックス効果を与えるにはiOS7から登場したUIMotionEffectを使いますね。tvOSではそれがそのまま、siri remoteを操作したときのエフェクトに利用できます。

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

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)

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

フォーカスされた時にふわっと浮き出るアニメーションを行う

標準アプリのこのUIをよくよく眺めると、フォーカス時にはブラーのかかったViewが浮き出て、フォーカスを外すとそれが消えるという動きだとわかります。iOSでブラー効果を手軽につけたければUIVisualEffectViewですね。それに対して影をつけて、CATransformでスケールさせればできそうです。

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

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みたいなもの)。そのイベントを使って押し込み時にも目立ったアニメーションがされるようにします。

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を使うのが一番ラクに要件を満たせます。

右端の領域に「さらに表示」おいてテキストを回り込ませるにはTextKitNSTextContainerにexclusivePathとしてUIBezierPathを渡してやればできるでしょう。UITextViewはそれ自体がTextKitを使って作られているので、そいつ自身が持っているtextContainerプロパティをいじれば大丈夫です。
行制限をして省略文字を表示させるにはUITextView自身のプロパティとtextContainerの値をいじればできます。

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パーツの実装全体はこちらに置いています。

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

スクリーンショット 2017-03-28 0.50.30.png

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

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でも渡して領域を伸ばすアニメーションを行えばよい。
9
6
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
9
6