SwiftUI首次移除卡片时过渡动画不生效问题求助
SwiftUI卡片移除过渡动画首次失效问题解决
问题描述
开发类Tinder的左右滑动卡片应用时,出现以下异常:
- 滑动卡片超过阈值移除时,首次移除第一张卡片的过渡动画未执行,但后续卡片的动画运行正常
- 放慢模拟器动画速度后,首次动画也能正常触发
相关代码
import SwiftUI struct ContentView: View { @GestureState private var dragState: DragState = .inactive @State private var zIndexCounter: Double = 1_000_000_000.0 @State private var lastIndex: Int = 1 @State private var cardView: [CardView] = Array(trips[0..<min(trips.count, 2)]).map({ CardView(trip: $0) }) @State private var removalTransition = AnyTransition.trailingBottom private let dragThreshold: CGFloat = 80.0 var body: some View { VStack(spacing: 0) { TopBar { } likeAction: { } ZStack { ForEach(cardView) { tripCard in tripCard .padding(.horizontal) .zIndex(zIndex(for: tripCard)) .overlay( ZStack { Image(systemName: "x.circle") .opacity(opacity(for: tripCard, withXMark: true)) Image(systemName: "heart.circle") .opacity(opacity(for: tripCard, withXMark: false)) } .font(.system(size: 100)) .foregroundColor(.white) ) .offset(offset(for: tripCard)) .scaleEffect(scaleEffect(for: tripCard)) .rotationEffect(rotationEffect(for: tripCard)) .animation(.interpolatingSpring(stiffness: 180, damping: 100)) .transition(removalTransition) .gesture( LongPressGesture(minimumDuration: 0.01) .sequenced(before: DragGesture()) .updating($dragState) { value, state, transaction in switch value { case .first(true): state = .pressing case .second(true, let drag): state = .dragging(translation: drag?.translation ?? .zero) default: () } } .onChanged { value in guard case .second(true, let drag?) = value else { return } if drag.translation.width < -self.dragThreshold { self.removalTransition = .leadingBottom } if drag.translation.width > self.dragThreshold { self.removalTransition = .trailingBottom } } .onEnded { value in guard case .second(true, let drag?) = value, drag.translation.width < -dragThreshold || drag.translation.width > dragThreshold else { return } moveCard() } ) } } Spacer(minLength: 20) BottomBar { } likeAction: { } bookItNowAction: { } .opacity(dragState.isDragging ? 0.0 : 1.0) .animation(.default) } } private func isTopCard(_ trip: CardView) -> Bool { guard let index = cardView.firstIndex(where: { $0.id == trip.id }) else { return false } return index == 0 } private func zIndex(for trip: CardView) -> Double { let idx = isTopCard(trip) ? zIndexCounter : zIndexCounter - 1 return idx } private func offset(for trip: CardView) -> CGSize { return isTopCard(trip) ? CGSize(width: dragState.tranlation.width, height: dragState.tranlation.height) : .zero } private func scaleEffect(for trip: CardView) -> CGFloat { if isTopCard(trip) { return dragState.isDragging ? 0.95 : 1.0 } return 1.0 } private func rotationEffect(for trip: CardView) -> Angle { return isTopCard(trip) ? .degrees(Double(dragState.tranlation.width / 10)) : .zero } private func opacity(for trip: CardView, withXMark isXMark: Bool) -> Double { if isTopCard(trip) { if isXMark { return dragState.tranlation.width < -dragThreshold ? 1.0 : 0 } return dragState.tranlation.width > dragThreshold ? 1.0 : 0 } return 0 } private func moveCard() { cardView.removeFirst() lastIndex += 1 zIndexCounter -= 1 cardView.append(CardView(trip: trips[lastIndex % trips.count])) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } enum DragState: Equatable { case inactive case pressing case dragging(translation: CGSize) var tranlation: CGSize { switch self { case .inactive, .pressing: return .zero case let .dragging(translation): return translation } } var isDragging: Bool { switch self { case .dragging: return true case .pressing, .inactive: return false } } var isPressing: Bool { switch self { case .dragging, .pressing: return true case .inactive: return false } } static func == (lhs: Self, rhs: Self) -> Bool { var result = false switch (lhs, rhs) { case (.inactive, .inactive), (.pressing, pressing): result = true case let (.dragging(translation1), .dragging(translation2)): result = translation1 == translation2 default: result = false } return result } } extension AnyTransition { static var trailingBottom: AnyTransition { AnyTransition.asymmetric( insertion: .identity, removal: AnyTransition .move(edge: .trailing) .combined(with: .move(edge: .bottom)) ) } static var leadingBottom: AnyTransition { AnyTransition.asymmetric( insertion: .identity, removal: AnyTransition .move(edge: .leading) .combined(with: .move(edge: .bottom)) ) } } struct CardView: View, Identifiable { let id = UUID() let trip: Trip var body: some View { Image(trip.image) .resizable() .scaledToFill() .frame(minWidth: 0, maxWidth: .infinity) .cornerRadius(10) .overlay( Text(trip.destination) .font(.system(.headline, design: .rounded)) .fontWeight(.bold) .foregroundColor(.primary) .padding(.vertical, 10) .padding(.horizontal, 30) .background(Color.white) .cornerRadius(5) .padding(.bottom), alignment: .bottom) } } struct Trip: Identifiable { let id = UUID() var destination: String var image: String } var trips = [ Trip(destination: "Yosemite, USA", image: "yosemite-usa"), Trip(destination: "Venice, Italy", image: "venice-italy"), Trip(destination: "Hong Kong", image: "hong-kong"), Trip(destination: "Barcelona, Spain", image: "barcelona-spain"), Trip(destination: "Braies, Italy", image: "braies-italy"), Trip(destination: "Kanangra, Australia", image: "kanangra-australia"), Trip(destination: "Mount Currie, Canada", image: "mount-currie-canada"), Trip(destination: "Ohrid, Macedonia", image: "ohrid-macedonia"), Trip(destination: "Oia, Greece", image: "oia-greece"), Trip(destination: "Palawan, Philippines", image: "palawan-philippines"), Trip(destination: "Salerno, Italy", image: "salerno-italy"), Trip(destination: "Tokyo, Japan", image: "tokyo-japan"), Trip(destination: "West Vancouver, Canada", image: "west-vancouver-canada"), Trip(destination: "Singapore", image: "garden-by-bay-singapore"), Trip(destination: "Perhentian Islands, Malaysia", image: "perhentian-islands-malaysia") ] struct BottomBar: View { let discardAction: () -> Void let likeAction: () -> Void let bookItNowAction: () -> Void var body: some View { HStack { Button(action: discardAction) { Image(systemName: "xmark") .font(.title) } Spacer() Button(action: bookItNowAction) { Text("BOOK IT NOW") .font(.system(.subheadline, design: .rounded)) .fontWeight(.bold) .padding(.horizontal, 35) .padding(.vertical, 15) .foregroundColor(.white) .background(Color.primary) .cornerRadius(10) } Spacer() Button(action: likeAction) { Image(systemName: "heart") .font(.title) } } .foregroundColor(.primary) .padding() } } struct TopBar: View { let menuAction: () -> Void let likeAction: () -> Void var body: some View { HStack { Button(action: menuAction) { Image(systemName: "line.horizontal.3") .font(.title) } Spacer() Image(systemName: "mappin.and.ellipse") .font(.largeTitle) Spacer() Button(action: likeAction) { Image(systemName: "heart.circle.fill") .font(.title) } } .padding() .foregroundColor(.primary) } }
解决建议
1. 修复拼写错误
DragState中的属性名tranlation为拼写错误,正确应为translation,需修正所有调用处:
- 将
DragState内的var tranlation: CGSize改为var translation: CGSize - 更新
offset(for:)、rotationEffect(for:)、opacity(for:)方法中所有dragState.tranlation为dragState.translation
该错误会导致拖拽偏移量计算异常,直接干扰动画触发逻辑。
2. 用withAnimation显式触发过渡动画
在moveCard()方法中,用withAnimation包裹卡片移除操作,确保过渡动画被正确调度:
private func moveCard() { withAnimation(.interpolatingSpring(stiffness: 180, damping: 100)) { cardView.removeFirst() } lastIndex += 1 cardView.append(CardView(trip: trips[lastIndex % trips.count])) }
同时移除卡片上的全局.animation(.interpolatingSpring(...))修饰符,避免动画上下文冲突。
3. 优化zIndex计算逻辑
当前zIndexCounter初始值过大,易引发渲染优先级异常,改用数组索引计算更可靠:
private func zIndex(for trip: CardView) -> Double { guard let index = cardView.firstIndex(where: { $0.id == trip.id }) else { return 0 } return Double(cardView.count - index) }
同时可删除@State private var zIndexCounter: Double = 1_000_000_000.0及moveCard()中的zIndexCounter -= 1。
4. 确保过渡状态更新在动画上下文内
在onChanged中设置removalTransition时,用withAnimation包裹状态变更,确保SwiftUI正确捕获状态变化:
.onChanged { value in guard case .second(true, let drag?) = value else { return } withAnimation { if drag.translation.width < -self.dragThreshold { self.removalTransition = .leadingBottom } if drag.translation.width > self.dragThreshold { self.removalTransition = .trailingBottom } } }
原因说明
首次动画失效核心原因:
- 拼写错误导致拖拽状态未正确更新,过渡动画触发条件不满足
- 全局动画修饰符干扰过渡动画上下文,首次移除时动画未被正确调度
- 显式用
withAnimation包裹状态变更,可确保SwiftUI正确处理动画序列,避免首次渲染时的动画丢失
内容的提问来源于stack exchange,提问作者Awe
相关产品推荐
相关产品推荐

