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

SwiftUI 实现固定底部的Sticky/Pinned Bottom Sheet弹层问题求解

问题根源

你当前的实现通过修改整个弹层容器的全局offset控制显隐,spring动画的弹性超调会让offset变为负数(弹层整体上移),自然会露出弹层下方的背景区域。

解决方案

实现底部固定的核心是将弹层的变换锚点固定在底部,所有动画、拖拽位移都以底部为基准点,这样无论顶部怎么弹跳,底部都会牢牢吸附在屏幕底部。
你只需要对原有代码做3处改动即可:

  1. 移除原有offset控制逻辑,改用锚点定位固定底部
  2. 调整动画的作用范围,仅响应isOpen状态变化
  3. 补充锚点配置

改后完整代码

import SwiftUI

// 补充原有代码依赖的ChildSizeReader实现
struct ChildSizeReader: ViewModifier {
    @Binding var size: CGSize
    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader { proxy in
                    Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size)
                }
            )
            .onPreferenceChange(SizePreferenceKey.self) { size = $0 }
    }
}

extension View {
    func readSize(_ size: Binding<CGSize>) -> some View {
        self.modifier(ChildSizeReader(size: size))
    }
}

private struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

struct BottomSheetView<Content: View>: View {
    @Binding var isOpen: Bool
    @ViewBuilder let content: Content
    @State private var contentSize: CGSize = .zero

    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 0.0) {
                content
                    .readSize($contentSize)
            }
            .frame(
                width: geometry.size.width,
                height: contentSize.height,
                alignment: .top
            )
            .clipShape(
                Path(
                    UIBezierPath(
                        roundedRect: CGRect(x: 0.0, y: 0.0, width: geometry.size.width, height: contentSize.height + 200), // 增加裁切高度,避免超调时顶部圆角被裁切
                        byRoundingCorners: [.topLeft, .topRight],
                        cornerRadii: CGSize(width: 16.0, height: 16.0)
                    )
                    .cgPath
                )
            )
            // 核心改动:固定底部锚点
            .position(x: geometry.size.width / 2, y: geometry.size.height)
            .anchorPoint(.bottom)
            // 核心改动:用y方向偏移控制显隐,锚点固定在底部不会出现底部漏空
            .offset(y: isOpen ? 0 : contentSize.height)
            .animation(
                .spring()
                    .speed(1.25),
                value: isOpen // 限制动画仅响应isOpen变化,避免其他状态触发异常动画
            )
        }
        .edgesIgnoringSafeArea(.bottom)
    }
}

// 补充SwiftUI锚点设置扩展
extension View {
    func anchorPoint(_ anchor: UnitPoint) -> some View {
        self.transformEffect(.init(translationX: anchor.x * -1, y: anchor.y * -1))
            .transformEffect(.init(scaleX: 1, y: 1))
            .transformEffect(.init(translationX: anchor.x, y: anchor.y))
    }
}

struct BottomSheetView_Previews: PreviewProvider {
    static var previews: some View {
        ZStack {
            Color.gray.opacity(0.3).edgesIgnoringSafeArea(.all)
            BottomSheetView(isOpen: .constant(true)) {
                Text("Bottom Sheet")
                    .frame(maxWidth: .infinity, minHeight: 200.0)
                    .background(Color.red)
            }
        }
    }
}
拖拽手势适配说明

后续添加拖拽手势时,只需要把拖拽的偏移量直接叠加到现有offset属性上即可,因为锚点已经固定在底部,无论怎么拖拽都不会出现底部漏空的问题。拖拽结束时只需要判断偏移量阈值,决定是收回弹层还是回弹到展开位置即可。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.26 16:54:08