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

轮播组件出现非对称动画卡顿,原因何在?

无限循环轮播组件向前滚动卡顿问题

我正在做一个支持无限循环的轮播组件——滑到最后一张图能切到第一张,反过来也一样,但只有向前滚动时,动画会在显示正确的新图前出现卡顿,向后滚动完全正常。前后滑动逻辑是对称的,大部分代码也是共用的,这让我搞不懂问题出在哪。

编辑说明:示例使用5张1920x1080的图片,命名为"PH1"、"PH2"……"PH5"。

CarouselModel.swift

import Foundation

class CarouselModel: ObservableObject {
    var uuid: UUID = UUID()

    @Published internal var onScreenBuffer: [BufferedImage] = []
    private var offScreenBuffer: [BufferedImage] = []
    internal var currentImage: Int = 0
    internal var offsetImageSelector: Int = 0

    @Published internal var imagesIDs: [String] = [] {
    didSet {
            guard imagesIDs.count > 0 else { return }
            self.makeBuffer(for: self.currentImage, buffer: &self.onScreenBuffer)
            self.makeBuffer(for: self.currentImage, buffer: &self.offScreenBuffer)
        }
    }

    internal var bufferedImagesIDs: [BufferedImage] {
        return self.imagesIDs.enumerated().map { i, image in
            return BufferedImage(assetImageName: image, position: i)
        }
    }

    init(imagesIDs: [String]) {
        self.imagesIDs = imagesIDs
    }

    internal func makeBuffer(for centralIdx: Int, buffer: inout [BufferedImage])  {
        precondition(centralIdx >= 0 && centralIdx < self.imagesIDs.count)
        var tempBuffer: [BufferedImage] = [BufferedImage].init(repeating: .zero, count: 3)
    
        for i in -1...1 {
            let next = (centralIdx + i + imagesIDs.count) % imagesIDs.count
            tempBuffer[i+1] = BufferedImage(assetImageName: imagesIDs[next], position: i+1)
        }
    
        buffer = tempBuffer
    }

    func prepareForNext(forward: Bool = true) {
        let nextIdx = forward ? (currentImage+1)%imagesIDs.count : (currentImage-1+imagesIDs.count) % imagesIDs.count
    
        if forward {
            self.offsetImageSelector -= 1
        } else {
            self.offsetImageSelector += 1
        }
    
        assert(nextIdx >= 0 && nextIdx <= imagesIDs.count)
    
        self.makeBuffer(for: nextIdx, buffer: &self.offScreenBuffer)
        currentImage = nextIdx
    }

    func swapOnscreenBuffer() {
        self.onScreenBuffer = Array(self.offScreenBuffer)
        self.offsetImageSelector = 0
    }


    internal struct BufferedImage: Identifiable {
        var assetImageName: String
        var position: Int
        var id: String
    
        init(assetImageName: String, position: Int) {
            self.assetImageName = assetImageName
            self.position = position
            self.id = "\(assetImageName)\(position)"
        }
    
        public static let zero = BufferedImage(assetImageName: "placeholder", position: .zero)
    }

}

ContentView.swift

import SwiftUI

struct ContentView: View {

    @StateObject private var carouselModel = CarouselModel(
        imagesIDs: (1..<6).map { i in
            return "PH\(i)"
        }
    )
    @State private var dragOffset: CGFloat = .zero

    var body: some View {
        GeometryReader { geo in
            VStack {
                HStack(spacing: 0) {
                    ForEach(carouselModel.onScreenBuffer, id: \.id) { bufferedImage in
                        Image(bufferedImage.assetImageName)
                            .resizable()
                            .aspectRatio(16.0/9.0, contentMode: .fit)
                            .frame(width: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing, alignment: .leading)
                    }
                }
                .offset(
                    x: -(geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing - dragOffset)*CGFloat((1-carouselModel.offsetImageSelector))
                )
            
                .frame(maxWidth: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing, alignment: .leading)
                .clipped()
                .overlay {
                    ChevronButtonsOverlay(galleryWidth: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing)
                }
            
            }
        }
    }

    @ViewBuilder
    func ChevronButton() -> some View {
        ZStack {
            Circle()
                .fill(.ultraThinMaterial)
                .frame(width: 35, height: 35)
                .background {
                    Color(UIColor.label).opacity(0.8).clipShape(Circle())
                }
        
            Image(systemName: "chevron.left")
                .font(.system(size: 15).weight(.black))
                .blendMode(.destinationOut)
                .offset(y: -1)
        }
        .compositingGroup()
    }

    @ViewBuilder
    func ChevronButtonsOverlay(galleryWidth: CGFloat) -> some View {
        HStack(alignment: .center, spacing: 0) {
            Button(action: {
                if abs(carouselModel.offsetImageSelector) < 1 {
                    withAnimation(.easeOut(duration: 0.25)) {
                        carouselModel.prepareForNext(forward: false)
                        self.dragOffset = galleryWidth
                    }
                
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                        self.dragOffset = 0
                        carouselModel.swapOnscreenBuffer()
                    }
                }
            }) {
                ChevronButton()
            }
        
            Spacer()
        
            Button(action: {
                if abs(carouselModel.offsetImageSelector) < 1 {
                    withAnimation(.easeOut(duration: 0.25)) {
                        carouselModel.prepareForNext(forward: true)
                        self.dragOffset = -galleryWidth
                    }
                
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                        self.dragOffset = 0
                        carouselModel.swapOnscreenBuffer()
                    }
                }
            }) {
                ChevronButton()
            }
            .rotationEffect(.degrees(180))
        }
        .padding(5)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

问题原因及修复方案

问题根源

  1. 偏移量计算逻辑不对称:向前滚动时offsetImageSelector为-1,代入偏移公式后会产生两倍宽度的偏移,和向后滚动的偏移逻辑完全不一致,导致动画过程中视图布局异常。
  2. 视图ID冲突:BufferedImage的id仅用图片名+位置生成,当循环滚动时,不同缓冲池中的图片会出现重复ID,SwiftUI无法正确识别视图身份,引发视图复用错误,出现短暂卡顿。

修复步骤

1. 修正偏移量计算逻辑

把offset公式改成对称逻辑,确保前后滚动的偏移变化一致:

.offset(
    x: (dragOffset - (geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing)) * CGFloat(carouselModel.offsetImageSelector)
)

2. 修复视图ID冲突

给BufferedImage的id加入模型的唯一UUID,避免不同缓冲池的视图ID重复:

// 修改BufferedImage的初始化方法
init(assetImageName: String, position: Int, parentUUID: UUID) {
    self.assetImageName = assetImageName
    self.position = position
    self.id = "\(parentUUID)-\(assetImageName)-\(position)"
}

// 修改makeBuffer方法中的创建逻辑
tempBuffer[i+1] = BufferedImage(assetImageName: imagesIDs[next], position: i+1, parentUUID: self.uuid)

3. 对齐动画与缓冲切换时机

把缓冲切换的延迟时间改成和动画时长一致,同时关闭切换缓冲时的动画,避免额外的视图波动:

// 前后按钮的异步回调都改成这样
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
    self.dragOffset = 0
    withAnimation(nil) {
        carouselModel.swapOnscreenBuffer()
    }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.07 11:18:13