Search code examples
swiftswiftuigraphuikitgradient

SwiftUI Add Bottom Gradient To Line Graph


I want to add a bottom gradient in my line graph like in the picture. I have the lines plotted out but I am not sure how to actually add the gradient. I was able to do this in UIKit but am not sure how to replicate it for SwiftUI.

SwiftUI Line Plot View:

var endingBalanceChart: some View {
    GeometryReader { geometry in
        Path { path in
            for index in viewModel.endingBalance.indices {
                let xPosition: CGFloat = geometry.size.width / CGFloat(viewModel.endingBalance.count) * CGFloat(index + 1)
                
                let yAxis: CGFloat = maxY - minY
                
                let maxYPosition: CGFloat = (1 - CGFloat((Double(viewModel.endingBalance[index].y) - minY) / yAxis)) * geometry.size.height
                let yPosition: CGFloat = index == 0 ? 200 : maxYPosition

                if index == 0 {
                    path.move(to: CGPoint(x: 0, y: yPosition))
                }
                
                path.addLine(to: CGPoint(x: xPosition, y: yPosition))
            }
        }
        .stroke(Color.blue, style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round))
    }
}

enter image description here

enter image description here

I tried converting the path in to a UIBezierPath and trying my UIKit implementation but no luck

This was my UIKit implementation:

func addGradient(path: UIBezierPath, hexString: String){
        let color = UIColor(hexString: hexString).withAlphaComponent(0.4).cgColor

        guard let clippingPath = path.copy() as? UIBezierPath else { return }
        clippingPath.addLine(to: CGPoint(x: self.bounds.width, y: self.bounds.height))
        clippingPath.addLine(to: CGPoint(x: 0, y: bounds.height))
        clippingPath.close()
        clippingPath.addClip()

        let colors = [color, UIColor.clear.cgColor]
        let colorSpace = CGColorSpaceCreateDeviceRGB()
        let colorLocations: [CGFloat] = [0.0, 1.0]
        guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: colorLocations) else { return }

        guard let context = UIGraphicsGetCurrentContext() else { return }
        let startPoint = CGPoint(x: 1, y: 1)
        let endPoint = CGPoint(x: 1, y: bounds.maxY)

        context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: .drawsAfterEndLocation)
    }

Solution

  • Here would be a pure SwiftUI implementation.

    I suggest to convert the var into an own struct that conforms to Shape protocol. Then you can use it for both stroke and fill background.

    This has the positive side effect that you don't need a GeometryReader any more, as Shape provides you with the drawing rectangle with func path(in rect: CGRect).

    The result looks like this:

    enter image description here

    let endingBalance: [Double] = [0, 1, 2, 4, 7, 11, 16, 22, 29, 37, 46, 56] // dummy data
    
    
    struct ContentView: View {
        
        var body: some View {
            VStack {
                Text("Chart")
                
                EndingBalanceChart()
                    .stroke(Color.blue, style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round)) // line
                    .background(
                        EndingBalanceChart(isBackground: true)
                            .fill(.linearGradient(colors: [.cyan, .clear], startPoint: .top, endPoint: .bottom)) // background fill
                        )
                    .frame(height: 200)
                    .padding()
            }
        }
    }
    
    
    struct EndingBalanceChart: Shape { // chnaged var to a Shape struct
        
        var isBackground: Bool = false
        
        
        func path(in rect: CGRect) -> Path {
            Path { path in
                for index in endingBalance.indices {
                    let xPosition: CGFloat = rect.width / CGFloat(endingBalance.count) * CGFloat(index + 1)
                    
                    let maxY = endingBalance.max() ?? 0
                    let minY = endingBalance.min() ?? 0
                    
                    let yAxis: CGFloat = maxY - minY
                    
                    let yPosition: CGFloat = (1 - CGFloat((Double(endingBalance[index]) - minY) / yAxis)) * rect.height
                    
                    if index == 0 {
                        path.move(to: CGPoint(x: 0, y: rect.height))
                    }
                    path.addLine(to: CGPoint(x: xPosition, y: yPosition))
                }
                if isBackground { // this is needed so the backkground shape is filled correctly (closing the shape)
                    path.addLine(to: CGPoint(x: rect.width, y: rect.height))
                }
            }
        }
    }