概要
tvOSアプリでは、Viewに収まりきらないテキストをフォーカス可能にし、別画面へpresentさせて全文表示を行うというシンプルなUIが多用されています。色んな所で使えるものの作らなければならない部分も多いため、実装パターンとして残しておこうと思います。
具体的には以下のような仕上がりになり、Buttonの下にあるViewがそれにあたります。極力標準アプリに近い形のデザインを目指して作りました。
実装
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を使うのが一番ラクに要件を満たせます。
右端の領域に「さらに表示」おいてテキストを回り込ませるにはTextKitのNSTextContainerに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を置く形です。
レイアウトは都合上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でも渡して領域を伸ばすアニメーションを行えばよい。