I have different images of different foods that I add to a UIView (I choose to use an UIView instead of an UIImageView). The original color of the images are black and I change them to .lightGray using .alwaysTemplate.

// the imageWithColor function on the end turns it .lightGray: [https://stackoverflow.com/a/24545102/4833705][1]
let pizzaImage = UIImage(named: "pizzaImage")?.withRenderingMode(.alwaysTemplate).imageWithColor(color1: UIColor.lightGray)
foodImages.append(pizzaImage) 

I add the food images to the UIView in cellForRow

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: foodCell, for: indexPath) as! FoodCell

    cell.myView.layer.contents = foodImages[indexPath.item].cgImage

    return cell
}

The UIView is inside a cell and in the cell's layoutSubviews I add a gradientLayer with an animation that gives a shimmer effect but when the cells appear on screen the animation doesn't occur.

What's the issue?

class FoodCell: UICollectionViewCell {

let myView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    view.layer.cornerRadius = 7
    view.layer.masksToBounds = true
    view.layer.contentsGravity = CALayerContentsGravity.center
    view.tintColor = .lightGray
    return view
}()

override init(frame: CGRect) {
    super.init(frame: frame)
    backgroundColor = .white

    setAnchors()
}

override func layoutSubviews() {
    super.layoutSubviews()

    let gradientLayer = CAGradientLayer()
    gradientLayer.colors = [UIColor.clear.cgColor, UIColor.white.cgColor, UIColor.clear.cgColor]
    gradientLayer.locations = [0, 0.5, 1]
    gradientLayer.frame = myView.frame

    let angle = 45 * CGFloat.pi / 180
    gradientLayer.transform = CATransform3DMakeRotation(angle, 0, 0, 1)

    let animation = CABasicAnimation(keyPath: "transform.translation.x")
    animation.duration = 2
    animation.fromValue = -self.frame.width
    animation.toValue = self.frame.width
    animation.repeatCount = .infinity

    gradientLayer.add(animation, forKey: "...")
}

fileprivate func setAnchors() {
    addSubview(myView)

    myView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 0).isActive = true
    myView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: 0).isActive = true
    myView.topAnchor.constraint(equalTo: self.topAnchor, constant: 0).isActive = true
    myView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: 0).isActive = true
}
}

enter image description here

2 Answers

0
Lance Samaria On Best Solutions

I got it working.

I took @Matt's advice in the comments under the question and added myView to the cell's contentView property instead of the cell directly. I can't find the post but I just read that for animations to work in a cell, whichever views the animations are on needs to be added to the cell's contentView

I moved the gradientLayer from layoutSubviews and instead made it a lazy property.

I also moved the animation into it's own lazy property.

I used this answer and set the gradientLayer's frame to the cell's bounds property (I initially had it set to the cell's frame property)

I added a function that adds the gradientLayer to myView's layer's insertSublayer property and call that function in cellForRow. Also as per @Matt's comments under my answer to prevent the gradientLayer from constantly getting added over again I add a check to see if the gradient is in the UIView's layer's hierarchy (I got the idea from here even though it's used for a different reason). If it isn't there I add and if is I don't add it.

// I added both the animation and the gradientLayer here
func addAnimationAndGradientLayer() {

    if let _ = (myView.layer.sublayers?.compactMap { $0 as? CAGradientLayer })?.first {
        print("it's already in here so don't readd it")
    } else {

        gradientLayer.add(animation, forKey: "...") // 1. added animation
        myView.layer.insertSublayer(gradientLayer, at: 0) // 2. added the gradientLayer
        print("it's not in here so add it")
    }
}

To call the function to add the gradientLayer to the cell it's called in cellForRow

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: foodCell, for: indexPath) as! FoodCell

    cell.removeGradientLayer() // remove the gradientLayer due to going to the background and back issues

    cell.myView.layer.contents = foodImages[indexPath.item].cgImage

    cell.addAnimationAndGradientLayer() // I call it here

    return cell
}

Updated code for the cell

class FoodCell: UICollectionViewCell {

let myView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    view.layer.cornerRadius = 7
    view.layer.masksToBounds = true
    view.layer.contentsGravity = CALayerContentsGravity.center
    view.tintColor = .lightGray
    return view
}()

