15
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ZOZOテクノロジーズ #4Advent Calendar 2019

Day 20

アニメーションを止めるな!

Last updated at Posted at 2019-12-19

はじめに

この記事は、ZOZOテクノロジーズ #4AdventCalendar2019の記事です。
昨日は@Kyou13さんの「開発合宿でVue.js+Firebase+Onsen UIで食事の栄養管理アプリを作った話」でした。

さて、iOSでは、アニメーション中に画面を移動するとアニメーションが停止してしまうのは既知の事実かと思います。

基本的には ViewController のライフサイクルに合わせて調整してあげれば良いですが、コンポーネント化されたカスタムViewに関しては、コンポーネント側で管理されている方が利用しやすいケースの方が多いと思います。

この記事では、コンポーネント化されたカスタムView側でアニメーションを管理するTipsを紹介します。

アニメーションが止まるタイミング

まず、アニメーションが止まるタイミングですが、次の2つが考えられます。

  • アプリ内で画面を移動する
  • アプリをバックグラウンドに移動させる

では、下記のカスタムViewを例に、それぞれの対応方法を見ていきます。

動作環境
  • Xcode 10.3
  • Swift 5
  • iOS 12.4
import UIKit

class IndicatorView: UIView {
    private let strokeLayer = CAShapeLayer()
    private var isAnimating = false

    override init(frame: CGRect) {
        super.init(frame: frame)
        initialize()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        initialize()
    }

    override func draw(_ rect: CGRect) {
        let lineWidth: CGFloat = 2.0
        let radius = (rect.width - lineWidth) / 2
        let startAngle = CGFloat.pi * (3 / 2)
        let endAngle = startAngle + CGFloat.pi * 2.0
        let path = UIBezierPath(
            arcCenter: CGPoint(x: rect.width / 2.0, y: rect.height / 2.0),
            radius: radius,
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: true
        )
        strokeLayer.frame = rect
        strokeLayer.path = path.cgPath
        strokeLayer.lineWidth = lineWidth
    }

    private func initialize() {
        strokeLayer.fillColor = UIColor.clear.cgColor
        strokeLayer.strokeColor = UIColor.blue.cgColor
        strokeLayer.strokeEnd = 0.8
        layer.insertSublayer(strokeLayer, at: 0)
    }

    private func addAnimation() {
        let animation = CABasicAnimation(keyPath: "transform.rotation")
        animation.fromValue = 0.0
        animation.toValue = 2.0 * Double.pi
        animation.duration = 1.0
        animation.repeatCount = .infinity
        animation.timingFunction = CAMediaTimingFunction(name: .linear)

        strokeLayer.add(animation, forKey: "rotate")
    }

    func startAnimating() {
        guard !isAnimating else { return }
        isAnimating = true
        addAnimation()
    }

    func stopAnimating() {
        guard isAnimating else { return }
        isAnimating = false
        strokeLayer.removeAllAnimations()
    }
}

アプリ内で画面を移動した場合

ナビゲーションのプッシュやタブ移動などがこちらの対象になります。

Before

video1.gif

画面に戻ってくるとアニメーションが止まっていることがわかります。

対応方法

UIView の、 didMoveToWindow() メソッドを利用すると、 window が変わったタイミングでイベントを取得することができます。
画面に表示されるタイミングでは、 window プロパティに値が入っているので、そのタイミングでアニメーションを追加します。

    override func didMoveToWindow() {
        super.didMoveToWindow()

        if let _ = window, isAnimating {
            addAnimation()
        }
    }

After

video2.gif

アプリをバックグラウンドに移動させた場合

Before

video3.gif

バックグラウンドに移動後、アプリに戻ってくるとアニメーションが止まっています。

対応方法

NotificationCenter を利用するとアプリがフォアグラウンドなったタイミングでイベントを取得できるので、そのタイミングでアニメーションを追加してあげます。

    private func initialize() {
        strokeLayer.fillColor = UIColor.clear.cgColor
        strokeLayer.strokeColor = UIColor.blue.cgColor
        strokeLayer.strokeEnd = 0.8
        layer.insertSublayer(strokeLayer, at: 0)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleEnterForeground),
            name: UIApplication.willEnterForegroundNotification,
            object: nil
        )
    }

    @objc func handleEnterForeground(_: Notification) {
        if let _ = window, isAnimating {
            addAnimation()
        }
    }

After

video4.gif

これでアプリを利用している間はアニメーションを止めることなく、動かし続けることができます。

最終的なコードです。

class IndicatorView: UIView {
    private let strokeLayer = CAShapeLayer()
    private var isAnimating = false

    override init(frame: CGRect) {
        super.init(frame: frame)
        initialize()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        initialize()
    }

    override func draw(_ rect: CGRect) {
        let lineWidth: CGFloat = 2.0
        let radius = (rect.width - lineWidth) / 2
        let startAngle = CGFloat.pi * (3 / 2)
        let endAngle = startAngle + CGFloat.pi * 2.0
        let path = UIBezierPath(
            arcCenter: CGPoint(x: rect.width / 2.0, y: rect.height / 2.0),
            radius: radius,
            startAngle: startAngle,
            endAngle: endAngle,
            clockwise: true
        )
        strokeLayer.frame = rect
        strokeLayer.path = path.cgPath
        strokeLayer.lineWidth = lineWidth
    }

    override func didMoveToWindow() {
        super.didMoveToWindow()

        if let _ = window, isAnimating {
            addAnimation()
        }
    }

    private func initialize() {
        strokeLayer.fillColor = UIColor.clear.cgColor
        strokeLayer.strokeColor = UIColor.blue.cgColor
        strokeLayer.strokeEnd = 0.8
        layer.insertSublayer(strokeLayer, at: 0)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleEnterForeground),
            name: UIApplication.willEnterForegroundNotification,
            object: nil
        )
    }

    @objc func handleEnterForeground(_: Notification) {
        if let _ = window, isAnimating {
            addAnimation()
        }
    }

    private func addAnimation() {
        let animation = CABasicAnimation(keyPath: "transform.rotation")
        animation.fromValue = 0.0
        animation.toValue = 2.0 * Double.pi
        animation.duration = 1.0
        animation.repeatCount = .infinity
        animation.timingFunction = CAMediaTimingFunction(name: .linear)

        strokeLayer.add(animation, forKey: "rotate")
    }

    func startAnimating() {
        guard !isAnimating else { return }
        isAnimating = true
        addAnimation()
    }

    func stopAnimating() {
        guard isAnimating else { return }
        isAnimating = false
        strokeLayer.removeAllAnimations()
    }
}

さいごに

「動作はつづける、アニメーションは止めない!」

明日は@iiizukkeyiii さんです。

お楽しみに!

15
7
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
15
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?