■ 概要
本記事では「CIFilter」を使った画面遷移のアニメーション実装の紹介をします。
動機としてはCIFilterの種類を調べる用事がありリファレンスを参照していたときに、遷移カテゴリのFilterを利用して画面遷移アニメーションを実装できないかと思ったのがきっかけです。

ただそれらのFilterは基本的に
・inputImage
(遷移前の画像)
・inputTargetImage
(遷移後の画像)
の2つの画像をセットし、inputTime
(アニメーションの時間)を指定することでエフェクトが適用された画像がペラ1で返されます。

つまり指定した秒数に応じて↑のような画像だけが取得される形になるため、ViewController間の遷移で利用するにはひと工夫必要そうな感じです。
そのため1つのアプローチとして、下記のように実装してみました。
■ 環境
開発動作環境:MacBook Pro 16インチ
OSバージョン:macOS Sequoia バージョン15.0.1
Xcodeバージョン:16.2
実機動作環境:iPhone 16 Pro
iOSバージョン:18.3.1
■ 実装のアウトプット
兎に角まずは実装のアウトプットから。


「Screen 1」と「Screen 2」は異なるViewControllerになっており、画面遷移のアニメーションとしてCIFilterを利用している実装になっています。
(表示させているGIFでは若干アニメーションがもっさりしていますが、実機では問題なく動作しています。)
またサンプルとして2パターン載せてみました!
(↓で記載するソースコードの、Filterの名前を変更すれば動作するようになっています)
■ 実装の解説
順を追って実装のポイントを解説していきます。
1. 実装のソースコード
import UIKit
class TransitionView: UIView {
protocol AnimationFinishedDelegate : AnyObject {
func animationFinished(animationType: AnimationType)
}
enum AnimationType {
case In
case Out
}
private static let filter: CIFilter? = CIFilter(name: "CIModTransition") // or "CIFlashTransition"
private weak var delegate: AnimationFinishedDelegate? = nil
private var baseColor: UIColor = UIColor.white
private var inputImage: UIImage = UIImage()
private var targetImage: UIImage = UIImage()
private var imageView: UIImageView = UIImageView()
private var duration: TimeInterval = 1.0
private var currentAnimationType: AnimationType = .Out
private var startTime: CFTimeInterval = 0.0
private var displayLink: CADisplayLink?
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
displayLink?.invalidate()
displayLink = nil
}
init(frame: CGRect, baseColor: UIColor, delegate: AnimationFinishedDelegate?) {
super.init(frame: frame)
isUserInteractionEnabled = false
self.delegate = delegate
self.baseColor = baseColor
// ②「 inputImage 」と「 inputTargetImage 」の生成
// ・入力画像(遷移開始時の画像)として透過画像を生成する。
// ・ターゲット画像(遷移終了時の画像)として指定カラーの画像を生成する。
inputImage = UIColor.clear.uiImage(size: frame.size) ?? UIImage()
targetImage = baseColor.uiImage(size: frame.size) ?? UIImage()
imageView = UIImageView(frame: frame)
imageView.image = targetImage
imageView.contentMode = .scaleAspectFit
imageView.isUserInteractionEnabled = false
addSubview(imageView)
}
public func startAnimation(duration: TimeInterval, animationType: AnimationType) {
// CADisplayLinkがnilではない、つまりアニメーション中の場合は新しくアニメーションをスタートさせない。
if displayLink != nil { return }
self.duration = duration
self.currentAnimationType = animationType
startTime = CACurrentMediaTime()
// ③ CADisplayLinkの生成
// 画面のリフレッシュレートに応じてフレームごとにコールバックメソッド(updateAnimationメソッド)を呼び出すことで
// リフレッシュレートに同期したアニメーションを実現させる。
displayLink = CADisplayLink(target: self, selector: #selector(updateAnimation))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func updateAnimation() {
var normalizedTime: TimeInterval = 0.0
// ④ アニメーションの種類に応じて処理を分岐
// アニメーションの順再生、逆再生を制御することで画面遷移のアニメーションを表現する
switch currentAnimationType {
case .In:
let elapsedTime = CACurrentMediaTime() - startTime
normalizedTime = CGFloat(elapsedTime / duration)
if normalizedTime > 1.0 {
normalizedTime = 1.0
displayLink?.invalidate()
displayLink = nil
animationFinished(animationType: self.currentAnimationType)
}
case .Out:
let elapsedTime = CACurrentMediaTime() - startTime
normalizedTime = 1.0 - CGFloat(elapsedTime / duration)
if normalizedTime < 0.0 {
normalizedTime = 0.0
displayLink?.invalidate()
displayLink = nil
animationFinished(animationType: self.currentAnimationType)
}
}
imageView.image = filteredImage(time: normalizedTime)?.uiImage()
}
private func filteredImage(time: TimeInterval) -> CIImage? {
guard let filter = TransitionView.filter else {
return nil
}
filter.setValue(inputImage.ciImage(), forKey: kCIInputImageKey)
filter.setValue(targetImage.ciImage(), forKey: kCIInputTargetImageKey)
filter.setValue(time, forKey: kCIInputTimeKey)
filter.setValue(CIVector(x: bounds.size.width * 1.5, y: bounds.size.height * 1.5), forKey: kCIInputCenterKey)
return filter.outputImage
}
private func animationFinished(animationType: AnimationType) {
delegate?.animationFinished(animationType: animationType)
}
}
extension UIImage {
func ciImage() -> CIImage? {
guard let cgImage = self.cgImage else {
return nil
}
return CIImage(cgImage: cgImage)
}
}
extension CIImage {
func uiImage() -> UIImage? {
let context = CIContext()
guard let cgImage = context.createCGImage(self, from: self.extent) else {
return nil
}
return UIImage(cgImage: cgImage)
}
}
extension UIColor {
func uiImage(size: CGSize = CGSize(width: 1, height: 1)) -> UIImage? {
let rect = CGRect(origin: .zero, size: size)
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
self.setFill()
UIRectFill(rect)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image
}
}
そして、各ViewControllerに↓と同じ実装を組み込みます。
class FirstViewController: UIViewController, TransitionView.AnimationFinishedDelegate {
private static let baseColor = UIColor(white: 0.2, alpha: 1.0)
private var transitionView: TransitionView!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = FirstViewController.baseColor
// ------
// 各UIパーツの配置
// ------
// ① ViewControllerの画面と同じサイズのViewを生成
transitionView = TransitionView(frame: view.bounds,
baseColor: FirstViewController.baseColor,
delegate: self)
view.addSubview(transitionView)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// viewDidAppear()でアニメーションを逆再生でスタート
transitionView.startAnimation(duration: 1.0, animationType: TransitionView.AnimationType.Out)
}
@objc func buttonPressed(_ shootButton: UIButton) {
// ボタン押下など画面遷移のタイミングでアニメーションを順再生でスタート
transitionView.startAnimation(duration: 1.0, animationType: TransitionView.AnimationType.In)
}
// アニメーション完了時に通知される
func animationFinished(animationType: TransitionView.AnimationType) {
if animationType == TransitionView.AnimationType.In {
let vc = SecondViewController(nibName: nil, bundle: nil)
navigationController?.pushViewController(vc, animated: false)
}
}
}
2. 実装のアプローチ
① ViewControllerの画面と同じサイズのViewを生成
transitionView = TransitionView(frame: view.bounds,
baseColor: FirstViewController.baseColor,
delegate: self)
view.addSubview(transitionView)
ViewControllerの一番手前のレイヤーに配置し、画面全体を覆うようにします。
②「 inputImage 」と「 inputTargetImage 」の生成
inputImage = UIColor.clear.uiImage(size: bounds.size) ?? UIImage()
targetImage = baseColor.uiImage(size: bounds.size) ?? UIImage()
・inputImage
(遷移前の画像)には透過画像を設定
・inputTargetImage
(遷移後の画像)にはViewControllerのbackgroundColorと同じカラーの画像を設定
この2つの画像をアニメーションさせて、その出力結果の画像を一番手前のレイヤーに配置されたViewにセットすることで画面遷移のアニメーションを実現します。
③ CADisplayLinkの生成
displayLink = CADisplayLink(target: self, selector: #selector(updateAnimation))
displayLink?.add(to: .main, forMode: .common)
画面のリフレッシュレートに応じてフレームごとにコールバックメソッド(updateAnimationメソッド)を呼び出すことで、CIFilterによる遷移アニメーションを更新します。
④ アニメーションの種類に応じて処理を分岐
switch currentAnimationType {
case .In:
let elapsedTime = CACurrentMediaTime() - startTime
normalizedTime = CGFloat(elapsedTime / duration)
if normalizedTime > 1.0 {
normalizedTime = 1.0
displayLink?.invalidate()
displayLink = nil
animationFinished(animationType: self.currentAnimationType)
}
case .Out:
let elapsedTime = CACurrentMediaTime() - startTime
normalizedTime = 1.0 - CGFloat(elapsedTime / duration)
if normalizedTime < 0.0 {
normalizedTime = 0.0
displayLink?.invalidate()
displayLink = nil
animationFinished(animationType: self.currentAnimationType)
}
}
}
imageView.image = filteredImage(time: normalizedTime)?.uiImage()
アニメーションの順再生、逆再生を制御することで画面遷移のアニメーションを表現しています。
■ 最後に (感想)
正直結構めんどくさかったです。(元々こういう使い方で想定されていないものを無理矢理実装した感じですね)
そしてもっと良いやり方があるような気がしてならないです。
もしCIFliterに画面遷移のアニメーションとして使いたいものがあれば、少しでも実装の参考になれば嬉しいです。
最後まで読んでいただきありがとうございました。