iosswiftmobileuikitios-simulator

Walkthrough animation CAShapeLayer x UIBezierPath


Good afternoon!

I have a problem with animation in the function addHoleToMovableView.

When trying the slow animation from point A to point B, I have not obtained a satisfactory result. I hope to have a very short animation of the type that moves from one place to another on the y-axis from top to bottom or bottom to top. It's as if it moves from one point to another with defined seconds.

My controller:

import UIKit

class TutorialViewController: UIViewController {
    var tutorialView: TutorialView!
    var view1: UIView!
    var view2: UIView!
    var view3: UIView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        tutorialView = TutorialView()
        tutorialView.translatesAutoresizingMaskIntoConstraints = false
        tutorialView.backgroundColor = .black.withAlphaComponent(0.7)
 
        setViews()
        view.addSubview(tutorialView)
        
        NSLayoutConstraint.activate([
            tutorialView.topAnchor.constraint(equalTo: view.topAnchor),
            tutorialView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tutorialView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tutorialView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
        
        tutorialView.onNextButtonTapped = { [weak self] in
            self?.moveMovableViewToCurrentPosition()
        }
        
        tutorialView.onPreviousButtonTapped = { [weak self] in
            self?.moveMovableViewToCurrentPosition()
        }
        
        tutorialView.onOkButtonTapped = { [weak self] in
            self?.tutorialView.currentState += 1
            self?.removeTutorialViewFromSuperView()
        }
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        moveMovableViewToCurrentPosition()
    }
    
    private func addHoleToMovableView(frame: CGRect) {
        let holeLayer = CAShapeLayer()
        let path = UIBezierPath(rect: self.tutorialView.bounds)
        let holePath = UIBezierPath(rect: frame)
        
        path.append(holePath.reversing())
        holeLayer.path = path.cgPath
        holeLayer.fillRule = .evenOdd
        self.tutorialView.layer.mask = holeLayer
        
        let animation = CABasicAnimation(keyPath: "patch")
        animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
        holeLayer.add(animation, forKey: "positionAnimation")
    }


    func setViews() {
        view1 = UIView()
        view2 = UIView()
        view3 = UIView()
        
        view1.translatesAutoresizingMaskIntoConstraints = false
        view2.translatesAutoresizingMaskIntoConstraints = false
        view3.translatesAutoresizingMaskIntoConstraints = false
        
        view1.backgroundColor = .blue
        view2.backgroundColor = .yellow
        view3.backgroundColor = .green
        
        view.addSubview(view1)
        view.addSubview(view2)
        view.addSubview(view3)
        
        NSLayoutConstraint.activate([
            view1.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
            view1.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            view1.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            view1.heightAnchor.constraint(equalToConstant: 60),
            
            view2.topAnchor.constraint(equalTo: view1.bottomAnchor, constant: 10),
            view2.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10),
            view2.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
            view2.heightAnchor.constraint(equalToConstant: 200),
            
            view3.topAnchor.constraint(equalTo: view2.bottomAnchor, constant: 10),
            view3.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            view3.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            view3.bottomAnchor.constraint(equalTo: view.bottomAnchor)
            
            
        ])
    }
    
    func removeTutorialViewFromSuperView() {
        tutorialView.removeFromSuperview()
        view.layoutIfNeeded()
    }
    
    func moveMovableViewToCurrentPosition() {
        switch tutorialView.currentState {
        case 0:
            tutorialView.updateControlContainerConstraints(position: .below, referenceFrame: view1.frame)
            addHoleToMovableView(frame: view1.frame)
        case 1:
            tutorialView.updateControlContainerConstraints(position: .below, referenceFrame: view2.frame)
            addHoleToMovableView(frame: view2.frame)
        case 2:
            tutorialView.updateControlContainerConstraints(position: .above, referenceFrame: view3.frame)
            addHoleToMovableView(frame: view3.frame)
        default:
            break
        }
    }
    
}

This is currently the "blinking" effect.

Simulator


