admin管理员组

文章数量:1291800

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
                            }
                        })
                }
            }
        }
    }
}

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?

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
                            }
                        })
                }
            }
        }
    }
}

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?

Share Improve this question edited Feb 13 at 16:24 malhal 30.8k7 gold badges122 silver badges150 bronze badges asked Feb 13 at 13:00 MallenowMallenow 133 bronze badges
Add a comment  | 

2 Answers 2

Reset to default 0

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))
    }
}

chartAnnotation is not suitable for this. I'd suggest putting the tooltip in the chartOverlay instead. You can use the ChartProxy you get to find the coordinates of the top of the bar, and position your view there.

Here is a complete example, where I just use position to position the view. For a tooltip, you should probably move it up a bit with offset.

struct Bar: Identifiable {
    let x: String
    let y: Double
    
    var id: String { x }
}

struct ContentView: View {
    let values = [
        Bar(x: "A", y: 10),
        Bar(x: "B", y: 1),
        Bar(x: "C", y: 10),
        Bar(x: "D", y: 1),
        Bar(x: "E", y: 10),
        Bar(x: "F", y: 1),
        Bar(x: "G", y: 10),
        Bar(x: "H", y: 1),
        Bar(x: "I", y: 10),
        Bar(x: "J", y: 1),
        Bar(x: "K", y: 10),
        Bar(x: "L", y: 1),
        Bar(x: "M", y: 10),
    ]
    @State private var selectedItem: Bar?

    var body: some View {
        Chart {
            ForEach(values) { value in

                BarMark(
                    x: .value("X", value.x),
                    y: .value("Y", value.y)
                )
            }
        }
        .chartOverlay { pr in
            GeometryReader { geoProxy in
                ZStack {
                    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 = values.first(where: { $0.x == selected }) {
                                self.selectedItem = dataItem
                            } else {
                                self.selectedItem = nil
                            }
                        })
                    if let selectedItem,
                       let x = pr.position(forX: selectedItem.x),
                       let y = pr.position(forY: selectedItem.y) {
                        
                        Text("Some Looooooong Tooltip Text")
                            .background(.red)
                            .position(x: x, y: y)
                    }
                }
            }
        }
    }
}

A more sophisticated way to position the tooltip would be to align its bottom center at the top left of the chart, and then offset it using the x and y coordinates from the ChartProxy.

Text("Some Looooooong Text")
    .background(.red)
    .alignmentGuide(HorizontalAlignment.leading) { $0[HorizontalAlignment.center] }
    .alignmentGuide(VerticalAlignment.top) { $0[.bottom] }
    .offset(x: x, y: y - 10) // 10pts above the bar
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)

本文标签: iosSwiftUI Chart BarMark Annotation covered by following BarMarksStack Overflow