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

SwiftUI中实现类UITableView/UICollectionView的可复用滚动组件方案

SwiftUI 实现高性能可复用滚动组件(兼容iOS15)

针对你需要的类似Instagram探索页的滚动组件需求,SwiftUI原生组件的局限可以通过封装UICollectionView来解决——UIKit的UICollectionView在复用、滚动控制、内存管理上的能力完全覆盖你的需求,且能通过UIViewRepresentable无缝接入SwiftUI,兼容iOS15。

核心思路

用UIViewRepresentable封装UICollectionView,将SwiftUI视图作为cell内容嵌入,同时暴露滚动控制、数据源更新等接口,结合UICollectionView原生的代理方法实现所有需求功能。

关键功能实现代码

1. UICollectionView封装组件

import SwiftUI
import UIKit

struct CollectionViewWrapper<Item: Identifiable, Content: View>: UIViewRepresentable {
    typealias Cell = HostingCollectionViewCell<Content>
    
    var items: [Item]
    var content: (Item) -> Content
    var onScrollToPoint: ((CGPoint) -> Void)? // 滚动到指定点的回调
    var onGetIndexForPoint: ((CGPoint) -> Int?)? // 获取对应点的index
    var onScrollToItem: ((Int, Bool) -> Void)? // 滚动到指定项的回调
    var onDragEnd: ((CGPoint, UnsafeMutablePointer<CGPoint>) -> Void)? // 拖拽结束处理吸附
    
    func makeUIView(context: Context) -> UICollectionView {
        let layout = UICollectionViewFlowLayout()
        layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize // 自适应高度
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.delegate = context.coordinator
        collectionView.dataSource = context.coordinator
        collectionView.register(Cell.self, forCellWithReuseIdentifier: Cell.reuseID)
        collectionView.showsVerticalScrollIndicator = false
        return collectionView
    }
    
    func updateUIView(_ uiView: UICollectionView, context: Context) {
        context.coordinator.items = items
        context.coordinator.content = content
        uiView.reloadData()
        
        // 处理滚动到指定点的请求
        if let scrollPoint = context.coordinator.scrollPoint {
            uiView.setContentOffset(scrollPoint, animated: context.coordinator.scrollAnimated)
            context.coordinator.scrollPoint = nil
        }
        
        // 处理滚动到指定项的请求
        if let targetIndex = context.coordinator.targetIndex {
            let indexPath = IndexPath(item: targetIndex, section: 0)
            uiView.scrollToItem(at: indexPath, at: .centeredVertically, animated: context.coordinator.scrollAnimated)
            context.coordinator.targetIndex = nil
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UICollectionViewDelegate, UICollectionViewDataSource {
        var parent: CollectionViewWrapper
        var items: [Item]
        var content: (Item) -> Content
        var scrollPoint: CGPoint?
        var scrollAnimated: Bool = false
        var targetIndex: Int?
        
        init(_ parent: CollectionViewWrapper) {
            self.parent = parent
            self.items = parent.items
            self.content = parent.content
        }
        
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            items.count
        }
        
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Cell.reuseID, for: indexPath) as! Cell
            let item = items[indexPath.item]
            cell.hostingController.rootView = content(item)
            cell.hostingController.view.frame = cell.contentView.bounds
            cell.hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            return cell
        }
        
        // 获取CGPoint对应的index
        func indexForPoint(_ point: CGPoint) -> Int? {
            guard let collectionView = parent.makeUIView(context: .init()) as? UICollectionView,
                  let indexPath = collectionView.indexPathForItem(at: point) else { return nil }
            return indexPath.item
        }
        
        // 拖拽结束处理吸附效果
        func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
            parent.onDragEnd?(velocity, targetContentOffset)
        }
    }
}

// 封装SwiftUI视图的UICollectionViewCell
class HostingCollectionViewCell<Content: View>: UICollectionViewCell {
    static let reuseID = "HostingCollectionViewCell"
    let hostingController: UIHostingController<Content>
    
