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
相关产品推荐
相关产品推荐

