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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 11:12:03