CIFilterで画面遷移アニメーションを実装【Swift, iOS, CIFilter, 画面遷移】

# ■ 概要
本記事では「CIFilter」を使った画面遷移のアニメーション実装の紹介をします。

動機としてはCIFilterの種類を調べる用事があり[リファレンス](https://developer.apple.com/library/archive/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html)を参照していたときに、[遷移カテゴリ](https://developer.apple.com/library/archive/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html#//apple_ref/doc/uid/TP30000136-SW239)のFilterを利用して画面遷移アニメーションを実装できないかと思ったのがきっかけです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730387/2d7d59dc-7442-4119-88df-1d94383c4d6f.png" width="600">

ただそれらのFilterは基本的に

・`inputImage`（遷移前の画像）
・`inputTargetImage`（遷移後の画像）

の2つの画像をセットし、`inputTime`（アニメーションの時間）を指定することでエフェクトが適用された画像がペラ1で返されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730387/fdc6f822-9119-4ca1-bea5-f95efac20e72.png" width="200">

つまり指定した秒数に応じて↑のような画像だけが取得される形になるため、ViewController間の遷移で利用するにはひと工夫必要そうな感じです。

そのため1つのアプローチとして、下記のように実装してみました。

# ■ 環境
開発動作環境：MacBook Pro 16インチ
OSバージョン：macOS Sequoia バージョン15.0.1

Xcodeバージョン：16.2

実機動作環境：iPhone 16 Pro
iOSバージョン：18.3.1

# ■ 実装のアウトプット
兎に角まずは実装のアウトプットから。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730387/b4aea785-6038-46b6-9e99-c721b79e9512.gif" width="200">

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730387/704b3010-ac84-42ca-a415-38b7d4a502ae.gif" width="200">

「Screen 1」と「Screen 2」は異なるViewControllerになっており、画面遷移のアニメーションとしてCIFilterを利用している実装になっています。

<font color="gray">(表示させているGIFでは若干アニメーションがもっさりしていますが、実機では問題なく動作しています。)</font>

またサンプルとして2パターン載せてみました！
<font color="gray">(↓で記載するソースコードの、Filterの名前を変更すれば動作するようになっています)</font>

# ■ 実装の解説

順を追って実装のポイントを解説していきます。

## 1. 実装のソースコード

```Swift:TransitionView.swift
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に↓と同じ実装を組み込みます。


```Swift:FirstViewController.swift
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を生成

```Swift
transitionView = TransitionView(frame: view.bounds,
                                baseColor: FirstViewController.baseColor,
                                delegate: self)
view.addSubview(transitionView)
```

ViewControllerの一番手前のレイヤーに配置し、画面全体を覆うようにします。

### ②「 inputImage 」と「 inputTargetImage 」の生成

```Swift
inputImage = UIColor.clear.uiImage(size: bounds.size) ?? UIImage()
targetImage = baseColor.uiImage(size: bounds.size) ?? UIImage()
```

・`inputImage`（遷移前の画像）には透過画像を設定
・`inputTargetImage`（遷移後の画像）にはViewControllerのbackgroundColorと同じカラーの画像を設定


この2つの画像をアニメーションさせて、その出力結果の画像を一番手前のレイヤーに配置されたViewにセットすることで画面遷移のアニメーションを実現します。

### ③ CADisplayLinkの生成

```Swift
displayLink = CADisplayLink(target: self, selector: #selector(updateAnimation))
displayLink?.add(to: .main, forMode: .common)
```

画面のリフレッシュレートに応じてフレームごとにコールバックメソッド（updateAnimationメソッド）を呼び出すことで、CIFilterによる遷移アニメーションを更新します。

### ④ アニメーションの種類に応じて処理を分岐

```Swift
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に画面遷移のアニメーションとして使いたいものがあれば、少しでも実装の参考になれば嬉しいです。

最後まで読んでいただきありがとうございました。