Search code examples
iosswiftobjective-cswiftuiuikit

Animate and resize a linear gradient (CAGradientLayer) using drag and pan gesture


I am trying to animate and resize a CAGradientLayer using a pan and tap gesture but it's lagging. I am animating other things along with this gradientLayer but speed of animation of gradientLayer slower than that of other UI animation. I have created a sample UIViewController in which I have a rectangle inside which I have my gradient layer. You can simply drag the rectangle to resize it or you can tap on it. You can see the timing difference in animation of rectangle's grey border and gradient layer.

ViewController

class ViewController: UIViewController {
    
    var rectangle: UIView!
    var gradientLayer: CAGradientLayer?
    var widthConstraint:NSLayoutConstraint!
    var shouldExpand = false
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        rectangle = UIView()
        view.addSubview(rectangle)
        
        rectangle.translatesAutoresizingMaskIntoConstraints = false
        rectangle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: view.frame.width * 0.05).isActive = true
        rectangle.heightAnchor.constraint(equalToConstant: 200).isActive = true
        widthConstraint = rectangle.widthAnchor.constraint(equalToConstant: 100)
        widthConstraint.isActive = true
        rectangle.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        
        rectangle.layer.cornerRadius = 15
        rectangle.layer.borderWidth = 5
        rectangle.layer.borderColor = UIColor.lightGray.cgColor
        
        setUpGesture()
        setUpGradient()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if let layer = gradientLayer {
            layer.frame = rectangle.bounds
        }
    }
    
    func setUpGesture() {
        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture))
        rectangle.addGestureRecognizer(panGesture)
        
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture))
        rectangle.addGestureRecognizer(tapGesture)
    }
    
    func setUpGradient() {
        gradientLayer = CAGradientLayer()
        gradientLayer!.frame = rectangle.bounds
        gradientLayer!.startPoint = .init(x: -1, y: 0)
        gradientLayer!.endPoint = .init(x: 1, y: 0)
        gradientLayer!.colors = [UIColor.red.cgColor, UIColor.yellow.cgColor]
        gradientLayer!.cornerRadius = 15
        
        rectangle.layer.addSublayer(gradientLayer!)
    }
    
    @objc func handlePanGesture(gesture: UIPanGestureRecognizer) {
        
        guard !shouldExpand else { return }
        
        let translation = gesture.translation(in: gesture.view)
        
        switch gesture.state {
        case .began, .changed:
            
            if translation.x < -65 || translation.x > (view.frame.width - (100 + (view.frame.width * 0.1)) ) {
                gesture.state = .ended
            }
            
            widthConstraint.constant = 100 + translation.x
            if let layer = gradientLayer {
                layer.frame.size.width = 100 + translation.x
            }
        case .ended, .cancelled:
            UIView.animate(withDuration: 0.7, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 5, options: [.curveEaseOut, .allowUserInteraction]) {
                self.widthConstraint.constant = 100
                if let layer = self.gradientLayer {
                    layer.frame.size.width = 100
                }
                self.view.layoutIfNeeded()
            }
        default:
            break
        }
    }
    
    @objc func handleTapGesture(gesture: UITapGestureRecognizer) {
        
        shouldExpand.toggle()
        
        if shouldExpand {
            UIView.animate(withDuration: 0.7, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 5, options: [.curveEaseOut, .allowUserInteraction]) {
                self.widthConstraint.constant = self.view.frame.width - (self.view.frame.width * 0.1)
                if let layer = self.gradientLayer {
                    layer.frame.size.width = self.view.frame.width - (self.view.frame.width * 0.1)
                }
                self.view.layoutIfNeeded()
            }
        } else {
            UIView.animate(withDuration: 0.7, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 5, options: [.curveEaseOut, .allowUserInteraction]) {
                self.widthConstraint.constant = 100
                if let layer = self.gradientLayer {
                    layer.frame.size.width = 100
                }
                self.view.layoutIfNeeded()
            }
        }
    }
}

Alternative approach using SwiftUI

I tried another approach using swiftUI's LinearGeadient. With this approach the drag is smooth, gesture follows rectangle's border as you drag but swiftUI's LinearGeadient does not animate. You can tap on the rectangle and see it not animating. Here's code for other approach. Just remove setUpGradient() function call from viewDidLoad and put setUpSwiftUIGradient function in the view controller and call it in ViewDidLoad.

SwiftUI View

struct SwiftUIGradientView: View {
    var uiColors: [UIColor]
    
    var body: some View {
        
        let colors = uiColors.map { Color(uiColor: $0) }
        
        GeometryReader { _ in
            LinearGradient(colors: colors, startPoint: .leading, endPoint: .trailing)
                .cornerRadius(15)
        }
    }
}

setUpSwiftUIGradient