lazy var gradientLayer: CAGradientLayer = {

    let gradientLayer = CAGradientLayer()
    gradientLayer.colors = [UIColor.clear.cgColor, UIColor.white.cgColor, UIColor.clear.cgColor]
    gradientLayer.locations = [0, 0.5, 1]
    gradientLayer.frame = self.bounds

    let angle = 45 * CGFloat.pi / 180
    gradientLayer.transform = CATransform3DMakeRotation(angle, 0, 0, 1)
    return gradientLayer
}()

lazy var animation: CABasicAnimation = {

    let animation = CABasicAnimation(keyPath: "transform.translation.x")
    animation.duration = 2
    animation.fromValue = -self.frame.width
    animation.toValue = self.frame.width
    animation.repeatCount = .infinity
    animation.fillMode = CAMediaTimingFillMode.forwards
    animation.isRemovedOnCompletion = false

    return animation
}()

override init(frame: CGRect) {
    super.init(frame: frame)
    backgroundColor = .white

    setAnchors()
}

func addAnimationAndGradientLayer() {

    // make sure the gradientLayer isn't already in myView's hierarchy before adding it
    if let _ = (myView.layer.sublayers?.compactMap { $0 as? CAGradientLayer })?.first {
        print("it's already in here so don't readd it")
    } else {

        gradientLayer.add(animation, forKey: "...") // 1. add animation
        myView.layer.insertSublayer(gradientLayer, at: 0) // 2. add gradientLayer
        print("it's not in here so add it")
    }
}

// this function is explained at the bottom of my answer and is necessary if you want the animation to not pause when coming from the background 
func removeGradientLayer() {

    myView.layer.sublayers?.removeAll()
    gradientLayer.removeFromSuperlayer()

    setNeedsDisplay() // these 2 might not be necessary but i called them anyway
    layoutIfNeeded()

    if let _ = (iconImageView.layer.sublayers?.compactMap { $0 as? CAGradientLayer })?.first {
        print("no man the gradientLayer is not removed")
    } else {
        print("yay the gradientLayer is removed")
    }
}

fileprivate func setAnchors() {

    self.contentView.addSubview(myView)

    myView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0).isActive = true
    myView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 0).isActive = true
    myView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 0).isActive = true
    myView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 0).isActive = true
}
}

enter image description here

As a side note this BELOW works great if the users CAN’T scroll the cells (placeholder cells) but if they CAN make sure to test before adding because it's buggy

Another problem I ran into was when I would go to the background and come back the animation wouldn't move. I followed this answer (code below on how to use it) which works although in that same thread I amended that answer to use this answer to start the animation from the beginning which works BUT there are issues.

I noticed even though I came back from the foreground and the animation worked sometimes when I scrolled the animation got stuck. To get around it I called cell.removeGradientLayer() in cellForRow and then again as explained below. However it still got stuck when scrolling but by calling the above it got unstuck. It works for what I need it for because I only show these cells while the actual cells are loading. I'm disabling scrolling when the animation occurs anyway so I don't have to worry about it. FYI this stuck issue only seems to happen when coming back from the background and then scrolling.

I also had to remove the gradientLayer from the cell by calling cell.removeGradientLayer() when the app went to the background and then when it came back to the foreground I had to call cell.addAnimationAndGradientLayer() to add it again. I did that by adding background/foreground Notifications in the class that has the collectionView. In the accompanying Notification functions I just scroll through the visible cells and call the cell's functions that are necessary (code is also below).

class PersistAnimationView: UIView {

