swiftcglayer

Rotating UIControl with CAGradientLayer not updating correctly Swift


Rather than using a normal button, I subclassed a UIControl because I needed to add a gradient to it. I also have a way to add a shadow and an activity indicator (not visible in the image below) as a stateful button to stop users hammering the button if (for example) an API call is being made.

It was really tricky to try to get the UIControl to rotate, and to be able to do this I added the shadow as a separate view to a container view containing the UIControl so a shadow could be added.

Now the issue is the control does not behave quite like a view on rotation - let me show you a screen grab for context: enter image description here

This is mid-rotation but is just about visible to the eye - the image shows that the Gradient is 75% of the length of a blue UIView in the image.

https://github.com/stevencurtis/statefulbutton

In order to perform this rotation I remove the shadowview and then change the frame of the gradient frame to its bounds, and this is the problem.

func viewRotated() {
    CATransaction.setDisableActions(true)

    shadowView!.removeFromSuperview()
    shadowView!.frame = self.frame
    shadowView!.layer.masksToBounds = false
    shadowView!.layer.shadowOffset = CGSize(width: 0, height: 3)
    shadowView!.layer.shadowRadius = 3
    shadowView!.layer.shadowOpacity = 0.3
    shadowView!.layer.shadowPath = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 20, height: 20)).cgPath
    shadowView!.layer.shouldRasterize = true
    shadowView!.layer.rasterizationScale = UIScreen.main.scale

    self.gradientViewLayer.frame = self.bounds
    self.selectedViewLayer.frame = self.bounds

    CATransaction.commit()

    self.insertSubview(shadowView!, at: 0)

}

So this rotation method is called through the parent view controller:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    coordinator.animate(alongsideTransition: { context in
        context.viewController(forKey: UITransitionContextViewControllerKey.from)
        //inform the loginButton that it is being rotated
        self.loginButton.viewRotated()
    }, completion: { context in
        //  can call here when completed the transition
    })
}

I know this is the problem, and I guess it is not happening at quite the right time to act the same way as a UIView. Now the issue is that I have tried many things to get this to work, and my best solution (above) is not quite there.

It isn't helpful to suggest to use a UIButton, to use an image for the gradient (please don't suggest using a gradient image as a background for a UIButton, I've tried this) or a third party library. This is my work, it functions but does not work acceptably to me and I want to get it to work as well as a usual view (or at least know why not). I have tried the other solutions above as well, and have gone for my own UIControl. I know I can lock the view if there is an API call, or use other ways to stop the user pressing the button too many times. I'm trying to fix my solution, not invent ways of getting around this issue with CAGradientLayer.

The problem: I need to make a UIControlView with a CAGradientLayer as a background rotate in the same way as a UIView, and not exhibit the issue shown in the image above.

Full Example: https://github.com/stevencurtis/statefulbutton


Solution

  • Here is working code:

    https://gist.github.com/alldne/22d340b36613ae5870b3472fa1c64654

    These are my recommendations to your code:

    1. A proper place for setting size and the position of sublayers

    The size of a view, namely your button, is determined after the layout is done. What you should do is just to set the proper size of sublayers after the layout. So I recommend you to set the size and position of the gradient sublayers in layoutSubviews.

        override func layoutSubviews() {
            super.layoutSubviews()
    
            let center = CGPoint(x: self.bounds.width / 2, y: self.bounds.height / 2)
            selectedViewLayer.bounds = self.bounds
            selectedViewLayer.position = center
            gradientViewLayer.bounds = self.bounds
            gradientViewLayer.position = center
        }
    
    

    2. You don’t need to use an extra view to draw shadow

    Remove shadowView and just set the layer properties:

    layer.shadowOffset = CGSize(width: 0, height: 3)
    layer.shadowRadius = 3
    layer.shadowOpacity = 0.3
    layer.shadowColor = UIColor.black.cgColor
    clipsToBounds = false
    
    

    If you have to use an extra view to draw shadow, then you can add the view once in init() and set the proper size and position in layoutSubviews or you can just programmatically set auto layout constraints to the superview.

    3. Animation duration & timing function

    After setting proper sizes, your animation of the gradient layers and the container view doesn’t sync well.

    It seems that:

    To sync the animations, explicitly set the animation duration and the timing function like below:

    class ViewController: UIViewController {
        ...
    
        override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
            super.viewWillTransition(to: size, with: coordinator)
            CATransaction.setAnimationDuration(coordinator.transitionDuration)
            CATransaction.setAnimationTimingFunction(coordinator.completionCurve.timingFunction)
        }
    
        ...
    }
    
    // Swift 4
    extension UIView.AnimationCurve {
        var timingFunction: CAMediaTimingFunction {
            let functionName: CAMediaTimingFunctionName
            switch self {
            case .easeIn:
                functionName = kCAMediaTimingFunctionEaseIn as CAMediaTimingFunctionName
            case .easeInOut:
                functionName = kCAMediaTimingFunctionEaseInEaseOut as CAMediaTimingFunctionName
            case .easeOut:
                functionName = kCAMediaTimingFunctionEaseOut as CAMediaTimingFunctionName
            case .linear:
                functionName = kCAMediaTimingFunctionLinear as CAMediaTimingFunctionName
            }
            return CAMediaTimingFunction(name: functionName as String)
        }
    }