// // RotationTestView.swift // POCPlayerPlaybackSwiftUI // // Created for testing CollectionView rotation behavior. // import SwiftUI import Combine struct RotationTestView: View { @StateObject private var viewModel = RotationTestViewModel() var body: some View { ZStack { Color.black.ignoresSafeArea() PageCollectionView( snapshot: viewModel.snapshot, lastUpdatedUUID: viewModel.lastUpdatedUUID, currentPage: viewModel.currentPage, accessibilityIdentifier: "CollectionView: RotationTest", page: viewModel.pageSubject, willChangePage: viewModel.willChangePage, willBeginDragging: nil, scrollTo: viewModel.scrollTo, cellProvider: viewModel.cellProvider ) .collectionViewBackgroundColor(.black) .ignoresSafeArea() VStack { Text("Page \(viewModel.currentPage + 1) of \(viewModel.itemCount)") .font(.headline) .foregroundStyle(.white) .padding(8) .background(.black.opacity(0.6)) .clipShape(RoundedRectangle(cornerRadius: 8)) Spacer() HStack(spacing: 20) { Button("< Prev") { viewModel.goToPrevious() } Button("Next >") { viewModel.goToNext() } } .font(.title3.bold()) .foregroundStyle(.white) .padding(.bottom, 40) } .padding(.top, 60) } } } final class RotationTestViewModel: ObservableObject { typealias Section = Int typealias Snapshot = NSDiffableDataSourceSnapshot @Published var lastUpdatedUUID: UUID = UUID() @Published var currentPage: Int = 0 let pageSubject = PassthroughSubject() let willChangePage = PassthroughSubject() let scrollTo = PassthroughSubject() private(set) var snapshot: Snapshot = { var snap = Snapshot() snap.appendSections([0]) return snap }() private var cancellables = Set() private(set) var cellRegistration: UICollectionView.CellRegistration! var itemCount: Int { snapshot.numberOfItems } init() { setupCellRegistration() setupBindings() loadItems() } private func setupBindings() { pageSubject .receive(on: DispatchQueue.main) .sink { [weak self] info in self?.currentPage = info.page } .store(in: &cancellables) } private func setupCellRegistration() { cellRegistration = UICollectionView.CellRegistration { cell, indexPath, item in cell.contentConfiguration = UIHostingConfiguration { RotationTestCell(item: item, index: indexPath.item) } .margins(.all, 0) } } func cellProvider( _ collectionView: UICollectionView, indexPath: IndexPath, item: RotationTestItem ) -> UICollectionViewCell { collectionView.dequeueConfiguredReusableCell( using: cellRegistration, for: indexPath, item: item ) } private func loadItems() { let colors: [Color] = [.red, .blue, .green, .orange, .purple, .pink, .cyan, .yellow] let items = colors.enumerated().map { index, color in RotationTestItem(id: index, color: color, label: "Page \(index + 1)") } snapshot.appendItems(items) lastUpdatedUUID = UUID() } func goToNext() { let nextPage = min(currentPage + 1, itemCount - 1) guard nextPage != currentPage else { return } let info = RotationTestPageInfo(page: nextPage, direction: .next) let change = RotationTestPageChange(pageInfo: info, animated: true) scrollTo.send(change) } func goToPrevious() { let prevPage = max(currentPage - 1, 0) guard prevPage != currentPage else { return } let info = RotationTestPageInfo(page: prevPage, direction: .back) let change = RotationTestPageChange(pageInfo: info, animated: true) scrollTo.send(change) } } // MARK: - Models struct RotationTestItem: Hashable { let id: Int let color: Color let label: String } struct RotationTestPageInfo: PageInfo { var page: Int var direction: PageDirection init(page: Int, direction: PageDirection) { self.page = page self.direction = direction } } struct RotationTestPageChange: PageChange { var pageInfo: RotationTestPageInfo var animated: Bool } // MARK: - Cell View struct RotationTestCell: View { let item: RotationTestItem let index: Int var body: some View { GeometryReader { geo in ZStack { item.color VStack(spacing: 12) { Text(item.label) .font(.largeTitle.bold()) .foregroundStyle(.white) Text("\(Int(geo.size.width)) x \(Int(geo.size.height))") .font(.title2) .foregroundStyle(.white.opacity(0.8)) Text("Index: \(index)") .font(.body) .foregroundStyle(.white.opacity(0.6)) } } } } } extension PageCollectionView { public typealias UIKitCollectionView = FullScreenPageCollectionView public typealias DataSource = FullScreenPageDiffableDataSource public typealias Snapshot = NSDiffableDataSourceSnapshot public typealias UpdateCompletion = () -> Void } public struct PageCollectionView where SectionIDType: Hashable & Sendable, ItemIdentifierType: Hashable & Sendable, Change: PageChange { private let snapshot: Snapshot private let lastUpdatedUUID: UUID private let currentPage: Int private let accessibilityIdentifier: String private let cellProvider: DataSource.CellProvider private(set) var backgroundColor: UIColor = .white private(set) var animatingDifferences: Bool = true private(set) var updateCallBack: UpdateCompletion? private let page: PassthroughSubject private let willChangePage: PassthroughSubject? private let scrollTo: PassthroughSubject private let willBeginDragging: PassthroughSubject? public init( snapshot: Snapshot, lastUpdatedUUID: UUID, currentPage: Int, accessibilityIdentifier: String, page: PassthroughSubject, willChangePage: PassthroughSubject?, willBeginDragging: PassthroughSubject?, scrollTo: PassthroughSubject, cellProvider: @escaping DataSource.CellProvider ) { self.snapshot = snapshot self.lastUpdatedUUID = lastUpdatedUUID self.currentPage = currentPage self.accessibilityIdentifier = accessibilityIdentifier self.cellProvider = cellProvider self.page = page self.willChangePage = willChangePage self.scrollTo = scrollTo self.willBeginDragging = willBeginDragging } } extension PageCollectionView: UIViewRepresentable { public func makeUIView(context: Context) -> UIKitCollectionView { let collectionView = UIKitCollectionView( frame: .zero, page: page, willChangePage: willChangePage, scrollTo: scrollTo, willBeginDragging: willBeginDragging, cellProvider: cellProvider ) collectionView.accessibilityIdentifier = accessibilityIdentifier return collectionView } public func updateUIView(_ uiView: UIKitCollectionView, context: Context) { uiView.backgroundColor = backgroundColor if lastUpdatedUUID != context.coordinator.lastUpdatedUUID { uiView.apply( snapshot, currentPage: currentPage, animatingDifferences: animatingDifferences, completion: updateCallBack ) context.coordinator.lastUpdatedUUID = lastUpdatedUUID } } public func makeCoordinator() -> Coordinator { return Coordinator() } public class Coordinator { var lastUpdatedUUID: UUID? } } public extension PageCollectionView { func animateDifferences(_ animate: Bool) -> Self { var selfCopy = self selfCopy.animatingDifferences = animate return selfCopy } func onUpdate(_ perform: (() -> Void)?) -> Self { var selfCopy = self selfCopy.updateCallBack = perform return selfCopy } func collectionViewBackgroundColor(_ color: Color) -> Self { var selfCopy = self selfCopy.backgroundColor = UIColor(color) return selfCopy } } public enum PageDirection { case next case back } public protocol PageInfo { var page: Int { get set } var direction: PageDirection { get } init(page: Int, direction: PageDirection) } public protocol PageChange where Page: PageInfo { associatedtype Page var pageInfo: Page { get } var animated: Bool { get } } final public class FullScreenPageCollectionView: UICollectionView, UICollectionViewDelegate, PageFlowLayoutDelegate where SectionIDType: Hashable & Sendable, ItemIdentifierType: Hashable & Sendable, Change: PageChange { public typealias DataSource = FullScreenPageDiffableDataSource public typealias Snapshot = NSDiffableDataSourceSnapshot private let cellProvider: DataSource.CellProvider private lazy var collectionDataSource: DataSource = { let dataSource = DataSource( collectionView: self, cellProvider: cellProvider ) return dataSource }() private let pageFlowLayout = PageFlowLayout() private var scrollDirection: UICollectionView.ScrollDirection { pageFlowLayout.scrollDirection } private var cancellables = Set() private var currentPageIndex: Int = .zero private let willChangePage: PassthroughSubject? private let page: PassthroughSubject private let scrollTo: PassthroughSubject private let willBeginDragging: PassthroughSubject? override public var safeAreaInsets: UIEdgeInsets { .zero } public init( frame: CGRect, page: PassthroughSubject, willChangePage: PassthroughSubject?, scrollTo: PassthroughSubject, willBeginDragging: PassthroughSubject?, cellProvider: @escaping DataSource.CellProvider ) { self.page = page self.willChangePage = willChangePage self.scrollTo = scrollTo self.cellProvider = cellProvider self.willBeginDragging = willBeginDragging super.init(frame: frame, collectionViewLayout: pageFlowLayout) delegate = self pageFlowLayout.delegate = self configure() setupBindings() } public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func configure() { showsHorizontalScrollIndicator = false showsVerticalScrollIndicator = false if #available(iOS 17.4, *) { bouncesVertically = false } isPagingEnabled = true contentInsetAdjustmentBehavior = .never } var onHoldPage: Change.Page? private func setupBindings() { scrollTo .sink { [weak self] change in guard let self else { return } let newPage = change.pageInfo.page guard numberOfSections > 0, numberOfItems(inSection: 0) > newPage else { return } let pageIndex = IndexPath(row: newPage, section: 0) if change.animated { onHoldPage = change.pageInfo } self.willChangePage?.send(newPage) self.scrollToItem( at: pageIndex, at: [.centeredHorizontally, .centeredVertically], animated: change.animated ) if !change.animated { self.currentPageIndex = change.pageInfo.page self.page.send(change.pageInfo) } } .store(in: &cancellables) } func apply(_ snapshot: Snapshot, currentPage: Int, animatingDifferences: Bool = false, completion: (() -> Void)? = nil) { currentPageIndex = currentPage collectionDataSource.apply( snapshot, animatingDifferences: animatingDifferences, completion: completion ) } // MARK: ScrollView delegate public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { willBeginDragging?.send() } public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { let targetPage = page(for: targetContentOffset.pointee) guard targetPage != currentPageIndex else { return } let targetItem = collectionDataSource.snapshot().itemIdentifiers[targetPage] willChangePage?.send(targetPage) } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { let newPage = page(for: scrollView.contentOffset) guard currentPageIndex != newPage else { return } self.page.send(Change.Page(page: newPage, direction: newPage - currentPageIndex > 0 ? .next : .back)) // Activate scroll when page change if !scrollView.isScrollEnabled { scrollView.isScrollEnabled = true } currentPageIndex = newPage } public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { if let onHoldPage { currentPageIndex = onHoldPage.page page.send(onHoldPage) self.onHoldPage = nil } } private func page(for point: CGPoint) -> Int { var page: Int = 0 if scrollDirection == .horizontal { let pageWidth = frame.size.width page = Int(floor((point.x - pageWidth / 2) / pageWidth) + 1) } else { let pageWidth = frame.size.height page = Int(floor((point.y - pageWidth / 2) / pageWidth) + 1) } return page } private func contentOffset(for page: Int) -> CGPoint { let pageOffset = CGFloat(page) * frame.width return CGPoint(x: pageOffset, y: 0) } // MARK: - PageFlowLayoutDelegate func currentPage() -> Int { currentPageIndex } } protocol PageFlowLayoutDelegate: AnyObject { func currentPage() -> Int } extension FullScreenPageCollectionView { class PageFlowLayout: UICollectionViewFlowLayout { override class var layoutAttributesClass: AnyClass { UICollectionViewLayoutAttributes.self } private var calculatedAttributes: [UICollectionViewLayoutAttributes] = [] private var calculatedContentWidth: CGFloat = 0 private var calculatedContentHeight: CGFloat = 0 public weak var delegate: PageFlowLayoutDelegate? override var collectionViewContentSize: CGSize { return CGSize(width: self.calculatedContentWidth, height: self.calculatedContentHeight) } override init() { super.init() self.estimatedItemSize = .zero self.scrollDirection = .horizontal self.minimumLineSpacing = 0 self.minimumInteritemSpacing = 0 self.sectionInset = .zero } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepare() { guard let collectionView = collectionView, collectionView.numberOfSections > 0, calculatedAttributes.isEmpty else { return } estimatedItemSize = collectionView.bounds.size for item in 0.. [UICollectionViewLayoutAttributes]? { return calculatedAttributes.compactMap { return $0.frame.intersects(rect) ? $0 : nil } } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return calculatedAttributes[indexPath.item] } override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { guard let collectionView else { return false } if newBounds.size != collectionView.bounds.size { return true } if newBounds.size.width > 0 { let pages = calculatedContentWidth / newBounds.size.width // If the contentWidth matches the number of pages, // if not it requires to layout the cells let arePagesExact = pages.truncatingRemainder(dividingBy: 1) == 0 return !arePagesExact } return false } override func invalidateLayout() { calculatedAttributes = [] super.invalidateLayout() } override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool { guard let collectionView, #available(iOS 18.0, *) else { return false } return preferredAttributes.size != collectionView.bounds.size } override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { guard let customContext = context as? UICollectionViewFlowLayoutInvalidationContext else { return } if let collectionView, let currentPage = delegate?.currentPage() { let delta = (CGFloat(currentPage) * collectionView.bounds.width) - collectionView.contentOffset.x customContext.contentOffsetAdjustment.x += delta } calculatedAttributes = [] super.invalidateLayout(with: customContext) } override func prepare(forAnimatedBoundsChange oldBounds: CGRect) { super.prepare(forAnimatedBoundsChange: oldBounds) guard let collectionView else { return } if oldBounds.width != collectionView.bounds.width { invalidateLayout() } } override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { guard let collectionView, let currentPage = delegate?.currentPage() else { return .zero } let targetContentOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset) let targetPage = targetContentOffset.x / collectionView.frame.width if targetPage != CGFloat(currentPage) { let xPosition = CGFloat(currentPage) * collectionView.frame.width return CGPoint(x: xPosition, y: 0) } return targetContentOffset } // This function updates the contentOffset in case is wrong override func finalizeCollectionViewUpdates() { guard let collectionView, let currentPage = delegate?.currentPage() else { return } let xPosition = CGFloat(currentPage) * collectionView.bounds.width if xPosition != collectionView.contentOffset.x { let offset = CGPoint(x: xPosition, y: 0) collectionView.setContentOffset(offset, animated: false) } } } } public class FullScreenPageDiffableDataSource: UICollectionViewDiffableDataSource where SectionIDType: Hashable & Sendable, ItemIdentifierType: Hashable & Sendable { } #Preview { RotationTestView() }