[SOLVED] CAShapeLayer with different Colors

Issue

I have a CAShapeLayer based on this answer that animates along with a UISlider.

enter image description here

It works fine but as the shapeLayer follows along its just 1 red CAGradientLayer color. What I want is the shapeLayer to change colors based on certain points of the slider. An example is at 0.4 – 0.5 it’s red, 0.7-0.8 red, 0.9-0.95 red. Those aren’t actual values, the actual values will vary. I figure that any time it doesn’t meet the condition to turn red it should probably just be a clear color, which will just show the black track underneath it. The result would look something like this (never mind the shape)

enter image description here

The red colors are based on the user scrubbing the slider and the letting go. The different positions of the slider that determine the red color is based on whatever condition. How can I do this.

UISlider

lazy var slider: UISlider = {
    let s = UISlider()
    s.translatesAutoresizingMaskIntoConstraints = false
    s.minimumTrackTintColor = .blue
    s.maximumTrackTintColor = .white
    s.minimumValue = 0
    s.maximumValue = 1
    s.addTarget(self, action: #selector(onSliderChange), for: .valueChanged)
    return s
    s.addTarget(self, action: #selector(onSliderEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
    return s
}()

lazy var progressView: GradientProgressView = {
    let v = GradientProgressView()
    v.translatesAutoresizingMaskIntoConstraints = false
    return v
}()

@objc fileprivate func onSliderChange(_ slider: UISlider) {

    let condition: Bool = // ...

    let value = slider.value
    progressView.setProgress(CGFloat(value), someCondition: condition, slider_X_Position: slider_X_PositionInView())
}

@objc fileprivate func onSliderEnded(_ slider: UISlider) {

    let value = slider.value
    progressView.resetProgress(CGFloat(value))
}

// ... progressView is the same width as the the slider

func slider_X_PositionInView() -> CGFloat {
    
    let trackRect = slider.trackRect(forBounds: slider.bounds)
    let thumbRect = slider.thumbRect(forBounds: slider.bounds,
                                           trackRect: trackRect,
                                           value: slider.value)

    let convertedThumbRect = slider.convert(thumbRect, to: self.view)
    
    return convertedThumbRect.midX
}

GradientProgressView:

public class GradientProgressView: UIView {

    var shapeLayer: CAShapeLayer = {
       // ...
    }()

    private var trackLayer: CAShapeLayer = {
        let trackLayer = CAShapeLayer()
        trackLayer.strokeColor = UIColor.black.cgColor
        trackLayer.fillColor = UIColor.clear.cgColor
        trackLayer.lineCap = .round
        return trackLayer
    }()

    private var gradient: CAGradientLayer = {
        let gradient = CAGradientLayer()
        let redColor = UIColor.red.cgColor
        gradient.colors = [redColor, redColor]
        gradient.locations = [0.0, 1.0]
        gradient.startPoint = CGPoint(x: 0, y: 0)
        gradient.endPoint = CGPoint(x: 1, y: 0)
        return gradient
    }()

    // ... add the above layers as subLayers to self ...

    func updatePaths() { // added in layoutSubviews

        let lineWidth = bounds.height / 2
        trackLayer.lineWidth = lineWidth * 0.75
        shapeLayer.lineWidth = lineWidth

        let path = UIBezierPath()
        path.move(to: CGPoint(x: bounds.minX + lineWidth / 2, y: bounds.midY))
        path.addLine(to: CGPoint(x: bounds.maxX - lineWidth / 2, y: bounds.midY))

        trackLayer.path = path.cgPath
        shapeLayer.path = path.cgPath

        gradient.frame = bounds
        gradient.mask = shapeLayer
        
        shapeLayer.duration = 1
        shapeLayer.strokeStart = 0
        shapeLayer.strokeEnd = 0
    }

    public func setProgress(_ progress: CGFloat, someCondition: Bool, slider_X_Position: CGFloat) {

        // slider_X_Position might help with shapeLayer's x position for the colors ???  

        if someCondition {
             // redColor until the user lets go
        } else {
            // otherwise always a clearColor
        }

        shapeLayer.strokeEnd = progress
    }
}

    public func resetProgress(_ progress: CGFloat) {

        // change to clearColor after finger is lifted
    }
}

Solution

To get this:

enter image description here

We can use a CAShapeLayer for the red "boxes" and a CALayer as a .mask on that shape layer.

To reveal / cover the boxes, we set the frame of the mask layer to a percentage of the width of the bounds.

Here’s a complete example:

class StepView: UIView {
    public var progress: CGFloat = 0 {
        didSet {
            setNeedsLayout()
        }
    }
    public var steps: [[CGFloat]] = [[0.0, 1.0]] {
        didSet {
            setNeedsLayout()
        }
    }
    public var color: UIColor = .red {
        didSet {
            stepLayer.fillColor = color.cgColor
        }
    }
    
    private let stepLayer = CAShapeLayer()
    private let maskLayer = CALayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        backgroundColor = .black
        layer.addSublayer(stepLayer)
        stepLayer.fillColor = color.cgColor
        stepLayer.mask = maskLayer
        // mask layer can use any solid color
        maskLayer.backgroundColor = UIColor.white.cgColor
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        
        stepLayer.frame = bounds
        
        let pth = UIBezierPath()
        steps.forEach { pair in
            // rectangle for each "percentage pair"
            let w = bounds.width * (pair[1] - pair[0])
            let b = UIBezierPath(rect: CGRect(x: bounds.width * pair[0], y: 0, width: w, height: bounds.height))
            pth.append(b)
        }
        stepLayer.path = pth.cgPath
        
        // update frame of mask layer
        var r = bounds
        r.size.width = bounds.width * progress
        maskLayer.frame = r
        
    }
}

