diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb3917c6..290ff2fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,10 @@ jobs: - uses: actions/checkout@v2 - name: Setting up Xcode - run: sudo xcode-select -s "/Applications/Xcode_14.3.app" + run: sudo xcode-select -s "/Applications/Xcode_15.1.app" - name: Run tests - run: set -o pipefail && xcrun xcodebuild build test -scheme QuranEngine-Package -sdk "iphonesimulator" -destination "name=iPhone 14 Pro,OS=16.4" | xcpretty + run: set -o pipefail && xcrun xcodebuild build test -scheme QuranEngine-Package -sdk "iphonesimulator" -destination "name=iPhone 14 Pro,OS=17.2" | xcpretty - uses: codecov/codecov-action@v3 with: @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v2 - name: Setting up Xcode - run: sudo xcode-select -s "/Applications/Xcode_14.3.app" + run: sudo xcode-select -s "/Applications/Xcode_15.1.app" - name: Build run: set -o pipefail && xcrun xcodebuild build -workspace Example/QuranEngineApp.xcworkspace -scheme QuranEngineApp -sdk "iphonesimulator" -destination 'generic/platform=iOS' CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO | xcpretty diff --git a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1FullSura.1.json b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1FullSura.1.json index f7a8a8a7..79d707ae 100644 --- a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1FullSura.1.json +++ b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1FullSura.1.json @@ -15,11 +15,11 @@ }, { "endTime" : null, - "startTime" : 17.731999999999999 + "startTime" : 17.732 }, { "endTime" : null, - "startTime" : 22.329999999999998 + "startTime" : 22.33 }, { "endTime" : null, @@ -27,11 +27,11 @@ }, { "endTime" : null, - "startTime" : 33.567999999999998 + "startTime" : 33.568 }, { "endTime" : null, - "startTime" : 39.009999999999998 + "startTime" : 39.01 } ], "url" : "\/audio_files\/mishari_alafasy\/001.mp3" diff --git a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SuraEndsEarly.1.json b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SuraEndsEarly.1.json index a23d0d61..0c500d34 100644 --- a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SuraEndsEarly.1.json +++ b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SuraEndsEarly.1.json @@ -1,7 +1,7 @@ { "playing" : { "_0" : { - "endTime" : 33.567999999999998, + "endTime" : 33.568, "files" : [ { "frames" : [ @@ -11,11 +11,11 @@ }, { "endTime" : null, - "startTime" : 17.731999999999999 + "startTime" : 17.732 }, { "endTime" : null, - "startTime" : 22.329999999999998 + "startTime" : 22.33 }, { "endTime" : null, diff --git a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SurasHasEndTimestamp.1.json b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SurasHasEndTimestamp.1.json index db851c5a..d9917a24 100644 --- a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SurasHasEndTimestamp.1.json +++ b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SurasHasEndTimestamp.1.json @@ -11,7 +11,7 @@ }, { "endTime" : 284.899, - "startTime" : 275.88099999999997 + "startTime" : 275.881 } ], "url" : "\/audio_files\/mishari_alafasy\/077.mp3" diff --git a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SurasHasEndTimestampStopBeforeEnd.1.json b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SurasHasEndTimestampStopBeforeEnd.1.json index 715a13f6..c29dbf7b 100644 --- a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SurasHasEndTimestampStopBeforeEnd.1.json +++ b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter1SurasHasEndTimestampStopBeforeEnd.1.json @@ -1,7 +1,7 @@ { "playing" : { "_0" : { - "endTime" : 275.88099999999997, + "endTime" : 275.881, "files" : [ { "frames" : [ diff --git a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter2Suras1stSuraHasEndTimestamp.1.json b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter2Suras1stSuraHasEndTimestamp.1.json index bba3e0bd..d8b1c1d8 100644 --- a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter2Suras1stSuraHasEndTimestamp.1.json +++ b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter2Suras1stSuraHasEndTimestamp.1.json @@ -1,7 +1,7 @@ { "playing" : { "_0" : { - "endTime" : 33.093000000000004, + "endTime" : 33.093, "files" : [ { "frames" : [ @@ -11,7 +11,7 @@ }, { "endTime" : 284.899, - "startTime" : 275.88099999999997 + "startTime" : 275.881 } ], "url" : "\/audio_files\/mishari_alafasy\/077.mp3" @@ -24,11 +24,11 @@ }, { "endTime" : null, - "startTime" : 21.518999999999998 + "startTime" : 21.519 }, { "endTime" : null, - "startTime" : 27.111999999999998 + "startTime" : 27.112 } ], "url" : "\/audio_files\/mishari_alafasy\/078.mp3" diff --git a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter3FullSura.1.json b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter3FullSura.1.json index 09c8f3b5..27492a02 100644 --- a/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter3FullSura.1.json +++ b/Domain/QuranAudioKit/Tests/__Snapshots__/QuranAudioPlayerTests/testPlayingDownloadedGaplessReciter3FullSura.1.json @@ -11,11 +11,11 @@ }, { "endTime" : null, - "startTime" : 10.034000000000001 + "startTime" : 10.034 }, { "endTime" : null, - "startTime" : 12.680999999999999 + "startTime" : 12.681 }, { "endTime" : null, @@ -32,15 +32,15 @@ }, { "endTime" : null, - "startTime" : 10.805999999999999 + "startTime" : 10.806 }, { "endTime" : null, - "startTime" : 14.606999999999999 + "startTime" : 14.607 }, { "endTime" : null, - "startTime" : 19.893999999999998 + "startTime" : 19.894 }, { "endTime" : null, @@ -61,7 +61,7 @@ }, { "endTime" : null, - "startTime" : 18.951000000000001 + "startTime" : 18.951 }, { "endTime" : null, @@ -69,11 +69,11 @@ }, { "endTime" : null, - "startTime" : 32.968000000000004 + "startTime" : 32.968 }, { "endTime" : null, - "startTime" : 41.412999999999997 + "startTime" : 41.413 } ], "url" : "\/audio_files\/mishari_alafasy\/114.mp3" diff --git a/Package.swift b/Package.swift index e6f9489f..864c8258 100644 --- a/Package.swift +++ b/Package.swift @@ -160,6 +160,7 @@ private func uiTargets() -> [[Target]] { target(type, name: "ViewConstrainer", hasTests: false), target(type, name: "UIx", hasTests: false, dependencies: [ "ViewConstrainer", + "VLogging", ]), target(type, name: "NoorFont", hasTests: false, resources: [ .process("Resources"), diff --git a/UI/UIx/SwiftUI/CollectionView/CollectionView.swift b/UI/UIx/SwiftUI/CollectionView/CollectionView.swift index d47f7331..ce1154f1 100644 --- a/UI/UIx/SwiftUI/CollectionView/CollectionView.swift +++ b/UI/UIx/SwiftUI/CollectionView/CollectionView.swift @@ -7,6 +7,10 @@ import SwiftUI +public enum ScrollAnchor { + case center +} + public struct CollectionView< SectionId: Hashable, Item: Identifiable & Hashable, @@ -17,7 +21,7 @@ public struct CollectionView< public init( layout: UICollectionViewLayout, sections: [ListSection], - content: @escaping (SectionId, Item) -> ItemContent + @ViewBuilder content: @escaping (SectionId, Item) -> ItemContent ) { self.layout = layout self.sections = sections @@ -31,14 +35,33 @@ public struct CollectionView< layout: layout, sections: sections, configure: configure, - content: content + content: content, + isPagingEnabled: isPagingEnabled, + scrollAnchorId: scrollAnchorId, + scrollAnchor: scrollAnchor ) } public func configureCollectionView(configure: @escaping (UICollectionView) -> Void) -> Self { - var collectionView = self - collectionView.configure = configure - return collectionView + mutateSelf { + $0.configure = configure + } + } + + public func anchorScrollTo( + id scrollAnchorId: Binding, + anchor: ScrollAnchor = .center + ) -> Self { + mutateSelf { + $0.scrollAnchorId = scrollAnchorId + $0.scrollAnchor = anchor + } + } + + public func pagingEnabled(_ isPagingEnabled: Bool) -> Self { + mutateSelf { + $0.isPagingEnabled = isPagingEnabled + } } // MARK: Private @@ -47,6 +70,11 @@ public struct CollectionView< private let sections: [ListSection] private let content: (SectionId, Item) -> ItemContent private var configure: ((UICollectionView) -> Void)? + + private var isPagingEnabled: Bool = false + + private var scrollAnchorId: Binding? + private var scrollAnchor: ScrollAnchor = .center } private struct CollectionViewBody< @@ -54,7 +82,7 @@ private struct CollectionViewBody< Item: Identifiable & Hashable, ItemContent: View >: UIViewControllerRepresentable { - typealias UIViewControllerType = CollectionViewController + typealias UIViewControllerType = CollectionViewController // MARK: Internal @@ -63,14 +91,16 @@ private struct CollectionViewBody< let configure: ((UICollectionView) -> Void)? let content: (SectionId, Item) -> ItemContent + let isPagingEnabled: Bool + + let scrollAnchorId: Binding? + let scrollAnchor: ScrollAnchor + func makeUIViewController(context: Context) -> UIViewControllerType { - let viewController = UIViewControllerType(collectionViewLayout: layout) - viewController.collectionView.backgroundColor = .clear + let viewController = UIViewControllerType(collectionViewLayout: layout, content: content) configure?(viewController.collectionView) context.coordinator.viewController = viewController - context.coordinator.setUpDataSource(content: content) - updateUIViewController(viewController, context: context) return viewController @@ -91,7 +121,15 @@ private struct CollectionViewBody< viewController.collectionView.collectionViewLayout = layout } - context.coordinator.updateData(sections: sections) + viewController.dataSource?.sections = sections + + viewController.scroller.isPagingEnabled = isPagingEnabled + viewController.scroller.onScrollAnchorIdUpdated = { + scrollAnchorId?.wrappedValue = $0 + } + if let scrollAnchorId { + viewController.scroller.anchorScrollTo(id: scrollAnchorId.wrappedValue, anchor: scrollAnchor) + } } func makeCoordinator() -> Coordinator { @@ -101,112 +139,11 @@ private struct CollectionViewBody< extension CollectionViewBody { class Coordinator { - // MARK: Lifecycle - - init(_ parent: CollectionViewBody) { - self.parent = parent - } - - // MARK: Internal - let parent: CollectionViewBody - var dataSource: UICollectionViewDiffableDataSource? weak var viewController: UIViewControllerType? - func updateData(sections: [ListSection]) { - let oldSections = self.sections - self.sections = sections - - updateData(oldSections: oldSections, newSections: sections) - } - - func setUpDataSource(content: @escaping (SectionId, Item) -> ItemContent) { - guard let viewController else { - fatalError("setUpDataSource called before setting the viewController.") - } - - let cellType = UIViewControllerType.CellType.self - viewController.collectionView.register(cellType, forCellWithReuseIdentifier: cellType.reuseId) - - dataSource = UICollectionViewDiffableDataSource(collectionView: viewController.collectionView) { - [weak self] _, indexPath, itemId in - guard let self, let viewController = self.viewController else { - return UICollectionViewCell() - } - - // Get the item. - let section = sections[indexPath.section] - let item = sections[indexPath.section].items[indexPath.item] - assert(item.id == itemId, "Sections data doesn't match data source snapshot.") - - // Get & configure the cell. - let cell = viewController.collectionView.dequeueReusableCell(UIViewControllerType.CellType.self, for: indexPath) - cell.configure(content: content(section.id, item), dataId: itemId) - - return cell - } - } - - // MARK: Private - - private var sections: [ListSection] = [] - - private func updateData( - oldSections: [ListSection], - newSections: [ListSection] - ) { - guard let dataSource else { - return - } - - var snapshot = dataSource.snapshot() - var hasDataSourceChanged = false - defer { - if hasDataSourceChanged { - dataSource.apply(snapshot, animatingDifferences: false) - } - } - - // Early return for initial update. - guard !oldSections.isEmpty else { - hasDataSourceChanged = true - - snapshot.deleteAllItems() - for newSection in newSections { - snapshot.appendSections([newSection.sectionId]) - snapshot.appendItems(newSection.items.map(\.id)) - } - return - } - - // Build new snapshot, if any item/section id changed. - let oldSectionIds = oldSections.map(\.sectionId) - let newSectionIds = newSections.map(\.sectionId) - let oldItemIds = oldSections.map { $0.items.map(\.id) } - let newItemIds = newSections.map { $0.items.map(\.id) } - - if oldSectionIds != newSectionIds || oldItemIds != newItemIds { - hasDataSourceChanged = true - snapshot = .init() - for newSection in newSections { - snapshot.appendSections([newSection.sectionId]) - snapshot.appendItems(newSection.items.map(\.id)) - } - } - - // Reload updated items. - let allOldItems = oldSections.flatMap(\.items) - let oldItemsDictionary = Dictionary(grouping: allOldItems, by: \.id).mapValues(\.first) - - let allNewItems = newSections.flatMap(\.items) - let newItemsDictionary = Dictionary(grouping: allNewItems, by: \.id).mapValues(\.first) - - for (itemId, newItem) in newItemsDictionary { - if newItem != oldItemsDictionary[itemId] { - hasDataSourceChanged = true - snapshot.backwardCompatibleReconfigureItems([itemId]) - } - } + init(_ parent: CollectionViewBody) { + self.parent = parent } } } @@ -229,7 +166,9 @@ struct StaticCollectionView_Previews: PreviewProvider { ) let item = NSCollectionLayoutItem(layoutSize: size) let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitem: item, count: 1) - let collectionViewLayout = UICollectionViewCompositionalLayout(section: .init(group: group)) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 60 + let collectionViewLayout = UICollectionViewCompositionalLayout(section: section) return collectionViewLayout }() @@ -240,17 +179,30 @@ struct StaticCollectionView_Previews: PreviewProvider { ), ] + @State var scrollAnchorId: Int = 45 { + didSet { + print("Scrolled to item \(scrollAnchorId)") + } + } + var body: some View { - CollectionView(layout: layout, sections: sections) { _, item in - VStack { - Text("\(item.text.uppercased())") - .fontWeight(.bold) - .padding() - Divider() + ZStack { + CollectionView(layout: layout, sections: sections) { _, item in + VStack { + Text(item.text) + .padding() + Divider() + } + .border(.purple) } - } - .configureCollectionView { collectionView in - collectionView.contentInsetAdjustmentBehavior = .never + .configureCollectionView { collectionView in + collectionView.contentInsetAdjustmentBehavior = .never + } + .anchorScrollTo(id: $scrollAnchorId) + + Circle() + .foregroundColor(.purple) + .frame(width: 10) } } } diff --git a/UI/UIx/SwiftUI/CollectionView/CollectionViewController.swift b/UI/UIx/SwiftUI/CollectionView/CollectionViewController.swift index 9f3275e5..de24a82a 100644 --- a/UI/UIx/SwiftUI/CollectionView/CollectionViewController.swift +++ b/UI/UIx/SwiftUI/CollectionView/CollectionViewController.swift @@ -7,15 +7,26 @@ import SwiftUI -final class CollectionViewController: UIViewController, UICollectionViewDelegate { +final class CollectionViewController< + SectionId: Hashable, + Item: Identifiable & Hashable, + ItemContent: View +>: UIViewController, UICollectionViewDelegate { typealias CellType = HostingCollectionViewCell // MARK: Lifecycle - init(collectionViewLayout: UICollectionViewLayout) { + init( + collectionViewLayout: UICollectionViewLayout, + content: @escaping (SectionId, Item) -> ItemContent + ) { collectionView = .init(frame: .zero, collectionViewLayout: collectionViewLayout) + collectionView.backgroundColor = .clear + super.init(nibName: nil, bundle: nil) collectionView.delegate = self + + setUpDataSource(content: content) } @available(*, unavailable) @@ -23,13 +34,39 @@ final class CollectionViewController: UIViewController, UICol fatalError("init(coder:) has not been implemented") } - // MARK: Public + // MARK: Internal + + let collectionView: UICollectionView + lazy var scroller = CollectionViewScroller(collectionView: collectionView) + + var dataSource: CollectionViewDataSource? { + didSet { + scroller.dataSource = dataSource + } + } + + override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + scroller.scrollToInitialItemIfNeeded() + } - public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + scroller.animateToSize(size, with: coordinator) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + scroller.scrollToAnchoredItemIfNeeded() + } + + // MARK: - Cell Lifecycle + + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { (cell as? CellType)?.cellWillDisplay(animated: false) } - public func collectionView( + func collectionView( _ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath @@ -37,11 +74,62 @@ final class CollectionViewController: UIViewController, UICol (cell as? CellType)?.cellDidEndDisplaying(animated: false) } - // MARK: Internal + // MARK: - Paging - let collectionView: UICollectionView + func collectionView(_ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { + scroller.targetContentOffsetForProposedContentOffset(proposedContentOffset) + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + targetContentOffset.pointee = scroller.targetContentOffsetForProposedContentOffset(targetContentOffset.pointee) + } + + // MARK: - Scrolling + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scroller.startInteractiveScrolling() + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + scroller.endInteractiveScrolling() + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + scroller.endInteractiveScrolling() + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + scroller.endInteractiveScrolling() + } override func loadView() { view = collectionView } + + // MARK: Private + + private func setUpDataSource(content: @escaping (SectionId, Item) -> ItemContent) { + collectionView.register(CellType.self, forCellWithReuseIdentifier: CellType.reuseId) + + dataSource = CollectionViewDataSource(collectionView: collectionView) { + [weak self] _, indexPath, itemId in + guard let self, let dataSource else { + return nil + } + + guard let section = dataSource.section(from: indexPath), let item = dataSource.item(at: indexPath) else { + return nil + } + + assert(item.id == itemId, "Sections data doesn't match data source snapshot.") + + // Get & configure the cell. + let cell = collectionView.dequeueReusableCell(CellType.self, for: indexPath) + cell.configure(content: content(section.id, item), dataId: itemId) + + return cell + } + } } diff --git a/UI/UIx/SwiftUI/CollectionView/CollectionViewDataSource.swift b/UI/UIx/SwiftUI/CollectionView/CollectionViewDataSource.swift new file mode 100644 index 00000000..df70da1d --- /dev/null +++ b/UI/UIx/SwiftUI/CollectionView/CollectionViewDataSource.swift @@ -0,0 +1,94 @@ +// +// CollectionViewDataSource.swift +// +// +// Created by Mohamed Afifi on 2024-01-10. +// + +import SwiftUI + +final class CollectionViewDataSource< + SectionId: Hashable, + Item: Identifiable & Hashable +>: UICollectionViewDiffableDataSource { + // MARK: Internal + + var sections: [ListSection] = [] { + didSet { + updateSections(oldSections: oldValue, newSections: sections) + } + } + + func section(from indexPath: IndexPath) -> ListSection? { + if indexPath.section < 0 || indexPath.section >= sections.count { + return nil + } + return sections[indexPath.section] + } + + func item(at indexPath: IndexPath) -> Item? { + guard let section = section(from: indexPath) else { + return nil + } + if indexPath.item < 0 || indexPath.item >= section.items.count { + return nil + } + return section.items[indexPath.item] + } + + // MARK: Private + + private func updateSections( + oldSections: [ListSection], + newSections: [ListSection] + ) { + var snapshot = snapshot() + var hasDataSourceChanged = false + defer { + if hasDataSourceChanged { + apply(snapshot, animatingDifferences: false) + } + } + + // Early return for initial update. + guard !oldSections.isEmpty else { + hasDataSourceChanged = true + + snapshot.deleteAllItems() + for newSection in newSections { + snapshot.appendSections([newSection.sectionId]) + snapshot.appendItems(newSection.items.map(\.id)) + } + return + } + + // Build new snapshot, if any item/section id changed. + let oldSectionIds = oldSections.map(\.sectionId) + let newSectionIds = newSections.map(\.sectionId) + let oldItemIds = oldSections.map { $0.items.map(\.id) } + let newItemIds = newSections.map { $0.items.map(\.id) } + + if oldSectionIds != newSectionIds || oldItemIds != newItemIds { + hasDataSourceChanged = true + snapshot = .init() + for newSection in newSections { + snapshot.appendSections([newSection.sectionId]) + snapshot.appendItems(newSection.items.map(\.id)) + } + } + + // Reload updated items. + let allOldItems = oldSections.flatMap(\.items) + let oldItemsDictionary = Dictionary(grouping: allOldItems, by: \.id).mapValues(\.first) + + let allNewItems = newSections.flatMap(\.items) + let newItemsDictionary = Dictionary(grouping: allNewItems, by: \.id).mapValues(\.first) + + for (itemId, newItem) in newItemsDictionary { + if newItem != oldItemsDictionary[itemId] { + hasDataSourceChanged = true + snapshot.backwardCompatibleReconfigureItems([itemId]) + } + } + } +} diff --git a/UI/UIx/SwiftUI/CollectionView/CollectionViewScroller.swift b/UI/UIx/SwiftUI/CollectionView/CollectionViewScroller.swift new file mode 100644 index 00000000..015f3a1a --- /dev/null +++ b/UI/UIx/SwiftUI/CollectionView/CollectionViewScroller.swift @@ -0,0 +1,343 @@ +// +// CollectionViewScroller.swift +// +// +// Created by Mohamed Afifi on 2024-01-12. +// + +import SwiftUI +import VLogging + +@MainActor +final class CollectionViewScroller< + SectionId: Hashable, + Item: Identifiable & Hashable +> { + init(collectionView: UICollectionView) { + scrollToItemHelper = CollectionViewScrollToItemHelper(collectionView: collectionView) + self.collectionView = collectionView + } + + var dataSource: CollectionViewDataSource? + + var onScrollAnchorIdUpdated: ((Item.ID) -> Void)? + + var isPagingEnabled: Bool = false { + didSet { + collectionView.decelerationRate = isPagingEnabled ? .fast : .normal + } + } + + private var transitioningToNewSize = false + private var programmaticScrollingInProgress = false + + private let collectionView: UICollectionView + private let scrollToItemHelper: CollectionViewScrollToItemHelper + + private var hasScrolledToInitialItem = false + private var scrollAnchor: ScrollAnchor = .center + private var scrollAnchorId: Item.ID? { + didSet { + assert(scrollAnchorId != nil, "scrollAnchorId shouldn't be nil") + if let scrollAnchorId, oldValue != scrollAnchorId { + onScrollAnchorIdUpdated?(scrollAnchorId) + } + } + } + + private var isUserScrolling = false { + didSet { + if !isUserScrolling { + updateScrollAnchorId() + } + } + } +} + +extension CollectionViewScroller { + // MARK: - Pagingation + + func targetContentOffsetForProposedContentOffset(_ proposedContentOffset: CGPoint) -> CGPoint { + if !isPagingEnabled { + return proposedContentOffset + } + + guard let scrollAnchorId, let anchorIndexPath = dataSource?.indexPath(for: scrollAnchorId) else { + logger.error("targetContentOffset couldn't find the anchor with id: \(String(describing: scrollAnchorId))") + return proposedContentOffset + } + + let scrollsHorizontally = collectionView.scrollsHorizontally + if scrollsHorizontally && collectionView.scrollsVertically { + logger.error("isPagingEnabled doesn't support UICollectionView scrolling in both directions.") + return proposedContentOffset + } + + let targetIndexPaths = [ + collectionView.previousIndexPath(anchorIndexPath), + anchorIndexPath, + collectionView.nextIndexPath(anchorIndexPath), + ].compactMap { $0 } + + let centeredContentOffset = collectionView.centeredContentOffset(proposedContentOffset) + + let indexPathDistances = targetIndexPaths.compactMap { indexPath in + if let layoutAttributes = collectionView.layoutAttributesForItem(at: indexPath) { + let distance = layoutAttributes.frame.squaredDistance(to: centeredContentOffset) + return (layoutAttributes: layoutAttributes, distance: distance) + } + return nil + } + + let targetLayoutAttributes = indexPathDistances.min { $0.distance < $1.distance }?.layoutAttributes + if let targetLayoutAttributes { + return collectionView.contentOffsetCentering(targetLayoutAttributes, proposedContentOffset: proposedContentOffset) + } else { + logger.error("targetContentOffset couldn't find layout attributes for the target nor the anchor.") + return proposedContentOffset + } + } +} + +extension CollectionViewScroller { + // MARK: - Scroll Anchor + + func anchorScrollTo(id scrollAnchorId: Item.ID, anchor: ScrollAnchor) { + let oldScrollAnchorId = self.scrollAnchorId + self.scrollAnchorId = scrollAnchorId + scrollAnchor = anchor + + // Animate to the new value, if changed. + if scrollAnchorId != oldScrollAnchorId { + scrollToAnchoredItem(animated: true) + } + } + + func animateToSize(_ size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + // Update scroll item in case user scrolling is in progress. + updateScrollAnchorId() + + transitioningToNewSize = true + + coordinator.animate(alongsideTransition: { _ in + self.scrollToAnchoredItem(animated: false) + }, completion: { _ in + self.transitioningToNewSize = false + self.scrollToAnchoredItem(animated: false) + }) + } + + func scrollToAnchoredItemIfNeeded() { + if transitioningToNewSize { + scrollToAnchoredItem(animated: false) + } + } + + func scrollToInitialItemIfNeeded() { + if !hasScrolledToInitialItem { + hasScrolledToInitialItem = true + if scrollAnchorId != nil { + scrollToAnchoredItem(animated: false) + + if !isAnchorVisible { + // Try to scroll again, if first time failed to scroll. + DispatchQueue.main.async { + self.collectionView.reloadData() + self.collectionView.setNeedsLayout() + self.collectionView.layoutIfNeeded() + self.scrollToAnchoredItem(animated: false) + } + } + } + } + } + + func startInteractiveScrolling() { + isUserScrolling = true + } + + func endInteractiveScrolling() { + // Give scroll view time to rest before calculating the new anchor. + DispatchQueue.main.async { + self.isUserScrolling = false + } + } +} + +extension CollectionViewScroller { + // MARK: - Helpers + + private func scrollToAnchoredItem(animated: Bool) { + // Prevent rescursive anchoring. + if programmaticScrollingInProgress { + return + } + programmaticScrollingInProgress = true + defer { + DispatchQueue.main.async { + self.programmaticScrollingInProgress = false + } + } + + if let scrollAnchorId, + let scrollIndexPath = dataSource?.indexPath(for: scrollAnchorId), + let scrollPositionOfScrollAnchor + { + scrollToItemHelper.accuratelyScrollToItem(at: scrollIndexPath, position: scrollPositionOfScrollAnchor, animated: animated) + if !animated { + collectionView.contentOffset = targetContentOffsetForProposedContentOffset(collectionView.contentOffset) + } + } + } + + private func updateScrollAnchorId() { + if let scrollIndex = indexPathClosestToAnchor { + if let item = dataSource?.item(at: scrollIndex) { + scrollAnchorId = item.id + } + } + } + + private var isAnchorVisible: Bool { + guard let scrollAnchorId else { + return false + } + guard let anchorIndexPath = dataSource?.indexPath(for: scrollAnchorId) else { + return false + } + return collectionView.indexPathsForVisibleItems.contains(anchorIndexPath) + } + + private var indexPathClosestToAnchor: IndexPath? { + let anchorPoint = anchorPoint + let superviewAnchor = CGPoint( + x: anchorPoint.x * collectionView.bounds.width + collectionView.frame.minX, + y: anchorPoint.y * collectionView.bounds.height + collectionView.frame.minY + ) + let collectionViewAnchor = collectionView.convert(superviewAnchor, from: collectionView.superview) + return closestIndexPath(to: collectionViewAnchor) + } + + private func closestIndexPath(to point: CGPoint) -> IndexPath? { + // First, check if there's an item exactly at the point + if let exactIndexPath = collectionView.indexPathForItem(at: point) { + return exactIndexPath + } + + let indexPathDistances = collectionView.indexPathsForVisibleItems.compactMap { indexPath in + if let frame = collectionView.layoutAttributesForItem(at: indexPath)?.frame { + let distance = frame.squaredDistance(to: point) + return (indexPath: indexPath, distance: distance) + } + return nil + } + + return indexPathDistances + .min { $0.distance < $1.distance } + .map(\.indexPath) + } + + private var anchorPoint: UnitPoint { + switch scrollAnchor { + case .center: + return UnitPoint(x: 0.5, y: 0.5) + } + } + + private var scrollPositionOfScrollAnchor: UICollectionView.ScrollPosition? { + let scrollsHorizontally = collectionView.scrollsHorizontally + let scrollsVertically = collectionView.scrollsVertically + + switch scrollAnchor { + case .center: + if scrollsVertically && !scrollsHorizontally { + return .centeredVertically + } else if !scrollsVertically && scrollsHorizontally { + return .centeredHorizontally + } + return nil + } + } +} + +private extension UICollectionView { + var scrollsHorizontally: Bool { + let availableWidth = bounds.width - + adjustedContentInset.left - + adjustedContentInset.right + return contentSize.width > availableWidth + } + + var scrollsVertically: Bool { + let availableHeight = bounds.height - + adjustedContentInset.top - + adjustedContentInset.bottom + + return contentSize.height > availableHeight + } + + func nextIndexPath(_ indexPath: IndexPath) -> IndexPath? { + if indexPath.item < numberOfItems(inSection: indexPath.section) - 1 { + // Next item in the same section + return IndexPath(item: indexPath.item + 1, section: indexPath.section) + } else if indexPath.section < numberOfSections - 1 { + // First item in the next section + return IndexPath(item: 0, section: indexPath.section + 1) + } else { + // Already at the last item of the last section + return nil + } + } + + func previousIndexPath(_ indexPath: IndexPath) -> IndexPath? { + if indexPath.item > 0 { + // Previous item in the same section + return IndexPath(item: indexPath.item - 1, section: indexPath.section) + } else if indexPath.section > 0 { + // Last item in the previous section + let previousSection = indexPath.section - 1 + let lastItemInPreviousSection = numberOfItems(inSection: previousSection) - 1 + return IndexPath(item: lastItemInPreviousSection, section: previousSection) + } else { + // Already at the first item of the first section + return nil + } + } + + func contentOffsetCentering(_ layoutAttributes: UICollectionViewLayoutAttributes, proposedContentOffset: CGPoint) -> CGPoint { + let cellCenter = layoutAttributes.center + if scrollsHorizontally { + let offsetX = cellCenter.x - bounds.width / 2 + return CGPoint(x: offsetX, y: proposedContentOffset.y) + } else { + let offsetY = cellCenter.y - bounds.height / 2 + return CGPoint(x: proposedContentOffset.x, y: offsetY) + } + } + + func centeredContentOffset(_ contentOffset: CGPoint) -> CGPoint { + CGPoint( + x: contentOffset.x + bounds.width / 2, + y: contentOffset.y + bounds.height / 2 + ) + } +} + +private extension CGRect { + func cornerClosest(to point: CGPoint) -> CGPoint { + let closestX = max(minX, min(maxX, point.x)) + let closestY = max(minY, min(maxY, point.y)) + return CGPoint(x: closestX, y: closestY) + } + + func squaredDistance(to point: CGPoint) -> CGFloat { + let corner = cornerClosest(to: point) + return UIx.squaredDistance(point, corner) + } +} + +private func squaredDistance(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat { + let deltaX = p2.x - p1.x + let deltaY = p2.y - p1.y + return deltaX * deltaX + deltaY * deltaY +} diff --git a/UI/UIx/SwiftUI/Epoxy/CollectionViewScrollToItemHelper.swift b/UI/UIx/SwiftUI/Epoxy/CollectionViewScrollToItemHelper.swift new file mode 100644 index 00000000..bbd4ce11 --- /dev/null +++ b/UI/UIx/SwiftUI/Epoxy/CollectionViewScrollToItemHelper.swift @@ -0,0 +1,471 @@ +// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112 + +// Created by Bryan Keller on 10/20/20. +// Copyright © 2020 Airbnb Inc. All rights reserved. + +import UIKit +import VLogging + +// MARK: - CollectionViewScrollToItemHelper + +/// This class facilitates scrolling to an item at a particular index path, since the built-in +/// scroll-to-item functionality is broken for self-sizing cells. +/// +/// The fix for the animated case involves driving the scroll animation ourselves using a +/// `CADisplayLink`. +/// +/// The fix for the non-animated case involves repeatedly calling the UIKit `scrollToItem` +/// implementation until we land at a stable content offset. +final class CollectionViewScrollToItemHelper { + // MARK: Lifecycle + + /// The collection view instance is weakly-held. + init(collectionView: UICollectionView) { + self.collectionView = collectionView + } + + // MARK: Internal + + func accuratelyScrollToItem( + at indexPath: IndexPath, + position: UICollectionView.ScrollPosition, + animated: Bool + ) { + if animated { + accurateScrollToItemWithAnimation(itemIndexPath: indexPath, position: position) + } else { + accurateScrollToItemWithoutAnimation(itemIndexPath: indexPath, position: position) + } + } + + /// Cancels an in-flight animated scroll-to-item, if there is one. + /// + /// Call this function if your collection view is about to deallocate. For example, you can call + /// this from `viewWillDisappear` in a view controller, or `didMoveToWindow` when `window == nil` + /// in a view. You can also call this when a user interacts with the collection view so that + /// control is returned to the user. + func cancelAnimatedScrollToItem() { + scrollToItemContext = nil + } + + // MARK: Private + + private weak var collectionView: UICollectionView? + private weak var scrollToItemDisplayLink: CADisplayLink? + + private var scrollToItemContext: ScrollToItemContext? { + willSet { + scrollToItemDisplayLink?.invalidate() + } + } + + private func accurateScrollToItemWithoutAnimation( + itemIndexPath: IndexPath, + position: UICollectionView.ScrollPosition + ) { + guard let collectionView else { return } + + // Programmatically scrolling to an item, even without an animation, when using self-sizing + // cells usually results in slightly incorrect scroll offsets. By invoking `scrollToItem` + // multiple times in a row, we can force the collection view to eventually end up in the right + // spot. + // + // This usually only takes 3 iterations: 1 to get to an estimated offset, 1 to get to the + // final offset, and 1 to verify that we're at the final offset. If it takes more than 5 + // attempts, we'll stop trying since we're blocking the main thread during these attempts. + var previousContentOffset = CGPoint( + x: CGFloat.greatestFiniteMagnitude, + y: CGFloat.greatestFiniteMagnitude + ) + var numberOfAttempts = 1 + while + + abs(collectionView.contentOffset.x - previousContentOffset.x) >= 1 || + abs(collectionView.contentOffset.y - previousContentOffset.y) >= 1, + numberOfAttempts <= 5 + { + if numberOfAttempts > 1 { + collectionView.setNeedsLayout() + collectionView.layoutIfNeeded() + } + + previousContentOffset = collectionView.contentOffset + collectionView.scrollToItem(at: itemIndexPath, at: position, animated: false) + + numberOfAttempts += 1 + } + + if numberOfAttempts > 5 { + logger.warning( + "Gave up scrolling to an item without an animation because it took more than 5 attempts.") + } + } + + private func accurateScrollToItemWithAnimation( + itemIndexPath: IndexPath, + position: UICollectionView.ScrollPosition + ) { + guard let collectionView else { return } + + let scrollPosition: UICollectionView.ScrollPosition + if position == [] { + guard + let closestScrollPosition = closestRestingScrollPosition( + forTargetItemIndexPath: itemIndexPath, + collectionView: collectionView + ) + else { + // If we can't find a closest-scroll-position, it's because the item is already fully + // visible. In this situation, we can return early / do nothing. + return + } + scrollPosition = closestScrollPosition + } else { + scrollPosition = position + } + + scrollToItemContext = ScrollToItemContext( + targetIndexPath: itemIndexPath, + targetScrollPosition: scrollPosition, + animationStartTime: CACurrentMediaTime() + ) + + startScrollingTowardTargetItem() + } + + private func startScrollingTowardTargetItem() { + let scrollToItemDisplayLink = CADisplayLink( + target: self, + selector: #selector(scrollToItemDisplayLinkFired) + ) + if #available(iOS 15.1, *) { + #if swift(>=5.5) // Proxy check for being built with the iOS 14 & below SDK, running on iOS 15. + scrollToItemDisplayLink.preferredFrameRateRange = CAFrameRateRange( + minimum: 80, + maximum: 120, + preferred: 120 + ) + #endif + } + scrollToItemDisplayLink.add(to: .main, forMode: .common) + self.scrollToItemDisplayLink = scrollToItemDisplayLink + } + + /// Removes our scroll-to-item context and finalizes our custom scroll-to-item by invoking the + /// original function. This guarantees that our last frame of animation ends us in the correct + /// position. + private func finalizeScrollingTowardItem( + for scrollToItemContext: ScrollToItemContext, + animated: Bool + ) { + self.scrollToItemContext = nil + + guard let collectionView else { return } + + // Calling `scrollToItem(…)` with in invalid index path raises an exception: + // > NSInternalInconsistencyException: Attempted to scroll the collection view to an out-of- + // > bounds item + // We must guard against this to check to ensure that this never happens, as we call this method + // repeatedly and the items/section may change out from under us. + if + case let indexPath = scrollToItemContext.targetIndexPath, + indexPath.section < collectionView.numberOfSections, + indexPath.item < collectionView.numberOfItems(inSection: indexPath.section) + { + collectionView.scrollToItem( + at: indexPath, + at: scrollToItemContext.targetScrollPosition, + animated: animated + ) + } + + if !animated { + collectionView.delegate?.scrollViewDidEndScrollingAnimation?(collectionView) + } + } + + @objc + private func scrollToItemDisplayLinkFired() { + guard let collectionView else { return } + guard let scrollToItemContext else { + assertionFailure( + """ + Expected `scrollToItemContext` to be non-nil when programmatically scrolling toward an \ + item. + """) + return + } + + // Don't start programmatically scrolling until we have a greater-than`.zero` `bounds.size`. + // This might happen if `scrollToItem` is called before the collection view has been laid out. + guard collectionView.bounds.width > 0, collectionView.bounds.height > 0 else { return } + + // Figure out which axis to use for scrolling. + guard let scrollAxis = scrollAxis(for: collectionView) else { + // If we can't determine a scroll axis, it's either due to the collection view being too small + // to be scrollable along either axis, or the collection view being scrollable along both + // axes. In either scenario, we can just fall back to the default scroll-to-item behavior. + finalizeScrollingTowardItem(for: scrollToItemContext, animated: true) + return + } + + let maximumPerAnimationTickOffset = maximumPerAnimationTickOffset( + for: scrollAxis, + collectionView: collectionView + ) + + // After 3 seconds, the scrolling reaches is maximum speed. + let secondsSinceAnimationStart = CACurrentMediaTime() - scrollToItemContext.animationStartTime + let offset = maximumPerAnimationTickOffset * CGFloat(min(secondsSinceAnimationStart / 3, 1)) + + // Apply this scroll animation "tick's" offset adjustment. This is what actually causes the + // scroll position to change, giving the illusion of smooth scrolling as this happens 60+ times + // per second. + let positionBeforeLayout = positionRelativeToVisibleBounds( + forTargetItemIndexPath: scrollToItemContext.targetIndexPath, + collectionView: collectionView + ) + + switch positionBeforeLayout { + case .before: + collectionView.contentOffset[scrollAxis] -= offset + + case .after: + collectionView.contentOffset[scrollAxis] += offset + + // If the target item is partially or fully visible, then we don't need to apply a full `offset` + // adjustment of the content offset. Instead, we do some special logic to look at how close we + // currently are to the target origin, then change our content offset based on how far away we + // are from that target. + case .partiallyOrFullyVisible(let frame): + let targetContentOffset = targetContentOffsetForVisibleItem( + withFrame: frame, + inBounds: collectionView.bounds, + contentSize: collectionView.contentSize, + adjustedContentInset: collectionView.adjustedContentInset, + targetScrollPosition: scrollToItemContext.targetScrollPosition, + scrollAxis: scrollAxis + ) + + let targetOffset = targetContentOffset[scrollAxis] + let currentOffset = collectionView.contentOffset[scrollAxis] + let distanceToTargetOffset = targetOffset - currentOffset + + switch distanceToTargetOffset { + case ...(-1): + collectionView.contentOffset[scrollAxis] += max(-offset, distanceToTargetOffset) + case 1...: + collectionView.contentOffset[scrollAxis] += min(offset, distanceToTargetOffset) + default: + finalizeScrollingTowardItem(for: scrollToItemContext, animated: false) + } + + case .none: + break + } + + collectionView.setNeedsLayout() + collectionView.layoutIfNeeded() + } + + private func scrollAxis(for collectionView: UICollectionView) -> ScrollAxis? { + let availableWidth = collectionView.bounds.width - + collectionView.adjustedContentInset.left - + collectionView.adjustedContentInset.right + let availableHeight = collectionView.bounds.height - + collectionView.adjustedContentInset.top - + collectionView.adjustedContentInset.bottom + let scrollsHorizontally = collectionView.contentSize.width > availableWidth + let scrollsVertically = collectionView.contentSize.height > availableHeight + + switch (scrollsHorizontally: scrollsHorizontally, scrollsVertically: scrollsVertically) { + case (scrollsHorizontally: false, scrollsVertically: true): + return .vertical + + case (scrollsHorizontally: true, scrollsVertically: false): + return .horizontal + + case (scrollsHorizontally: true, scrollsVertically: true), + (scrollsHorizontally: false, scrollsVertically: false): + return nil + } + } + + private func maximumPerAnimationTickOffset( + for scrollAxis: ScrollAxis, + collectionView: UICollectionView + ) + -> CGFloat + { + let offset: CGFloat + switch scrollAxis { + case .vertical: offset = collectionView.bounds.height + case .horizontal: offset = collectionView.bounds.width + } + + return offset * 1.5 + } + + /// Returns the position (before, after, visible) of an item relative to the current viewport. + /// Note that the position (before, after, visible) is agnostic of scroll axis. + private func positionRelativeToVisibleBounds( + forTargetItemIndexPath targetIndexPath: IndexPath, + collectionView: UICollectionView + ) + -> PositionRelativeToVisibleBounds? + { + let indexPathsForVisibleItems = collectionView.indexPathsForVisibleItems.sorted() + + if let targetItemFrame = collectionView.layoutAttributesForItem(at: targetIndexPath)?.frame { + return .partiallyOrFullyVisible(frame: targetItemFrame) + } else if + let firstVisibleIndexPath = indexPathsForVisibleItems.first, + targetIndexPath < firstVisibleIndexPath + { + return .before + } else if + let lastVisibleIndexPath = indexPathsForVisibleItems.last, + targetIndexPath > lastVisibleIndexPath + { + return .after + } else { + assertionFailure( + "Could not find a position relative to the visible bounds for item at \(targetIndexPath)") + return nil + } + } + + /// If a scroll position is not specified, this function is called to find the closest scroll + /// position to make the item as visible as possible. If the item is already completely visible, + /// this function returns `nil`. + private func closestRestingScrollPosition( + forTargetItemIndexPath targetIndexPath: IndexPath, + collectionView: UICollectionView + ) + -> UICollectionView.ScrollPosition? + { + guard let scrollAxis = scrollAxis(for: collectionView) else { + return nil + } + + let positionRelativeToVisibleBounds = positionRelativeToVisibleBounds( + forTargetItemIndexPath: targetIndexPath, + collectionView: collectionView + ) + + let insetBounds = collectionView.bounds.inset(by: collectionView.adjustedContentInset) + + switch (scrollAxis, positionRelativeToVisibleBounds) { + case (.vertical, .before): + return .top + case (.vertical, .after): + return .bottom + case (.vertical, .partiallyOrFullyVisible(let itemFrame)): + guard !insetBounds.contains(itemFrame) else { return nil } + return itemFrame.midY < insetBounds.midY ? .top : .bottom + case (.horizontal, .before): + return .left + case (.horizontal, .after): + return .right + case (.horizontal, .partiallyOrFullyVisible(let itemFrame)): + guard !insetBounds.contains(itemFrame) else { return nil } + return itemFrame.midX < insetBounds.midX ? .left : .right + default: + assertionFailure("Unsupported scroll position.") + return nil + } + } + + /// Returns the correct content offset for a scroll-to-item action for the current viewport. + /// + /// This will be used to determine how much farther we need to programmatically scroll on each + /// animation tick. + private func targetContentOffsetForVisibleItem( + withFrame itemFrame: CGRect, + inBounds bounds: CGRect, + contentSize: CGSize, + adjustedContentInset: UIEdgeInsets, + targetScrollPosition: UICollectionView.ScrollPosition, + scrollAxis: ScrollAxis + ) + -> CGPoint + { + let itemPosition, itemSize, viewportSize, minContentOffset, maxContentOffset: CGFloat + let visibleBounds = bounds.inset(by: adjustedContentInset) + switch scrollAxis { + case .vertical: + itemPosition = itemFrame.minY + itemSize = itemFrame.height + viewportSize = visibleBounds.height + minContentOffset = -adjustedContentInset.top + maxContentOffset = -adjustedContentInset.top + contentSize.height - visibleBounds.height + case .horizontal: + itemPosition = itemFrame.minX + itemSize = itemFrame.width + viewportSize = visibleBounds.width + minContentOffset = -adjustedContentInset.left + maxContentOffset = -adjustedContentInset.left + contentSize.width - visibleBounds.width + } + + let newOffset: CGFloat + switch targetScrollPosition { + case .top, .left: + newOffset = itemPosition + minContentOffset + case .bottom, .right: + newOffset = itemPosition + itemSize - viewportSize + minContentOffset + case .centeredVertically, .centeredHorizontally: + newOffset = itemPosition + (itemSize / 2) - (viewportSize / 2) + minContentOffset + default: + assertionFailure("Unsupported scroll position.") + return itemFrame.origin + } + + let clampedOffset = min(max(newOffset, minContentOffset), maxContentOffset) + + var targetOffset = itemFrame.origin + targetOffset[scrollAxis] = clampedOffset + return targetOffset + } +} + +// MARK: - ScrollToItemContext + +private struct ScrollToItemContext { + let targetIndexPath: IndexPath + let targetScrollPosition: UICollectionView.ScrollPosition + let animationStartTime: CFTimeInterval +} + +// MARK: - ScrollAxis + +private enum ScrollAxis { + case vertical + case horizontal +} + +// MARK: - PositionRelativeToVisibleBounds + +private enum PositionRelativeToVisibleBounds { + case before + case after + case partiallyOrFullyVisible(frame: CGRect) +} + +// MARK: - CGPoint + +extension CGPoint { + fileprivate subscript(axis: ScrollAxis) -> CGFloat { + get { + switch axis { + case .vertical: return y + case .horizontal: return x + } + } + set { + switch axis { + case .vertical: y = newValue + case .horizontal: x = newValue + } + } + } +} diff --git a/UI/UIx/SwiftUI/Mutate.swift b/UI/UIx/SwiftUI/Mutate.swift new file mode 100644 index 00000000..d5c7cd88 --- /dev/null +++ b/UI/UIx/SwiftUI/Mutate.swift @@ -0,0 +1,16 @@ +// +// Mutate.swift +// +// +// Created by Mohamed Afifi on 2024-01-13. +// + +import SwiftUI + +extension View { + func mutateSelf(_ body: (inout Self) -> Void) -> Self { + var copy = self + body(©) + return copy + } +}