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

SwiftUI闹钟视图动画异常问题求助

Netflix风格提醒动画快速点击异常修复

问题描述

实现类似Netflix应用“New & Hot”页面的提醒动画效果时,当前代码存在以下问题:在默认状态(显示铃铛图标、文本为“Remind Me”)下,点击按钮触发铃铛晃动动画时,若快速再次点击,会短暂显示对勾图标后回到铃铛,但文本仍保持“Reminder Set”而非切换回“Remind Me”。期望实现:快速点击时,取消铃铛动画,不显示对勾图标,同时文本能正确切换。

当前代码:

struct AlarmView: View {
    @State var alarmSet = false
    @State var animateBell = false
    @State var alarmText = "Remind Me"
    @State var shouldAnimate = false
    @State private var bellID: UUID = UUID()
    @State private var timeSinceLastTap = Date.now
    
    var body: some View {
        ZStack {
            Color.black
            VStack {
                Button(action: {
                    // animate only if there is some time elapsed between taps
                    let duration = min(0.3, abs(timeSinceLastTap.timeIntervalSinceNow))
                    shouldAnimate = duration > 0.2
                    alarmText = !alarmSet ? "Reminder Set" : "Remind Me"

                    if shouldAnimate {
                        if alarmSet == true { // turn OFF alarm
                            alarmSet.toggle()
                        } else { // animate the bell, then turn ON alarm
                            animateBell.toggle()
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                                alarmSet.toggle()
                            }
                        }
                    } else {
                        // to cancel animation
                        bellID = UUID()
                        alarmSet.toggle()
                    }
                    timeSinceLastTap = Date.now
                    
                }, label: {
                    VStack {
                        VStack {
                            if alarmSet {
                                Image(systemName: "checkmark")
                                    .font(.system(size: 150))
                                    .foregroundColor(.white)
                            } else {
                                Image(systemName: "bell.fill")
                                    .font(.system(size: 150))
                                    .foregroundColor(.white)
                                    .id(bellID)
                                    .keyframeAnimator(initialValue: Angle.zero, trigger: animateBell) { content, value in
                                        content.rotationEffect(value, anchor: .top)
                                    } keyframes: { _ in
                                        KeyframeTrack {
                                            let spring = Spring(mass: 0.5, stiffness: 170, damping: 2.5)
                                            SpringKeyframe(Angle.degrees(10), duration: 0.05, spring: spring)
                                            SpringKeyframe(Angle.degrees(-10), duration: 0.1, spring: spring)
                                            SpringKeyframe(Angle.degrees(10), duration: 0.1, spring: spring)
                                            SpringKeyframe(Angle.degrees(-10), duration: 0.1, spring: spring)
                                            SpringKeyframe(Angle.degrees(10), duration: 0.1, spring: spring)
                                            SpringKeyframe(Angle.zero, duration: 0.05, spring: spring, startVelocity: Angle.zero)
                                        }
                                    }
                            }
                        }
                        .frame(width: 150, height: 150)
                        Text(alarmText)
                            .font(.largeTitle)
                            .foregroundStyle(.gray)
                    }
                })
                .buttonStyle(NoAnimationStyle())
            }
        }
        .ignoresSafeArea()
    }
}

struct NoAnimationStyle: PrimitiveButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .contentShape(Rectangle())
            .onTapGesture(perform: configuration.trigger)
    }
}

解决方案

核心问题是快速点击时,之前的异步任务仍会执行导致alarmSet状态错误切换,且文本更新逻辑没有考虑取消动画的场景。修改后的完整代码如下:

struct AlarmView: View {
    @State var alarmSet = false
    @State var animateBell = false
    @State var alarmText = "Remind Me"
    @State private var bellID: UUID = UUID()
    @State private var timeSinceLastTap = Date.now
    // 管理可取消的异步任务,避免错误状态切换
    @State private var cancelableTask: Task<Void, Never>?
    
    var body: some View {
        ZStack {
            Color.black
            VStack {
                Button(action: {
                    let duration = abs(timeSinceLastTap.timeIntervalSinceNow)
                    let shouldAnimate = duration > 0.2
                    
                    // 取消未完成的异步任务,阻止错误状态更新
                    cancelableTask?.cancel()
                    cancelableTask = nil
                    
                    if shouldAnimate {
                        if alarmSet {
                            // 关闭提醒:直接切换状态和文本
                            alarmSet.toggle()
                            alarmText = "Remind Me"
                        } else {
                            // 开启动画:更新文本并启动异步状态切换
                            animateBell.toggle()
                            alarmText = "Reminder Set"
                            cancelableTask = Task {
                                try? await Task.sleep(nanoseconds: 500_000_000)
                                if !Task.isCancelled {
                                    alarmSet = true
                                }
                            }
                        }
                    } else {
                        // 快速点击:重置所有状态,终止动画
                        animateBell = false
                        bellID = UUID()
                        alarmSet = false
                        alarmText = "Remind Me"
                    }
                    
                    timeSinceLastTap = Date.now
                    
                }, label: {
                    VStack {
                        VStack {
                            if alarmSet {
                                Image(systemName: "checkmark")
                                    .font(.system(size: 150))
                                    .foregroundColor(.white)
                            } else {
                                Image(systemName: "bell.fill")
                                    .font(.system(size: 150))
                                    .foregroundColor(.white)
                                    .id(bellID)
                                    .keyframeAnimator(initialValue: Angle.zero, trigger: animateBell) { content, value in
                                        content.rotationEffect(value, anchor: .top)
                                    } keyframes: { _ in
                                        KeyframeTrack {
                                            let spring = Spring(mass: 0.5, stiffness: 170, damping: 2.5)
                                            SpringKeyframe(Angle.degrees(10), duration: 0.05, spring: spring)
                                            SpringKeyframe(Angle.degrees(-10), duration: 0.1, spring: spring)
                                            SpringKeyframe(Angle.degrees(10), duration: 0.1, spring: spring)
                                            SpringKeyframe(Angle.degrees(-10), duration: 0.1, spring: spring)
                                            SpringKeyframe(Angle.degrees(10), duration: 0.1, spring: spring)
                                            SpringKeyframe(Angle.zero, duration: 0.05, spring: spring, startVelocity: Angle.zero)
                                        }
                                    }
                            }
                        }
                        .frame(width: 150, height: 150)
                        Text(alarmText)
                            .font(.largeTitle)
                            .foregroundStyle(.gray)
                    }
                })
                .buttonStyle(NoAnimationStyle())
            }
        }
        .ignoresSafeArea()
        // 视图销毁时清理任务,避免内存泄漏
        .onDisappear {
            cancelableTask?.cancel()
        }
    }
}

struct NoAnimationStyle: PrimitiveButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .contentShape(Rectangle())
            .onTapGesture(perform: configuration.trigger)
    }
}

关键修改点说明

  • 添加可取消任务:用Task替代DispatchQueue异步回调,快速点击时可直接取消未完成的状态切换,避免alarmSet被错误设置为true
  • 快速点击逻辑优化:直接重置alarmSet为false,同步更新文本为“Remind Me”,同时重置铃铛id终止当前动画
  • 文本更新逻辑调整:根据操作场景明确设置文本,不再依赖!alarmSet前置判断,避免状态不同步
  • 视图销毁清理:在onDisappear中取消任务,防止内存泄漏

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.04 14:25:31