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

SwiftUI ScrollView捏合缩放子项高度并锚定捏合中心的问题

实现iOS日历风格的日视图捏合缩放(锚点为捏合中心)

我正在开发类似iOS日历的日视图,需要通过捏合手势调整小时间距。目前已实现缩放效果,但锚点默认在ScrollView顶部,导致缩放时所有子项会被向下推出视野。希望将锚点设置为捏合中心,让用户捏合的位置始终保持可见。

当前效果:
当前效果

预期效果(iOS日历):
预期效果(iOS日历)

当前代码

import SwiftUI

final class HourItem: Identifiable {
    let id: UUID
    let hour: Int
    
    init(hour: Int) {
        self.id = UUID()
        self.hour = hour
    }
}

struct TestView: View {
    let minHourHeight: CGFloat = 50
    let maxHourHeight: CGFloat = 400
    
    @State private var isZooming: Bool = false
    @State private var previousZoomAmount: CGFloat = 0.0
    @State private var currentZoomAmount: CGFloat = 0.0
    
    private var zoomAmount: CGFloat {
        1 + currentZoomAmount + previousZoomAmount
    }
    
    private var hourHeight: CGFloat {
        100 * zoomAmount
    }
    
    private let currentTime: Date = Date.now
    private let hourItems = (0..<25).map {
        HourItem(hour: $0)
    }
    
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                ForEach(hourItems) { hourItem in
                    HourMarkView(
                        hour: hourItem.hour,
                        height: hourHeight,
                        currentTime: currentTime
                    )
                }
            }
        }
        .simultaneousGesture(magnification)
    }
    
    private var magnification: some Gesture {
        MagnifyGesture(minimumScaleDelta: 0)
            .onChanged(handleZoomChange)
            .onEnded(handleZoomEnd)
    }
    
    private func handleZoomChange(_ value: MagnifyGesture.Value) {
        if !isZooming {
            isZooming = true
        }
        
        let newZoomAmount = value.magnification - 1
        currentZoomAmount = clampedZoomAmount(newZoomAmount)
    }
    
    private func handleZoomEnd(_: MagnifyGesture.Value) {
        isZooming = false
        previousZoomAmount += currentZoomAmount
        currentZoomAmount = 0
    }
    
    private func clampedZoomAmount(_ newZoomAmount: CGFloat) -> CGFloat {
        if hourHeight > maxHourHeight && newZoomAmount > currentZoomAmount {
            return currentZoomAmount - 0.000001
        } else if hourHeight < minHourHeight && newZoomAmount < currentZoomAmount {
            return currentZoomAmount + 0.000001
        }
        
        return newZoomAmount
    }
}

struct HourMarkView: View {
    var hour: Int
    var height: CGFloat
    var currentTime: Date

    var body: some View {
        HStack(spacing: 10) {
            Text(formatTime(hour))
                .font(.caption)
                .fontWeight(.medium)
                .frame(width: 40, alignment: .trailing)
            Rectangle()
                .fill(Color.gray)
                .frame(height: 1)
        }
        .frame(height: height)
        .background(Color.white)
    }
    
    private func formatTime(_ hour: Int) -> String {
        return String(format: "%02d:00", hour)
    }
}

解决方案

要实现以捏合中心为锚点的缩放,需要在缩放时同步调整ScrollView的滚动偏移,确保捏合点的相对位置不变。核心思路是:

  1. 记录捏合开始时的初始缩放值、初始滚动偏移、以及捏合点在内容坐标系中的位置
  2. 缩放过程中,根据缩放比例的变化,计算需要调整的滚动偏移量,保持捏合点在屏幕上的位置固定

修改后的完整代码:

import SwiftUI

final class HourItem: Identifiable {
    let id: UUID
    let hour: Int
    
    init(hour: Int) {
        self.id = UUID()
        self.hour = hour
    }
}

struct TestView: View {
    let minHourHeight: CGFloat = 50
    let maxHourHeight: CGFloat = 400
    let baseHourHeight: CGFloat = 100
    
    @State private var isZooming: Bool = false
    @State private var totalZoom: CGFloat = 1.0
    @State private var initialZoom: CGFloat = 1.0
    @State private var initialScrollOffset: CGFloat = 0.0
    @State private var initialPinchContentY: CGFloat = 0.0
    
    private var hourHeight: CGFloat {
        baseHourHeight * totalZoom
    }
    
    private let currentTime: Date = Date.now
    private let hourItems = (0..<25).map {
        HourItem(hour: $0)
    }
    
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                VStack(spacing: 0) {
                    ForEach(hourItems) { hourItem in
                        HourMarkView(
                            hour: hourItem.hour,
                            height: hourHeight,
                            currentTime: currentTime
                        )
                        .id(hourItem.id)
                    }
                }
                .background(GeometryReader { geo in
                    Color.clear
                        .preference(key: ContentOffsetKey.self, value: geo.frame(in: .global).minY)
                })
                .onPreferenceChange(ContentOffsetKey.self) { offset in
                    if !isZooming {
                        initialScrollOffset = -offset
                    }
                }
            }
            .simultaneousGesture(
                MagnifyGesture(minimumScaleDelta: 0)
                    .onChanged { value in
                        if !isZooming {
                            isZooming = true
                            initialZoom = totalZoom
                            // 获取捏合点在内容坐标系中的Y位置
                            let pinchLocation = value.location.y
                            initialPinchContentY = pinchLocation + initialScrollOffset
                        }
                        
                        let newZoom = initialZoom * value.magnification
                        // 限制缩放范围
                        let clampedZoom = min(max(newZoom, minHourHeight / baseHourHeight), maxHourHeight / baseHourHeight)
                        totalZoom = clampedZoom
                        
                        // 计算需要调整的滚动偏移,保持捏合点位置不变
                        let newPinchContentY = initialPinchContentY * (totalZoom / initialZoom)
                        let targetScrollOffset = newPinchContentY - value.location.y
                        
                        proxy.scrollTo(hourItems.first?.id, anchor: .top)
                        withAnimation(.none) {
                            proxy.scrollBy(x: 0, y: targetScrollOffset - initialScrollOffset)
                        }
                    }
                    .onEnded { _ in
                        isZooming = false
                    }
            )
        }
    }
}

// 自定义PreferenceKey获取内容偏移
struct ContentOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

struct HourMarkView: View {
    var hour: Int
    var height: CGFloat
    var currentTime: Date

    var body: some View {
        HStack(spacing: 10) {
            Text(formatTime(hour))
                .font(.caption)
                .fontWeight(.medium)
                .frame(width: 40, alignment: .trailing)
            Rectangle()
                .fill(Color.gray)
                .frame(height: 1)
        }
        .frame(height: height)
        .background(Color.white)
    }
    
    private func formatTime(_ hour: Int) -> String {
        return String(format: "%02d:00", hour)
    }
}

关键改动说明

  • 使用ScrollViewReader直接控制滚动偏移,避免依赖ScrollView的内置偏移管理
  • 通过GeometryReader和自定义PreferenceKey获取内容的实时偏移量
  • 捏合开始时记录初始状态:初始缩放值、初始滚动偏移、捏合点在内容中的绝对位置
  • 缩放过程中,根据缩放比例计算捏合点的新位置,调整滚动偏移确保捏合点在屏幕上的位置固定
  • 优化缩放范围限制,直接通过min/max计算符合要求的缩放值,逻辑更清晰

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 04:20:01