You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何利用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)
}

关键说明

  1. 尺寸获取:通过PreferenceKey获取Chart和ScrollView的实际尺寸,为偏移计算提供基础
  2. 位置计算:利用ChartProxy.position(forX:)获取目标Bar的x坐标,结合ScrollView宽度计算出需要滚动的偏移量,确保Bar居中
  3. 边界限制:对偏移量做范围限制,避免滚动超出Chart的可视区域
  4. 动画过渡:用withAnimation包装偏移更新,实现平滑的滚动效果

内容的提问来源于stack exchange,提问作者miltenkot

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.31 08:56:08