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

SwiftUI复刻Apple Freeform画布视图的技术问题求助

解决SwiftUI复刻Freeform画布的缩放与手势问题

问题1:scaleEffect缩放导致内容裁切与混乱

根源分析

当前实现用scaleEffect后调整frame,但ScrollView基于缩放后的frame计算滚动范围,导致滚动偏移与缩放锚点不同步,进而出现内容裁切、滚动逻辑混乱的问题。正确思路是让ScrollView基于原始画布尺寸计算滚动范围,通过同步滚动偏移来适配缩放后的视图位置。

修复方案

修改CanvasView,去掉缩放后的frame调整,改用滚动偏移同步来修正视图位置:

struct CanvasView<Content: View>: View {
    let content: () -> Content
    let canvasSize: CGSize = CGSize(width: 3000, height: 3000)
    
    @State private var zoom: CGFloat = 0.5
    @State private var scrollOffset: CGPoint = .zero
    
    var body: some View {
        ScrollView([.horizontal, .vertical], showsIndicators: true) {
            content()
                .frame(width: canvasSize.width, height: canvasSize.height)
                .scaleEffect(zoom)
                .offset(x: scrollOffset.x, y: scrollOffset.y)
        }
        .defaultScrollAnchor(.topLeading)
        .onScrollGeometryChange(for: CGPoint.self, of: { $0.contentOffset }) { old, new in
            scrollOffset = new
        }
        .overlay(alignment: .bottomTrailing) {
            VStack(spacing: 12) {
                HStack {
                    Text("Zoom: \(zoom, specifier: "%.2f")")
                        .frame(width: 80)
                    Slider(value: $zoom, in: 0.1...5, onEditingChanged: handleZoomChange)
                        .frame(width: 150)
                }
            }
            .padding(16)
            .background(.ultraThinMaterial)
            .cornerRadius(12)
            .padding()
        }
    }
    
    private func handleZoomChange(_ isEditing: Bool) {
        guard !isEditing else { return }
        // 缩放后修正滚动偏移,保持视图位置一致
        let scaleRatio = zoom / (zoom * 0.99) // 近似计算缩放变化率
        scrollOffset = CGPoint(
            x: scrollOffset.x * scaleRatio,
            y: scrollOffset.y * scaleRatio
        )
    }
}

关键改进

  • 保持内容frame为原始画布尺寸,ScrollView基于原始尺寸计算滚动范围
  • 用offset配合滚动偏移,解决缩放后的视图位置偏移问题
  • 滑块结束编辑时修正滚动偏移,确保缩放后视图位置不跳变

问题2:包装UIScrollView时子视图手势异常

根源分析

UIScrollView自带的捏合缩放手势会与SwiftUI子视图的拖拽手势冲突,且缩放后子视图的坐标系统未同步缩放比例,导致拖拽位置计算错误。

修复方案

自定义UIKitScrollViewRepresentable,处理手势优先级与坐标转换:

// 1. 自定义UIScrollView包装器
struct UIKitCanvasView<Content: View>: UIViewRepresentable {
    let content: Content
    let canvasSize: CGSize
    @Binding var zoom: CGFloat
    @Binding var scrollOffset: CGPoint
    
    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()
        scrollView.delegate = context.coordinator
        scrollView.minimumZoomScale = 0.1
        scrollView.maximumZoomScale = 5
        scrollView.showsVerticalScrollIndicator = true
        scrollView.showsHorizontalScrollIndicator = true
        
        // 添加SwiftUI内容容器
        let hostingController = UIHostingController(rootView: content)
        hostingController.view.frame = CGRect(origin: .zero, size: canvasSize)
        scrollView.addSubview(hostingController.view)
        scrollView.contentSize = canvasSize
        
        // 允许子视图接收手势,避免UIScrollView拦截
        scrollView.panGestureRecognizer.cancelsTouchesInView = false
        scrollView.pinchGestureRecognizer.cancelsTouchesInView = false
        
        return scrollView
    }
    
    func updateUIView(_ uiView: UIScrollView, context: Context) {
        if uiView.zoomScale != zoom {
            uiView.setZoomScale(zoom, animated: true)
        }
        scrollOffset = uiView.contentOffset
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UIScrollViewDelegate {
        var parent: UIKitCanvasView
        
        init(_ parent: UIKitCanvasView) {
            self.parent = parent
        }
        
        func viewForZooming(in scrollView: UIScrollView) -> UIView? {
            scrollView.subviews.first
        }
        
        func scrollViewDidZoom(_ scrollView: UIScrollView) {
            parent.zoom = scrollView.zoomScale
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            parent.scrollOffset = scrollView.contentOffset
        }
    }
}

// 2. 修正DraggableSymbol的坐标计算
struct DraggableSymbol: View {
    @Binding var position: Symbol
    @Binding var zoom: CGFloat // 传入当前缩放比例
    @State private var dragOffset = CGSize.zero

    var body: some View {
        Circle()
            .fill(Color.blue)
            .frame(width: 30, height: 30)
            .position(x: position.x + dragOffset.width, y: position.y + dragOffset.height)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        // 拖拽偏移除以缩放比例,修正坐标系统
                        dragOffset = CGSize(
                            width: value.translation.width / zoom,
                            height: value.translation.height / zoom
                        )
                    }
                    .onEnded { value in
                        position.x += value.translation.width / zoom
                        position.y += value.translation.height / zoom
                        dragOffset = .zero
                    }
            )
    }
}

// 3. 使用示例
struct SchematicView: View {
    @State private var symbols: [Symbol] = [
        Symbol(x: 100, y: 100),
        Symbol(x: 200, y: 200)
    ]
    @State private var zoom: CGFloat = 0.5
    @State private var scrollOffset: CGPoint = .zero
    
    var body: some View {
        UIKitCanvasView(
            content: {
                ForEach($symbols) { $symbol in
                    DraggableSymbol(position: $symbol, zoom: $zoom)
                }
            },
            canvasSize: CGSize(width: 3000, height: 3000),
            zoom: $zoom,
            scrollOffset: $scrollOffset
        )
        .overlay(alignment: .bottomTrailing) {
            HStack {
                Text("Zoom: \(zoom, specifier: "%.2f")")
                Slider(value: $zoom, in: 0.1...5)
            }
            .padding(10)
            .background(.white)
            .cornerRadius(8)
            .padding()
        }
    }
}

关键改进

  • 设置cancelsTouchesInView = false,允许子视图接收拖拽手势
  • 拖拽偏移量除以当前缩放比例,修正缩放后的坐标计算
  • 通过UIScrollViewDelegate同步缩放比例与滚动偏移到SwiftUI状态

额外优化建议

  • 支持动态扩展画布尺寸:当内容超出当前画布范围时,自动增大canvasSize
  • 添加双指捏合缩放手势,替代仅用滑块控制,更符合Freeform的交互逻辑
  • 优化缩放锚点:以手势中心为锚点进行缩放,提升用户体验

内容的提问来源于stack exchange,提问作者devon't

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 13:28:10