経緯
仕事の関係で
トリミングする枠を変形させて画像をトリミングできる画面や
グラデーションで色が変化していくプログレスバーなどを作成する機会をいただき
今まで馴染みのなかったCore Animationを使用することになりました。
正直、CA〇〇とかCG〇〇という名前を見ると
「うっ」となってしまい苦手意識を持っていました。
あまり知識がないことから何が起きているのかがわからず
不思議な挙動に出会うと解決するのに
時間がかかるのが非常に辛く感じていました。
(今もそうですが。。。)
ただ
今回調べたことでちょっと仲良くなれたのかなと感じており
せっかくの機会ですので調べたことをまとめてみたいと思います。
Core Animationとは?
Appleのドキュメントによると
Viewやその他の画面に表示されるような要素を
アニメーションさせるために使われる
グラフィックの描画やアニメーションのインフラストラクチャ
とあります。
Core Animation Programming Guide
※ドキュメントにも記載がありますが、このドキュメントは古いものとなっております
つまり、名前にもあるようにViewをアニメーションさせるためのフレームワークです。
UIKitの下にあり、QuartzCore.frameworkに含まれています。
Core Animationでアニメーション可能なプロパティを操作することで
デフォルトでアニメーションを含めて変化したり
独自にアニメーションを定義して細かくアニメーションできるようになります。
UIKitとの違い
UIViewは画面に表示するオブジェクトを直接管理し、
画面のレイアウトやユーザーからのタッチイベントなどを色々なことを行っています。
Core Animationは
ハードウェア上でアプリのコンテンツを操作したり構成するために使用されます。
中心的な存在としてlayerオブジェクトあります。
アプリのデータを管理するいわゆるモデルオブジェクトです。
layerは情報をbitmap形式で管理をしており、
同様にbitmap形式のデータを必要するGPUから簡単に操作できるようにします。
UIViewの描画はメインスレッドかつCPUを用いて描画を行います。
一方でGPUはメインスレッドをブロックすることなく
描画に特化しているため軽量で高速です。
よく使われるものとしてCALayerがあり、これはViewに関する情報を管理します。
CALayer
iOSに関していえばUIViewは必ずlayerというプロパティにCALayerを持っています。
UIViewは画面の描画やアニメーションの実行も担っているように見えますが、
実は内部でこのCALayerへ処理を委譲しています。
例えば、boundsの設定を行うと、UIViewはlayerプロパティに設定されているCALayerへ処理を渡します。
このケースの場合
ここで知らないと「あれ?」と思うことがあります。
上記でCore Animationは「Viewをアニメーションさせる」と言いましたが
このケースではアニメーションはしません。
なぜか?と言いますと、
UIViewはlayerのCALayerDelegateを実装しており、
action(for:forKey:)というメソッドで
アニメーション可能な値を探索します。
その際にNSNullを返すことで
探索するのを止めることが可能になりますが
UIViewはここで全てNSNullを返すようにしているため
アニメーションが起きません。
CALayerはUIViewと同様に階層構造を持ち、
画面の初期表示時はUIViewと同じ階層構造を持ちます。
一方で独立した階層を作成することもできます。
CALayerのサブクラスを返すことも可能
デフォルトではCALayerを返しますが、layerClassをoverrideすることで、サブクラス返すことも可能です。
override class var layerClass : AnyClass {
return CATiledLayer.self
}
CALayerの3つのレイヤー
CALayerは3つのレイヤに分かれています。
モデルレイヤ
描画やアニメーションに必要な情報を管理しており、私たちがアクセスするプロパティのほとんどはここで管理されています。
アニメーション後の最終的な値もここに設定することでアニメーション後の状態を保つことができます。
プレゼンテーションレイヤ
上記のモデルデータと反対に、アニメーション中の現在値を保持します。
アニメーション中のその時点の値を参照したい場合はここから値を取得する必要があります。
アニメーションの一時停止、再開をしたいときなどに活用できます。
描画レイヤ
プレゼンテーションレイヤとは別スレッドで実際の描画を行います。
これはCore AnimationのPrivateクラスなので直接操作することはできません。
UIViewとCore Animationの処理の流れ
今までのことを図にしてみると、このような感じになります。
Core Animationが必要なとき
基本的にはUIViewで良いと思っています。
UIViewの方が簡単に実装ができます。
しかし、独自の形の図形を描画したかったり、
よい細かいアニメーションを実現したい場合は、Core Animationが力を発揮します。
また、パフォーマンスに問題がある場合に、より軽量で高速なCore Animationを使用することで
問題が解決することもあるかもしれません、
また、コンテンツをキャッシュするという特徴を生かして、
複数の箇所で使用するような静的な画像などは、
複数のLayerで同じ画像データを参照するためメモリの使用量を抑えることができます。
Contentsの提供方法
3つの方法があります。
contentsプロパティにCGImageRef型のデータを設定する
通常の画像などを提供する場合に使用します。
一点注意点としてcontentsScaleプロパティに適切な値を設定する必要があります。
これはRetinaディスプレイへの対応で正しい値が設定されていないと表示される画像の大きさが変わります。
直接値を設定するというよりも端末のscaleに合わせる記述方法が多く見られます。
sublayer.contentsScale = UIScreen.main.scale
デリゲートメソッドを使用する
draw(_:)かdisplay(_:)を使用します。
下記はdisplayメソッドの例です。
contentsに画像データなどを設定する場合に使用します。
class DemoView: UIView {
/*
!!!!ハマりポイント!!!!
*/
override func draw(_ rect: CGRect) {
super.draw(rect)
}
// 画像などのデータを提供する
override func display(_ layer: CALayer) {
layer.contentsScale = UIScreen.main.scale
layer.contents = UIImage(named: "test2.png")?.cgImage
}
}
上記で「ハマりポイント」と示しましたが
layerに独自のコンテンツを表示したい場合は、
drawメソッドをオーバーライドする必要があります。
そうすると自動的に、対応するレイヤのデリゲートを生成し、
必要なメソッドを実装されます。
逆にここをコメントアウトすると何も描画されなくなります。
下記はdrawメソッドの例です。
動的に図形などを描画したい場合に使用します。
class DemoView: UIView {
/*
!!!!はまりポイント!!!!
自動レイヤつきビューに独自のコンテンツを表示したい場合は、
当該ビューの描画メソッドをオーバーライドする。
ビューは自動的に、対応するレイヤのデリゲートを生成し、
必要なメソッドを実装する。
したがって、コンテンツを描画するためには、ビューのdrawRect:メソッドを実装する。
*/
override func draw(_ rect: CGRect) {
super.draw(rect)
}
// CGContextに書き込む
override func draw(_ layer: CALayer, in ctx: CGContext) {
super.draw(layer, in: ctx)
ctx.move(to: CGPoint(x: 0.0, y: 0.0))
ctx.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
ctx.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
ctx.addLine(to: CGPoint(x: self.frame.size.width, y: 0.0))
ctx.closePath()
ctx.setFillColor(UIColor.red.cgColor)
ctx.fillPath()
}
}
ちなみに、両方のメソッドを実装した場合は、displayメソッドが優先されます。
さらに、初期化時contentsに画像を設定した場合は
これが一番優先されます。
サブクラスを作成する
CALayerのサブクラスを作成し、layerClassをoverrideすることもできます。
こちらも同様にdisplay(_:)かdisplay(_:)を使用します。
displayの例
// サブクラス
class DemoLayer: CALayer {
// contentsにプロパティを設定
override func display() {
super.display()
contents = UIImage(named: "test3.png")?.cgImage
}
}
class DemoView: UIView {
// サブクラス化
override class var layerClass : AnyClass {
return DemoLayer.self
}
override func draw(_ rect: CGRect) {
super.draw(rect)
}
}
drawの例
// サブクラス
class DemoLayer: CALayer {
// drawメソッドで動的に描画する
// contextに描きこんでいく
override func draw(in ctx: CGContext) {
super.draw(in: ctx)
ctx.move(to: CGPoint(x: 0.0, y: 0.0))
ctx.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
ctx.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
ctx.addLine(to: CGPoint(x: self.frame.size.width, y: 0.0))
ctx.closePath()
ctx.setFillColor(UIColor.red.cgColor)
ctx.fillPath()
}
}
class DemoView: UIView {
// サブクラス化
override class var layerClass : AnyClass {
return DemoLayer.self
}
override func draw(_ rect: CGRect) {
super.draw(rect)
}
}
CAShapeLayer
CALayerのサブクラスはたくさん用意されており
それぞれが特徴的な機能を持っています。
その中でもCAShapeLayerは使われることは多いです。
CAShapeLayerの特徴
Vector形式の図形を描画する
Vector形式の画像は解像度に依らないという特徴があり
Core Animationが自動で最適なbitmapデータを作成してくれます。
アニメーション可能なプロパティが豊富
Core Animationではデフォルトでアニメーションが定義されていたり
独自にアニメーションを定義することができるため
アプリの表現力を高めることができます。
アニメーションに関しては後ほどご紹介させていただきます。
pathに図形を設定する
pathプロパティにCGPathを設定することで図形の描画などが可能です。
CGPathは名前の通り、CoreGraphicsに含まれるクラスです。
Core GraphicはCore Animationよりも
より低レイヤーにあるフレームワークです。
古いフレームワークで
iPhoneがまだシングルコアで動いてたころからのものでもあり
メインスレッドでCPUベースで動きます。
そのため
古い端末などでは描画に時間がかかって動きがカクカクすることもあります。
これをCore AnimationでGPUで動くCAShapeLayerで使うことで
よりスムーズな描画を実現することも可能になります。
layerとして使用する
以下に例を示します。
class DemoView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .darkGray
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
// 使う必要はない!!
// override func draw(_ rect: CGRect) {
// super.draw(rect)
// }
func makeShapeLayer() {
let path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/2, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
path.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
path.close()
// pathの設定は無視される
UIColor.red.setFill()
path.fill()
UIColor.blue.setStroke()
path.stroke()
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
// 設定しないと黒
shapeLayer.fillColor = UIColor.orange.cgColor
shapeLayer.strokeColor = UIColor.brown.cgColor
shapeLayer.lineWidth = 3.0
layer.addSublayer(shapeLayer)
}
}
- CAShapeLayerオブジェクトを生成
- UIBezierPathオブジェクトを生成
- UIBezierPathで描きたい図形を設定する
- CAShapeLayerのcgPathにUIBezierPathを設定する
上記ではlayerにサブレイヤとして追加していますが、
UIViewのlayerに設定する方法も可能です。
コメントでも記載していますが
drawメソッドをoverrideする必要はありません。
またUIBezierPathで設定した色などは無視され、
CAShapeLayerで設定しないとデフォルトの黒が設定されます。
下記に描画結果を示します。
maskとして使用する
単純に図形を描画するだけではなく
マスクとしても活用できます。
マスクをするというのは
Layerで描画された図形以外の部分を透明なピクセルのブロックで覆うことで
このLayerの下のLayerもその図形内のみが表示されるようにすることです。
※ 調査した結果を記載しましたが、間違っていたらご指摘ください🙇🏻♂️
class DemoView: UIView {
var path: UIBezierPath!
override init(frame: CGRect) {
super.init(frame: frame)
// 色はこちらで
backgroundColor = .darkGray
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func makeShapeLayer() {
self.makeTriangle()
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
// 色は反映されない
shapeLayer.fillColor = UIColor.yellow.cgColor
shapeLayer.strokeColor = UIColor.brown.cgColor
shapeLayer.lineWidth = 3.0
//layer.addSublayer(shapeLayer)
// Layerの形に切り抜かれる
layer.mask = shapeLayer
}
func makeTriangle() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/2, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
path.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
path.close()
}
}
下記が表示例です。
まず図形以外の箇所は透明なブロックで覆われたため背景色が見えなくなりました。
また、shapeLayerで色を設定していますが、色は反映されません。
色を設定したい場合はbackgroundColorで設定します。
複数設定が可能
CAShapeLayerは複数追加することが可能になります。
class DemoView: UIView {
var path: UIBezierPath!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .darkGray
twoShapes()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func twoShapes() {
let width: CGFloat = self.frame.size.width/2
let height: CGFloat = self.frame.size.height/2
let path1 = UIBezierPath()
path1.move(to: CGPoint(x: width/2, y: 0.0))
path1.addLine(to: CGPoint(x: 0.0, y: height))
path1.addLine(to: CGPoint(x: width, y: height))
path1.close()
let path2 = UIBezierPath()
path2.move(to: CGPoint(x: width/2, y: height))
path2.addLine(to: CGPoint(x: 0.0, y: 0.0))
path2.addLine(to: CGPoint(x: width, y: 0.0))
path2.close()
let shapeLayer1 = CAShapeLayer()
shapeLayer1.path = path1.cgPath
shapeLayer1.fillColor = UIColor.yellow.cgColor
shapeLayer1.backgroundColor = UIColor.magenta.cgColor
let shapeLayer2 = CAShapeLayer()
shapeLayer2.path = path2.cgPath
shapeLayer2.fillColor = UIColor.green.cgColor
self.layer.addSublayer(shapeLayer1)
self.layer.addSublayer(shapeLayer2)
}
}
出力結果は下記になります。
図形が被ってしまいました。
どちらも原点からの描画を行っているからです。
描画の位置を変えて描画するということも可能ですが
より簡単な方法としてpositionプロパティを使用します。
class DemoView: UIView {
var path: UIBezierPath!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .darkGray
twoShapes()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func twoShapes() {
let width: CGFloat = self.frame.size.width/2
let height: CGFloat = self.frame.size.height/2
let path1 = UIBezierPath()
path1.move(to: CGPoint(x: width/2, y: 0.0))
path1.addLine(to: CGPoint(x: 0.0, y: height))
path1.addLine(to: CGPoint(x: width, y: height))
path1.close()
let path2 = UIBezierPath()
path2.move(to: CGPoint(x: width/2, y: height))
path2.addLine(to: CGPoint(x: 0.0, y: 0.0))
path2.addLine(to: CGPoint(x: width, y: 0.0))
path2.close()
let shapeLayer1 = CAShapeLayer()
shapeLayer1.path = path1.cgPath
shapeLayer1.fillColor = UIColor.yellow.cgColor
shapeLayer1.backgroundColor = UIColor.magenta.cgColor
let shapeLayer2 = CAShapeLayer()
shapeLayer2.path = path2.cgPath
shapeLayer2.fillColor = UIColor.green.cgColor
self.layer.addSublayer(shapeLayer1)
self.layer.addSublayer(shapeLayer2)
// 2つのpositionの調整
shapeLayer2.position = CGPoint(x: width/2, y: height)
shapeLayer1.position = CGPoint(x: width/2, y: 0.0)
}
}
ここで一つハマりポイントがあります。
このpositionで設定する値とboundsのoriginで設定する値の方向が逆になります。
どういうことかと言いますと下記の例をご覧ください。
class DemoView: UIView {
var path: UIBezierPath!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .darkGray
twoShapes()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func twoShapes() {
let width: CGFloat = self.frame.size.width/2
let height: CGFloat = self.frame.size.height/2
let path1 = UIBezierPath()
path1.move(to: CGPoint(x: width/2, y: 0.0))
path1.addLine(to: CGPoint(x: 0.0, y: height))
path1.addLine(to: CGPoint(x: width, y: height))
path1.close()
let path2 = UIBezierPath()
path2.move(to: CGPoint(x: width/2, y: height))
path2.addLine(to: CGPoint(x: 0.0, y: 0.0))
path2.addLine(to: CGPoint(x: width, y: 0.0))
path2.close()
let shapeLayer1 = CAShapeLayer()
shapeLayer1.path = path1.cgPath
shapeLayer1.fillColor = UIColor.yellow.cgColor
shapeLayer1.backgroundColor = UIColor.magenta.cgColor
let shapeLayer2 = CAShapeLayer()
shapeLayer2.path = path2.cgPath
shapeLayer2.fillColor = UIColor.green.cgColor
self.layer.addSublayer(shapeLayer1)
self.layer.addSublayer(shapeLayer2)
// 2つのpositionの調整が必要になる
shapeLayer2.position = CGPoint(x: width/2, y: height)
shapeLayer1.position = CGPoint(x: width/2, y: 0.0)
// 右下に移動する(通常とは逆になる)
shapeLayer1.bounds.origin = CGPoint(x: -20.0, y: -40.0)
}
}
見てわかるようにマイナス値を設定すると右下に移動します。
これはiOSの世界では逆の動きにあっており、結構違和感を感じるポイントではないかと思います。
※ 理由までは調べ切れませんでした。もしご存知の方いらっしゃいましたら教えてください🙇🏻♂️
もっと複雑な図形も可能
今までは三角や四角といった単純な図形を描画してきましたが、
UIBezierPathを使えばどんな形の図形でも作成することができます。
class DemoView: UIView {
var path: UIBezierPath!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .darkGray
complexShape()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func complexShape() {
path = UIBezierPath()
path.move(to: CGPoint(x: 0.0, y: 0.0))
path.addLine(to: CGPoint(x: self.frame.size.width/2 - 50.0, y: 0.0))
path.addArc(withCenter: CGPoint(x: self.frame.size.width/2 - 25.0, y: 25.0),
radius: 25.0,
startAngle: CGFloat(180.0).toRadians(),
endAngle: CGFloat(0.0).toRadians(),
clockwise: false)
path.addLine(to: CGPoint(x: self.frame.size.width/2, y: 0.0))
path.addLine(to: CGPoint(x: self.frame.size.width - 50.0, y: 0.0))
path.addCurve(to: CGPoint(x: self.frame.size.width, y: 200.0),
controlPoint1: CGPoint(x: self.frame.size.width + 100.0, y: 25.0),
controlPoint2: CGPoint(x: self.frame.size.width - 150.0, y: 50.0))
path.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
path.addCurve(to: CGPoint(x: self.frame.size.width - 100.0, y: self.frame.size.height),
controlPoint1: CGPoint(
x: self.frame.size.width/2 - 50.0,
y: self.frame.size.height - 150.0),
controlPoint2: CGPoint(
x: self.frame.size.width/2 - 100.0,
y: self.frame.size.height - 100))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
path.close()
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
self.backgroundColor = UIColor.orange
self.layer.mask = shapeLayer
}
}
UIBezierPathについては今回割愛させていただきますが、
Paint Codeというツールを活用すると
自動でpathを作成してくれるのでそれを参考に図形の作成をすることもできます。
アニメーション
冒頭でも紹介しましたが、
Core AnimationはViewをアニメーションさせるために使われます。
多くのプロパティは設定するとデフォルトのアニメーションに合わせて値が反映されます。
CAAnimationという抽象クラスのサブクラスを使用することで
独自のアニメーションを行うことが可能です。
アニメーション可能なプロパティは下記から確認できます。
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreAnimation_guide/AnimatableProperties/AnimatableProperties.html#//apple_ref/doc/uid/TP40004514-CH11-SW1
デフォルトのアニメーション
下記の例ではopacityを変更しています。
class DemoView: UIView {
var path: UIBezierPath!
var shapeLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .darkGray
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func makeShapeLayer() {
self.makeTriangle()
shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
layer.addSublayer(shapeLayer)
}
func makeTriangle() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/2, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
path.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
path.close()
}
func animateLayer() {
// 何も設定しなくてもアニメーションする
shapeLayer.opacity = 0.1
}
}
UIViewのlayerはアニメーションしない
最初の方でも記載しましたが、UIViewのlayerはアニメーションがオフになっています。
そのため値の変更がすぐに反映されます。
class DemoView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .red
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}
class MyViewController : UIViewController {
var demoView: DemoView!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
view.backgroundColor = .white
let width: CGFloat = 300
let height: CGFloat = 300
let size = view.frame.size
demoView = DemoView(frame: CGRect(
x: size.width/2 - width/2,
y: size.height/2 - height/2,
width: width, height: height))
view.addSubview(demoView)
let button = UIButton(frame: CGRect(
x: size.width/2 - 75, y: size.height - 100,
width: 150, height: 50))
button.setTitle("アニメーション", for: .normal)
button.backgroundColor = .blue
button.addTarget(self, action: #selector(animate), for: .touchUpInside)
view.addSubview(button)
}
@objc func animate() {
// layerではアニメーションしない
demoView.layer.opacity = 0.1
}
}
CABasicAnimation
名前にあるように基本的なアニメーションを実行することができます。
class DemoView: UIView {
var path: UIBezierPath!
var shapeLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func makeShapeLayer() {
self.makeTriangle()
shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.lineWidth = 3.0
layer.mask = shapeLayer
}
func makeTriangle() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/2, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
path.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
path.close()
}
func animateLayer() {
// keyPath!!
let animation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
animation.fromValue = 1
animation.toValue = 0.1
animation.duration = 3
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
// アニメーション終了後の状態を維持する
animation.isRemovedOnCompletion = false
animation.fillMode = .forwards
shapeLayer.add(animation, forKey: nil)
}
}
fromValueとtoValueでアニメーションの開始と終了の値を設定し、durationでアニメーションの時間を決めます。
CALayerのadd(_:forKey:)に追加されることでアニメーションを開始します。
isRemovedOnCompletionをfalseに、かつfillModeをforwardsに指定すると、
アニメーションの状態を終了時に留まらせることができます。
だたし、この場合モデルレイヤ自体の値は更新されないので、
値の更新を目的とした場合には注意が必要です。
class DemoView: UIView {
var path: UIBezierPath!
var shapeLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func makeShapeLayer() {
self.makeTriangle()
shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.lineWidth = 3.0
layer.mask = shapeLayer
}
func makeTriangle() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/2, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
path.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
path.close()
}
func animateLayer() {
let animation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
animation.fromValue = 1
animation.toValue = 0.1
animation.duration = 3
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
// アニメーション終了後の状態を維持する
animation.isRemovedOnCompletion = false
animation.fillMode = .forwards
animation.delegate = self
shapeLayer.add(animation, forKey: nil)
}
}
extension DemoView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
// 1.0が出力される
print(shapeLayer.opacity)
}
}
アニメーション後の状態を保持し、かつモデルレイヤの値も更新したい場合は
プロパティの値を変更します。
class DemoView: UIView {
var path: UIBezierPath!
var shapeLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func makeShapeLayer() {
self.makeTriangle()
shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.lineWidth = 3.0
layer.mask = shapeLayer
}
func makeTriangle() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/2, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
path.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
path.close()
}
func animateLayer() {
let animation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
animation.fromValue = 1
animation.toValue = 0.1
animation.duration = 3
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
// アニメーション終了後の値を維持する
// ↓↓↓↓↓↓↓↓↓
shapeLayer.opacity = 0.1
// animation.isRemovedOnCompletion = false
// animation.fillMode = .forwards
animation.delegate = self
shapeLayer.add(animation, forKey: nil)
}
}
extension DemoView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
// 0.1が出力される
print(shapeLayer.opacity)
}
}
また
Core Animationとは直接関係ありませんが、
keyPathがパワーアップしたことで文字列で指定していたところを
コンパイル時にチェックが走る形で記述ができるようになりました。
let animation = CABasicAnimation(keyPath: "opacity")
↓
let animation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
先に出してしまいましたが
CAAnimationはdelegateという
CAAnimationDelegateプロトコルのプロパティを持っており
これに適合することでアニメーションの開始と終わりに処理を追加することができます。
fromValue, toValue, byValueの値の設定による違い
fromValue, toValue, byValueの設定の仕方で
フレームワークのアニメーションの仕方が変わります。
設定したdurationの間に与えられた値の変化をどう実現するかです。
下記のようなバリエーションがあります。
ここはあまり飲み込めませんでした。もしご存知の方いらっしゃいましたら教えていただけると嬉しいです🙇🏻♂️
前提として全てOptionalですが、最大2つの値を指定する必要があります。
- fromValueとtoValueが設定されている場合はこの間で計算される
- fromValueとbyValueが設定されている場合はfromValueとfromValue + byValueの間で計算される
- toValueとbyValueが設定されている場合はtoValue - byValueとtoValueとの間で計算される
- fromValueのみ設定されている場合はfromValueと現在表示されている値の間で計算される
- toValueのみ設定されている場合はプレゼンテーションレイヤーの現在値とtoValueとの間で計算される
- byValueのみ設定されている場合はプレゼンテーションレイヤーの現在値とその値 + byValueとの間で計算される
- 全てがnilの場合は前回アニメーション時のプレゼンテーションレイヤーの値と現在のプレゼンテーションレイヤーの値との間で計算される
CAKeyframeAnimation
CABasicAnimationよりもより細かい設定ができます。
CGPathを設定するで図形を描くような動きのアニメーションなどが実現できます。
class DemoView: UIView {
var path: UIBezierPath!
var shapeLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func makeShapeLayer() {
self.makeTriangle()
shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.lineWidth = 3.0
layer.mask = shapeLayer
}
func makeTriangle() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/10, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height / 10))
path.addLine(to: CGPoint(x: self.frame.size.width / 10, y: self.frame.size.height / 10))
path.close()
}
func animateLayer() {
let keyAnimation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.position))
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addCurve(to: CGPoint(x: 74, y: 200),
control1: CGPoint(x: 120, y: 200),
control2: CGPoint(x: 120, y: 74))
path.addCurve(to: CGPoint(x: 120, y: 200),
control1: CGPoint(x: 366, y: 200),
control2: CGPoint(x: 366, y: 74))
keyAnimation.path = path
keyAnimation.duration = 3
shapeLayer.add(keyAnimation, forKey: nil)
}
}
calculationModeやrotationModeを設定すると色々動きが変わりますので
ご興味ございましたらぜひ試してみてください!
CAAnimationGroup
複数のアニメーションをまとめて同じ設定を反映させることができます。
また
デリゲートメソッドを実装し、delegateプロパティを設定することで
グループ内のアニメーションが全て完了したあとの処理を
追加することもできます。
※ グループ内の個々のアニメーションのdelegateプロパティに値を設定してもデリゲートメソッドは呼ばれません。
class DemoView: UIView {
var path: UIBezierPath!
var shapeLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
// 色はこっちで設定する
backgroundColor = .black
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func makeShapeLayer() {
self.makeTriangle()
shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.lineWidth = 3.0
layer.mask = shapeLayer
}
func makeTriangle() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/10, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height / 10))
path.addLine(to: CGPoint(x: self.frame.size.width / 10, y: self.frame.size.height / 10))
path.close()
}
func animateLayer() {
let fadeOut = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
fadeOut.fromValue = 1
fadeOut.toValue = 0
fadeOut.duration = 5
let expandScale = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
expandScale.valueFunction = CAValueFunction(name: .scale)
expandScale.fromValue = [1, 1, 1]
expandScale.toValue = [3, 3, 3]
let fadeAndScale = CAAnimationGroup()
fadeAndScale.animations = [fadeOut, expandScale]
fadeAndScale.duration = 1
fadeAndScale.delegate = self
shapeLayer.add(fadeAndScale, forKey: nil)
}
}
extension DemoView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
let transition = CABasicAnimation(keyPath: #keyPath(CALayer.position))
transition.fromValue = shapeLayer.position
transition.toValue = CGPoint(x: shapeLayer.position.x + 100.0, y: shapeLayer.position.y + 100)
transition.duration = 2
shapeLayer.add(transition, forKey: nil)
}
}
注意点として
個々のアニメーションにdurationを設定した場合は
そちらも適応されるため
意図して設定していない際は思わぬ動きをしてしまうかもしれません。
class DemoView: UIView {
...
func animateLayer() {
...
expandScale.duration = 1
fadeAndScale.duration = 3
...
}
}
CATransaction
UINavigationControllerの遷移時のアニメーションの変更などをすることができます。
https://developer.apple.com/documentation/quartzcore/catransition
class ViewController : UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
view.backgroundColor = .red
let size = view.frame.size
let button = UIButton(frame: CGRect(
x: size.width/2 - 75, y: size.height - 100,
width: 150, height: 50))
button.setTitle("遷移", for: .normal)
button.setTitleColor(.black, for: .normal)
button.backgroundColor = .yellow
button.addTarget(self, action: #selector(animate), for: .touchUpInside)
view.addSubview(button)
}
@objc func animate() {
let vc2 = ViewController2()
let transition = CATransition()
transition.duration = 0.6
transition.timingFunction = CAMediaTimingFunction(name: .easeIn)
transition.type = .fade
transition.subtype = .fromTop
self.navigationController?.view.layer.add(transition, forKey: nil)
self.navigationController?.pushViewController(vc2, animated: false)
}
}
class ViewController2 : UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
view.backgroundColor = .blue
}
}
let vc = ViewController()
let nc = UINavigationController(rootViewController: vc)
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = nc
UINavigationControllerのViewのlayerプロパティにアニメーションを追加します。
注意点としてpushViewControllerのanimatedをtrueにすると
デフォルトの左から右への動作も動いてしまいます。
CATransaction
名前間違えやすいですが
上が「Transition(トランジション)」で
こちらは「Transaction(トランザクション)」です。
CAAnimationGroupと同様にアニメーションをまとめることができますが、
多数のレイヤー階層に設定されたアニメーションを一つのバッチ更新として処理してくれます。
そもそもCore Animationはレイヤーが修正された場合
明示的にトランザクションが作成されていない際は自動でトランザクションを作成し
次のrunloopのイテレーションの中で実行されるようになります。
CAAnimationGroupと同様に
同じ設定をトランザクション内のアニメーションに適用することができ、
setCompletionBlock(_:)でアニメーション完了後の処理を追加することができます。
さらにsetDisableActions(_:)をtrueに設定することで
暗黙のトランザクションを無効化することもできます。
class DemoView: UIView {
var path: UIBezierPath!
var shapeLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func makeShapeLayer() {
self.makeTriangle()
shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.fillColor = UIColor.yellow.cgColor
shapeLayer.strokeColor = UIColor.brown.cgColor
shapeLayer.lineWidth = 3.0
layer.mask = shapeLayer
}
func makeTriangle() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/2, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
path.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
path.close()
}
func animateLayer() {
CATransaction.begin()
// 終わったあとの処理を定義できる
CATransaction.setCompletionBlock { [weak self] in
self?.shapeLayer.fillColor = UIColor.blue.cgColor
self?.shapeLayer.lineWidth = 3.0
self?.shapeLayer.opacity = 1
}
CATransaction.setAnimationDuration(2)
CATransaction.setDisableActions(true)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeIn))
shapeLayer.lineWidth = 50
shapeLayer.opacity = 0.1
CATransaction.commit()
}
}
さらにトランザクションを入れ子にすることもでき
特定のアニメーションだけ別の設定を適用することもできます。
class DemoView: UIView {
var path: UIBezierPath!
var shapeLayer: CAShapeLayer!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
makeShapeLayer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func makeShapeLayer() {
self.makeTriangle()
shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.fillColor = UIColor.yellow.cgColor
shapeLayer.strokeColor = UIColor.brown.cgColor
shapeLayer.lineWidth = 3.0
layer.mask = shapeLayer
}
func makeTriangle() {
path = UIBezierPath()
path.move(to: CGPoint(x: self.frame.width/2, y: 0.0))
path.addLine(to: CGPoint(x: 0.0, y: self.frame.size.height))
path.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height))
path.close()
}
func animateLayer() {
CATransaction.begin()
// 終わったあとの処理を定義できる
CATransaction.setCompletionBlock { [weak self] in
self?.shapeLayer.fillColor = UIColor.blue.cgColor
self?.shapeLayer.lineWidth = 3.0
self?.shapeLayer.opacity = 1
}
CATransaction.setAnimationDuration(4)
CATransaction.setDisableActions(true)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeIn))
shapeLayer.lineWidth = 50
// Transactionの中のTransactionで設定を変えることもできる
CATransaction.begin()
CATransaction.setAnimationDuration(2)
shapeLayer.opacity = 0.1
CATransaction.commit()
CATransaction.commit()
}
}
サブクラス
他にもフレームワークで用意されている様々なサブクラスがあります。
この場では割愛させていただきますが
下記のオープンソースでそれぞれ紹介されており
どの値を設定するとどうアニメーションが変わるかなどが見てわかるようにできていますので
ご興味がございましたらぜひ見てみてください
余興
今回の内容は2018/12/21に開催されましたROPPONGI.swiftでもさせて頂いたのですが
テーマが「望年会」ということもあり
最後にCore Animationを使ってこんなものを作ってみました。
まとめ
Core Animationについて調べてみた結果
機能が非常に豊富でアプリの表現力を高めることができるフレームワークであることがわかりました。
ただし、基本的にUIViewでできることはUIViewでと考えております。
UIViewの方が簡単に記述ができますし、コードも複雑になりません。
それでも必要になってくる場面は出てくると思いますし
そういういざという時のためにも知っておいても損はないのかなと思っています。
今回の内容が少しでもお役に立てましたら幸いです。
ご指摘などございましたらぜひ教えてください