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

SwiftUI Charts中如何区分水平滚动与放大手势避免冲突?

Swift Charts中缩放与水平滚动手势冲突的解决方案

问题描述

我正在使用Swift Charts开发SwiftUI图表界面,已通过MagnifyGesture实现了基于X轴的缩放功能,但该手势与图表的水平滚动功能存在识别冲突。垂直方向执行放大手势时识别效果较好,水平方向则易与滚动操作混淆。当禁用图表交互时,放大手势可正常工作,但会丢失水平滚动功能,手动实现该功能会增加交互逻辑复杂度。

现有实现代码

import Charts
import SwiftUI

@MainActor
struct GraphScreen: View {
    @Environment(ThemeManager.self) private var theme
    @Environment(DataModel.self) private var dataModel: DataModel
    
    @State private var scrollPosition = Date()
    @State private var selectedPrice: CandlestickDataPoint?
    
    @State var investmentData: [CandlestickDataPoint] = []
    @State var minimum: Double = 0
    @State var maximum: Double = 1
    @State var minimumAdjusting: Double = 0.995
    @State var maximumAdjusting: Double = 1.005
    
    @State private var currentZoom = 0.0
    @State private var totalZoom = 24.0
    @State private var strideMinuteCount = 15
    @State private var barWidth: CGFloat = 10
    
    var body: some View {
        content
            .task {
                do {
                    let response = try await dataModel.userStock()
                    investmentData = ChartManager.shared.process(response: response)
                    totalZoom = 12
                } catch {
                    print(error.localizedDescription)
                }
            }
            .onChange(of: totalZoom, { oldValue, newValue in
                // Adjust the barWidth and strideMinuteCount based on the zoom level
                strideMinuteCount = Int(5 * newValue) // Adjust the stride count based on some formula
                barWidth = max(1, CGFloat(48 / newValue)) // Adjust the bar width
                
                let visibleData = getVisibleValues()
                setMinMax(forValues: visibleData)
            })
    }
    
    func getVisibleValues() -> [CandlestickDataPoint] {
        let minDate = scrollPosition
        let maxDate = scrollPosition.addingTimeInterval((currentZoom + totalZoom) * 1800)
        let values = investmentData.filter({ $0.date >= minDate && $0.date <= maxDate })
        return values
    }
    
    func setMinMax(forValues values: [CandlestickDataPoint]) {
        minimum = (values.map({ $0.low }).min() ?? 0) //* minimumAdjusting
        maximum = (values.map({ $0.high }).max() ?? 1) //* maximumAdjusting
    }
}

extension GraphScreen {
    var content: some View {
        
        ZStack {
            theme.selected.backgroundScreenColor
                .ignoresSafeArea()
            
            VStack {
                
                chart
            }
            .padding(.bottom)
        }
    }
    
    var magnifyGesture: some Gesture {
        return MagnifyGesture()
            .onChanged { value in
                currentZoom = 1 - value.magnification
            }
            .onEnded { value in
                totalZoom += currentZoom
                currentZoom = 0
                totalZoom = min(max(totalZoom, 1), 24)
            }
    }
    
    var chart: some View {
        Chart(investmentData) { item in
            
            BarMark(
                x: .value("Date", item.date),
                yStart: .value("Open", min(item.open, item.close)),
                yEnd: .value("Close", max(item.open, item.close)),
                width: .fixed(barWidth)
            )
            .foregroundStyle(item.close > item.open ? Color.green : Color.red)
            
            RuleMark(
                x: .value("Date", item.date),
                yStart: .value("Low", item.low),
                yEnd: .value("High", item.high)
            )
            .foregroundStyle(item.close > item.open ? Color.green : Color.red)
        }
        .chartYScale(domain: [minimum, maximum])
        .chartYAxis {
            AxisMarks(position: .automatic, values: .automatic) {
                AxisGridLine()
                    .foregroundStyle(theme.selected.bodyTextGrayColor)
                
                AxisValueLabel()
                    .foregroundStyle(theme.selected.bodyTextGrayColor)
            }
        }
        .chartXAxis {
            AxisMarks(values: .stride(by: .minute, count: strideMinuteCount)) {
                AxisGridLine()
                    .foregroundStyle(theme.selected.bodyTextGrayColor)
                
                AxisValueLabel()
                    .foregroundStyle(theme.selected.bodyTextGrayColor)
            }
        }
        //.disabled(true)
        .chartScrollableAxes([.horizontal])
        .chartScrollPosition(x: $scrollPosition)
        .onChange(of: scrollPosition) {
            print(scrollPosition)
            let visibleData = getVisibleValues()
            setMinMax(forValues: visibleData)
        }
        .chartXVisibleDomain(length: (currentZoom + totalZoom) * 1800)
        .chartGesture { chartProxy in
            magnifyGesture
        }
    }
}

