0

Current Code

Currently I have the following code for AnimationView.swift

import Foundation
import UIKit

class AnimationView: UIView {

    private let backgroundView = UIView()
    private let foregroundView = UIView()

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

And also I have the following code for ViewController.swift

import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    override func viewDidAppear(_ animated: Bool) {
        let x = AnimationView(frame: .zero)
...

Problem

The block animation code has issues related to the view controller's life cycle and whether or not it was added to the view hierarchy before calling UIView.animate(...). Ideally, I want the animation to just run continuously on a loop without any interruptions or concerns about these factors. Anyone have any suggestions?

UIView.animate(withDuration: 2.0, delay: 0, options: [.autoreverse, .repeat], animations: {
    self.foregroundView.transform = CGAffineTransform(scaleX: 0.3, y: 0.3)
}, completion: nil)

Screenshots

enter image description here

AlanSTACK
  • 5,525
  • 3
  • 40
  • 99
  • So, viewDidAppear is called every time....well, your view appears. So, each time you reopen your application to that screen it creates another AnimationView and adds it to the hierarchy. – ChrisTech44 Apr 12 '23 at 02:03
  • Run your code in the simulator. Open the app and home back (do this a couple times). While keeping the simulator running, go to Xcode, go to the debug session (spray can with an x), on the left hand side there is a panel with your app name and to the right a circle with a couple of lines. Click this circle and go to view UI hierarchy. This will show you how many views have been added as well as their properties. – ChrisTech44 Apr 12 '23 at 02:06
  • You could also use the viewDidLoad method to create the view and use viewWillAppear/Disappear methods to remove the animation and restart it. – ChrisTech44 Apr 12 '23 at 02:12
  • @ChrisTech44 Your last suggestion would require a `CustomView: UIView` to be aware of the view controller's lifecycle. Is it possible to create a custom view that is completely disjoint from the VC that just "works"? – AlanSTACK Apr 12 '23 at 02:42
  • @ChrisTech44 I do not believe `viewDidAppear` is called by sending the app to be background and then returning the app to the foreground. Of course if the app is killed and restarted then `viewDidAppear` will be called but then it's a whole new instance of the app and view controller. – HangarRash Apr 12 '23 at 04:06
  • @AlanSTACK - do you need the animation to ***resume*** from its current state? Or, would re-starting the animation be ok? – DonMag Apr 12 '23 at 12:55
  • @DonMag Restarting will be fine. No need for exact state continuity. As long as the animation is running. – AlanSTACK Apr 13 '23 at 02:40

1 Answers1

1

This question could be closed as a duplicate, but because there are a couple things that are different and the solution may not completely obvious I'm posting this answer...

For reference, this would be the duplicate post: https://stackoverflow.com/a/50074722/6257435

and the related code can be found here: https://gist.github.com/ArtFeel/ad4b108f026e53723c7457031e291bc8

However, links can go away, so I'll include all the code below.

First, as mentioned in the comments, you are adding a new instance of your animation view every time viewDidAppear() is called. So, let's move the view creation to viewDidLoad():

class PersistentAnimationViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // do this here in viewDidLoad
        //  NOT in viewDidAppear!
        let x = AnimationView(frame: .zero)
        x.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(x)
        
        x.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor).isActive = true
        x.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        x.widthAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.widthAnchor).isActive = true
        x.heightAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.heightAnchor).isActive = true
    }
    
}

Next, it's generally a bad idea to start animations (or other activity) in a class init, so we'll put the animation code in didMoveToSuperview() instead.

Also, you were executing foregroundView.layer.cornerRadius = self.frame.width / 2 during init, when the view may or may not have a frame. That should go in layoutSubviews().

We can get better control of animations by using CABasicAnimation instead of UIView.animate(...), so we'll make that change as well.

Finally, we'll use the referenced extensions and custom layer subclass to enable the animation to pause/resume when the app goes into / comes back from the background.

So, your AnimationView class (modified):

class AnimationView: UIView {
    
