swiftuibezierpathcashapelayercagradientlayer

Wave progress view inside a bezier path in Swift


I am trying to make a progress view which consists of two moving waves with gradient fill. The waves should only be inside (bound by) a bezier path (here a triangle). This is my code:


class WaveView: UIView {
    
    private let firstLayer = CAShapeLayer()
    private let secondLayer = CAShapeLayer()
    private let gradientLayer = CAGradientLayer()
    private let shapeLayer = CAShapeLayer()
    
    private var trianglePath: UIBezierPath {
        let w = bounds.width
        let h = bounds.height
        let path = UIBezierPath()
        path.move(to: CGPoint(x: w / 2, y: 0))
        path.addLine(to: CGPoint(x: w, y: h))
        path.addLine(to: CGPoint(x: 0, y: h))
        path.close()
        return path
    }
    
    private var firstColor: UIColor = .clear
    private var secondColor: UIColor = .clear
    
    private var offset: CGFloat = 0.0
    private let twoPie: CGFloat = 2.0 * .pi
    
    var showSingleWave: Bool = false
    private var start: Bool = false
    
    private(set) var progress: CGFloat = 0.0
    
    private var waveHeight: CGFloat = 0.0
    
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupViews() {

        waveHeight = 20.0
        
        firstColor = .cyan
        secondColor = .cyan.withAlphaComponent(0.4)
        
        createStarLayer()
        
        createFirstLayer()
        
        if !showSingleWave {
            createSecondLayer()
        }
        
        createGradientLayer()
    }
    
    private func createStarLayer() {
        
        shapeLayer.path = trianglePath.cgPath
        //shapeLayer.masksToBounds = true
        
    }
    
    private func createGradientLayer() {
        gradientLayer.frame = bounds
        gradientLayer.colors = [UIColor.blue.cgColor, UIColor.red.cgColor]
        gradientLayer.startPoint = CGPoint(x: 0.5, y: 1.0)
        gradientLayer.endPoint = CGPoint(x: 0.5, y: 0.0)
        
        gradientLayer.mask = shapeLayer
        
        layer.insertSublayer(gradientLayer, at: 0)
    }
    
    private func createFirstLayer() {
        firstLayer.frame = bounds
        firstLayer.anchorPoint = .zero
        firstLayer.fillColor = firstColor.cgColor
        
        shapeLayer.addSublayer(firstLayer)

    }
    
    private func createSecondLayer() {
        secondLayer.frame = bounds
        secondLayer.anchorPoint = .zero
        secondLayer.fillColor = secondColor.cgColor
        
        shapeLayer.addSublayer(secondLayer)
    }
    
    func setProgress(_ pr: CGFloat) {
        progress = min(pr, 1.0)
        
        let top: CGFloat = progress * bounds.height
        firstLayer.setValue(bounds.width - top, forKeyPath: "position.y")
        secondLayer.setValue(bounds.width - top, forKeyPath: "position.y")

        if !start {
            DispatchQueue.main.async {
                self.startAnim()
            }
        }
    }
    
    private func startAnim() {
        start = true
        waterWaveAnim()
    }
    
    private func waterWaveAnim() {
        let w = bounds.width
        let h = bounds.height
        let W = w * 10
        
        let bezier = UIBezierPath()
        
        let startOffsetY = waveHeight * CGFloat(sinf(Float(offset * twoPie / w)))
        var originOffsetY: CGFloat = 0.0
        
        bezier.move(to: CGPoint(x: 0.0, y: startOffsetY))
        
        for i in stride(from: 0.0, to: W, by: 20.0) {
            originOffsetY = waveHeight * CGFloat(sinf(Float(twoPie / w * i + offset * twoPie / w)))
            bezier.addLine(to: CGPoint(x: i, y: originOffsetY))
        }
        
        bezier.addLine(to: CGPoint(x: W, y: originOffsetY))
        bezier.addLine(to: CGPoint(x: W, y: h))
        bezier.addLine(to: CGPoint(x: 0.0, y: h))
        bezier.addLine(to: CGPoint(x: 0.0, y: startOffsetY))
        bezier.close()
        
        let anim = CABasicAnimation(keyPath: "transform.translation.x")
        anim.duration = 1.0
        anim.fromValue = -w * 0.5
        anim.toValue = -w - w * 0.5
        anim.repeatCount = .infinity
        anim.isRemovedOnCompletion = false
        
        
        firstLayer.fillColor = firstColor.cgColor
        firstLayer.path = bezier.cgPath
        firstLayer.add(anim, forKey: nil)
        
        if !showSingleWave {
            let bezier = UIBezierPath()
            
            let startOffsetY = waveHeight * CGFloat(sinf(Float(offset * twoPie / w)))
            var originOffsetY: CGFloat = 0.0
            
            bezier.move(to: CGPoint(x: 0.0, y: startOffsetY))
            
            for i in stride(from: 0.0, to: W, by: 20.0) {
                originOffsetY = waveHeight * CGFloat(-sinf(Float(twoPie / w * i + offset * twoPie / w)))
                bezier.addLine(to: CGPoint(x: i, y: originOffsetY))
            }
            
            bezier.addLine(to: CGPoint(x: W, y: originOffsetY))
            bezier.addLine(to: CGPoint(x: W, y: h))
            bezier.addLine(to: CGPoint(x: 0.0, y: h))
            bezier.addLine(to: CGPoint(x: 0.0, y: startOffsetY))
            bezier.close()
            
            secondLayer.fillColor = secondColor.cgColor
            secondLayer.path = bezier.cgPath
            secondLayer.add(anim, forKey: nil)
        }
    }
}

Usage:

let wv = WaveView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
wv.showSingleWave = false
view.addSubview(wv)
        
wv.snp.makeConstraints { make in
    make.size.equalTo(wv.frame.size)
    make.center.equalToSuperview()
}
        
        wv.setProgress(0.5)

The result is:

enter image description here

which is not what I am looking for. The waves fill the frame according to the progress value but the triangle shape is totally filled with the gradient colors. How can I make the waves be only inside the path (triangle) and such that the path is filled with the wave based on the progress value.


Solution

  • A lot of your setup code should be re-worked, so the view can adjust to size changes... but, for now...

    You want to apply the mask to the view's layer -- not to the shapeLayer.

    Change your setupViews() to this:

    private func setupViews() {
    
        // set the "base" background color
        self.layer.backgroundColor = UIColor.red.cgColor
        
        waveHeight = 20.0
        
        firstColor = .cyan
        secondColor = .cyan.withAlphaComponent(0.4)
        
        // don't call this
        //createStarLayer()
        
        createFirstLayer()
        
        if !showSingleWave {
            createSecondLayer()
        }
        
        createGradientLayer()
        
        // apply the triangle shape mask to self.layer
        let maskLayer = CAShapeLayer()
        maskLayer.path = trianglePath.cgPath
        self.layer.mask = maskLayer
        
    }
    

    an example controller - "progress" starts at 0.1 each tap will increment it by 0.1 until it reaches 1.0:

    class WaveTestVC: UIViewController {
        
        var wView: WaveView!
        var pct: CGFloat = 0.1
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .black
            
            let wv = WaveView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
            wv.showSingleWave = false
            view.addSubview(wv)
            wView = wv
            
            wv.snp.makeConstraints { make in
                make.size.equalTo(wv.frame.size)
                make.center.equalToSuperview()
            }
            
            wv.setProgress(pct)
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            pct += 0.1
            pct = min(pct, 1.0)
            wView.setProgress(pct)
        }
        
    }
    

    This will be the result:

    enter image description here