Search code examples
iosswiftuiviewcalayercabasicanimation

Not all pixels clipped when using UIView.layer.clipsToBounds = true in conjunction with CABasicAnimation


What I'm doing: I am creating a music app. Within this music app, I have a music player that links with Apple Music and Spotify. The music player displays the current song's album art on a UIImageView. Whenever the song is playing, the UIImageView rotates (like a record on a record player).

What my problem is: The album art for the UIImageView is square by default. I am rounding the UIImageView's layer's corners and setting UIImageView.clipsToBounds equal to true to make it appear as a circle. Whenever the UIImageView rotates, some of the pixels outside of the UIImageView's layer (the part that is cut off after rounding the image) are bleeding through.

Here is what the bug looks like: https://www.youtube.com/watch?v=OJxX5PQc7Jo&feature=youtu.be

My code: The UIImageView is rounded by setting its layer's cornerRadius equal to UIImageView.frame.height / 2 and setting UIImageView.clipsToBounds = true:

class MyViewController: UIViewController {

    @IBOutlet var albumArtImageView: UIImageView()

    override func viewDidLoad() {
        super.viewDidLoad()
        albumArtImageView.layer.cornerRadius = albumArtImageView.frame.height / 2
        albumArtImageView.clipsToBounds = true

        //I've also tried the following code, and am getting the same behavior:
        /*
        albumArtImageView.layer.cornerRadius = albumArtImageView.frame.height / 2
        albumArtImageView.layer.masksToBounds = true
        albumArtImageView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner,.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
        */
    }
}

Whenever a button is pressed, the UIImageView begins to rotate. I've used the following extension to make UIView's rotate:

extension UIView {
    func rotate(duration: Double = 1, startPoint: CGFloat) {
        if layer.animation(forKey: UIView.kRotationAnimationKey) == nil {
            let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation")

            rotationAnimation.fromValue = startPoint
            rotationAnimation.toValue = (CGFloat.pi * 2.0) + startPoint
            rotationAnimation.duration = duration
            rotationAnimation.repeatCount = Float.infinity

            layer.add(rotationAnimation, forKey: UIView.kRotationAnimationKey)
        }
    }
}

I also have the following extension to end the rotation:

extension UIView {

    ...

    func stopRotating(beginTime: Double!, startingAngle: CGFloat) -> CGFloat? {
        if layer.animation(forKey: UIView.kRotationAnimationKey) != nil {
            let animation = layer.animation(forKey: UIView.kRotationAnimationKey)!
            let elapsedTime = CACurrentMediaTime() - beginTime
            let angle = elapsedTime.truncatingRemainder(dividingBy: animation.duration)/animation.duration

            layer.transform = CATransform3DMakeRotation((CGFloat(angle) * (2 * CGFloat.pi)) + startingAngle, 0.0, 0.0, 1.0)
            layer.removeAnimation(forKey: UIView.kRotationAnimationKey)
            return (CGFloat(angle) * (2 * CGFloat.pi)) + startingAngle
        } else {
            return nil
        }
    }
}

This is how these extension functions are used in the context of my view controller:

class MyViewController: UIViewController {

    var songBeginTime: Double!
    var currentRecordAngle: CGFloat = 0.0
    var isPlaying = false

    ...

    @IBAction func playButtonPressed(_ sender: Any) {
        if isPlaying {
            if let angle = albumArtImageView.stopRotating(beginTime: songBeginTime, startingAngle: currentRecordAngle) {
                currentRecordAngle = angle
            }
            songBeginTime = nil
        } else {
            songBeginTime = CACurrentMediaTime()
            albumArtImageView.rotate(duration: 3, startPoint: currentRecordAngle)
        }
    }
}

So, all together, MyViewController looks something like this:

class MyViewController: UIViewController {

    @IBOutlet var albumArtImageView: UIImageView()

    var songBeginTime: Double!
    var currentRecordAngle: CGFloat = 0.0
    var isPlaying = false

    override func viewDidLoad() {
        super.viewDidLoad()
        albumArtImageView.layer.cornerRadius = albumArtImageView.frame.height / 2
        albumArtImageView.clipsToBounds = true
    }

    @IBAction func playButtonPressed(_ sender: Any) {
        if isPlaying {
            if let angle = albumArtImageView.stopRotating(beginTime: songBeginTime, startingAngle: currentRecordAngle) {
                currentRecordAngle = angle
            }
            songBeginTime = nil
        } else {
            songBeginTime = CACurrentMediaTime()
            albumArtImageView.rotate(duration: 3, startPoint: currentRecordAngle)
        }
    }
}

Solution

  • I copy your code into the project and I can reproduce this issue. But if you add the animation to another CALayer, that seems to resolve the issue.

    extension UIView {
    
    static var kRotationAnimationKey: String {
        return "kRotationAnimationKey"
    }
    
    func makeAnimationLayer() -> CALayer {
    
        let results: [CALayer]? = layer.sublayers?.filter({ $0.name ?? "" == "animationLayer" })
    
        let animLayer: CALayer
        if let sublayers = results, sublayers.count > 0 {
            animLayer = sublayers[0]
        }
        else {
            animLayer = CAShapeLayer()
            animLayer.name = "animationLayer"
            animLayer.frame = self.bounds
            animLayer.contents = UIImage(named: "imageNam")?.cgImage
            layer.addSublayer(animLayer)
        }
    
        return animLayer
    }
    
    func rotate(duration: Double = 1, startPoint: CGFloat) {
        if layer.animation(forKey: UIView.kRotationAnimationKey) == nil {
    
            let animLayer = makeAnimationLayer()
    
            let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation")
            rotationAnimation.fromValue = startPoint
            rotationAnimation.toValue = (CGFloat.pi * 2.0) + startPoint
            rotationAnimation.duration = duration
            rotationAnimation.repeatCount = Float.infinity
            animLayer.add(rotationAnimation, forKey: UIView.kRotationAnimationKey)
        }
    }
    
    func stopRotating(beginTime: Double!, startingAngle: CGFloat) -> CGFloat? {
    
        let animLayer = makeAnimationLayer()
    
        if animLayer.animation(forKey: UIView.kRotationAnimationKey) != nil {
            let animation = animLayer.animation(forKey: UIView.kRotationAnimationKey)!
            let elapsedTime = CACurrentMediaTime() - beginTime
            let angle = elapsedTime.truncatingRemainder(dividingBy: animation.duration)/animation.duration
            animLayer.transform = CATransform3DMakeRotation(CGFloat(angle) * (2 * CGFloat.pi) + startingAngle, 0.0, 0.0, 1.0)
            animLayer.removeAnimation(forKey: UIView.kRotationAnimationKey)
            return (CGFloat(angle) * (2 * CGFloat.pi)) + startingAngle
        }
        else {
            return nil
        }
    }