Carl Sarkis
Carl Sarkis

Reputation: 21

In SwiftUI Charts, is it possible to change the color of linemarks and areamarks points based on custom criteria (not y)?

I want to draw an AreaMark chart with SwiftUI, with different colors based on some properties of my points that are not y, like this: Chart with different colors

Setting a foregroundStyle for each point will set the color of the last point. It looks pretty easy to set the color of linemarks and areamarks based on the y value, but I can't find how to base it on custom criteria. Is it possible to do it with Charts?

Upvotes: 1

Views: 1283

Answers (2)

Carl Sarkis
Carl Sarkis

Reputation: 168

After some tests, it is a bit more complicated (and my formula was wrong).

Here is my code if anyone wants to reuse and improve it (please share your results then!).

I know this code does not support:

  • the device rotation (the GeometryReader will not recompute)
  • Y axis marks insets different from 4 (accessibility settings? iPads?)

The idea is to compute a background image 1-pixel high and 300 pixels long (I picked 300 as it seamed reasonable for an iPhone, it can be changed easily.). The image must be pre-computed outside of the chart declaration, otherwise it will be computed for each point of the AreaMark.

Then you need to scale the image to perfectly match the chart width. Otherwise the background image will be displayed with its native size and repeat itself horizontally.

The hard part is to compute the chart size, excluding the axis marks. I used geometry readers to get the total size of the chart component, then the size of the Y axis marks, and added 4 for the margin between the 2.

The chartBackground(colorPicker:, size:) method supposes that path is a list of Point objects, ordered by a distance variable. The actual implementation of Point is custom but irrelevant here.

struct LinesGraphView: View {
    var path: [Point]

    @State var image = Image(pixels: [PixelData(a: 1, r: 1, g: 1, b: 1)], width: 1)
    @State var chartSize: CGSize = CGSize(width: 1, height: 1)
    @State var yMarksSize: CGSize = CGSize(width: 1, height: 1)

    var body: some View {
Chart(path) {
            switch graphType {
            case .altitude:
            AreaMark(
                // your stuff
            )
            .foregroundStyle(.image(image,
                                    scale: (chartSize.width - yMarksSize.width - 4) / 300.0))
            }
        }
        .chartXScale(domain: yourMin...yourMax) // To avoid margins in the chart
        .frame(height: 100)
        .chartYAxis {
            AxisMarks(position: .leading) { value in
                AxisValueLabel {
                    Text("\(value)") // Put your custom format
                        .saveSize(in: $yMarksSize,
                                  maximizeWidth: true)
                }
            }
        }
        .saveSize(in: $chartSize)
        .onAppear {
            computeImage()
        }
    }

    func computeImage() {
        let colorPicker: ((Point) -> Color) = {
            // Your way of associating a Point to a color
            $0.color
        }
        self.image = chartBackground(colorPicker: colorPicker,
                                     size: 300)
        self.graphType = type
    }

    func chartBackground(colorPicker: (Point) -> Color,
                         size: Int = 100) -> Image {

        let distances = path.map { $0.distance }

        // Avoid crash cases
        guard path.count >= 2,
              size > 2,
              let min = distances.min(),
              let max = distances.max() else {
            return Image(pixels: [PixelData(a: 0, r: 0, g: 0, b: 0)], width: size)
        }

        // Compute a step distance per pixel.
        let distance = max - min
        let step = distance / (Double(size - 1))

        // The algorithm will compute the relevant point for each pixel.
        // If there are more pixels than points, the pixels will be duplicated with the color of the closest point.
        var previous: PathPoint = path[0]
        var currentDistance = min

        var colors = [Color]()

        for point in path {
            // Case of first element
            if point == previous {
                continue
            }

            // If there are several pixels between the previous and the new point, fill with pixels colored according to the closest point
            while currentDistance <= point.distance {
                if point.distance - currentDistance > currentDistance - previous.distance {
                    colors.append(colorPicker(previous))
                } else {
                    colors.append(colorPicker(point))
                }
                currentDistance += step
            }
            previous = point
        }

        // If Double precision issues make the final currentDistance greater than the last point distance, the last pixel may be missing
        while colors.count < size {
            colors.append(colorPicker(previous))
        }

        // Should not happen. Anyways.
        if colors.count > size {
            colors = Array(colors.prefix(size))
        }

        let pixels = colors.map {
            PixelData(a: UInt8($0.components.opacity * 255.0),
                      r: UInt8($0.components.red * 255.0),
                      g: UInt8($0.components.green * 255.0),
                      b: UInt8($0.components.blue * 255.0))
        }

        let res = Image(pixels: pixels, width: size)
        return res
    }
}

saveSize is a modifier found on the web to record the size more easily. I added a maximizeWidth parameter to make sure I get the largest of the Y axis marks widths.

import SwiftUI

struct SizeCalculator: ViewModifier {

    @Binding var size: CGSize
    var maximizeWidth: Bool

    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader { proxy in
                    Color.clear // we just want the reader to get triggered, so let's use an empty color
                        .onAppear {
                            if !maximizeWidth || size.width < proxy.size.width {
                                size = proxy.size
                            }
                        }
                }
            )
    }
}

extension View {
    func saveSize(in size: Binding<CGSize>, maximizeWidth: Bool = false) -> some View {
        modifier(SizeCalculator(size: size, maximizeWidth: maximizeWidth))
    }
}

Upvotes: 0

Carl Sarkis
Carl Sarkis

Reputation: 21

Ok, in the end I managed to do it. For those who would like to do the same thing, the idea is to create an image of height 1 and of width of the data array count.

Then, use the image shape style:

.foregroundStyle(.image(chartColors, scale: <Data length>))

Depending on the data size, you may want to duplicate the pixels to reduce the blur. The scale needs to be adapted then (divide by the number of duplications. Result

Upvotes: 0

Related Questions