Search code examples
swiftuikituiimageviewgradient

Add a shimmering animation on an image with alpha channel


I'm trying to add a golden sheen on an UIImageView (variable named assetImage) to signify that it is something you can click on. This is the code that I'm using:

let shimmeringGradientLayer = CAGradientLayer()
shimmeringGradientLayer.frame = assetImage.bounds
shimmeringGradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0)
shimmeringGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
shimmeringGradientLayer.colors = [
    Constants.Shimmering.gradientColorOne,
    Constants.Shimmering.gradientColorTwo,
    Constants.Shimmering.gradientColorOne
]
shimmeringGradientLayer.locations = [0.0, 0.5, 1.0]
/* Adding the gradient layer on to the view */
assetImage.layer.addSublayer(shimmeringGradientLayer)

let animation = CABasicAnimation(keyPath: "shimmer")
animation.fromValue = [-1.0, -0.5, 0.0]
animation.toValue = [1.0, 1.5, 2.0]
animation.repeatCount = .infinity
animation.duration = 0.9
shimmeringGradientLayer.add(animation, forKey: animation.keyPath)

However, since the image that I'm using has alpha channels, the result looks like this:

enter image description here

But what I want is something more like this, where the gradient only shows on the parts of the UIImageView that has a non-transparent alpha. Note that this image isn't entirely clipped to the bounds of the image -- I'm not the best at using Figma!

enter image description here

Is this possible? I've tried adding the gradient layer as a mask of the assetImage instead of adding it as a sublayer (using the code below), but the gradient is no longer visible.

assetImage.layer.mask = shimmeringGradientLayer

Solution

  • We can do this by using the same image as a mask for the gradient layer.

    I'll use this image (kinda, sorta, similar to yours):

    enter image description here

    and a clouds image for the "background" image.

    Looks like this:

    enter image description here

    Nothing special... that's exactly what we expect.

    Now, we'll add a gradient layer on the "cabinet" image view:

    enter image description here

    Again, just what we expect, but not what we want.

    So, we set the .opacity of the gradient layer to 0.5:

    enter image description here

    Still not what we want, and you've already done all of that.

    The "tricky" part comes next. We'll have subclassed UIImageView where we've added the gradient layer. Then:

    // new CALayer
    let cl = CALayer()
    cl.frame = bounds
    // set its contents to the image
    cl.contents = image?.cgImage
    // use it to mask the gradient layer
    shimmeringGradientLayer.mask = cl
    

    and it looks like this:

    enter image description here

    Now we can change the gradient colors and animate them to get a "golden sheen":

    enter image description here

    Here's a sample custom class:

    class GradientMaskedImageView: UIImageView {
        
        // so we can toggle on/off
        public var isShimmering: Bool = false {
            didSet {
                doShimmer()
            }
        }
        
        // gradient colors: adjust as desired, or set from controller class
        public var gradColors: [UIColor] = [
            .yellow.withAlphaComponent(0.25),
            .yellow,
            .yellow.withAlphaComponent(0.25),
        ] {
            didSet {
                shimmeringGradientLayer.colors = gradColors.map({ $0.cgColor })
            }
        }
        
        private let shimmeringGradientLayer = CAGradientLayer()
        
        convenience init() {
            self.init(frame: .zero)
        }
        override init(image: UIImage?) {
            super.init(image: image)
            commonInit()
        }
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        
        private func commonInit() -> Void {
            
            shimmeringGradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0)
            shimmeringGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
            shimmeringGradientLayer.colors = gradColors.map({ $0.cgColor })
            shimmeringGradientLayer.locations = [0.0, 0.5, 1.0]
            
            /* Adding the gradient layer on to the view */
            layer.addSublayer(shimmeringGradientLayer)
            
        }
        
        override func layoutSubviews() {
            shimmeringGradientLayer.frame = bounds
            // we want to start with the gradient layer opacity at Zero
            shimmeringGradientLayer.opacity = 0.0
        }
        
        public func doShimmer() {
            
            if !isShimmering {
                
                // stop the animation and "hide" the gradient layer
                shimmeringGradientLayer.removeAllAnimations()
                shimmeringGradientLayer.opacity = 0.0
                
            } else {
                
                // we only need to setup the mask once
                if shimmeringGradientLayer.mask == nil {
                    // new CALayer
                    let cl = CALayer()
                    cl.frame = bounds
                    // set its contents to the image
                    cl.contents = image?.cgImage
                    // use it to mask the gradient layer
                    shimmeringGradientLayer.mask = cl
                }
                
                let animation = CABasicAnimation(keyPath: "locations")
                animation.fromValue = [-1.0, -0.5, 0.0]
                animation.toValue = [1.0, 1.5, 2.0]
                animation.repeatCount = .infinity
                animation.duration = 0.9
                shimmeringGradientLayer.add(animation, forKey: animation.keyPath)
                
                // adjust as desired
                shimmeringGradientLayer.opacity = 0.5
            }
            
        }
        
    }
    

    and an example controller:

    class ShimmerTestVC: UIViewController {
        
        let bkgImageView = UIImageView()
        
        let testView = GradientMaskedImageView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.backgroundColor = .systemYellow
            
            guard let img = UIImage(named: "cabinet"),
                  let bkImg = UIImage(named: "clouds")
            else {
                return
            }
    
            testView.image = img
            
            bkgImageView.image = bkImg
    
            bkgImageView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(bkgImageView)
    
            testView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
    
                bkgImageView.widthAnchor.constraint(equalToConstant: 300.0),
                bkgImageView.heightAnchor.constraint(equalTo: bkgImageView.widthAnchor, multiplier: 1.0),
                bkgImageView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                bkgImageView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
    
                // we want the custom view to have the same aspect ratio
                //  as the image we're using
                testView.widthAnchor.constraint(equalToConstant: 200.0),
                testView.heightAnchor.constraint(equalTo: testView.widthAnchor, multiplier: img.size.height / img.size.width),
                testView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                testView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                
            ])
    
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            testView.isShimmering.toggle()
        }
        
    }
    

    Tapping anywhere will toggle the "shimmer" on/off.