Search code examples
iosswiftuibezierpath

How to change stroke and path color on UIBezierPath when it crosses a threshold


New to Core Graphics and have a question I'm not sure how to approach.

I need to draw a graph that changes stroke and fill color when the path crosses a threshold line as seen in this example:

enter image description here

I can draw the graph in all green with no problem. It is the parts that are above or below the threshold lines that I am not sure how to do. The path may cross the threshold in between points.

I have thought of drawing the graph twice, first in orange, then in green, then adding a mask over the green path with the threshold lines forming a rect that would allow the orange version of the graph to show through.

I'm sure there is a better approach. Any pointers would be appreciated. I'm using Swift 4.


Solution

  • You're on the right track. For each color band:

    1. Save the graphics state.
    2. Clip the context to just the area covered by the current color band.
    3. Fill and stroke the full path in the appropriate colors.
    4. Restore the graphics state.

    In code, it could look something like this:

        for band in bands {
            let y0 = max(CGPoint(x: 0, y: band.min).applying(transform).y, 0)
            let y1 = min(CGPoint(x: 0, y: band.max).applying(transform).y, mySize.height)
            gc.saveGState(); do {
                gc.clip(to: CGRect(x: 0, y: y0, width: mySize.width, height: y1 - y0))
                band.fillColor.setFill()
                gc.addPath(pathForFilling)
                gc.fillPath()
                band.strokeColor.setStroke()
                gc.addPath(pathForStroking)
                gc.strokePath()
            }; gc.restoreGState()
        }
    

    Result:

    demo result

    Here's my full playground code:

    import UIKit
    
    class BandedGraphView: UIView {
    
        struct Band {
            var min: CGFloat // In data geometry
            var max: CGFloat // In data geometry
            var strokeColor: UIColor
            var fillColor: UIColor { return strokeColor.withAlphaComponent(0.2) }
        }
    
        var bands: [Band] = [] {
            didSet { setNeedsDisplay() }
        }
    
        /// The minimum visible data geometry coordinate
        var minVisiblePoint = CGPoint.zero {
            didSet { setNeedsDisplay() }
        }
    
        /// The maximum visible data geometry coordinate
        var maxVisiblePoint = CGPoint(x: 1, y: 1) {
            didSet { setNeedsDisplay() }
        }
    
        /// Data points, in data geometry.
        var data: [CGPoint] = [] {
            didSet { setNeedsDisplay() }
        }
    
        var lineWidth: CGFloat = 2 {
            didSet { setNeedsDisplay() }
        }
    
        override func draw(_ rect: CGRect) {
            guard
                minVisiblePoint.x != maxVisiblePoint.x,
                minVisiblePoint.y != maxVisiblePoint.y,
                !bands.isEmpty,
                !data.isEmpty,
                let gc = UIGraphicsGetCurrentContext()
                else { return }
    
            let mySize = bounds.size
    
            var transform = CGAffineTransform.identity
            transform = transform.scaledBy(x: mySize.width / (maxVisiblePoint.x - minVisiblePoint.x), y: mySize.height / (maxVisiblePoint.y - minVisiblePoint.y))
            transform = transform.translatedBy(x: -minVisiblePoint.x, y: -minVisiblePoint.y)
    
            let pathForStroking = CGMutablePath()
            pathForStroking.addLines(between: data, transform: transform)
    
            let pathForFilling = pathForStroking.mutableCopy(using: nil)!
            let firstPoint = data.first!.applying(transform)
            let lastPoint = data.last!.applying(transform)
            print(pathForFilling)
            pathForFilling.addLine(to: CGPoint(x: lastPoint.x, y: -CGFloat.greatestFiniteMagnitude))
            pathForFilling.addLine(to: CGPoint(x: firstPoint.x, y: -CGFloat.greatestFiniteMagnitude))
            pathForFilling.closeSubpath()
            print(pathForFilling)
    
            // Transform the context so the origin is at the lower left.
            gc.translateBy(x: 0, y: mySize.height)
            gc.scaleBy(x: 1, y: -1)
    
            for band in bands {
                let y0 = max(CGPoint(x: 0, y: band.min).applying(transform).y, 0)
                let y1 = min(CGPoint(x: 0, y: band.max).applying(transform).y, mySize.height)
                gc.saveGState(); do {
                    gc.clip(to: CGRect(x: 0, y: y0, width: mySize.width, height: y1 - y0))
                    band.fillColor.setFill()
                    gc.addPath(pathForFilling)
                    gc.fillPath()
                    band.strokeColor.setStroke()
                    gc.addPath(pathForStroking)
                    gc.strokePath()
                }; gc.restoreGState()
            }
        }
    
    }
    
    import PlaygroundSupport
    
    let view = BandedGraphView(frame: CGRect(x: 0, y: 0, width: 400, height: 300))
    view.backgroundColor = .white
    view.bands = [
        .init(min: -CGFloat.infinity, max: -0.8, strokeColor: .blue),
        .init(min: -0.8, max: 0.2, strokeColor: .red),
        .init(min: 0.2, max: CGFloat.infinity, strokeColor: .orange),
    ]
    view.minVisiblePoint = CGPoint(x: 0, y: -2)
    view.maxVisiblePoint = CGPoint(x: 10, y: 2)
    view.lineWidth = 2
    view.data = stride(from: CGFloat(0), through: CGFloat(10), by: CGFloat(0.01)).map { CGPoint(x: $0, y: cos($0)) }
    
    PlaygroundPage.current.liveView = view