    private let backgroundView = UIView()
    private let foregroundView = UIView()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }

    private func commonInit() {
        // setting up the views
        backgroundView.backgroundColor = .green
        foregroundView.backgroundColor = .orange
        
        // if you want rounded corners (or a "circle" view)
        //  put this in layoutSubviews()
        //foregroundView.layer.cornerRadius = self.frame.width / 2
        
        // adding the views
        addSubview(backgroundView)
        addSubview(foregroundView)
        
        // activating the views
        backgroundView.translatesAutoresizingMaskIntoConstraints = false
        foregroundView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            backgroundView.topAnchor.constraint(equalTo: self.topAnchor),
            backgroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
            backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
            backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
            
            foregroundView.topAnchor.constraint(equalTo: self.topAnchor),
            foregroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
            foregroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
            foregroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
        ])
        
        // make the layer animation
        //  pause when app goes to background
        //  resume when app comes to foreground
        // see below
        self.foregroundView.layer.makeAnimationsPersistent()
    }
    
    // don't start the animation during init
    //  this is a reasonable place
    override func didMoveToSuperview() {
        super.didMoveToSuperview()

        let animation = CABasicAnimation(keyPath:"transform.scale.xy")
        animation.beginTime = CACurrentMediaTime()
        animation.fromValue = 1.0
        animation.toValue = 0.3
        // set desired timing, or leave it at the default
        //animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
        animation.duration = 2.0
        animation.repeatCount = .infinity
        animation.autoreverses = true
        self.foregroundView.layer.add(animation, forKey: "scale")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // here in layoutSubviews we have a valid frame/bounds
        //  so do anything like rounding corners, etc
        foregroundView.layer.cornerRadius = bounds.width / 2
    }
    
}

and the required extension / layer subclass code:

// from: https://stackoverflow.com/a/50074722/6257435
// extensions and LayerPersistentHelper class found here:
//  https://gist.github.com/ArtFeel/ad4b108f026e53723c7457031e291bc8
public extension CALayer {
    var isAnimationsPaused: Bool {
        return speed == 0.0
    }
    
    func pauseAnimations() {
        if !isAnimationsPaused {
            let currentTime = CACurrentMediaTime()
            let pausedTime = convertTime(currentTime, from: nil)
            speed = 0.0
            timeOffset = pausedTime
        }
    }
    
    func resumeAnimations() {
        let pausedTime = timeOffset
        speed = 1.0
        timeOffset = 0.0
        beginTime = 0.0
        let currentTime = CACurrentMediaTime()
        let timeSincePause = convertTime(currentTime, from: nil) - pausedTime
        beginTime = timeSincePause
    }
}

public extension CALayer {
    static private var persistentHelperKey = "CALayer.LayerPersistentHelper"
    
    func makeAnimationsPersistent() {
        var object = objc_getAssociatedObject(self, &CALayer.persistentHelperKey)
        if object == nil {
            object = LayerPersistentHelper(with: self)
            let nonatomic = objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC
            objc_setAssociatedObject(self, &CALayer.persistentHelperKey, object, nonatomic)
        }
    }
}

public class LayerPersistentHelper {
    private var persistentAnimations: [String: CAAnimation] = [:]
    private var persistentSpeed: Float = 0.0
    private weak var layer: CALayer?
    
    public init(with layer: CALayer) {
        self.layer = layer
        addNotificationObservers()
    }
    
    deinit {
        removeNotificationObservers()
    }
}

private extension LayerPersistentHelper {
    func addNotificationObservers() {
        let center = NotificationCenter.default
        let enterForeground = UIApplication.willEnterForegroundNotification
        let enterBackground = UIApplication.didEnterBackgroundNotification
        center.addObserver(self, selector: #selector(didBecomeActive), name: enterForeground, object: nil)
        center.addObserver(self, selector: #selector(willResignActive), name: enterBackground, object: nil)
    }
    
    func removeNotificationObservers() {
        NotificationCenter.default.removeObserver(self)
    }
    
    func persistAnimations(with keys: [String]?) {
        guard let layer = self.layer else { return }
        keys?.forEach { (key) in
            if let animation = layer.animation(forKey: key) {
                persistentAnimations[key] = animation
            }
        }
    }
    
    func restoreAnimations(with keys: [String]?) {
        guard let layer = self.layer else { return }
        keys?.forEach { (key) in
            if let animation = persistentAnimations[key] {
                layer.add(animation, forKey: key)
            }
        }
    }
}

@objc extension LayerPersistentHelper {
    func didBecomeActive() {
        guard let layer = self.layer else { return }
        restoreAnimations(with: Array(persistentAnimations.keys))
        persistentAnimations.removeAll()
        if persistentSpeed == 1.0 { // if layer was playing before background, resume it
            layer.resumeAnimations()
        }
    }
    
    func willResignActive() {
        guard let layer = self.layer else { return }
        persistentSpeed = layer.speed
        layer.speed = 1.0 // in case layer was paused from outside, set speed to 1.0 to get all animations
        persistAnimations(with: layer.animationKeys())
        layer.speed = persistentSpeed // restore original speed
        layer.pauseAnimations()
    }
}
DonMag
  • 69,424
  • 5
  • 50
  • 86