swiftswiftuiswiftcharts

Swift Chart foregroundstyle(for:) chartOverlay change colour of indicator based on where the user is dragging


I have a chart - see screenshot below. As you can see I have a gradient on the colour on the LineMark. I also have a Circle on the chartOverlay which tracks where the user drags. This is currently white, but I want to update that to be the color of the line at that point. I have found this (foregroundstyle(for:)) in the apple docs but doesn't work with any data I feed it and always returns nil.

Here is my code

struct ContentView: View {
    @State private var numbers = (0...10).map { _ in
        Int.random(in: 0...10)
    }

    @State private var indicatorLocation = CGPointMake(0, 0)
    
    var body: some View {
        Chart {
            ForEach(Array(zip(numbers, numbers.indices)), id: \.0) { number, index in
                LineMark(
                    x: .value("Index", index),
                    y: .value("Value", number)
                )
                .foregroundStyle(
                    .linearGradient(
                        colors: [.green, .red],
                        startPoint: .bottom,
                        endPoint: .top
                    )
                )
            }
        }
        .chartOverlay { proxy in
            GeometryReader { geometry in
                Circle()
                    .fill(Color.white)
                    .frame(width: 30, height: 30)
                    .offset(x: -15.0, y: -15.0)
                    .offset(x: indicatorLocation.x, y: indicatorLocation.y)
                
                Rectangle().fill(.clear).contentShape(Rectangle())
                    .gesture(
                        DragGesture()
                            .onChanged { value in
                                let origin = geometry[proxy.plotAreaFrame].origin
                                let location = CGPoint(
                                    x: value.location.x - origin.x,
                                    y: value.location.y - origin.y
                                )
                                let (index, number) = proxy.value(at: location, as: (Int, Double).self) ?? (0, 0)
                               ....logic to get indicator location 
                                indicatorLocation = CGPoint(x: value.location.x, y: y)
                            }
                    )
            }
        }
        
    }
}

enter image description here


Solution

  • First, you need a function for interpolating between colours. I have adapted this answer to work with SwiftUI's Color.Resolved.

    extension Array where Element == Color.Resolved {
        // percentage should be between 0 and 1
        func intermediate(percentage: Float) -> Color.Resolved {
            let percentage = Swift.max(Swift.min(percentage, 1), 0)
            switch percentage {
            case 0: return first ?? Color.Resolved(red: 0, green: 0, blue: 0, opacity: 0)
            case 1: return last ?? Color.Resolved(red: 0, green: 0, blue: 0, opacity: 0)
            default:
                let approxIndex = percentage / (1 / Float(count - 1))
                let firstIndex = Int(approxIndex.rounded(.down))
                let secondIndex = Int(approxIndex.rounded(.up))
                
                let firstColor = self[firstIndex]
                let secondColor = self[secondIndex]
                
                var (r1, g1, b1, a1): (Float, Float, Float, Float) = (
                    firstColor.red, firstColor.green, firstColor.blue, firstColor.opacity
                )
                var (r2, g2, b2, a2): (Float, Float, Float, Float) = (
                    secondColor.red, secondColor.green, secondColor.blue, secondColor.opacity
                    )
                
                let intermediatePercentage = approxIndex - Float(firstIndex)
                return Color.Resolved(
                    red: (r1 + (r2 - r1) * intermediatePercentage),
                    green: (g1 + (g2 - g1) * intermediatePercentage),
                    blue: (b1 + (b2 - b1) * intermediatePercentage),
                    opacity: (a1 + (a2 - a1) * intermediatePercentage)
                )
            }
        }
    }
    

    Color.Resolved is only available on iOS 17+. If you want to support lower versions, just use the answer as it is. But note that the percentage parameter in that answer takes a value between 0 and 100. You should change the first line a little:

    let percentage = Swift.max(Swift.min(percentage, 1), 0)
    

    Then, add a @State to store the color:

    @State private var color: Color.Resolved = .init(red: 0, green: 0, blue: 0, opacity: 0)
    // before iOS 17:
    @State private var color: UIColor = .clear
    

    After that, the color can be calculated like this:

    color = [
        Color.red.resolve(in: env),
        Color.green.resolve(in: env)
    ].intermediate(percentage: Float((y - origin.y) / frame.height))
    // before iOS 17:
    color = [
        UIColor(Color.red),
        UIColor(Color.green)
    ]
    .intermediate(percentage: (y - origin.y) / frame.height)
    

    Note that pre-iOS 17 you cannot resolve colors with regards to the SwiftUI environment, so the colors will not be affected by SwiftUI modifiers like .environment(\.colorScheme, .dark). Technically, if you don't plan on doing things like .environment(\.colorScheme, .dark), you don't need to resolve the colors either - just use UIColors like you do pre-iOS 17.