SwiftUI 无限循环轮播图 支持手动控制

先看效果

前言

在移动应用开发中,轮播图(Banner)是一个非常常见的 UI 组件,用于展示广告、推荐内容或重要信息。虽然 SwiftUI 提供了 TabView 配合 .tabViewStyle(.page) 可以快速实现轮播效果,但它有一些局限性:

  • 无法实现真正的无限循环滚动
  • 难以实现精确的吸附效果
  • 自定义控制能力有限

本文将介绍如何使用 SwiftUI + UIKit 结合的方式,实现一个功能完善的无限循环轮播图组件,支持以下特性:

  • ✅ 真正的无限循环滚动
  • ✅ 自动轮播功能
  • ✅ 流畅的手势拖拽
  • ✅ 精确的卡片吸附
  • ✅ 外部控制接口(上一张、下一张、跳转指定索引)
  • ✅ 防抖处理避免重复触发

核心设计思路

1. 无限循环的实现原理

无限循环的关键在于复制内容 。我们在原始内容的后面复制若干份相同的卡片,当滚动到边界时,通过瞬间重置 contentOffset 的方式跳转到等价位置,从而实现视觉上的无缝循环。

css 复制代码
原始数据:[A, B, C]
实际渲染:[A, B, C, A, B, C, A, B, ...]

当用户滑到最后一张 C 继续向右滑时,会显示复制的 A,此时瞬间将 scrollView 的 offset 重置到第一个 A 的位置,用户完全感觉不到跳转。

2. 组件架构

整个轮播图组件由三部分组成:

  1. BannerView - SwiftUI 视图,负责 UI 渲染
  2. ScrollViewHelper - UIViewRepresentable,桥接 UIScrollView
  3. LoopingScrollController - 滚动控制器,提供外部控制接口

核心代码实现

1. BannerView - 主视图

swift 复制代码
struct BannerView<Content: View, Item: RandomAccessCollection>: View where Item.Element: Identifiable {
    var width: CGFloat
    var spacing: CGFloat = 0
    var items: Item
    var controller: LoopingScrollController? = nil
    @Binding var currentIndex: Int
    @ViewBuilder var content: (Int, Item.Element) -> Content
    
    @State private var hasAppear = false
    
    var body: some View {
        GeometryReader {
            let size = $0.size
            let itemsArray = Array(items)
            
            guard !itemsArray.isEmpty, width > 0 else {
                return AnyView(EmptyView())
            }
            
            // 计算需要重复的次数,至少需要2倍来保证无缝循环
            let repeatingCount = max(Int((size.width / width).rounded()) + 2, 2)
            
            return AnyView(
                ScrollView(.horizontal) {
                    LazyHStack(spacing: spacing) {
                        // 原始卡片
                        ForEach(Array(itemsArray.enumerated()), id: \.element.id) { index, item in
                            content(index, item)
                                .frame(width: width)
                        }
                        
                        // 复制的卡片 - 使用唯一的 id 避免冲突
                        ForEach(0 ..< repeatingCount, id: \.self) { repeatIndex in
                            let actualIndex = repeatIndex % itemsArray.count
                            let item = itemsArray[actualIndex]
                            content(actualIndex, item)
                                .frame(width: width)
                                .id("repeat_\(repeatIndex)_\(item.id)")
                        }
                    }
                    .background() {
                        ScrollViewHelper(
                            width: width,
                            spacing: spacing,
                            itemsCount: items.count,
                            repeatingCount: repeatingCount,
                            controller: controller,
                            currentIndex: $currentIndex
                        )
                    }
                }
                .scrollIndicators(.hidden)
                .onAppear {
                    guard hasAppear == false else { return }
                    hasAppear = true
                    controller?.startAutoScroll() // 自动切换
                }
            )
        }
    }
}

关键点:

  • 使用 GeometryReader 获取容器尺寸
  • 通过 repeatingCount 计算需要复制的次数
  • 为重复卡片添加唯一 ID:"repeat_\(repeatIndex)_\(item.id)"
  • .background() 中嵌入 ScrollViewHelper 获取底层 UIScrollView

2. ScrollViewHelper - UIKit 桥接

这是整个组件最核心的部分,通过 UIViewRepresentable 协议桥接 UIScrollView,实现精确的滚动控制。

