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

SwiftUI动画期间禁用默认插值,实现视图逐帧重绘需求咨询

解决SwiftUI动画中禁用默认插值、逐帧重绘视图的问题

我希望下方示例中的视图在数值变化的动画过程中,不使用SwiftUI默认的插值逻辑,而是直接重绘整个视图。拖动滑块从10到100的效果是符合预期的,我想让动画也达到同样的效果——不是让所有视图渐入,而是在动画时长内逐帧单独绘制每个步骤。我试过带关键帧和不带关键帧的自定义动画,还有自定义转场,但都没得到想要的结果。

import Foundation
import SwiftUI

struct SampleView: View {

    private let value: Int

    init(value: Double) {
        self.value = Int(max(min(value, 1.0), 0.0) * 100)
    }

    var body: some View {
        VStack(spacing: 0.0) {
            ForEach(0 ..< 101) { value in
                Rectangle()
                    .fill(color(for: value))
                    .frame(width: 100, height: 6.0)
                    .opacity(value < self.value ? 1.0 : 0.0)
            }
        }
    }

    private func color(for value: Int) -> Color {
        switch value % 4 {
        case 0: return Color.red
        case 1: return Color.blue
        case 2: return Color.yellow
        case 3: return Color.green
        default: return Color.black
        }
    }

}

#Preview("Interactive Sample") {
    struct InteractiveTestView: View {

        @State var value: Double = 0.1

        var body: some View {
            VStack {
                Text("\(Int(value * 100))%").font(.largeTitle)
                Button("Set to max") {
                    withAnimation(.easeInOut(duration: 5.0)) {
                        value = 0.9
                    }
                }
                Slider(value: $value, in: 0.0...1.0, step: 0.01).padding()
                SampleView(value: value)
            }
        }

    }

    return InteractiveTestView()
}

核心解决方案

SwiftUI默认会对视图的可动画属性(比如opacity)做平滑插值过渡,要实现逐帧重绘,关键是让SampleView在动画的每一帧都根据当前的整数value完全重建,而非对浮点中间值做插值。以下两种方法可以实现需求:

方法一:定时器驱动整数逐帧更新

通过定时器按动画时长拆分步骤,逐帧更新整数value,让SampleView每次都完全重建,完全规避插值逻辑:

import SwiftUI

class ValueViewModel: ObservableObject {
    @Published var intValue: Int = 10
    
    func update(to target: Double, duration: TimeInterval) {
        let targetInt = Int(max(min(target, 1.0), 0.0) * 100)
        let steps = targetInt - intValue
        guard steps != 0 else { return }
        
        let stepDuration = duration / Double(abs(steps))
        let step = steps > 0 ? 1 : -1
        
        Timer.scheduledTimer(withTimeInterval: stepDuration, repeats: true) { timer in
            self.intValue += step
            if self.intValue == targetInt {
                timer.invalidate()
            }
        }
    }
}

struct SampleView: View {
    let value: Int
    
    var body: some View {
        VStack(spacing: 0.0) {
            ForEach(0 ..< 101) { value in
                Rectangle()
                    .fill(color(for: value))
                    .frame(width: 100, height: 6.0)
                    .opacity(value < self.value ? 1.0 : 0.0)
            }
        }
    }
    
    private func color(for value: Int) -> Color {
        switch value % 4 {
        case 0: return .red
        case 1: return .blue
        case 2: return .yellow
        case 3: return .green
        default: return .black
        }
    }
}

#Preview("Interactive Sample") {
    struct InteractiveTestView: View {
        @StateObject private var vm = ValueViewModel()
        @State private var sliderValue: Double = 0.1
        
        var body: some View {
            VStack {
                Text("\(vm.intValue)%").font(.largeTitle)
                Button("Set to max") {
                    vm.update(to: 0.9, duration: 5.0)
                }
                Slider(value: $sliderValue, in: 0.0...1.0, step: 0.01)
                    .padding()
                    .onChange(of: sliderValue) { newValue in
                        vm.intValue = Int(max(min(newValue, 1.0), 0.0) * 100)
                    }
                SampleView(value: vm.intValue)
            }
        }
    }
    
    return InteractiveTestView()
}

方法二:通过事务禁用属性插值

修改视图事务,强制SwiftUI不对SampleView的属性做插值,配合withAnimation控制时长,实现逐帧重绘:

import SwiftUI

struct SampleView: View {
    let value: Int
    
    var body: some View {
        VStack(spacing: 0.0) {
            ForEach(0 ..< 101) { value in
                Rectangle()
                    .fill(color(for: value))
                    .frame(width: 100, height: 6.0)
                    .opacity(value < self.value ? 1.0 : 0.0)
            }
        }
        .transaction { transaction in
            // 禁用该视图及其子视图的属性插值
            transaction.disablesAnimations = true
        }
    }
    
    private func color(for value: Int) -> Color {
        switch value % 4 {
        case 0: return .red
        case 1: return .blue
        case 2: return .yellow
        case 3: return .green
        default: return .black
        }
    }
}

#Preview("Interactive Sample") {
    struct InteractiveTestView: View {
        @State private var value: Double = 0.1
        @State private var animatedIntValue: Int = 10
        
        var body: some View {
            VStack {
                Text("\(animatedIntValue)%").font(.largeTitle)
                Button("Set to max") {
                    let targetInt = Int(max(min(0.9, 1.0), 0.0) * 100)
                    withAnimation(.linear(duration: 5.0)) {
                        animatedIntValue = targetInt
                    }
                }
                Slider(value: $value, in: 0.0...1.0, step: 0.01)
                    .padding()
                    .onChange(of: value) { newValue in
                        animatedIntValue = Int(max(min(newValue, 1.0), 0.0) * 100)
                    }
                SampleView(value: animatedIntValue)
            }
        }
    }
    
    return InteractiveTestView()
}

效果说明

  • 方法一通过定时器精准控制每一步的整数更新,效果和拖动滑块完全一致,无任何插值过渡。
  • 方法二利用事务禁用插值,让SwiftUI在动画周期内,根据当前整数value逐帧重绘整个视图,同样能达到需求的效果。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.29 02:07:46