iosswiftswiftuiswiftui-charts

SwiftUI Chart BarMark Annotation covered by following BarMarks


First of all, I am quite new to SwiftUI. I have a problem with the annotation of the BarMarks. The annotation is covered by the following BarMarks. Is there a way to prevent this?

import Foundation
import SwiftUI
import Charts

struct WaterConsumptionChart: View {
    
    @ObservedObject var model: ConsumptionDataModel
    @State private var selectedItem: ConsumptionData?
    @State private var showTooltip: Bool = false
    
    private var gradientColors = [Color.primarySwiftUI.opacity(0.8),
                                  Color.groheLink.opacity(0.8)]

    var body: some View {
        VStack {
            Chart(model.data) { item in
                BarMark(
                    x: .value("", item.text),
                    y: .value("consumption", item.consumption ?? 0),
                    width: .automatic
                )
                .foregroundStyle(LinearGradient(
                    gradient: Gradient(colors: gradientColors),
                    startPoint: .top,
                    endPoint: .bottom
                ))
                .cornerRadius(4)
                .annotation(position: .top, alignment: .center) {
                    let show = ((selectedItem?.text ?? "") == item.text) && ((item.consumption ?? 0) > 0)
                    TooltipView(value: "\(selectedItem?.text ?? "") • \(selectedItem?.consumption ?? 0)l")
                        .opacity(show ? 1 : 0)
                        .offset(y: -2)
                }
                
                if let average = model.average, average > 0 {
                    RuleMark(y: .value("", average))
                        .foregroundStyle(.primarySwiftUI)
                        .lineStyle(.init(lineWidth: 1))
                } else {
                    RuleMark(y: .value("", 0.0))
                        .foregroundStyle(.primarySwiftUI)
                        .lineStyle(.init(lineWidth: 0))
                }
            }
            .padding([.leading, .trailing], 16)
            .chartXAxis {
                AxisMarks(values: .automatic(minimumStride: 3, desiredCount: 7)) { value in
                    let period = viewModel.period?.period
                    let modulo = period == .day ? 4 : 5
                    let entry = Int(value.as(String.self) ?? "") ?? 0
                    
                    if value.count <= 12 || entry % modulo == 0 || value.index == 0 {
                        let uiFont = Fonts.Inter.regular.of(size: 10)
                        AxisValueLabel()
                            .foregroundStyle(.primarySwiftUI)
                            .font(Font(uiFont))
                            .offset(x: 0, y: 10)
                        
                        AxisGridLine()
                            .foregroundStyle(.greyE1E3E7)
                            .offset(x: 0, y: 0)
                    }
                }
            }
            .chartYAxis {
                AxisMarks(values: .automatic(desiredCount: 7)) { value in
                    if let intValue = value.as(Int.self) {
                        if intValue == 0 {
                            AxisGridLine()
                                .foregroundStyle(.primarySwiftUI)
                        } else {
                            let uiFont = Fonts.Inter.regular.of(size: 10)
                            AxisValueLabel("\(intValue)l")
                                .foregroundStyle(.primarySwiftUI)
                                .font(Font(uiFont))
                            
                            AxisGridLine()
                                .foregroundStyle(.greyE1E3E7)
                        }
                    }
                }
            }
            .animation(.smooth, value: viewModel.data)
            .chartOverlay { pr in
                GeometryReader { geoProxy in
                    Rectangle()
                        .fill(.clear).contentShape(Rectangle()).padding([.leading, .trailing], 16)
                        .onTapGesture(perform: { value in
                            let origin = geoProxy[pr.plotAreaFrame].origin
                            let location = CGPoint(x: value.x - origin.x, y: value.y - origin.y)
                            
                            if let selected = pr.value(atX: location.x, as: String.self),
                               let dataItem = viewModel.data.first(where: { $0.text == selected }) {
                                if dataItem.consumption ?? 0 > 0 { UIImpactFeedbackGenerator(style: .light).impactOccurred() }
                                self.selectedItem = dataItem
                            } else {
                                self.selectedItem = nil
                            }
                        })
                }
            }
        }
    }
}

enter image description here

I tried to experiment with .zIndex(value: Double) but unfortunately I could not solve the problem. I also need compatibility with iOS 16 and .zIndex is only available from iOS 17. Does anyone have an idea?


Solution

  • The best way to ensure annotations are above all bars is to draw them separately. Instead of adding .annotation() inside the BarMark, create a second Chart layer on top (cleaner even if zIndex would be available).

    Chart {
        // First layer: Bars (rendered first, in background)
        ForEach(model.data, id: \.text) { item in
            BarMark(
                x: .value("", item.text),
                y: .value("consumption", item.consumption ?? 0),
                width: .automatic
            )
            .foregroundStyle(LinearGradient(
                gradient: Gradient(colors: gradientColors),
                startPoint: .top,
                endPoint: .bottom
            ))
            .cornerRadius(4)
        }
    
        // Second layer: Annotations (rendered last, on top)
        ForEach(model.data, id: \.text) { item in
            if let selectedItem = selectedItem, selectedItem.text == item.text, let consumption = selectedItem.consumption, consumption > 0 {
                PointMark(
                    x: .value("", item.text),
                    y: .value("consumption", item.consumption ?? 0)
                )
                .annotation(position: .top, alignment: .center) {
                    TooltipView(value: "\(selectedItem.text) • \(consumption)l")
                        .opacity(1)
                        .offset(y: -2)
                }
            }
        }
    
        // Third layer: RuleMark (always on top of bars)
        if let average = model.average, average > 0 {
            RuleMark(y: .value("", average))
                .foregroundStyle(.primarySwiftUI)
                .lineStyle(.init(lineWidth: 1))
        }
    }