swift 复制代码
fileprivate struct ScrollViewHelper: UIViewRepresentable {
    var width: CGFloat
    var spacing: CGFloat
    var itemsCount: Int
    var repeatingCount: Int
    var controller: LoopingScrollController?
    @Binding var currentIndex: Int
    
    func makeCoordinator() -> Coordinator {
        let coordinator = Coordinator(
            width: width,
            spacing: spacing,
            itemsCount: itemsCount,
            repeatingCount: repeatingCount,
            currentIndex: $currentIndex
        )
        coordinator.controller = controller
        controller?.coordinator = coordinator
        return coordinator
    }
    
    func makeUIView(context: Context) -> UIView {
        return .init()
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        if !context.coordinator.isAdded {
            if let scrollView = uiView.superview?.superview?.superview as? UIScrollView {
                scrollView.delegate = context.coordinator
                scrollView.decelerationRate = .fast // 快速减速,配合吸附效果
                context.coordinator.scrollView = scrollView
                context.coordinator.isAdded = true
            } else {
                // 延迟重试
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
                    if let scrollView = uiView.superview?.superview?.superview as? UIScrollView,
                       !context.coordinator.isAdded {
                        scrollView.delegate = context.coordinator
                        scrollView.decelerationRate = .fast
                        context.coordinator.scrollView = scrollView
                        context.coordinator.isAdded = true
                    }
                }
            }
        }
        
        // 更新参数
        context.coordinator.width = width
        context.coordinator.spacing = spacing
        context.coordinator.itemsCount = itemsCount
        context.coordinator.repeatingCount = repeatingCount
    }
}

关键点:

  • 通过视图层级关系获取底层 UIScrollView:uiView.superview?.superview?.superview
  • 设置 decelerationRate = .fast 实现快速减速
  • 使用延迟重试机制确保 ScrollView 被正确获取

3. Coordinator - 滚动逻辑核心

Coordinator 负责处理所有的滚动逻辑,包括无限循环、吸附效果、用户交互等。

3.1 无限循环边界检测

swift 复制代码
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard itemsCount > 0 else { return }
    let minX = scrollView.contentOffset.x
    let mainContentSize = CGFloat(itemsCount) * width
    let spacingSize = CGFloat(itemsCount) * spacing
    let sumLength = mainContentSize + spacingSize
    
    // 手动控制滚动期间,跳过边界检查,避免干扰动画
    if !isProgrammaticScrolling {
        if minX > sumLength {
            scrollView.contentOffset.x -= sumLength
        }
        if minX < 0 {
            scrollView.contentOffset.x += sumLength
        }
    }
    
    // 更新当前索引(对 itemsCount 取模,返回实际的索引)
    let itemWidth = width + spacing
    if itemWidth > 0 {
        let rawIndex = Int((scrollView.contentOffset.x / itemWidth).rounded())
        currentIndex = rawIndex % itemsCount
    }
}

关键点:

  • 当滚动超出原始内容范围时,瞬间重置 offset
  • 通过取模运算 % 计算真实的索引位置
  • 使用 isProgrammaticScrolling 标志避免在动画过程中触发边界检查

3.2 精确的卡片吸附

swift 复制代码
func scrollViewWillEndDragging(
    _ scrollView: UIScrollView,
    withVelocity velocity: CGPoint,
    targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
    guard itemsCount > 0, width > 0 else { return }
    
    let itemWidth = width + spacing
    let targetX = targetContentOffset.pointee.x
    
    // 计算最近的卡片索引(考虑速度方向)
    var targetIndex = round(targetX / itemWidth)
    
    // 如果速度较大,倾向于滑动到下一张/上一张
    if abs(velocity.x) > 0.5 {
        if velocity.x > 0 {
            targetIndex = ceil(targetX / itemWidth)
        } else {
            targetIndex = floor(targetX / itemWidth)
        }
    }
    
    // 计算应该吸附到的位置
    let snapOffset = targetIndex * itemWidth
    
    // 修改目标偏移量
    targetContentOffset.pointee.x = snapOffset
}

关键点:

  • scrollViewWillEndDragging 中直接修改 targetContentOffset 实现吸附
  • 根据速度方向智能判断应该吸附到哪一张
  • 速度阈值 0.5 可根据需求调整

3.3 滚动到下一张