func setUpSwiftUIGradient() {
        let host = UIHostingController(rootView: SwiftUIGradientView(uiColors: [.red, .yellow]))
        let uiView = host.view!
        uiView.backgroundColor = .clear
        
        rectangle.addSubview(uiView)
        
        uiView.translatesAutoresizingMaskIntoConstraints = false
        uiView.topAnchor.constraint(equalTo: rectangle.topAnchor).isActive = true
        uiView.leadingAnchor.constraint(equalTo: rectangle.leadingAnchor).isActive = true
        uiView.trailingAnchor.constraint(equalTo: rectangle.trailingAnchor).isActive = true
        uiView.bottomAnchor.constraint(equalTo: rectangle.bottomAnchor).isActive = true
    }

Can anyone help me get this thing right ?


Solution

  • The problem is that layers have their own built-in animation effects when certain properties are changed - frame begin one of them.

    You can disable the built-in animation like this:

            if let layer = gradientLayer {
                CATransaction.begin()
                CATransaction.setDisableActions(true)
                layer.frame.size.width = 100 + translation.x
                CATransaction.commit()
            }
    

    Which works fine while you are dragging. Unfortunately, it won't match when you animate the view's frame size.

    You will probably be much better off using a custom UIView subclass, setting its "base" layer to be a gradient layer.

    Quick example:

    class SomeGradientView: UIView {
        
        // this allows us to use the "base" layer as a gradient layer
        //  instead of adding a sublayer
        lazy var gradLayer: CAGradientLayer = self.layer as! CAGradientLayer
        override class var layerClass: AnyClass {
            return CAGradientLayer.self
        }
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder:aDecoder)
            commonInit()
        }
        private func commonInit() {
            gradLayer.startPoint = .init(x: -1, y: 0)
            gradLayer.endPoint = .init(x: 1, y: 0)
            gradLayer.colors = [UIColor.red.cgColor, UIColor.yellow.cgColor]
        }
        
    }
    

    With that class, you no longer need to add sublayers, and you don't need to deal with the gradient layer frame.

    Your example view controller, modified slightly:

    class ViewController: UIViewController {
        
        var rectangle: SomeGradientView!
        var widthConstraint:NSLayoutConstraint!
        var shouldExpand = false
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            rectangle = SomeGradientView()
            view.addSubview(rectangle)
            
            rectangle.translatesAutoresizingMaskIntoConstraints = false
            rectangle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: view.frame.width * 0.05).isActive = true
            rectangle.heightAnchor.constraint(equalToConstant: 200).isActive = true
            widthConstraint = rectangle.widthAnchor.constraint(equalToConstant: 100)
            widthConstraint.isActive = true
            rectangle.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
            
            rectangle.layer.cornerRadius = 15
            rectangle.layer.borderWidth = 5
            rectangle.layer.borderColor = UIColor.lightGray.cgColor
            
            setUpGesture()
            
        }
        
        func setUpGesture() {
            let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture))
            rectangle.addGestureRecognizer(panGesture)
            
            let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture))
            rectangle.addGestureRecognizer(tapGesture)
        }
        
        @objc func handlePanGesture(gesture: UIPanGestureRecognizer) {
            
            guard !shouldExpand else { return }
            
            let translation = gesture.translation(in: gesture.view)
            
            switch gesture.state {
            case .began, .changed:
                
                if translation.x < -65 || translation.x > (view.frame.width - (100 + (view.frame.width * 0.1)) ) {
                    gesture.state = .ended
                }
                
                widthConstraint.constant = 100 + translation.x
            case .ended, .cancelled:
                UIView.animate(withDuration: 0.7, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 5, options: [.curveEaseOut, .allowUserInteraction]) {
                    self.widthConstraint.constant = 100
                    self.view.layoutIfNeeded()
                }
            default:
                break
            }
        }
        
        @objc func handleTapGesture(gesture: UITapGestureRecognizer) {
            
            shouldExpand.toggle()
            
            if shouldExpand {
                UIView.animate(withDuration: 0.7, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 5, options: [.curveEaseOut, .allowUserInteraction]) {
                    self.widthConstraint.constant = self.view.frame.width - (self.view.frame.width * 0.1)
                    self.view.layoutIfNeeded()
                }
            } else {
                UIView.animate(withDuration: 0.7, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 5, options: [.curveEaseOut, .allowUserInteraction]) {
                    self.widthConstraint.constant = 100
                    self.view.layoutIfNeeded()
                }
            }
        }
    }
    

    If you want to change the gradient properties (colors, angle, etc), you can still do that like this:

        func setUpGradient() {
            rectangle.gradLayer.startPoint = .init(x: 0, y: 0)
            rectangle.gradLayer.endPoint = .init(x: 1, y: 1)
            rectangle.gradLayer.colors = [UIColor.blue.cgColor, UIColor.green.cgColor]
        }