如何在SwiftUI中实现App Store/Apple Music同款集合视图滚动吸附效果
可行实现方案
以下两种方案均满足「滚动时吸附对齐卡片、露出下一张卡片提示可滚动」的需求,避免了TabView整页滚动的问题。
方案1:iOS 17+ 原生实现(最推荐)
iOS 17 新增了系统级的滚动吸附修饰符,无需额外代码即可实现和App Store完全一致的顺滑手感,直接修改你原有代码的ScrollView部分即可:
ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 12) { ForEach(fetchedUserAccounts) { account in DiscoverUserGridItem(userAccount: account, size: (geometry.size.width - 18 * 2 - 12) / 2) } } .padding(.horizontal, 18) // 新增:指定滚动对齐的布局范围 .scrollTargetLayout() } // 新增:滚动吸附行为,自动对齐到子视图边界 .scrollTargetBehavior(.viewAligned) // 可选:调整滚动安全区域,避免首尾卡片贴边过近 .contentMargins(.horizontal, 18, for: .scrollContent)
方案2:低版本iOS兼容方案(iOS 14+)
如果需要兼容iOS16及更低版本,直接桥接UIKit的UICollectionView即可,复用系统成熟的布局能力,滚动手感和原生App完全一致:
第一步:添加通用桥接代码
// 横向吸附集合视图桥接层 struct SnappingCollectionView: UIViewRepresentable { let items: [UserAccount] let cardWidth: CGFloat let cardHeight: CGFloat let spacing: CGFloat let onItemTap: (UserAccount) -> Void func makeUIView(context: Context) -> UICollectionView { // 配置吸附布局 let layout = UICollectionViewCompositionalLayout { _, _ in let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(cardWidth), heightDimension: .absolute(cardHeight)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(cardWidth + spacing), heightDimension: .absolute(cardHeight)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = spacing section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18) // 核心配置:对齐到每组头部,和App Store滚动效果完全一致 section.orthogonalScrollingBehavior = .groupPagingLeading return layout } let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.showsHorizontalScrollIndicator = false collectionView.backgroundColor = .clear collectionView.register(HostingCollectionViewCell.self, forCellWithReuseIdentifier: "Cell") collectionView.dataSource = context.coordinator collectionView.delegate = context.coordinator return collectionView } func updateUIView(_ uiView: UICollectionView, context: Context) { context.coordinator.items = items uiView.reloadData() } func makeCoordinator() -> Coordinator { Coordinator(items: items, cardWidth: cardWidth, cardHeight: cardHeight, onItemTap: onItemTap) } class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate { var items: [UserAccount] let cardWidth: CGFloat let cardHeight: CGFloat let onItemTap: (UserAccount) -> Void init(items: [UserAccount], cardWidth: CGFloat, cardHeight: CGFloat, onItemTap: @escaping (UserAccount) -> Void) { self.items = items self.cardWidth = cardWidth self.cardHeight = cardHeight self.onItemTap = onItemTap } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { items.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! HostingCollectionViewCell let item = items[indexPath.item] cell.host(rootView: DiscoverUserGridItem(userAccount: item, size: cardWidth)) return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { onItemTap(items[indexPath.item]) } } } // 通用SwiftUI视图包装Cell class HostingCollectionViewCell: UICollectionViewCell { private var hostingController: UIHostingController<AnyView>? func host<V: View>(rootView: V) { if let hostingController = hostingController { hostingController.rootView = AnyView(rootView) } else { let hostingController = UIHostingController(rootView: AnyView(rootView)) hostingController.view.backgroundColor = .clear hostingController.view.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(hostingController.view) 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) ]) self.hostingController = hostingController } } }
第二步:替换原有ScrollView
把你原有代码里的ScrollView整块替换为以下代码即可:
SnappingCollectionView( items: fetchedUserAccounts, cardWidth: (geometry.size.width - 18 * 2 - 12) / 2, cardHeight: 你的卡片实际高度, spacing: 12, onItemTap: { account in // 处理卡片点击逻辑 } ) .frame(height: 你的卡片实际高度)
效果参考

内容的提问来源于stack exchange,提问作者Rob
相关产品推荐
相关产品推荐