class StepVC: UIViewController {
    let stepView = StepView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        stepView.translatesAutoresizingMaskIntoConstraints = false
        
        let slider = UISlider()
        slider.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(stepView)
        view.addSubview(slider)

        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            stepView.topAnchor.constraint(equalTo: g.topAnchor, constant: 80.0),
            stepView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            stepView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            stepView.heightAnchor.constraint(equalToConstant: 40.0),

            slider.topAnchor.constraint(equalTo: stepView.bottomAnchor, constant: 40.0),
            slider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            slider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),

        ])
        
        let steps: [[CGFloat]] = [
            [0.1, 0.3],
            [0.4, 0.5],
            [0.7, 0.8],
            [0.9, 0.95],
        ]
        stepView.steps = steps

        slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
        
    }
    
    @objc func sliderChanged(_ sender: UISlider) {
        
        // disable CALayer "built-in" animations
        CATransaction.setDisableActions(true)
        stepView.progress = CGFloat(sender.value)
        CATransaction.commit()
        
    }
}

Edit

I’m still not clear on your 0.4 - 0.8 requirement, but maybe this will help get you on your way:

enter image description here

Please note: this is Example Code Only!!!

struct RecordingStep {
    var color: UIColor = .black
    var start: Float = 0
    var end: Float = 0
    var layer: CALayer!
}

class StepView2: UIView {
    
    public var progress: Float = 0 {
        didSet {
            // move the progress layer
            progressLayer.position.x = bounds.width * CGFloat(progress)
            // if we're recording
            if isRecording {
                let i = theSteps.count - 1
                guard i > -1 else { return }
                // update current "step" end
                theSteps[i].end = progress
                setNeedsLayout()
            }
        }
    }
    
    private var isRecording: Bool = false
    
    private var theSteps: [RecordingStep] = []

    private let progressLayer = CAShapeLayer()
    
