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

SwiftUI:实现可拖动底部表单的ScrollView方案咨询

实现SwiftUI可拖动ScrollView抽屉的最简方案

我刚好研究过这种抽屉交互——苹果Maps、Music这类App里的设计核心是区分拖动行为是滚动内部内容还是拖动整个容器:当ScrollView处于顶部时,拖动会带动抽屉;当ScrollView已经滚动过内容,拖动只会滚动列表。下面是用SwiftUI原生API实现的最简方案,不用依赖第三方库,也不用复杂的UIKit包装:

核心思路

  1. 用DragGesture监听抽屉的拖动动作
  2. 通过滚动偏移判断ScrollView是否在顶部(iOS 17+用原生的scrollPosition,低版本可以用自定义ScrollView监听)
  3. 只有当ScrollView在顶部时,才响应抽屉的拖动;否则让ScrollView自己处理滚动

完整代码示例(iOS 17+)

import SwiftUI

struct DraggableScrollDrawer: View {
    // 控制抽屉的垂直偏移量
    @State private var drawerOffset: CGFloat = 0
    // 记录ScrollView的滚动偏移(iOS 17+原生支持)
    @State private var scrollOffset: CGFloat = 0
    
    // 可自定义的抽屉高度范围
    private let minDrawerHeight: CGFloat = 200
    private let maxDrawerHeight: CGFloat = UIScreen.main.bounds.height - 100
    
    var body: some View {
        ZStack {
            // 背景层(替换成你的主内容,比如地图)
            Color.blue.opacity(0.3)
                .ignoresSafeArea()
            
            // 可拖动的抽屉容器
            VStack(spacing: 0) {
                // 顶部拖动指示器,引导用户交互
                RoundedRectangle(cornerRadius: 3)
                    .frame(width: 40, height: 6)
                    .foregroundColor(.gray)
                    .padding(.vertical, 8)
                
                // 带滚动内容的ScrollView
                ScrollView {
                    LazyVStack(spacing: 16) {
                        ForEach(1...50, id: \.self) { index in
                            Text("列表项 \(index)")
                                .frame(maxWidth: .infinity)
                                .padding()
                                .background(.white)
                                .cornerRadius(8)
                                .padding(.horizontal)
                        }
                    }
                    .scrollTargetLayout() // 配合scrollPosition使用
                }
                .scrollPosition(id: $scrollOffset) // 监听滚动偏移
                .scrollIndicators(.hidden)
            }
            .background(.white)
            .cornerRadius(16, corners: [.topLeft, .topRight])
            // 限制抽屉在最小/最大高度之间移动
            .offset(y: max(minDrawerHeight, maxDrawerHeight - drawerOffset))
            .gesture(
                DragGesture()
                    .onChanged { value in
                        // 只有当ScrollView在顶部时,才允许拖动抽屉
                        if scrollOffset <= 0 {
                            drawerOffset = value.translation.height
                        }
                    }
                    .onEnded { value in
                        // 拖动结束自动吸附到最近的高度
                        let velocity = value.velocity.height
                        let midPoint = (maxDrawerHeight - minDrawerHeight)/2
                        if velocity < -1000 || drawerOffset > midPoint {
                            withAnimation(.spring()) {
                                drawerOffset = maxDrawerHeight - minDrawerHeight
                            }
                        } else {
                            withAnimation(.spring()) {
                                drawerOffset = 0
                            }
                        }
                    }
            )
        }
    }
}

#Preview {
    DraggableScrollDrawer()
}

关键细节说明

  • 交互逻辑:当ScrollView没有滚动(scrollOffset <= 0)时,拖动操作会带动抽屉;如果ScrollView已经向下滚动,拖动只会滚动内部列表,和苹果官方App的行为完全一致。
  • 自动吸附:拖动结束时,根据拖动速度和偏移距离,自动让抽屉吸附到最小或最大高度,避免停在中间的尴尬状态。
  • 兼容性方案(iOS 16及以下):如果需要支持旧版本系统,可以用UIViewRepresentable包装UIScrollView来监听滚动偏移,核心代码如下:
// 兼容iOS 16的自定义ScrollView,用于监听滚动偏移
struct ScrollViewWithOffset: UIViewRepresentable {
    @Binding var offset: CGFloat
    
    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()
        scrollView.delegate = context.coordinator
        scrollView.showsVerticalScrollIndicator = false
        return scrollView
    }
    
    func updateUIView(_ uiView: UIScrollView, context: Context) {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UIScrollViewDelegate {
        var parent: ScrollViewWithOffset
        
        init(_ parent: ScrollViewWithOffset) {
            self.parent = parent
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            parent.offset = scrollView.contentOffset.y
        }
    }
}

把原代码中的ScrollView替换成这个自定义组件,绑定scrollOffset就能实现相同的交互逻辑。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.30 18:42:50