将@State属性替换为@Binding属性导致SwiftUI底部弹窗动画异常
问题原因分析
当你将opened从@State改为@Binding后,出现动画异常的核心原因有两点:
状态更新的上下文丢失
@GestureState的特性是手势结束后会立即重置为初始值(这里是0)。当使用@State时,状态更新完全在组件内部,SwiftUI能跟踪手势结束时的临时位移状态,让动画自然衔接;但改为@Binding后,状态更新会触发父视图的重新渲染,此时translation已经被重置,SwiftUI无法关联手势结束时的位置和新的目标位置,导致动画从初始位置(而非手指抬起的位置)开始。在包含TabView的复杂视图中,父视图的重新渲染更频繁,这个问题会被进一步放大。初始化时的不安全访问
你的初始化方法中,计算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() } }
修改说明
- 修复初始化安全问题:将
capsuleHeight和capsulePadding改为static let,通过Self.访问,避免在初始化时访问未完全创建的self。 - 保留手势结束位移:新增
postGestureTranslation变量,临时保存手势结束时的位移值,直到动画完成后再重置,确保动画能从手指抬起的位置开始。 - 明确动画上下文:用
withAnimation包裹opened.toggle(),强制状态更新时携带动画上下文,同时让.animation监听opened和currentTranslation的变化,保证所有相关状态变更都触发正确的过渡动画。
这样修改后,无论是在Xcode预览还是复杂的TabView场景中,拖拽结束后弹窗都会平滑过渡到目标状态,不会出现跳变异常。
内容的提问来源于stack exchange,提问作者Flerex
相关产品推荐
相关产品推荐