swift 复制代码
func scrollToNext(animated: Bool) {
    guard let scrollView = scrollView, itemsCount > 0, isAdded else { return }
    
    let itemWidth = width + spacing
    let sumLength = CGFloat(itemsCount) * (width + spacing)
    let currentOffset = scrollView.contentOffset.x
    
    // 找到当前最接近的卡片位置
    let currentIndex = round(currentOffset / itemWidth)
    let alignedCurrentOffset = currentIndex * itemWidth
    
    // 计算下一张的位置
    let nextOffset = alignedCurrentOffset + itemWidth
    
    isProgrammaticScrolling = true
    
    // 检查是否会超出边界(需要循环)
    if nextOffset > sumLength {
        // 跳转到主区域的等价位置
        let normalizedAligned = alignedCurrentOffset.truncatingRemainder(dividingBy: sumLength)
        let actualAligned = normalizedAligned < 0 ? normalizedAligned + sumLength : normalizedAligned
        
        scrollView.contentOffset.x = actualAligned
        
        // 然后从新位置滚动到下一张
        let finalOffset = actualAligned + itemWidth
        scrollView.setContentOffset(CGPoint(x: finalOffset, y: 0), animated: animated)
    } else {
        // 正常滚动
        if abs(currentOffset - alignedCurrentOffset) > 0.5 {
            scrollView.contentOffset.x = alignedCurrentOffset
        }
        scrollView.setContentOffset(CGPoint(x: nextOffset, y: 0), animated: animated)
    }
    
    if !animated {
        isProgrammaticScrolling = false
    }
}

关键点:

  • 先对齐到当前卡片位置,再滚动到下一张
  • 使用 isProgrammaticScrolling 标志避免触发边界检查
  • 通过 truncatingRemainder 计算等价位置实现循环

4. LoopingScrollController - 外部控制器

提供外部控制接口,支持手动切换、自动轮播等功能。

swift 复制代码
class LoopingScrollController: ObservableObject {
    fileprivate weak var coordinator: ScrollViewHelper.Coordinator?
    private var timer: Timer?
    private var autoScrollInterval: TimeInterval = 3.0
    @Published var isAutoScrolling: Bool = false
    
    // 防抖相关属性
    private var lastScrollToNextTime: Date?
    private var lastScrollToPreviousTime: Date?
    private let debounceInterval: TimeInterval = 0.3
    
    // 滚动到下一张(带防抖)
    func scrollToNext(animated: Bool = true) {
        guard let coordinator = coordinator else { return }
        
        // 检查是否正在滚动或用户正在拖拽
        if coordinator.isProgrammaticScrolling || coordinator.isUserScrolling {
            return
        }
        
        // 时间防抖
        let currentTime = Date()
        if let lastTime = lastScrollToNextTime {
            let timeInterval = currentTime.timeIntervalSince(lastTime)
            if timeInterval < debounceInterval {
                return
            }
        }
        
        lastScrollToNextTime = currentTime
        coordinator.scrollToNext(animated: animated)
    }
    
    // 开始自动滚动
    func startAutoScroll(interval: TimeInterval = 3.0) {
        autoScrollInterval = interval
        restartAutoScroll()
    }
    
    // 重启自动滚动
    fileprivate func restartAutoScroll() {
        stopTimer()
        isAutoScrolling = true
        timer = Timer.scheduledTimer(
            withTimeInterval: autoScrollInterval,
            repeats: true
        ) { [weak self] _ in
            self?.scrollToNext(animated: true)
        }
    }
    
    // 停止自动滚动
    func stopAutoScroll() {
        stopTimer()
        isAutoScrolling = false
    }
    
    // 暂停定时器(用户拖拽时)
    fileprivate func pauseTimer() {
        stopTimer()
    }
    
    private func stopTimer() {
        timer?.invalidate()
        timer = nil
    }
}

关键点:

  • 使用 Timer 实现自动轮播
  • 通过双重防抖机制避免重复触发:状态检查 + 时间间隔
  • 用户拖拽时暂停定时器,松手后恢复

使用示例

swift 复制代码
struct ExploreTopView: View {
    @StateObject private var scrollController = LoopingScrollController()
    @State private var currentBannerIndex: Int = 0
    