Solution

  • To animate a path - whether it's for drawing or to use as a mask - we need to set the .toValue for the animation.

    The keyPath also needs to be "path" instead of "patch" (but I'm guessing that was a typo):

        let animation = CABasicAnimation(keyPath: "path")
        animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
        
        // fromValue is not strictly necessary here, but we use it for consistency
        animation.fromValue = curPath
        animation.toValue = newPath
        
        animation.isRemovedOnCompletion = false
        
        // adjust animation speed as desired
        animation.duration = 0.75
        
        holeLayer.add(animation, forKey: "positionAnimation")
    

    We also want to use CATransaction with a completion block, because we need to update the holeLayer.path when the animation ends.

    I think you'll also find it much easier to keep the "hole layer" and animation code inside the overlay view (your TutorialView).

    Here is some sample code, based on your example, with modifications:

    enum ControlPosition {
        case above, below
    }
    
    class TutorialViewController: UIViewController {
        
        var tutorialView: TutorialView = TutorialView()
        
        var view1: UIView!
        var view2: UIView!
        var view3: UIView!
        
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .white
            
            setViews()
            
            tutorialView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(tutorialView)
            
            NSLayoutConstraint.activate([
                tutorialView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0),
                tutorialView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0),
                tutorialView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0),
                tutorialView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0),
            ])
            
            tutorialView.onNextButtonTapped = { [weak self] in
                self?.moveMovableViewToCurrentPosition()
            }
            
            tutorialView.onPrevButtonTapped = { [weak self] in
                self?.moveMovableViewToCurrentPosition()
            }
    
            tutorialView.onDoneButtonTapped = { [weak self] in
                self?.removeTutorialViewFromSuperView()
            }
    
        }
        
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
    
            // let the translucent tutorial view show up to begin with
            
            // start with the "hole" above the top of the vivew
            tutorialView.updateHole(.init(x: 0.0, y: -20.0, width: tutorialView.frame.width, height: 20.0), animated: false, cPos: .above)
            
            // then animate
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
                self.moveMovableViewToCurrentPosition()
            })
            
        }
        
        func setViews() {
            view1 = UIView()
            view2 = UIView()
            view3 = UIView()
            
            view1.translatesAutoresizingMaskIntoConstraints = false
            view2.translatesAutoresizingMaskIntoConstraints = false
            view3.translatesAutoresizingMaskIntoConstraints = false
            
            view1.backgroundColor = .blue
            view2.backgroundColor = .yellow
            view3.backgroundColor = .green
            
            view.addSubview(view1)
            view.addSubview(view2)
            view.addSubview(view3)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                view1.topAnchor.constraint(equalTo: g.topAnchor, constant: 20),
                view1.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                view1.trailingAnchor.constraint(equalTo: g.trailingAnchor),
                view1.heightAnchor.constraint(equalToConstant: 60),
                
                view2.topAnchor.constraint(equalTo: view1.bottomAnchor, constant: 10),
                view2.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 10),
                view2.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -10),
                view2.heightAnchor.constraint(equalToConstant: 200),
                
                view3.topAnchor.constraint(equalTo: view2.bottomAnchor, constant: 10),
                view3.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                view3.trailingAnchor.constraint(equalTo: g.trailingAnchor),
                view3.bottomAnchor.constraint(equalTo: g.bottomAnchor)
            ])
        }
        
        func removeTutorialViewFromSuperView() {
            tutorialView.removeFromSuperview()
            view.layoutIfNeeded()
        }
        
        func moveMovableViewToCurrentPosition() {
            switch tutorialView.currentState {
            case 0:
                tutorialView.updateHole(view1.frame, animated: true, cPos: .below)
            case 1:
                tutorialView.updateHole(view2.frame, animated: true, cPos: .below)
            case 2:
                tutorialView.updateHole(view3.frame, animated: true, cPos: .above)
            default:
                break
            }
        }
        
    }
    
    class TutorialView: UIView {
        
        var onNextButtonTapped: (()->())?
        var onPrevButtonTapped: (()->())?
        var onDoneButtonTapped: (()->())?
        
        var currentState: Int = 0
    
        let translucentLayer = CALayer()
        let holeLayer = CAShapeLayer()
        
        var curPath: CGPath!
        var newPath: CGPath!
        
        let controlContainer = UIView()
        let prevBtn = UIButton()
        let nextBtn = UIButton()
        let doneBtn = UIButton()
        let infoLabel = UILabel()
        
        var ccTop: NSLayoutConstraint!
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() -> Void {
            holeLayer.fillColor = UIColor.black.cgColor
            holeLayer.fillRule = .evenOdd
            translucentLayer.backgroundColor = UIColor.black.withAlphaComponent(0.7).cgColor
            layer.addSublayer(translucentLayer)
            
            controlContainer.translatesAutoresizingMaskIntoConstraints = false
            addSubview(controlContainer)
            
            ccTop = controlContainer.topAnchor.constraint(equalTo: topAnchor)
            NSLayoutConstraint.activate([
                ccTop,
                controlContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
                controlContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
                controlContainer.heightAnchor.constraint(equalToConstant: 60.0),
            ])
            
            prevBtn.setTitle("Voltar", for: [])
            nextBtn.setTitle("Próximo", for: [])
            doneBtn.setTitle("OK, entendi", for: [])
            infoLabel.textAlignment = .center
            infoLabel.textColor = .white
            for v in [prevBtn, nextBtn, doneBtn, infoLabel] {
                v.translatesAutoresizingMaskIntoConstraints = false
                controlContainer.addSubview(v)
            }
            NSLayoutConstraint.activate([
                prevBtn.leadingAnchor.constraint(equalTo: controlContainer.leadingAnchor, constant: 8.0),
                prevBtn.bottomAnchor.constraint(equalTo: controlContainer.bottomAnchor, constant: -4.0),
                nextBtn.trailingAnchor.constraint(equalTo: controlContainer.trailingAnchor, constant: -8.0),
                nextBtn.bottomAnchor.constraint(equalTo: controlContainer.bottomAnchor, constant: -4.0),
                doneBtn.trailingAnchor.constraint(equalTo: controlContainer.trailingAnchor, constant: -8.0),
                doneBtn.bottomAnchor.constraint(equalTo: controlContainer.bottomAnchor, constant: -4.0),
                infoLabel.topAnchor.constraint(equalTo: controlContainer.topAnchor, constant: 8.0),
                infoLabel.leadingAnchor.constraint(equalTo: controlContainer.leadingAnchor, constant: 8.0),
                infoLabel.trailingAnchor.constraint(equalTo: controlContainer.trailingAnchor, constant: -8.0),
            ])
            
            nextBtn.addTarget(self, action: #selector(nextTapped(_:)), for: .touchUpInside)
            prevBtn.addTarget(self, action: #selector(prevTapped(_:)), for: .touchUpInside)
            doneBtn.addTarget(self, action: #selector(doneTapped(_:)), for: .touchUpInside)
            
            controlContainer.backgroundColor = .systemRed
            
            // we start with control container alpha at Zero
            controlContainer.alpha = 0.0
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            translucentLayer.frame = bounds
        }
        
        public func updateHole(_ r: CGRect, animated: Bool, cPos: ControlPosition) {
            
            translucentLayer.mask = holeLayer
            
            let path = UIBezierPath(rect: bounds)
            
            // create a path for the "hole" in the layer
            var newR: CGRect = r
            newR.origin.x = 0.0
            newR.size.width = bounds.width
            let holePath = UIBezierPath(rect: newR)
            
            // this "cuts a hole" in the path
            path.append(holePath)
            path.usesEvenOddFillRule = true
            
            newPath = path.cgPath
            
            if !animated {
                self.curPath = self.newPath
                self.holeLayer.path = self.curPath
            } else {
                // fade-out controlContainer
                UIView.animate(withDuration: 0.3, animations: {
                    self.controlContainer.alpha = 0.0
                }, completion: { _ in
                    // update next/prev/done buttons visiblity
                    self.nextBtn.isHidden = self.currentState == 2 ? true : false
                    self.prevBtn.isHidden = self.currentState == 0 ? true : false
                    self.doneBtn.isHidden = !self.nextBtn.isHidden
                    self.infoLabel.text = "Current State: \(self.currentState)"
            
                    // update control container position
                    self.ccTop.constant = cPos == .below ? r.maxY : r.minY - self.controlContainer.frame.height
                    self.animPath()
                })
            }
            
        }
        
        private func animPath() {
            
            CATransaction.begin()
            
            CATransaction.setCompletionBlock({
                // on animation completion, we want to
                //  update the holeLayer's path, and
                //  fade-in the controls container
                self.curPath = self.newPath
                self.holeLayer.path = self.curPath
                UIView.animate(withDuration: 0.3, animations: {
                    self.controlContainer.alpha = 1.0
                })
            })
            
            let animation = CABasicAnimation(keyPath: "path")
            animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
            
            // fromValue is not strictly necessary here, but we use it for consistency
            animation.fromValue = curPath
            animation.toValue = newPath
            
            animation.isRemovedOnCompletion = false
            
            // adjust animation speed as desired
            animation.duration = 0.75
            
            holeLayer.add(animation, forKey: "positionAnimation")
            
            CATransaction.commit()
            
        }
        
        @objc func nextTapped(_ sender: Any?) {
            currentState += 1
            onNextButtonTapped?()
        }
        @objc func prevTapped(_ sender: Any?) {
            currentState -= 1
            onPrevButtonTapped?()
        }
        @objc func doneTapped(_ sender: Any?) {
            onDoneButtonTapped?()
        }
    
    }