    override init(frame: CGRect) {
        let emptyView = Content.self.init() // 空初始化,后续会替换
        self.hostingController = UIHostingController(rootView: emptyView)
        super.init(frame: frame)
        configureHostingView()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func configureHostingView() {
        contentView.addSubview(hostingController.view)
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            hostingController.view.topAnchor.constraint(equalTo: contentView.topAnchor),
            hostingController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        // 清空内容,避免内存泄漏
        hostingController.rootView = Content.self.init()
    }
}

2. SwiftUI中使用示例

struct ExploreView: View {
    @State private var items: [Post] = (0..<50).map { Post(id: $0, height: CGFloat(Int.random(in: 100...400))) }
    @State private var coordinator: CollectionViewWrapper<Post, PostView>.Coordinator?
    
    var body: some View {
        CollectionViewWrapper(items: items, content: { post in
            PostView(post: post)
                .frame(maxWidth: .infinity)
        })
        .onAppear {
            // 获取coordinator用于后续滚动控制
            coordinator = CollectionViewWrapper(items: items, content: { _ in PostView(post: .init(id: 0, height: 0)) }).makeCoordinator()
        }
        .toolbar {
            Button("滚动到第20项") {
                coordinator?.targetIndex = 19
                coordinator?.scrollAnimated = true
            }
            Button("获取当前顶部项") {
                guard let collectionView = coordinator?.parent.makeUIView(context: .init()) as? UICollectionView else { return }
                let topPoint = CGPoint(x: 0, y: collectionView.contentOffset.y + 10)
                if let index = coordinator?.indexForPoint(topPoint) {
                    print("当前顶部项index: \(index)")
                }
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("LoadMoreData"))) { _ in
            // 模拟滚动加载更多
            let newItems = (items.count..<items.count+20).map { Post(id: $0, height: CGFloat(Int.random(in: 100...400))) }
            items.append(contentsOf: newItems)
        }
    }
}

// 示例数据和视图
struct Post: Identifiable {
    let id: Int
    let height: CGFloat
}

struct PostView: View {
    let post: Post
    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(Color.gray.opacity(0.2))
            .frame(height: post.height)
            .overlay(Text("Post \(post.id)"))
    }
}

3. 吸附效果实现

在CollectionViewWrapper的onDragEnd回调中添加逻辑:

CollectionViewWrapper(items: items, content: { post in
    PostView(post: post)
        .frame(maxWidth: .infinity)
})
.onDragEnd { velocity, targetOffset in
    // 根据实际布局动态计算item高度,这里用模拟值
    let layout = UICollectionViewFlowLayout()
    let itemHeight = layout.estimatedItemSize.height > 0 ? layout.estimatedItemSize.height : 200
    let currentOffset = targetOffset.pointee.y
    let targetY = round(currentOffset / itemHeight) * itemHeight
    targetOffset.pointee.y = targetY
}

内存优化要点

  • 严格复用Cell:通过dequeueReusableCell和prepareForReuse清空旧内容,避免UIHostingController重复创建
  • 动态高度计算:使用UICollectionViewFlowLayout.automaticSize让Cell自适应高度,无需提前计算
  • 按需加载数据:监听滚动位置,到达底部时触发数据加载,避免一次性加载所有内容
  • 图片资源优化:使用异步加载+内存缓存方案,不在Cell中持有强引用的大资源

方案优势对比

  • 解决ScrollView+LazyVStack的内存泄漏问题:UICollectionView会自动销毁不可见Cell,内存占用稳定
  • 完善的滚动控制:支持滚动到任意CGPoint、指定项,获取位置对应项的Index
  • 可靠的吸附效果:通过UIScrollView代理方法精准控制滚动终点
  • 兼容iOS15:所有API均支持iOS15及以上版本

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.08 04:35:26