    public func startRecording(_ color: UIColor) {
        // create a new "Recording Step"
        var st = RecordingStep()
        st.color = color
        st.start = progress
        st.end = progress
        let l = CALayer()
        l.backgroundColor = st.color.cgColor
        layer.insertSublayer(l, below: progressLayer)
        st.layer = l
        theSteps.append(st)
        isRecording = true
    }
    public func stopRecording() {
        isRecording = false
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        backgroundColor = .black
        progressLayer.lineWidth = 3
        progressLayer.strokeColor = UIColor.green.cgColor
        progressLayer.fillColor = UIColor.clear.cgColor
        layer.addSublayer(progressLayer)
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // only set the progessLayer frame if the bounds height has changed
        if progressLayer.frame.height != bounds.height + 7.0 {
            let r: CGRect = CGRect(origin: .zero, size: CGSize(width: 7.0, height: bounds.height + 7.0))
            let pth = UIBezierPath(roundedRect: r, cornerRadius: 3.5)
            progressLayer.frame = r
            progressLayer.position = CGPoint(x: 0, y: bounds.midY)
            progressLayer.path = pth.cgPath
        }
        
        theSteps.forEach { st in
            let x = bounds.width * CGFloat(st.start)
            let w = bounds.width * CGFloat(st.end - st.start)
            let r = CGRect(x: x, y: 0.0, width: w, height: bounds.height)
            st.layer.frame = r
        }
        
    }
}

class Step2VC: UIViewController {
    
    let stepView = StepView2()
    
    let actionButton: UIButton = {
        let b = UIButton()
        b.backgroundColor = .lightGray
        b.setImage(UIImage(systemName: "play.fill"), for: [])
        b.tintColor = .systemGreen
        return b
    }()
    
    var timer: Timer!
    
    let colors: [UIColor] = [
        .red, .systemBlue, .yellow, .cyan, .magenta, .orange,
    ]
    var colorIdx: Int = -1
    var action: Int = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        stepView.translatesAutoresizingMaskIntoConstraints = false
        actionButton.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(stepView)
        view.addSubview(actionButton)

        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            stepView.topAnchor.constraint(equalTo: g.topAnchor, constant: 80.0),
            stepView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            stepView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            stepView.heightAnchor.constraint(equalToConstant: 40.0),
            
            actionButton.topAnchor.constraint(equalTo: stepView.bottomAnchor, constant: 40.0),
            actionButton.widthAnchor.constraint(equalToConstant: 80.0),
            actionButton.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            
        ])

        actionButton.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
        
    }
    
    @objc func timerFunc(_ timer: Timer) {

        // don't set progress > 1.0
        stepView.progress = min(stepView.progress + 0.005, 1.0)

        if stepView.progress >= 1.0 {
            timer.invalidate()
            actionButton.isHidden = true
        }
        
    }
    
    @objc func btnTap(_ sender: UIButton) {
        switch action {
        case 0:
            // this will run for 15 seconds
            timer = Timer.scheduledTimer(timeInterval: 0.075, target: self, selector: #selector(timerFunc(_:)), userInfo: nil, repeats: true)
            stepView.stopRecording()
            actionButton.setImage(UIImage(systemName: "record.circle"), for: [])
            actionButton.tintColor = .red
            action = 1
        case 1:
            colorIdx += 1
            stepView.startRecording(colors[colorIdx % colors.count])
            actionButton.setImage(UIImage(systemName: "stop.circle"), for: [])
            actionButton.tintColor = .black
            action = 2
        case 2:
            stepView.stopRecording()
            actionButton.setImage(UIImage(systemName: "record.circle"), for: [])
            actionButton.tintColor = .red
            action = 1
        default:
            ()
        }
    }
    
}

For future reference, when posting here, it’s probably a good idea to fully explain what you’re trying to do. Showing code you’re working on is important, but if it’s really only sorta related to your actual goal, it makes this process pretty difficult.

Answered By – DonMag

Answer Checked By – Dawn Plyler (BugsFixing Volunteer)

Leave a Reply

Your email address will not be published.