1

I'm creating a line chart view, inspired by this blog post.

The lines are drawn with Path and the gradient with a LinearGradient and a mask, and they are in overlays on top of each other.

My main issue is that, as you can see in the screenshot, the start of all the gradients have a slope, they seem to close too soon if the value is zero (also the ends seem to be cut too early but it's not my main isssue). The starts also seem to begin too late if the value is > 0 (like the orange one).

swiftui line chart

What I want is for the gradient to fully fill the parts where the red arrows are pointing (removing the slopes and the abrupt cuts).

But I can't find a solution by just shuffling up the magic numbers. Besides, I'd prefer to understand what's happening rather than just poking around.

What am I doing wrong?


I've made a reproducible example, paste this is a ContentView:

struct ContentView: View {
    
    @State var values: [Double] = [0, 0, 1, 2, 0, 6, 4, 0, 1, 1, 0, 0]
    @State var totalMaxnum: Double = 6
    
    var body: some View {
        GeometryReader { proxy in
            ScrollView {
                VStack {
                    // some other view
                    
                    VStack {
                        VStack {
                            RoundedRectangle(cornerRadius: 5)
                                .overlay(
                                    ZStack {
                                        Color.white.opacity(0.95)
                                        
                                        drawValuesLine(values: values, color: .blue)
                                    }
                                )
                                .overlay(
                                    ZStack {
                                        Color.clear
                                        
                                        drawValuesGradient(values: values, start: .orange, end: .red)
                                    }
                                )
                                .frame(width: proxy.size.width - 40, height: proxy.size.height / 4)
                            
                            // some other view
                        }
                        
                        // some other view
                    }
                }
            }
        }
    }
    
    func drawValuesLine(values: [Double], color: Color) -> some View {
        GeometryReader { geo in
            Path { p in
                let scale = (geo.size.height - 40) / CGFloat(totalMaxnum)
                var index: CGFloat = 0
                
                let y1: CGFloat = geo.size.height - 10 - (CGFloat(values[0]) * scale)
                let y = y1.isNaN ? 0 : y1
                p.move(to: CGPoint(x: 8, y: y))
                
                for _ in values {
                    if index != 0 {
                        let x1: CGFloat = 8 + ((geo.size.width - 16) / CGFloat(values.count)) * index
                        let x = x1.isNaN ? 0 : x1
                        let yy: CGFloat = geo.size.height - 10 - (CGFloat(values[Int(index)]) * scale)
                        let y = yy.isNaN ? 0 : yy
                        p.addLine(to: CGPoint(x: x, y: y))
                    }
                    index += 1
                }
                
                
            }
            .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round, miterLimit: 80, dash: [], dashPhase: 0))
            .foregroundColor(color)
        }
    }
    
    func drawValuesGradient(values: [Double], start: Color, end: Color) -> some View {
        LinearGradient(gradient: Gradient(colors: [start, end]), startPoint: .top, endPoint: .bottom)
            .padding(.bottom, 1)
            .opacity(0.8)
            .mask(
                GeometryReader { geo in
                    Path { p in
                        let scale = (geo.size.height - 40) / CGFloat(totalMaxnum)
                        var index: CGFloat = 0
                        
                        // Move to the starting point
                        let y1: CGFloat = geo.size.height - (CGFloat(values[Int(index)]) * scale)
                        let y = y1.isNaN ? 0 : y1
                        p.move(to: CGPoint(x: 8, y: y))
                        
                        // Draw the lines
                        for _ in values {
                            if index != 0 {
                                let x1: CGFloat = 8 + ((geo.size.width - 16) / CGFloat(values.count)) * index
                                let x = x1.isNaN ? 0 : x1
                                let yy: CGFloat = geo.size.height - 10 - (CGFloat(values[Int(index)]) * scale)
                                let y = yy.isNaN ? 0 : yy
                                p.addLine(to: CGPoint(x: x, y: y))
                            }
                            index += 1
                        }
                        
                        // Close the subpath
                        let x1: CGFloat = 8 + ((geo.size.width - 16) / CGFloat(values.count)) * (index - 1)
                        let x = x1.isNaN ? 0 : x1
                        p.addLine(to: CGPoint(x: x, y: geo.size.height))
                        p.addLine(to: CGPoint(x: 8, y: geo.size.height))
                        p.closeSubpath()
                    }
                }
            )
    }
}

You can see in the screenshot how the orange gradient doesn't properly fill all the area below the line:

enter image description here

  • Seems like this line: `p.addLine(to: CGPoint(x: 8, y: geo.size.height))`. Try changing it to `p.addLine(to: CGPoint(x: 0, y: geo.size.height))`. – aheze Jun 19 '21 at 15:43
  • @aheze Thanks. I also thought about that but it didn't fix it. Actually I think `p.addLine(to: CGPoint(x: 8, y: geo.size.height))` should be `p.addLine(to: CGPoint(x: 8, y: geo.size.height - (CGFloat(values[0]) * scale)))` since we're supposed to come back to the original point, but when I do that it draws a diagonal line from bottom right to top left... – Butterfly Ball Jun 19 '21 at 16:03
  • Hmm ok. Can you make a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example), without stuff like `model.allvalues`? – aheze Jun 19 '21 at 16:05
  • @aheze I will try, sure – Butterfly Ball Jun 19 '21 at 16:07
  • Thanks! It's much easier to debug when we can just copy-paste into Xcode. – aheze Jun 19 '21 at 16:07
  • 1
    @aheze I have added a simple example you can copy/paste in a ContentView. – Butterfly Ball Jun 19 '21 at 16:29

1 Answers1

1

The problem is in this line:

let y1: CGFloat = geo.size.height - (CGFloat(values[Int(index)]) * scale)

y1 becomes the height of the entire graph.

Starting point of gradient, whose y is the graph's height, circled

You need to subtract 10, because that's what you've been doing for your blue line and all the gradient's other points.

                              /// subtract 10
let y1: CGFloat = geo.size.height - 10 - (CGFloat(values[Int(index)]) * scale)

Starting point of gradient, whose y is the graph's height minus 10 (exactly the place where the blue line starts), circled

aheze
  • 24,434
  • 8
  • 68
  • 125
  • 1
    I could swear I already tried that. Well, looks like I didn't... Thanks a lot for spotting this issue and adding an explanation with a nice image, this is very cool. – Butterfly Ball Jun 19 '21 at 16:43
  • @ButterflyBall np. Also you said "the ends seem to be cut too early," but it looks ok to me... – aheze Jun 19 '21 at 16:46