Search code examples
iosswiftuislidercashapelayer

CAShapeLayer with different Colors


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.