    private var persistentAnimations: [String: CAAnimation] = [:]
    private var persistentSpeed: Float = 0.0

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.commonInit()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.commonInit()
    }

    func commonInit() {

        NotificationCenter.default.addObserver(self, selector: #selector(willResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil)

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

    deinit {
        NotificationCenter.default.removeObserver(self)
    }

    func didBecomeActive() {
        self.restoreAnimations(withKeys: Array(self.persistentAnimations.keys))
        self.persistentAnimations.removeAll()
        if self.persistentSpeed == 1.0 { //if layer was plaiyng before backgorund, resume it
            self.layer.resume()
        }
    }

    func willResignActive() {
        self.persistentSpeed = self.layer.speed

        self.layer.speed = 1.0 //in case layer was paused from outside, set speed to 1.0 to get all animations
        self.persistAnimations(withKeys: self.layer.animationKeys())
        self.layer.speed = self.persistentSpeed //restore original speed

        self.layer.pause()
    }

    func persistAnimations(withKeys: [String]?) {
        withKeys?.forEach({ (key) in
            if let animation = self.layer.animation(forKey: key) {
                self.persistentAnimations[key] = animation
            }
        })
    }

    func restoreAnimations(withKeys: [String]?) {
        withKeys?.forEach { key in
            if let persistentAnimation = self.persistentAnimations[key] {
                self.layer.add(persistentAnimation, forKey: key)
            }
        }
    }
}

extension CALayer {
    func pause() {
        if self.isPaused() == false {
            let pausedTime: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil)
            self.speed = 0.0
            self.timeOffset = pausedTime
        }
    }

    func isPaused() -> Bool {
        return self.speed == 0.0
    }

    func resume() {
        let pausedTime: CFTimeInterval = self.timeOffset
        self.speed = 1.0
        self.timeOffset = 0.0
        self.beginTime = 0.0
        // as per the amended answer comment these 2 lines out to start the animation from the beginning when coming back from the background
        // let timeSincePause: CFTimeInterval = self.convertTime(CACurrentMediaTime(), from: nil) - pausedTime
        // self.beginTime = timeSincePause
    }
}

And in the cell class instead of making MyView and instance of UIView I instead made it an instance of PersistAnimationView like this:

class FoodCell: UICollectionViewCell {

    let MyView: PersistAnimationView = {
        let persistAnimationView = PersistAnimationView()
        persistAnimationView.translatesAutoresizingMaskIntoConstraints = false
        persistAnimationView.layer.cornerRadius = 7
        persistAnimationView.layer.masksToBounds = true
        persistAnimationView.layer.contentsGravity = CALayerContentsGravity.center
        persistAnimationView.tintColor = .lightGray
        return persistAnimationView
    }()

    // everything else in the cell class is the same

Here are the Notifications for the class with the collectionView. The animations also stop when the view disappears or reappears so you’ll have to manage this in viewWillAppear and viewDidDisappear too.

class MyClass: UIViewController, UICollectionViewDatasource, UICollectionViewDelegateFlowLayout {

    var collectionView: UICollectionView!

    // MARK:- View Controller Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()

        NotificationCenter.default.addObserver(self, selector: #selector(appHasEnteredBackground), name: UIApplication.willResignActiveNotification, object: nil)

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

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        addAnimationAndGradientLayerInFoodCell()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        removeGradientLayerInFoodCell()
    }

    // MARK:- Functions for Notifications
    @objc func appHasEnteredBackground() {

        removeGradientLayerInFoodCell()
    }

    @objc func appWillEnterForeground() {

        addAnimationAndGradientLayerInFoodCell()
    }

    // MARK:- Supporting Functions
    func removeGradientLayerInFoodCell() {

        // if you are using a tabBar, switch tabs, then go to the background, comeback, then switch back to this tab, without this check the animation will get stuck
        if (self.view.window != nil) {

            collectionView.visibleCells.forEach { (cell) in

                if let cell = cell as? FoodCell {
                    cell.removeGradientLayer()
                }
            }
        }
    }

    func addAnimationAndGradientLayerInFoodCell() {

        // if you are using a tabBar, switch tabs, then go to the background, comeback, then switch back to this tab, without this check the animation will get stuck
        if (self.view.window != nil) {

            collectionView.visibleCells.forEach { (cell) in

                if let cell = cell as? FoodCell {
                    cell.addAnimationAndGradientLayer()
                }
            }
        }
    }
}
0
thecoolwinter On

You could maybe try this, put this code inside it's own function:

func setUpGradient() {
let gradientLayer = CAGradientLayer()
    gradientLayer.colors = [UIColor.clear.cgColor, UIColor.white.cgColor, UIColor.clear.cgColor]    
    ...
    gradientLayer.add(animation, forKey: "...")
}

Then in your init function call it

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

It seems like your problem might be the layoutSubviews can be called a lot, but the init function will only be called when the view is initialized with a frame. Also putting the setup code in its own function will make it easier to do other things like update the gradient layer's frame if the frame changes.