5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-03-28

■ 概要

本記事では「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. 実装のソースコード

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に↓と同じ実装を組み込みます。

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を生成

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

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

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?