SwiftUI ScrollView捏合缩放子项高度并锚定捏合中心的问题
实现iOS日历风格的日视图捏合缩放(锚点为捏合中心)
我正在开发类似iOS日历的日视图,需要通过捏合手势调整小时间距。目前已实现缩放效果,但锚点默认在ScrollView顶部,导致缩放时所有子项会被向下推出视野。希望将锚点设置为捏合中心,让用户捏合的位置始终保持可见。
当前效果:
预期效果(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的滚动偏移,确保捏合点的相对位置不变。核心思路是:
- 记录捏合开始时的初始缩放值、初始滚动偏移、以及捏合点在内容坐标系中的位置
- 缩放过程中,根据缩放比例的变化,计算需要调整的滚动偏移量,保持捏合点在屏幕上的位置固定
修改后的完整代码:
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
相关产品推荐
相关产品推荐