解决方案

1. 利用手势优先级与条件识别

通过给缩放手势添加优先级判断,或设置缩放响应的阈值,减少与滚动操作的误判:

var magnifyGesture: some Gesture {
    MagnifyGesture()
        .onChanged { value in
            // 设置缩放阈值,只有缩放变化超过一定程度才响应
            let scaleDelta = abs(value.magnification - 1.0)
            if scaleDelta > 0.01 {
                currentZoom = 1 - value.magnification
            }
        }
        .onEnded { value in
            totalZoom += currentZoom
            currentZoom = 0
            totalZoom = min(max(totalZoom, 1), 24)
        }
        // 给拖动手势更高优先级,避免滚动时触发缩放
        .highPriorityGesture(DragGesture(minimumDistance: 5)
            .onChanged { _ in
                currentZoom = 0
            })
}

2. 使用Swift Charts内置缩放功能(推荐)

Swift Charts提供了官方的chartZoomable修饰符,可自动处理缩放与滚动的手势冲突,无需自定义MagnifyGesture:

var chart: some View {
    Chart(investmentData) { item in
        // 保留原有Mark代码
        BarMark(
            x: .value("Date", item.date),
            yStart: .value("Open", min(item.open, item.close)),
            yEnd: .value("Close", max(item.open, item.close)),
            width: .fixed(barWidth)
        )
        .foregroundStyle(item.close > item.open ? Color.green : Color.red)
        
        RuleMark(
            x: .value("Date", item.date),
            yStart: .value("Low", item.low),
            yEnd: .value("High", item.high)
        )
        .foregroundStyle(item.close > item.open ? Color.green : Color.red)
    }
    .chartYScale(domain: [minimum, maximum])
    .chartYAxis {
        // 保留原有Y轴配置
        AxisMarks(position: .automatic, values: .automatic) {
            AxisGridLine()
                .foregroundStyle(theme.selected.bodyTextGrayColor)
            
            AxisValueLabel()
                .foregroundStyle(theme.selected.bodyTextGrayColor)
        }
    }
    .chartXAxis {
        // 保留原有X轴配置
        AxisMarks(values: .stride(by: .minute, count: strideMinuteCount)) {
            AxisGridLine()
                .foregroundStyle(theme.selected.bodyTextGrayColor)
            
            AxisValueLabel()
                .foregroundStyle(theme.selected.bodyTextGrayColor)
        }
    }
    .chartScrollableAxes([.horizontal])
    .chartScrollPosition(x: $scrollPosition)
    .onChange(of: scrollPosition) {
        let visibleData = getVisibleValues()
        setMinMax(forValues: visibleData)
    }
    // 启用内置水平缩放,替换自定义手势
    .chartZoomable(axes: [.horizontal]) { proxy, scale in
        // 映射内置缩放比例到原有totalZoom变量
        totalZoom = 24 / scale
        strideMinuteCount = Int(5 * totalZoom)
        barWidth = max(1, CGFloat(48 / totalZoom))
        
        let visibleData = getVisibleValues()
        setMinMax(forValues: visibleData)
    }
}

3. 自定义手势识别区域

将缩放手势限制在图表的特定区域(如Y轴侧边),滚动操作保留在图表主体:

var chart: some View {
    HStack(spacing: 0) {
        // 左侧缩放触发区域
        Color.clear
            .frame(width: 40)
            .gesture(magnifyGesture)
        
        // 右侧滚动区域
        Chart(investmentData) { item in
            // 保留原有Mark代码
        }
        .chartScrollableAxes([.horizontal])
        .chartScrollPosition(x: $scrollPosition)
        // 保留其他图表配置
    }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.24 09:35:57