1

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

Bhavesh Nayi
  • 3,626
  • 1
  • 27
  • 42
Lance Samaria
  • 17,576
  • 18
  • 108
  • 256
  • 1
    This is not relevant, but you are saying `gradientLayer.add(animation, forKey: "...")` inside your `layoutSubviews` implementation. But `layoutSubviews` can be called thousands of times over the lifetime of your app. Do you really want thousands of gradients layers in every cell? This is a train wreck in the making. – matt Apr 27 '19 at 15:26
  • That’s true, I overlooked that, thanks. I initially added the code in the init method and when it wasn’t working I thought maybe the UIView’s frame wasn’t ready yet. That’s why I added it to layoutSubviews without thinking about anything else. Thanks for the insight! – Lance Samaria Apr 27 '19 at 15:29
  • Another possibly useful insight: you cannot animate a layer that is not _already_ in the layer hierarchy. This isn't just a matter of order; you cannot animate a layer _in the same code_ (i.e. the same transaction) that adds it to the layer hierarchy. – matt Apr 27 '19 at 15:35
  • Are you saying they have to be added at separate times. For example calling func1 adds the gradient and then calling func2 adds the animation? – Lance Samaria Apr 27 '19 at 15:47
  • Here's _another_ problem. You are saying `addSubview(myView)`, meaning `self. addSubview(myView)`. But `self` is a `FoodCell: UICollectionViewCell`. It is forbidden to add a view directly to a UICollectionViewCell; you must add it to the cell's `contentView`. If your goal is to display a view behind everything else, that is what the cell's `backgroundView` is for. – matt Apr 27 '19 at 16:33
  • why is it forbidden to add directly to the view itself as opposed to the contentView? I've always added the cell's subviews directly and never had an issue. I read your book (the whole thing) sometime ago and vaguely remember you mentioning the contentView but all the examples I've come across very few people use it. – Lance Samaria Apr 27 '19 at 16:38
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/192482/discussion-between-lance-samaria-and-matt). – Lance Samaria Apr 27 '19 at 16:55

2 Answers2

1

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()
                }
            }
        }
    }
}
Lance Samaria
  • 17,576
  • 18
  • 108
  • 256
  • OK but your code is still wrong for the same reason as before. `cellForItemAt` will receive the same cell many times, because cells are _reused_. Thus you will add many gradient layers to the same cell. – matt Apr 27 '19 at 17:35
  • You know you your right. That happened to me on a previous project I was working on. Frustrated the heck out me. What I did was created a function that removed the gradient and then in cellForRow before I added the image to the layer, I called that function. So 1. cell.removeGradient() 2. cell.myView.layer.contents = foodImages[indexPath.item].cgImage and 3. cell.addGradientLayer(). I'm not sure it was the most elegant way to do it but the it stopped the problem from occurring. I can also remove it in prepareForReuse but your book and plenty of posts say not to do that. – Lance Samaria Apr 27 '19 at 17:40
  • But my book also tells you the right answer. Add the gradient layer _conditionally_. Just _look_ to see if the gradient layer is already present in this cell, and if it is, don't add it again. – matt Apr 27 '19 at 17:42
  • @matt I updated the code to check if the gradientLayer is or isn't in myView's hierarchy. It works. Thanks – Lance Samaria Apr 27 '19 at 18:39
  • @matt not sure if you have time to look this over but I had to do a considerable amount of work to keep these animations working. Going to the background and back they stop, switching tabs they stop, switching tabs, going to the background, then coming back and then coming back to this tab they stop. Animations are a lot of work man. I had to constantly add and remove them again. – Lance Samaria Apr 28 '19 at 17:39
0

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.

thecoolwinter
  • 861
  • 7
  • 22