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

将@State属性替换为@Binding属性导致SwiftUI底部弹窗动画异常

问题原因分析

当你将opened从@State改为@Binding后,出现动画异常的核心原因有两点:

  1. 状态更新的上下文丢失
    @GestureState的特性是手势结束后会立即重置为初始值(这里是0)。当使用@State时,状态更新完全在组件内部,SwiftUI能跟踪手势结束时的临时位移状态,让动画自然衔接;但改为@Binding后,状态更新会触发父视图的重新渲染,此时translation已经被重置,SwiftUI无法关联手势结束时的位置和新的目标位置,导致动画从初始位置(而非手指抬起的位置)开始。在包含TabView的复杂视图中,父视图的重新渲染更频繁,这个问题会被进一步放大。

  2. 初始化时的不安全访问
    你的初始化方法中,计算headerHeight时直接访问了self.capsuleHeight和self.capsulePadding——但此时self还未完全初始化(这两个是实例常量),这种不安全的访问可能导致不可预期的布局计算误差,间接影响动画的连贯性。

解决方案

针对上述问题,我们可以通过以下修改修复动画异常:

import SwiftUI
import MapKit

struct ReleaseGesture<Header: View, Content: View>: View {
    // MARK: Init properties
    @Binding private var opened: Bool
    let headerHeight: CGFloat
    let contentHeight: CGFloat
    let separation: CGFloat
    let header: () -> Header
    let content: () -> Content

    // MARK: State
    @GestureState private var translation: CGFloat = 0
    @State private var postGestureTranslation: CGFloat = 0 // 保存手势结束后的临时位移

    // MARK: Constants
    static let capsuleHeight: CGFloat = 5
    static let capsulePadding: CGFloat = 5

    // MARK: Computed properties
    private var currentTranslation: CGFloat {
        // 拖拽时用GestureState的translation,否则用临时保存的postGestureTranslation
        translation != 0 ? translation : postGestureTranslation
    }

    private var offset: CGFloat {
        opened ? headerHeight + contentHeight : headerHeight
    }

    // MARK: Gesture
    private var gesture: some Gesture {
        DragGesture()
            .updating($translation) { value, state, transaction in
                state = -value.translation.height
            }
            .onEnded { value in
                postGestureTranslation = -value.translation.height
                // 用withAnimation包裹状态更新,确保动画上下文正确
                withAnimation(animation) {
                    opened.toggle()
                }
                // 动画完成后重置临时位移(匹配动画时长)
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    postGestureTranslation = 0
                }
            }
    }

    // MARK: Animation
    private var animation: Animation {
        .spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
    }

    // MARK: Drag Indicator
    private var dragIndicator: some View {
        Capsule()
            .fill(Color.gray.opacity(0.4))
            .frame(width: 40, height: Self.capsuleHeight)
            .padding(.vertical, Self.capsulePadding)
    }

    // MARK: Body
    var body: some View {
        GeometryReader { reader in
            VStack(spacing: 0) {
                VStack(spacing: 0) {
                    dragIndicator
                    VStack(content: header)
                        .padding(.bottom, separation)
                    VStack(content: content)
                }
                .padding(.horizontal, 10)
            }
            .frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
            .background(Color.white.opacity(0.8))
            .cornerRadius(10)
            .offset(y: reader.size.height - max(currentTranslation + offset, 0))
            .animation(animation, value: [opened, currentTranslation]) // 监听状态和位移变化
            .gesture(gesture)
        }
        .clipped()
    }

    // MARK: Initializer
    init(
        opened: Binding<Bool>,
        headerHeight: CGFloat,
        contentHeight: CGFloat,
        separation: CGFloat,
        @ViewBuilder header: @escaping () -> Header,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self._opened = opened
        // 使用Self访问静态常量,避免init中访问未初始化的self
        self.headerHeight = headerHeight + Self.capsuleHeight + Self.capsulePadding * 2 + separation
        self.contentHeight = contentHeight
        self.separation = separation
        self.header = header
        self.content = content
    }
}

struct ReleaseGesture_Previews: PreviewProvider {
    struct WrapperView: View {
        @State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
        @State private var opened = false

        var body: some View {
            ZStack {
                Map(coordinateRegion: $region)
                ReleaseGesture(
                    opened: $opened,
                    headerHeight: 25,
                    contentHeight: 300,
                    separation: 30,
                    header: {
                        RoundedRectangle(cornerRadius: 8)
                            .fill(Color.black.opacity(0.3))
                            .frame(height: 30)
                    },
                    content: {
                        RoundedRectangle(cornerRadius: 10)
                            .fill(Color.orange.opacity(0.2))
                            .frame(width: 300, height: 300)
                    }
                )
            }
            .ignoresSafeArea()
        }
    }

    static var previews: some View {
        WrapperView()
    }
}

修改说明

  1. 修复初始化安全问题:将capsuleHeight和capsulePadding改为static let,通过Self.访问,避免在初始化时访问未完全创建的self。
  2. 保留手势结束位移:新增postGestureTranslation变量,临时保存手势结束时的位移值,直到动画完成后再重置,确保动画能从手指抬起的位置开始。
  3. 明确动画上下文:用withAnimation包裹opened.toggle(),强制状态更新时携带动画上下文,同时让.animation监听opened和currentTranslation的变化,保证所有相关状态变更都触发正确的过渡动画。

这样修改后,无论是在Xcode预览还是复杂的TabView场景中,拖拽结束后弹窗都会平滑过渡到目标状态,不会出现跳变异常。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.29 09:07:28