Search code examples
swiftuiswiftui-charts

Scrollable Tappable Chart in Swiftui


I am developing an app in SwiftUI and I currently have a chart with vertically stacked bars on it. When the user clicks on a bar a subview pops up where they can alter some values of the bar. To accomplish this I have been using GeometryReader to detect where the user clicks.

```
.chartOverlay { proxy in         
    GeometryReader { geometry in              
        Rectangle().fill(.clear).contentShape(Rectangle())                  
            .onTapGesture { location in                   
            // Get the x and y value from the location.                   
            let (min, name) = proxy.value(at: location, as: (Int, String).self) ?? (0, "")
            print("Location: \(min), \(name)")              
        }          
    }      
}
```

However the user can also add bars and when they add enough the chart becomes scrollable and GeometryReader no longer seems to work because it assumes that the position of the bars never changes. Is there a way that I can make the bars tappable and act in the same way as they would with GeometryReader while being in a scrollable chart. Thanks.

I tried making it so that it generated new GeometryReaders for each individual bar, however that would just go out of the bounds of the chart and did not work. I also tried putting those GeometryReaders in a scrollview but then the chart could no longer be scrolled itself.


Solution

  • Instead of using geometry readers and invisible rectangles to implement the gesture, just use chartGesture.

    .chartGesture { proxy in
        SpatialTapGesture().onEnded { value in
            let location = value.location
            // find the x and y of the tapped location
            if let (name, tappedY) = proxy.value(at: value.location, as: (String, Int).self),
               // find the item that corresponds to the bar we tapped
               let tappedIndex = data.firstIndex(where: { $0.name == name }),
               // ensure that the tapped location is below the top of the bar
               tappedY <= data[tappedIndex].y {
                // selectedIndex is a @State
                selectedIndex = tappedIndex
                print("Location: \(data[tappedIndex].y), \(name)")
            } else {
                // tapping outside of a bar would deselect it
                selectedIndex = nil
            }
        }
    }
    

    Here is a complete example, where selecting a bar would cause a Stepper to show up that allows you to change the bar's height.

    struct Sample: Identifiable, Hashable {
        let id = UUID()
        let name: String
        var y: Int
    }
    
    struct ContentView: View {
        
        @State var selectedIndex: Int?
        
        @State var data = (1...20).map {
            Sample(name: "Name \($0)", y: .random(in: 1...30))
        }
        
        var body: some View {
            Chart(data) { sample in
                BarMark(
                    x: .value("Name", sample.name),
                    y: .value("Y", sample.y),
                    width: 50
                )
            }
            .chartScrollableAxes(.horizontal)
            .chartGesture { proxy in
                SpatialTapGesture().onEnded { value in
                    let location = value.location
                    if let (name, tappedY) = proxy.value(at: value.location, as: (String, Int).self),
                       let tappedIndex = data.firstIndex(where: { $0.name == name }),
                       tappedY <= data[tappedIndex].y {
                        selectedIndex = tappedIndex
                        print("Location: \(data[tappedIndex].y), \(name)")
                    } else {
                        selectedIndex = nil
                    }
                }
            }
            .chartOverlay(alignment: .bottom) { _ in
                if let selectedIndex {
                    Stepper(data[selectedIndex].name, value: $data[selectedIndex].y)
                        .padding()
                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
                        .padding()
                }
            }
        }
    }