    var body: some View {
        GeometryReader { geo in
            let bannerWidth = geo.size.width * 0.6
            
            // 创建数据
            let items: [Item] = bannerCards.map { Item(game: $0) }
            
            BannerView(
                width: bannerWidth,
                spacing: 10,
                items: items,
                controller: scrollController,
                currentIndex: $currentBannerIndex
            ) { index, item in
                // 自定义卡片内容
                WebImage(url: URL(string: item.game.content_img))
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .clipped()
                    .cornerRadius(12)
                    .onTapGesture {
                        print("点击了第 \(index) 张卡片")
                    }
                    .overlay(
                        // 添加渐变遮罩和文字
                        VStack(alignment: .leading) {
                            Spacer()
                            Text(item.game.title)
                                .font(.headline)
                                .foregroundColor(.white)
                                .padding()
                        }
                    )
            }
            .overlay(
                // 分页指示器
                HStack(spacing: 5) {
                    ForEach(0..<items.count, id: \.self) { i in
                        Circle()
                            .fill(i == currentBannerIndex ? Color.white : Color.white.opacity(0.4))
                            .frame(width: 6, height: 6)
                    }
                }
                .padding(.bottom, 24)
                , alignment: .bottomTrailing
            )
        }
    }
}

高级用法:外部控制

swift 复制代码
// 焦点控制:获得焦点时停止自动轮播
.onChange(of: isFocused) { _, newValue in
    if newValue {
        scrollController.stopAutoScroll()
    } else {
        scrollController.startAutoScroll()
    }
}

// 手柄控制:左右切换
.onReceive(viewModel.$isNeedSwiftLeft) { need in
    guard need else { return }
    scrollController.scrollToPrevious()
}
.onReceive(viewModel.$isNeedSwiftRight) { need in
    guard need else { return }
    scrollController.scrollToNext()
}

// 跳转到指定索引
Button("跳转到第3张") {
    scrollController.scrollToIndex(2, animated: true)
}

优化要点

1. 性能优化

  • 使用 LazyHStack:只渲染可见区域的视图,减少内存占用
  • 复制次数动态计算:根据屏幕宽度动态计算需要复制的次数,避免过度渲染
  • 防抖机制:避免频繁触发滚动导致的性能问题

2. 用户体验优化

  • 快速减速 :设置 decelerationRate = .fast 让滑动更跟手
  • 智能吸附:根据滑动速度判断应该停在哪一张
  • 拖拽时暂停:用户拖拽时暂停自动轮播,松手后恢复

3. 边界情况处理

  • 空数据保护guard !itemsArray.isEmpty 避免崩溃
  • 延迟重试:确保 UIScrollView 被正确获取
  • 动画期间保护 :使用 isProgrammaticScrolling 标志避免冲突

总结

本文介绍了一个功能完善的 SwiftUI 无限循环轮播图实现方案,核心思路是:

  1. 内容复制 - 通过复制卡片实现视觉上的无限循环
  2. UIKit 桥接 - 利用 UIScrollView 的强大能力实现精确控制
  3. 边界重置 - 在滚动到边界时瞬间重置 offset
  4. 智能吸附 - 根据速度和位置智能判断停靠位置
  5. 防抖保护 - 多重防抖机制避免重复触发

相比 SwiftUI 原生的 TabView,这个方案具有更好的可控性和扩展性,适合复杂的业务场景。

完整代码已在生产环境验证,运行流畅稳定。希望这篇文章能帮助你实现更优秀的轮播图组件!

源码在这

参考资料


如果觉得这篇文章对你有帮助,欢迎点赞收藏!有任何问题欢迎在评论区讨论 🎉

相关推荐
QuantumLeap丶5 小时前
《Flutter全栈开发实战指南:从零到高级》- 08 -导航与路由管理
flutter·ios·dart
LinkTime_Cloud7 小时前
苹果牵手SpaceX,iPhone 18 Pro将实现卫星直接上网
ios·iphone
2501_915921437 小时前
iOS 26 描述文件管理与开发环境配置 多工具协作的实战指南
android·macos·ios·小程序·uni-app·cocoa·iphone
2501_915909067 小时前
iOS 抓包实战 从原理到复现、定位与真机取证全流程
android·ios·小程序·https·uni-app·iphone·webview
2501_915106328 小时前
HBuilder 上架 iOS 应用全流程指南:从云打包到开心上架(Appuploader)上传的跨平台发布实践
android·ios·小程序·https·uni-app·iphone·webview
2501_916007479 小时前
免费iOS加固方案指南
android·macos·ios·小程序·uni-app·cocoa·iphone
Zender Han19 小时前
Flutter 状态管理详解:深入理解与使用 Bloc
android·flutter·ios
00后程序员张1 天前
iOS 26 开发者工具推荐,构建高效调试与性能优化工作流
android·ios·性能优化·小程序·uni-app·iphone·webview
小范馆1 天前
通过 useEventBus 和 useEventCallBack 实现与原生 Android、鸿蒙、iOS 的事件交互
android·ios·harmonyos