如何利用ScrollViewReader实现Charts中BarMark的滚动定位?
实现SwiftUI Charts BarMark更新后的滚动居中
问题说明
使用SwiftUI Charts库实现条形图时,更新partyStats中元素的count值后,对应BarMark会因数组按count降序排序而改变位置,需要将该BarMark滚动到屏幕中央。此前尝试用ScrollViewReader.scrollTo(id)未生效。
解决方案
核心思路是通过ChartProxy获取目标BarMark的位置,结合ScrollView的尺寸计算偏移量,实现平滑滚动居中。步骤如下:
- 定义两个
PreferenceKey,分别获取Chart和ScrollView的尺寸 - 保存
ChartProxy引用,用于获取目标Bar的坐标 - 更新数据后,计算目标Bar需要滚动到居中的偏移量,通过动画更新ScrollView的偏移
修改后的完整代码
import SwiftUI import Charts // 用于获取Chart尺寸的PreferenceKey struct ChartSizeKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() } } // 用于获取ScrollView尺寸的PreferenceKey struct ScrollViewSizeKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() } } struct PartyItem: Identifiable, Codable { var type: String var count: Double var color: Color var id = UUID() static var shared: [Self] = [ .init(type: "logo_1", count: 1, color: .blue), .init(type: "logo_2", count: 1, color: .orange), .init(type: "logo_3", count: 1, color: .purple), .init(type: "logo_4", count: 1, color: .yellow), .init(type: "logo_5", count: 1, color: .red), .init(type: "logo_6", count: 1, color: .green), .init(type: "logo_7", count: 1, color: .cyan), .init(type: "logo_8", count: 1, color: .indigo) ] } extension PartyItem: Comparable { static func < (lhs: PartyItem, rhs: PartyItem) -> Bool { lhs.count < rhs.count } static func == (lhs: PartyItem, rhs: PartyItem) -> Bool { lhs.count == rhs.count } } enum OperationType { case subtract, add } struct MainVoteView: View { @AppStorage(.partyStats) private var partyStats: [PartyItem] = [] typealias ChartLocation = (location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy)? @State private var chartLocation: ChartLocation = nil @State private var showingPopup = false @State private var scaleValue: CGFloat = 1 // 新增:保存ChartProxy、尺寸和滚动偏移 @State private var chartProxy: ChartProxy? @State private var chartSize: CGSize = .zero @State private var scrollViewSize: CGSize = .zero @State private var scrollOffset: CGFloat = 0 var body: some View { ScrollView(.horizontal) { GeometryReader { scrollGeo in Color.clear.preference(key: ScrollViewSizeKey.self, value: scrollGeo.size) VStack { Chart { ForEach(partyStats.sorted(by: >)) { shape in BarMark( x: .value("Party Type", shape.type), y: .value("Vote Count", shape.count) ) .annotation(position: .bottom, alignment: .center, spacing: .spacingMedium) { Image(shape.type) .resizable() .scaledToFit() .frame(width: .sizeMediumMediumSmall, height: .sizeMediumMediumSmall) } .annotation(position: .top, alignment: .center, spacing: .spacingSmall, content: { value in Text("\(Int(shape.count))") .bold() .font(.subheadline) .foregroundColor(.white) }) .foregroundStyle(shape.color) } } .scaleEffect(scaleValue) .animation(.easeInOut(duration: 0.5), value: scaleValue) .frame(width: .sizeExtraExtraLarge) .chartYAxis(.hidden) .chartXAxis(.hidden) .chartLegend(.hidden) .padding() .chartOverlay { proxy in self.chartProxy = proxy GeometryReader { geometry in Rectangle().fill(.clear).contentShape(Rectangle()).onTapGesture { location in scaleValue = 1.1 showingPopup = true chartLocation = (location, proxy, geometry) } } } .overlay(GeometryReader { chartGeo in Color.clear.preference(key: ChartSizeKey.self, value: chartGeo.size) }) } } } .scrollIndicators(.hidden) .padding() .background(Color.darkBlue) .navigationBarBackButtonHidden() .scrollPosition($scrollOffset) // iOS 17+,iOS 16及以下替换为.scrollContentOffset .onPreferenceChange(ChartSizeKey.self) { size in self.chartSize = size } .onPreferenceChange(ScrollViewSizeKey.self) { size in self.scrollViewSize = size } .popup(isPresented: $showingPopup) { PopUpView { scaleValue = 1 updateSelected(at: chartLocation, operation: .add) } nayAction: { scaleValue = 1 updateSelected(at: chartLocation, operation: .subtract) } abstainAction: { scaleValue = 1 } } } } extension MainVoteView { private func updateSelected(at chartLocation: ChartLocation, operation: OperationType) { guard let location = chartLocation?.location, let proxy = chartLocation?.proxy, let geometry = chartLocation?.geometry else { return } let xPosition = location.x - geometry[proxy.plotAreaFrame].origin.x guard let selected: String = proxy.value(atX: xPosition) else { return } guard let index = partyStats.enumerated().first(where: { $0.element.type == selected })?.offset else { return } // 更新数据 switch operation { case .subtract: if partyStats[index].count > 0 { partyStats[index].count -= 1 } case .add: partyStats[index].count += 1 } // 滚动到目标Bar居中 scrollToTargetBar(partyId: partyStats[index].id) } private func scrollToTargetBar(partyId: UUID) { guard let proxy = chartProxy, !scrollViewSize.width.isZero, !chartSize.width.isZero else { return } let sortedParties = partyStats.sorted(by: >) guard let targetParty = sortedParties.first(where: { $0.id == partyId }), let barPosition = proxy.position(forX: targetParty.type) else { return } // 计算偏移量:让Bar中心位于ScrollView中心 let barCenterX = barPosition.x let scrollCenterX = scrollViewSize.width / 2 let targetOffset = barCenterX - scrollCenterX // 限制偏移范围,防止滚动超出Chart边界 let maxOffset = chartSize.width - scrollViewSize.width let clampedOffset = targetOffset.clamped(to: 0...maxOffset) withAnimation(.easeInOut(duration: 0.5)) { scrollOffset = clampedOffset } } } // 补充AppStorage扩展(原代码中应有) extension AppStorage where Value == [PartyItem] { static var partyStats: Self { AppStorage("partyStats", store: UserDefaults.standard) } } // 补充PopupView简化实现(原代码中应有) struct PopUpView: View { let yesAction: () -> Void let nayAction: () -> Void let abstainAction: () -> Void var body: some View { VStack { Button("Add", action: yesAction) Button("Subtract", action: nayAction) Button("Abstain", action: abstainAction) } .padding() .background(Color.white) .cornerRadius(10) } } // 补充自定义尺寸扩展(原代码中应有) extension CGFloat { static let sizeMediumMediumSmall: CGFloat = 40 static let sizeExtraExtraLarge: CGFloat = 800 static let spacingMedium: CGFloat = 16 static let spacingSmall: CGFloat = 8 } extension Color { static let darkBlue: Color = Color(red: 0.05, green: 0.1, blue: 0.2) }
关键说明
- 尺寸获取:通过
PreferenceKey获取Chart和ScrollView的实际尺寸,为偏移计算提供基础 - 位置计算:利用
ChartProxy.position(forX:)获取目标Bar的x坐标,结合ScrollView宽度计算出需要滚动的偏移量,确保Bar居中 - 边界限制:对偏移量做范围限制,避免滚动超出Chart的可视区域
- 动画过渡:用
withAnimation包装偏移更新,实现平滑的滚动效果
内容的提问来源于stack exchange,提问作者miltenkot
相关产品推荐
相关产品推荐

