Edited at

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

More than 1 year has passed since last update.


概要

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

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

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