Search code examples
swiftuiswiftui-charts

How to style Swift Charts like Apple does?


70 year old newbie here. I am attempting to integrate a chart within my Mac app, and using Swift Charts (Apple's). It's relatively easy to make a chart. The problem is the default charts look different to Apple's charts and are missing key elements that Apple uses.

Example: Storage Chart Apples charts generaly have

  • A thin white separator between the elements
  • Nice Rounded corners on the top and bottom
  • Subtle internal shadow

I wrote the code:

struct BarChart: View {
    var body: some View {
        GroupBox {
            HStack {
                Spacer()
                Text("This is the label")
                    .foregroundColor(.secondary)
            }
            .padding(.horizontal)
            
            Chart(data) { item in
                BarMark(
                    x: .value("% ", item.count)
                    
                ) 
                .foregroundStyle(by: .value("Data:", item.usage))
            }
            .chartXAxis {
                AxisMarks(values: .automatic(desiredCount: 0))
            }
            .clipShape(RoundedRectangle(cornerRadius: 3))

        }
    }
}

But it looks like: enter image description here

Any idea how I can add:

  • A thin white separator between the elements
  • Nice Rounded corners on the top and bottom
  • Subtle internal shadow

Solution

  • The storage chart in the System Preferences app probably isn't implemented with SwiftUI Charts framework. The chart has been looking this way since ages ago. I'd assume it doesn't use SwiftUI at all.

    Anyway, the corner radius can be created with clipShape, but rather than applying it on the Chart, you should apply it on the BarMarks. Use the indices of your data source to determine which corners to round.

    .clipShape(clipShape(forIndex: i))
    
    ...
    
    func clipShape(forIndex i: Int) -> UnevenRoundedRectangle {
        if i == 0 {
            return UnevenRoundedRectangle(topLeadingRadius: 5, bottomLeadingRadius: 5)
        } else if i == data.count - 1 {
            return UnevenRoundedRectangle(bottomTrailingRadius: 5, topTrailingRadius: 5)
        } else {
            return UnevenRoundedRectangle()
        }
    }
    

    The separators can be created with transparent BarMarks that have a very small x value.

    BarMark(...) // the actual bar mark for your data
    
    if i != data.count - 1 {
        BarMark(x: .value("Separator", 0.1))
            .foregroundStyle(.clear)
    }
    

    The shadow is the hardest part. There is no such API to add an inner shadow, only on the top edge. In fact, SwiftUI charts doesn't seem to support ShapeStyles that have shadows at all. For example, using chartForegroundStyleScale like this does not create any shadow at all.

    .chartForegroundStyleScale([
        "Category 1": Color.red.shadow(.inner(radius: 5)),
        "Category 2": Color.green.shadow(.inner(radius: 5)),
        "Category 3": Color.yellow.shadow(.inner(radius: 5)),
    ])
    

    The only way I can think of is to nudge a blurred RectangleMark around. This creates a something quite similar, but not exactly.

    RectangleMark(yEnd: 3)
        .foregroundStyle(.black.opacity(0.34))
        .blur(radius: 1)
        // this inset is needed
        // or else the "shadow" extends beyond the rounded rectangles
        .clipShape(Rectangle().inset(by: 1))
        .offset(y: -1)
    

    Here is the full code:

    struct ContentView: View {
        let data = [
            Foo(width: 3, type: .one),
            Foo(width: 4, type: .two),
            Foo(width: 5, type: .three),
        ]
        var body: some View {
            Chart{
                ForEach(data.indices, id: \.self) { i in
                    BarMark(x: .value("X", data[i].width))
                        .clipShape(clipShape(forIndex: i))
                        .foregroundStyle(by: .value("X", data[i].type))
                    if i != data.count - 1 {
                        BarMark(x: .value("Separator", 0.1))
                            .foregroundStyle(.clear)
                    }
                }
                RectangleMark(yEnd: 3)
                    .foregroundStyle(.black.opacity(0.34))
                    .blur(radius: 1)
                    .clipShape(Rectangle().inset(by: 1))
                    .offset(y: -1)
            }
            .chartXAxis {
                AxisMarks(values: .automatic(desiredCount: 0))
            }
            .frame(height: 50)
            .padding()
        }
        
        func clipShape(forIndex i: Int) -> UnevenRoundedRectangle {
            if i == 0 {
                return UnevenRoundedRectangle(topLeadingRadius: 5, bottomLeadingRadius: 5)
            } else if i == data.count - 1 {
                return UnevenRoundedRectangle(bottomTrailingRadius: 5, topTrailingRadius: 5)
            } else {
                return UnevenRoundedRectangle()
            }
        }
    }
    
    enum FooType: String, Plottable {
        case one, two, three
    }
    
    struct Foo: Hashable {
        let width: Double
        let type: FooType
    }
    

    enter image description here