diff --git a/ASCollectionView-SwiftUI.podspec b/ASCollectionView-SwiftUI.podspec index 5b88ced..14410d0 100644 --- a/ASCollectionView-SwiftUI.podspec +++ b/ASCollectionView-SwiftUI.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'ASCollectionView-SwiftUI' - s.version = '1.5.0' + s.version = '1.6.0' s.summary = 'A SwiftUI collection view with support for custom layouts, preloading, and more. ' s.description = <<-DESC @@ -22,5 +22,5 @@ Pod::Spec.new do |s| s.ios.deployment_target = '11.0' s.swift_versions = '5.2' s.source_files = 'Sources/ASCollectionView/**/*' - + s.dependency 'DifferenceKit', '~> 1.1' end diff --git a/Demo/ASCollectionViewDemo.xcodeproj/project.pbxproj b/Demo/ASCollectionViewDemo.xcodeproj/project.pbxproj index 44b1299..09573b1 100644 --- a/Demo/ASCollectionViewDemo.xcodeproj/project.pbxproj +++ b/Demo/ASCollectionViewDemo.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ B8A95E3E236081500017A7EA /* MagazineLayoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A95E3D236081500017A7EA /* MagazineLayoutScreen.swift */; }; B8C00A972376350B0066348C /* AdjustableGridScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8C00A96237634E90066348C /* AdjustableGridScreen.swift */; }; B8D8292E2371077800D3F0AE /* SampleUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8292D2371077400D3F0AE /* SampleUsage.swift */; }; + B8FCC331244191F0003173CA /* TableViewDragAndDropScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FCC330244191F0003173CA /* TableViewDragAndDropScreen.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -75,6 +76,7 @@ B8A95E3D236081500017A7EA /* MagazineLayoutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagazineLayoutScreen.swift; sourceTree = ""; }; B8C00A96237634E90066348C /* AdjustableGridScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustableGridScreen.swift; sourceTree = ""; }; B8D8292D2371077400D3F0AE /* SampleUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SampleUsage.swift; path = ../readmeAssets/SampleUsage.swift; sourceTree = ""; }; + B8FCC330244191F0003173CA /* TableViewDragAndDropScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDragAndDropScreen.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -175,6 +177,7 @@ B86C6F38234B0B9D00522AEF /* Screens */ = { isa = PBXGroup; children = ( + B8FCC32F244191DD003173CA /* TableViewDragAndDrop */, B8268B502363EEF0008C99A3 /* Reminders */, B8A95E3723607F760017A7EA /* MagazineLayout */, B89129FB235DB6ED00D8BA90 /* Tags */, @@ -249,6 +252,14 @@ path = AdjustableLayout; sourceTree = ""; }; + B8FCC32F244191DD003173CA /* TableViewDragAndDrop */ = { + isa = PBXGroup; + children = ( + B8FCC330244191F0003173CA /* TableViewDragAndDropScreen.swift */, + ); + path = TableViewDragAndDrop; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -361,6 +372,7 @@ B86C6F4A234B0B9D00522AEF /* AppStoreScreen.swift in Sources */, B8268B582363EF37008C99A3 /* GroupModel.swift in Sources */, B86C6F48234B0B9D00522AEF /* App.swift in Sources */, + B8FCC331244191F0003173CA /* TableViewDragAndDropScreen.swift in Sources */, B8268B522363EF03008C99A3 /* RemindersScreen.swift in Sources */, B86C6F47234B0B9D00522AEF /* Post.swift in Sources */, B899D70123752CEC001BB5FA /* WaterfallScreen.swift in Sources */, diff --git a/Demo/ASCollectionViewDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/ASCollectionViewDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 904862e..3208346 100644 --- a/Demo/ASCollectionViewDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/ASCollectionViewDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "DifferenceKit", + "repositoryURL": "https://github.com/ra1028/DifferenceKit", + "state": { + "branch": null, + "revision": "14c66681e12a38b81045f44c6c29724a0d4b0e72", + "version": "1.1.5" + } + }, { "package": "MagazineLayout", "repositoryURL": "https://github.com/airbnb/MagazineLayout", diff --git a/Demo/ASCollectionViewDemo/MainView.swift b/Demo/ASCollectionViewDemo/MainView.swift index b5a7905..66eb24c 100644 --- a/Demo/ASCollectionViewDemo/MainView.swift +++ b/Demo/ASCollectionViewDemo/MainView.swift @@ -52,6 +52,11 @@ struct MainView: View Image(systemName: "8.square.fill") Text("Adjustable layout") } + NavigationLink(destination: TableViewDragAndDropScreen()) + { + Image(systemName: "9.square.fill") + Text("Multiple TableView drag&drop") + } } Section(header: Text("Modified examples")) { diff --git a/Demo/ASCollectionViewDemo/Screens/AdjustableLayout/AdjustableGridScreen.swift b/Demo/ASCollectionViewDemo/Screens/AdjustableLayout/AdjustableGridScreen.swift index 44206c5..393474e 100644 --- a/Demo/ASCollectionViewDemo/Screens/AdjustableLayout/AdjustableGridScreen.swift +++ b/Demo/ASCollectionViewDemo/Screens/AdjustableLayout/AdjustableGridScreen.swift @@ -80,6 +80,7 @@ struct AdjustableGridScreen: View ASCollectionView( section: section) .layout(self.layout) + .shouldInvalidateLayoutOnStateChange(true) .navigationBarTitle("Adjustable Layout", displayMode: .inline) } .navigationBarItems( diff --git a/Demo/ASCollectionViewDemo/Screens/AppStore/AppStoreScreen.swift b/Demo/ASCollectionViewDemo/Screens/AppStore/AppStoreScreen.swift index 9c6517a..a8541e5 100644 --- a/Demo/ASCollectionViewDemo/Screens/AppStore/AppStoreScreen.swift +++ b/Demo/ASCollectionViewDemo/Screens/AppStore/AppStoreScreen.swift @@ -61,8 +61,9 @@ struct AppStoreScreen: View { ASCollectionView(sections: self.sections) .layout(self.layout) - .edgesIgnoringSafeArea(.all) + .shouldAttemptToMaintainScrollPositionOnOrientationChange(maintainPosition: false) .navigationBarTitle("Apps", displayMode: .large) + .edgesIgnoringSafeArea(.all) } func onCellEvent(_ event: CellEvent, sectionID: Int) @@ -127,14 +128,14 @@ extension AppStoreScreen widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))) - let itemsGroup = NSCollectionLayoutGroup.horizontal( + let itemsGroup = NSCollectionLayoutGroup.vertical( layoutSize: NSCollectionLayoutSize( - widthDimension: .fractionalWidth(0.9 / columnsToFit), + widthDimension: .fractionalWidth(0.8 / columnsToFit), heightDimension: .absolute(280)), - subitems: [item]) - itemsGroup.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 8, bottom: 10, trailing: 8) + subitem: item, count: 1) let section = NSCollectionLayoutSection(group: itemsGroup) + section.interGroupSpacing = 20 section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20) section.orthogonalScrollingBehavior = .groupPaging return section @@ -164,7 +165,7 @@ extension AppStoreScreen let header = NSCollectionLayoutBoundarySupplementaryItem( layoutSize: NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), - heightDimension: .estimated(34)), + heightDimension: .absolute(34)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) header.contentInsets.leading = nestedGroup.contentInsets.leading @@ -201,7 +202,7 @@ extension AppStoreScreen let header = NSCollectionLayoutBoundarySupplementaryItem( layoutSize: NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), - heightDimension: .estimated(34)), + heightDimension: .absolute(34)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top) header.contentInsets.leading = nestedGroup.contentInsets.leading diff --git a/Demo/ASCollectionViewDemo/Screens/InstaFeed/InstaFeedScreen.swift b/Demo/ASCollectionViewDemo/Screens/InstaFeed/InstaFeedScreen.swift index 146df04..7168f92 100644 --- a/Demo/ASCollectionViewDemo/Screens/InstaFeed/InstaFeedScreen.swift +++ b/Demo/ASCollectionViewDemo/Screens/InstaFeed/InstaFeedScreen.swift @@ -51,7 +51,7 @@ struct InstaFeedScreen: View { item, _ in PostView(post: item) } - .tableViewSetEstimatedSizes(rowHeight: 500, headerHeight: 50) // Optional: Provide reasonable estimated heights for this section + .tableViewSetEstimatedSizes(headerHeight: 50) // Optional: Provide reasonable estimated heights for this section .sectionHeader { VStack(spacing: 0) diff --git a/Demo/ASCollectionViewDemo/Screens/InstaFeed/PostView.swift b/Demo/ASCollectionViewDemo/Screens/InstaFeed/PostView.swift index 1ce313b..2f9ca5b 100644 --- a/Demo/ASCollectionViewDemo/Screens/InstaFeed/PostView.swift +++ b/Demo/ASCollectionViewDemo/Screens/InstaFeed/PostView.swift @@ -76,7 +76,7 @@ struct PostView: View .onTapGesture { self.captionExpanded.toggle() - self.invalidateCellLayout() + self.invalidateCellLayout?(false) } Text("View all \(post.comments) comments").foregroundColor(Color(.systemGray)) } diff --git a/Demo/ASCollectionViewDemo/Screens/MagazineLayout/MagazineLayoutScreen.swift b/Demo/ASCollectionViewDemo/Screens/MagazineLayout/MagazineLayoutScreen.swift index 6a48a26..8a0a114 100644 --- a/Demo/ASCollectionViewDemo/Screens/MagazineLayout/MagazineLayoutScreen.swift +++ b/Demo/ASCollectionViewDemo/Screens/MagazineLayout/MagazineLayoutScreen.swift @@ -16,7 +16,11 @@ struct MagazineLayoutScreen: View { data.enumerated().map { (offset, sectionData) -> ASCollectionViewSection in - ASCollectionViewSection(id: offset, data: sectionData, onCellEvent: onCellEvent, contextMenuProvider: contextMenuProvider) + ASCollectionViewSection( + id: offset, + data: sectionData, + onCellEvent: onCellEvent, + contextMenuProvider: contextMenuProvider) { item, _ in ASRemoteImageView(item.url) .aspectRatio(1, contentMode: .fit) @@ -64,7 +68,7 @@ struct MagazineLayoutScreen: View } } - func contextMenuProvider(_ post: Post) -> UIContextMenuConfiguration? + func contextMenuProvider(index: Int, post: Post) -> UIContextMenuConfiguration? { let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in let testAction = UIAction(title: "Test") { _ in diff --git a/Demo/ASCollectionViewDemo/Screens/PhotoGrid/PhotoGridScreen.swift b/Demo/ASCollectionViewDemo/Screens/PhotoGrid/PhotoGridScreen.swift index ffe3ec1..00653c1 100644 --- a/Demo/ASCollectionViewDemo/Screens/PhotoGrid/PhotoGridScreen.swift +++ b/Demo/ASCollectionViewDemo/Screens/PhotoGrid/PhotoGridScreen.swift @@ -26,10 +26,8 @@ struct PhotoGridScreen: View data: data, selectedItems: $selectedItems, onCellEvent: onCellEvent, - itemProvider: { item in - // Example of returning a custom item provider (eg. to support drag-drop to other apps) - NSItemProvider(object: item.url as NSURL) - }) + dragDropConfig: dragDropConfig, + contextMenuProvider: contextMenuProvider) { item, state in ZStack(alignment: .bottomTrailing) { @@ -114,6 +112,20 @@ struct PhotoGridScreen: View } } + func contextMenuProvider(int: Int, post: Post) -> UIContextMenuConfiguration? + { + let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in + let testAction = UIAction(title: "Do nothing") { _ in + // + } + let testAction2 = UIAction(title: "Try dragging the photo") { _ in + // + } + return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [testAction, testAction2]) + } + return configuration + } + func destinationForItem(_ item: Post) -> some View { ScrollView { @@ -167,6 +179,15 @@ extension PhotoGridScreen } } } + + var dragDropConfig: ASDragDropConfig + { + ASDragDropConfig(dataBinding: $data) + .enableReordering(shouldMoveItem: nil) + .dragItemProvider { item in + NSItemProvider(object: item.url as NSURL) + } + } } struct GridView_Previews: PreviewProvider diff --git a/Demo/ASCollectionViewDemo/Screens/TableViewDragAndDrop/TableViewDragAndDropScreen.swift b/Demo/ASCollectionViewDemo/Screens/TableViewDragAndDrop/TableViewDragAndDropScreen.swift new file mode 100644 index 0000000..d49cf5c --- /dev/null +++ b/Demo/ASCollectionViewDemo/Screens/TableViewDragAndDrop/TableViewDragAndDropScreen.swift @@ -0,0 +1,117 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import ASCollectionView +import SwiftUI + +struct TableViewDragAndDropScreen: View +{ + @State var groupA: [String] = (0 ... 4).map { "Item A-\($0)" } + @State var groupB: [String] = (0 ... 4).map { "Item B-\($0)" } + @State var groupC: [String] = (0 ... 4).map { "Item C-\($0)" } + @State var groupD: [String] = (0 ... 4).map { "Item D-\($0)" } + + var body: some View + { + VStack { + Text("Drag within a tableview to move.\nDrag between tableviews to copy.") + .padding() + HStack { + ASTableView { + ASSection( + id: 0, + data: groupA, + dataID: \.self, + dragDropConfig: ASDragDropConfig(dataBinding: $groupA).enableReordering(), + onSwipeToDelete: { index, _, callback in + withAnimation { + self.groupA.remove(at: index) + callback(true) + } + }) + { item, _ in + Text(item) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .sectionHeader { + header("Section A") + } + ASSection( + id: 1, + data: groupB, + dataID: \.self, + dragDropConfig: ASDragDropConfig(dataBinding: $groupB).enableReordering(), + onSwipeToDelete: { index, _, callback in + withAnimation { + self.groupB.remove(at: index) + callback(true) + } + }) { item, _ in + Text(item) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .sectionHeader { + header("Section B") + } + } + Color.blue.frame(width: 10) + ASTableView { + ASSection( + id: 0, + data: groupC, + dataID: \.self, + dragDropConfig: ASDragDropConfig(dataBinding: $groupC).enableReordering(), + onSwipeToDelete: { index, _, callback in + withAnimation { + self.groupC.remove(at: index) + callback(true) + } + }) { item, _ in + Text(item) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .sectionHeader { + header("Section C") + } + ASSection( + id: 1, + data: groupD, + dataID: \.self, + dragDropConfig: ASDragDropConfig(dataBinding: $groupD).enableReordering(), + onSwipeToDelete: { index, _, callback in + withAnimation { + self.groupD.remove(at: index) + callback(true) + } + }) { item, _ in + Text(item) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .sectionHeader { + header("Section D") + } + } + } + } + .navigationBarTitle("Drag & drop", displayMode: .inline) + } + + func header(_ string: String) -> some View + { + Text(string) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + } +} + +struct TableViewDragAndDropScreen_Previews: PreviewProvider +{ + static var previews: some View + { + TableViewDragAndDropScreen() + } +} diff --git a/Demo/BuildTools/Package.resolved b/Demo/BuildTools/Package.resolved index 8825ca8..d520591 100644 --- a/Demo/BuildTools/Package.resolved +++ b/Demo/BuildTools/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/nicklockwood/SwiftFormat", "state": { "branch": null, - "revision": "2f1d164cb58c9a2da0452db846b7333d49f556af", - "version": "0.44.6" + "revision": "03989b9a28f98ea5cad5ca2b22024a8b67aca31c", + "version": "0.44.7" } } ] diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..afeaf32 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "DifferenceKit", + "repositoryURL": "https://github.com/ra1028/DifferenceKit", + "state": { + "branch": null, + "revision": "14c66681e12a38b81045f44c6c29724a0d4b0e72", + "version": "1.1.5" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index 802af48..3e5ab61 100644 --- a/Package.swift +++ b/Package.swift @@ -7,12 +7,14 @@ let package = Package(name: "ASCollectionView", platforms: [.iOS(.v11)], products: [// Products define the executables and libraries produced by a package, and make them visible to other packages. .library(name: "ASCollectionView", - targets: ["ASCollectionView"]) + targets: ["ASCollectionView"]), + .library(name: "ASCollectionViewDynamic", type: .dynamic, targets: ["ASCollectionView"]), ], dependencies: [ + .package(url: "https://github.com/ra1028/DifferenceKit", .upToNextMajor(from: Version(1, 1, 5))) ], targets: [ .target(name: "ASCollectionView", - dependencies: []), + dependencies: ["DifferenceKit"]), ] ) diff --git a/README.md b/README.md index 04f52ce..1b76d9a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Stargazers][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![MIT License][license-shield]][license-url] -[![Build Status](https://travis-ci.com/apptekstudios/ASCollectionView.svg?branch=master)](https://travis-ci.com/apptekstudios/ASCollectionView) +![Build status](https://github.com/apptekstudios/ASCollectionView/workflows/Build/badge.svg?branch=master) # ASCollectionView A SwiftUI implementation of UICollectionView & UITableView. Here's some of its useful features: diff --git a/Sources/ASCollectionView/ASCellContext.swift b/Sources/ASCollectionView/ASCellContext.swift new file mode 100644 index 0000000..a9915be --- /dev/null +++ b/Sources/ASCollectionView/ASCellContext.swift @@ -0,0 +1,13 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation + +/// The context passed to your dynamic section initialiser. Use this to change your view content depending on the context (eg. selected) +@available(iOS 13.0, *) +public struct ASCellContext +{ + public var isSelected: Bool + public var index: Int + public var isFirstInSection: Bool + public var isLastInSection: Bool +} diff --git a/Sources/ASCollectionView/ASCollectionView+Initialisers.swift b/Sources/ASCollectionView/ASCollectionView+Initialisers.swift new file mode 100644 index 0000000..75b5f45 --- /dev/null +++ b/Sources/ASCollectionView/ASCollectionView+Initialisers.swift @@ -0,0 +1,85 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +// MARK: Init for multi-section CVs + +@available(iOS 13.0, *) +public extension ASCollectionView +{ + /** + Initializes a collection view with the given sections + + - Parameters: + - sections: An array of sections (ASCollectionViewSection) + */ + init(sections: [Section]) + { + self.sections = sections + } + + /** + Initializes a collection view with the given sections + + - Parameters: + - sectionBuilder: A closure containing multiple sections (ASCollectionViewSection) + */ + init(@SectionArrayBuilder sectionBuilder: () -> [Section]) + { + sections = sectionBuilder() + } +} + +// MARK: Init for single-section CV + +@available(iOS 13.0, *) +public extension ASCollectionView where SectionID == Int +{ + /** + Initializes a collection view with a single section. + + - Parameters: + - section: A single section (ASCollectionViewSection) + */ + init(section: Section) + { + sections = [section] + } + + /** + Initializes a collection view with a single section of static content + */ + init(@ViewArrayBuilder staticContent: () -> ViewArrayBuilder.Wrapper) + { + self.init(sections: [ASCollectionViewSection(id: 0, content: staticContent)]) + } + + /** + Initializes a collection view with a single section. + */ + init( + data: DataCollection, + dataID dataIDKeyPath: KeyPath, + @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, ASCellContext) -> Content)) + where DataCollection.Index == Int + { + let section = ASCollectionViewSection( + id: 0, + data: data, + dataID: dataIDKeyPath, + contentBuilder: contentBuilder) + sections = [section] + } + + /** + Initializes a collection view with a single section with identifiable data + */ + init( + data: DataCollection, + @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, ASCellContext) -> Content)) + where DataCollection.Index == Int, DataCollection.Element: Identifiable + { + self.init(data: data, dataID: \.id, contentBuilder: contentBuilder) + } +} diff --git a/Sources/ASCollectionView/ASCollectionView+Modifiers.swift b/Sources/ASCollectionView/ASCollectionView+Modifiers.swift new file mode 100644 index 0000000..1d4d1eb --- /dev/null +++ b/Sources/ASCollectionView/ASCollectionView+Modifiers.swift @@ -0,0 +1,201 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +// MARK: Modifer: Custom Delegate + +@available(iOS 13.0, *) +public extension ASCollectionView +{ + /// Use this modifier to assign a custom delegate type (subclass of ASCollectionViewDelegate). This allows support for old UICollectionViewLayouts that require a delegate. + func customDelegate(_ delegateInitialiser: @escaping (() -> ASCollectionViewDelegate)) -> Self + { + var cv = self + cv.delegateInitialiser = delegateInitialiser + return cv + } +} + +// MARK: Modifer: Layout Invalidation + +@available(iOS 13.0, *) +public extension ASCollectionView +{ + /// For use in cases where you would like to change layout settings in response to a change in variables referenced by your layout closure. + /// Note: this ensures the layout is invalidated + /// - For UICollectionViewCompositionalLayout this means that your SectionLayout closure will be called again + /// - closures capture value types when created, therefore you must refer to a reference type in your layout closure if you want it to update. + func shouldInvalidateLayoutOnStateChange(_ shouldInvalidate: Bool, animated: Bool = true) -> Self + { + var this = self + this.shouldInvalidateLayoutOnStateChange = shouldInvalidate + this.shouldAnimateInvalidatedLayoutOnStateChange = animated + return this + } + + /// For use in cases where you would like to recreate the layout object in response to a change in state. Eg. for changing layout types completely + /// If not changing the type of layout (eg. to a different class) t is preferable to invalidate the layout and update variables in the `configureCustomLayout` closure + func shouldRecreateLayoutOnStateChange(_ shouldRecreate: Bool, animated: Bool = true) -> Self + { + var this = self + this.shouldRecreateLayoutOnStateChange = shouldRecreate + this.shouldAnimateRecreatedLayoutOnStateChange = animated + return this + } +} + +// MARK: Modifer: Other Modifiers + +@available(iOS 13.0, *) +public extension ASCollectionView +{ + /// Set a closure that is called whenever the collectionView is scrolled + func onScroll(_ onScroll: @escaping OnScrollCallback) -> Self + { + var this = self + this.onScrollCallback = onScroll + return this + } + + /// Set a closure that is called whenever the collectionView is scrolled to a boundary. eg. the bottom. + /// This is useful to enable loading more data when scrolling to bottom + func onReachedBoundary(_ onReachedBoundary: @escaping OnReachedBoundaryCallback) -> Self + { + var this = self + this.onReachedBoundaryCallback = onReachedBoundary + return this + } + + /// Set whether to show scroll indicators + func scrollIndicatorsEnabled(horizontal: Bool = true, vertical: Bool = true) -> Self + { + var this = self + this.horizontalScrollIndicatorEnabled = horizontal + this.verticalScrollIndicatorEnabled = vertical + return this + } + + /// Set the content insets + func contentInsets(_ insets: UIEdgeInsets) -> Self + { + var this = self + this.contentInsets = insets + return this + } + + /// Set a closure that is called when the collectionView is pulled to refresh + func onPullToRefresh(_ callback: ((_ endRefreshing: @escaping (() -> Void)) -> Void)?) -> Self + { + var this = self + this.onPullToRefresh = callback + return this + } + + /// Set whether the ASCollectionView should always allow bounce vertically + func alwaysBounceVertical(_ alwaysBounce: Bool = true) -> Self + { + var this = self + this.alwaysBounceVertical = alwaysBounce + return this + } + + /// Set whether the ASCollectionView should always allow bounce horizontally + func alwaysBounceHorizontal(_ alwaysBounce: Bool = true) -> Self + { + var this = self + this.alwaysBounceHorizontal = alwaysBounce + return this + } + + /// Set an initial scroll position for the ASCollectionView + func initialScrollPosition(_ position: ASCollectionViewScrollPosition?) -> Self + { + var this = self + this.initialScrollPosition = position + return this + } + + /// Set whether the ASCollectionView should animate on data refresh + func animateOnDataRefresh(_ animate: Bool = true) -> Self + { + var this = self + this.animateOnDataRefresh = animate + return this + } + + /// Set whether the ASCollectionView should attempt to maintain scroll position on orientation change, default is true + func shouldAttemptToMaintainScrollPositionOnOrientationChange(maintainPosition: Bool) -> Self + { + var this = self + this.maintainScrollPositionOnOrientationChange = true + return this + } +} + +// MARK: PUBLIC layout modifier functions + +@available(iOS 13.0, *) +public extension ASCollectionView +{ + func layout(_ layout: Layout) -> Self + { + var this = self + this.layout = layout + return this + } + + func layout( + scrollDirection: UICollectionView.ScrollDirection = .vertical, + interSectionSpacing: CGFloat = 10, + layoutPerSection: @escaping CompositionalLayout) -> Self + { + var this = self + this.layout = Layout( + scrollDirection: scrollDirection, + interSectionSpacing: interSectionSpacing, + layoutPerSection: layoutPerSection) + return this + } + + func layout( + scrollDirection: UICollectionView.ScrollDirection = .vertical, + interSectionSpacing: CGFloat = 10, + layout: @escaping CompositionalLayoutIgnoringSections) -> Self + { + var this = self + this.layout = Layout( + scrollDirection: scrollDirection, + interSectionSpacing: interSectionSpacing, + layout: layout) + return this + } + + func layout(customLayout: @escaping (() -> UICollectionViewLayout)) -> Self + { + var this = self + this.layout = Layout(customLayout: customLayout) + return this + } + + func layout(createCustomLayout: @escaping (() -> LayoutClass), configureCustomLayout: @escaping ((LayoutClass) -> Void)) -> Self + { + var this = self + this.layout = Layout(createCustomLayout: createCustomLayout, configureCustomLayout: configureCustomLayout) + return this + } +} + +@available(iOS 13.0, *) +public extension ASCollectionView +{ + func shrinkToContentSize(isEnabled: Bool = true, dimension: ShrinkDimension) -> some View + { + SelfSizingWrapper(content: self, shrinkDirection: dimension, isEnabled: isEnabled) + } + + func fitContentSize(isEnabled: Bool = true, dimension: ShrinkDimension) -> some View + { + SelfSizingWrapper(content: self, shrinkDirection: dimension, isEnabled: isEnabled, expandToFitMode: true) + } +} diff --git a/Sources/ASCollectionView/ASCollectionViewCells.swift b/Sources/ASCollectionView/ASCollectionViewCells.swift deleted file mode 100644 index f5a0fbb..0000000 --- a/Sources/ASCollectionView/ASCollectionViewCells.swift +++ /dev/null @@ -1,207 +0,0 @@ -// ASCollectionView. Created by Apptek Studios 2019 - -import Foundation -import SwiftUI - -@available(iOS 13.0, *) -protocol ASDataSourceConfigurableCell -{ - var hostingController: ASHostingControllerProtocol? { get set } -} - -@available(iOS 13.0, *) -class ASCollectionViewCell: UICollectionViewCell, ASDataSourceConfigurableCell -{ - var itemID: ASCollectionViewItemUniqueID? - var hostingController: ASHostingControllerProtocol? - { - didSet - { - if let hc = hostingController - { - if hc.viewController.view.superview != contentView - { - contentView.subviews.forEach { $0.removeFromSuperview() } - } - } - else - { - contentView.subviews.forEach { $0.removeFromSuperview() } - } - } - } - - weak var collectionView: UICollectionView? - - var selfSizingConfig: ASSelfSizingConfig = .init(selfSizeHorizontally: true, selfSizeVertically: true) - - var invalidateLayout: (() -> Void)? - - func willAppear(in vc: UIViewController) - { - hostingController.map - { hc in - if hc.viewController.parent != vc - { - hc.viewController.removeFromParent() - vc.addChild(hc.viewController) - } - - if hc.viewController.view.superview != contentView - { - contentView.subviews.forEach { $0.removeFromSuperview() } - contentView.addSubview(hc.viewController.view) - setNeedsLayout() - } - - hostingController?.viewController.didMove(toParent: vc) - } - } - - func didDisappear() - { - hostingController?.viewController.removeFromParent() - } - - override func prepareForReuse() - { - isSelected = false - hostingController = nil - } - - override func layoutSubviews() - { - super.layoutSubviews() - - if hostingController?.viewController.view.frame != contentView.bounds - { - hostingController?.viewController.view.frame = contentView.bounds - hostingController?.viewController.view.setNeedsLayout() - hostingController?.viewController.view.layoutIfNeeded() - } - } - - override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize - { - guard let hc = hostingController else - { - return CGSize(width: 1, height: 1) - } // Can't return .zero as UICollectionViewLayout will crash - - let size = hc.sizeThatFits( - in: targetSize, - maxSize: maxSizeForSelfSizing, - selfSizeHorizontal: selfSizingConfig.selfSizeHorizontally, - selfSizeVertical: selfSizingConfig.selfSizeVertically) - return size - } - - override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize - { - systemLayoutSizeFitting(targetSize) - } - - override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes - { - layoutAttributes.size = systemLayoutSizeFitting(layoutAttributes.size) - return layoutAttributes - } - - var maxSizeForSelfSizing: ASOptionalSize - { - ASOptionalSize( - width: selfSizingConfig.canExceedCollectionWidth ? nil : collectionView.map { $0.contentSize.width - 0.001 }, - height: selfSizingConfig.canExceedCollectionHeight ? nil : collectionView.map { $0.contentSize.height - 0.001 }) - } -} - -@available(iOS 13.0, *) -class ASCollectionViewSupplementaryView: UICollectionReusableView -{ - var hostingController: ASHostingControllerProtocol? - private(set) var id: Int? - - var maxSizeForSelfSizing: ASOptionalSize = .none - - func setupFor(id: Int, view: Content) - { - self.id = id - hostingController = ASHostingController(view) - } - - func setupAsEmptyView() - { - hostingController = nil - subviews.forEach { $0.removeFromSuperview() } - } - - func updateView(_ view: Content) - { - guard let hc = hostingController as? ASHostingController else { return } - hc.setView(view) - } - - func willAppear(in vc: UIViewController?) - { - hostingController.map - { - if $0.viewController.parent != vc - { - $0.viewController.removeFromParent() - vc?.addChild($0.viewController) - } - if $0.viewController.view.superview != self - { - $0.viewController.view.removeFromSuperview() - subviews.forEach { $0.removeFromSuperview() } - addSubview($0.viewController.view) - } - - setNeedsLayout() - - vc.map { hostingController?.viewController.didMove(toParent: $0) } - } - } - - func didDisappear() - { - hostingController?.viewController.removeFromParent() - } - - override func prepareForReuse() - { - hostingController = nil - } - - override func layoutSubviews() - { - super.layoutSubviews() - hostingController?.viewController.view.frame = bounds - hostingController?.viewController.view.setNeedsLayout() - } - - var selfSizingConfig: ASSelfSizingConfig = .init(selfSizeHorizontally: true, selfSizeVertically: true) - - override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize - { - guard let hc = hostingController else { return CGSize(width: 1, height: 1) } - let size = hc.sizeThatFits( - in: targetSize, - maxSize: maxSizeForSelfSizing, - selfSizeHorizontal: selfSizingConfig.selfSizeHorizontally, - selfSizeVertical: selfSizingConfig.selfSizeVertically) - - return size - } - - override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize - { - systemLayoutSizeFitting(targetSize) - } - - override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes - { - layoutAttributes.size = systemLayoutSizeFitting(layoutAttributes.size) - return layoutAttributes - } -} diff --git a/Sources/ASCollectionView/ASCollectionViewDelegate.swift b/Sources/ASCollectionView/ASCollectionViewDelegate.swift deleted file mode 100644 index 19ebabb..0000000 --- a/Sources/ASCollectionView/ASCollectionViewDelegate.swift +++ /dev/null @@ -1,154 +0,0 @@ -// ASCollectionView. Created by Apptek Studios 2019 - -import Foundation -import SwiftUI - -/// ASCollectionViewDelegate: Subclass this to create a custom delegate (eg. for supporting UICollectionViewLayouts that default to using the collectionView delegate) -@available(iOS 13.0, *) -open class ASCollectionViewDelegate: NSObject, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout -{ - weak var coordinator: ASCollectionViewCoordinator? - - public func getDataForItem(at indexPath: IndexPath) -> Any? - { - coordinator?.typeErasedDataForItem(at: indexPath) - } - - public func getDataForItem(at indexPath: IndexPath) -> T? - { - coordinator?.typeErasedDataForItem(at: indexPath) as? T - } - - open func collectionViewSelfSizingSettings(forContext: ASSelfSizingContext) -> ASSelfSizingConfig? - { - nil - } - - open var collectionViewContentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior - { - .scrollableAxes - } - - public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) - { - coordinator?.collectionView(collectionView, willDisplay: cell, forItemAt: indexPath) - } - - public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) - { - coordinator?.collectionView(collectionView, didEndDisplaying: cell, forItemAt: indexPath) - } - - public func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) - { - coordinator?.collectionView(collectionView, willDisplaySupplementaryView: view, forElementKind: elementKind, at: indexPath) - } - - public func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) - { - coordinator?.collectionView(collectionView, didEndDisplayingSupplementaryView: view, forElementOfKind: elementKind, at: indexPath) - } - - public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) - { - coordinator?.collectionView(collectionView, didSelectItemAt: indexPath) - } - - public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) - { - coordinator?.collectionView(collectionView, didDeselectItemAt: indexPath) - } - - /* - //REPLACED WITH CUSTOM PREFETCH SOLUTION AS PREFETCH API WAS NOT WORKING FOR COMPOSITIONAL LAYOUT - public func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) - public func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) - */ -} - -@available(iOS 13.0, *) -extension ASCollectionViewDelegate: UICollectionViewDragDelegate, UICollectionViewDropDelegate -{ - public func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] - { - guard let dragItem = coordinator?.dragItem(for: indexPath) else { return [] } - return [dragItem] - } - - public func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal - { - guard session.localDragSession != nil else - { - return UICollectionViewDropProposal(operation: .forbidden) - } - if collectionView.hasActiveDrag - { - if let destination = destinationIndexPath - { - guard coordinator?.canDrop(at: destination) ?? false else - { - return UICollectionViewDropProposal(operation: .cancel) - } - } - return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) - } - else - { - return UICollectionViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath) - } - } - - public func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) - { - var proposedDestinationIndexPath: IndexPath? = coordinator.destinationIndexPath - - if proposedDestinationIndexPath == nil, collectionView.numberOfSections != 0 - { - // Get last index path of collection view. - let section = collectionView.numberOfSections - 1 - let row = collectionView.numberOfItems(inSection: section) - proposedDestinationIndexPath = IndexPath(row: row, section: section) - } - - guard let destinationIndexPath = proposedDestinationIndexPath else { return } - - switch coordinator.proposal.operation - { - case .move: - coordinator.items.forEach - { item in - if let sourceIndex = item.sourceIndexPath - { - self.coordinator?.removeItem(from: sourceIndex) - } - } - - self.coordinator?.insertItems(coordinator.items.map { $0.dragItem }, at: destinationIndexPath) - /* self.coordinator?.afterNextUpdate = { - coordinator.items.forEach { (item) in - coordinator.drop(item.dragItem, toItemAt: destinationIndexPath) // This assumption is flawed if dropping multiple items - } - } */ - - case .copy: - self.coordinator?.insertItems(coordinator.items.map { $0.dragItem }, at: destinationIndexPath) - - default: - return - } - } - - public func scrollViewDidScroll(_ scrollView: UIScrollView) - { - self.coordinator?.scrollViewDidScroll(scrollView) - } -} - -@available(iOS 13.0, *) -extension ASCollectionViewDelegate -{ - public func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? - { - coordinator?.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPath, point: point) - } -} diff --git a/Sources/ASCollectionView/ASDragDropConfig+Public.swift b/Sources/ASCollectionView/ASDragDropConfig+Public.swift new file mode 100644 index 0000000..04874a1 --- /dev/null +++ b/Sources/ASCollectionView/ASDragDropConfig+Public.swift @@ -0,0 +1,37 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI +import UIKit + +@available(iOS 13.0, *) +public extension ASDragDropConfig +{ + init(dataBinding: Binding<[Data]>) + { + self.dataBinding = dataBinding + } + + static var disabled: ASDragDropConfig + { + ASDragDropConfig() + } + + func enableReordering(shouldMoveItem: ((_ sourceIndexPath: IndexPath, _ destinationIndexPath: IndexPath) -> Bool)? = nil) -> Self + { + var this = self + this.dragEnabled = true + this.dropEnabled = true + this.reorderingEnabled = true + this.shouldMoveItem = shouldMoveItem + return this + } + + func dragItemProvider(_ provider: @escaping ((_ item: Data) -> NSItemProvider?)) -> Self + { + var this = self + this.dragEnabled = true + this.dragItemProvider = provider + return this + } +} diff --git a/Sources/ASCollectionView/ASHostingController.swift b/Sources/ASCollectionView/ASHostingController.swift deleted file mode 100644 index 09ce99d..0000000 --- a/Sources/ASCollectionView/ASHostingController.swift +++ /dev/null @@ -1,90 +0,0 @@ -// ASCollectionView. Created by Apptek Studios 2019 - -import Foundation -import SwiftUI - -@available(iOS 13.0, *) -internal struct ASHostingControllerModifier: ViewModifier -{ - var invalidateCellLayout: (() -> Void) = {} - func body(content: Content) -> some View - { - content - .environment(\.invalidateCellLayout, invalidateCellLayout) - } -} - -@available(iOS 13.0, *) -internal protocol ASHostingControllerProtocol: AnyObject -{ - var viewController: UIViewController { get } - func applyModifier(_ modifier: ASHostingControllerModifier) - func sizeThatFits(in size: CGSize, maxSize: ASOptionalSize, selfSizeHorizontal: Bool, selfSizeVertical: Bool) -> CGSize -} - -@available(iOS 13.0, *) -internal class ASHostingController: ASHostingControllerProtocol -{ - init(_ view: ViewType, modifier: ASHostingControllerModifier = ASHostingControllerModifier()) - { - hostedView = view - self.modifier = modifier - uiHostingController = .init(rootView: view.modifier(modifier)) - } - - let uiHostingController: UIHostingController> - var viewController: UIViewController - { - uiHostingController.view.backgroundColor = .clear - uiHostingController.view.insetsLayoutMarginsFromSafeArea = false - return uiHostingController as UIViewController - } - - var hostedView: ViewType - var modifier: ASHostingControllerModifier - { - didSet - { - uiHostingController.rootView = hostedView.modifier(modifier) - } - } - - func setView(_ view: ViewType) - { - hostedView = view - uiHostingController.rootView = hostedView.modifier(modifier) - } - - func applyModifier(_ modifier: ASHostingControllerModifier) - { - self.modifier = modifier - } - - func sizeThatFits(in size: CGSize, maxSize: ASOptionalSize, selfSizeHorizontal: Bool, selfSizeVertical: Bool) -> CGSize - { - let fittingSize = CGSize( - width: selfSizeHorizontal ? .infinity : size.width, - height: selfSizeVertical ? .infinity : size.height).applyMaxSize(maxSize) - - // Find the desired size - var desiredSize = uiHostingController.sizeThatFits(in: fittingSize) - - // Accounting for 'greedy' swiftUI views that take up as much space as they can - switch (desiredSize.width, desiredSize.height) - { - case (.infinity, .infinity): - desiredSize = uiHostingController.sizeThatFits(in: size) - case (.infinity, _): - desiredSize = uiHostingController.sizeThatFits(in: CGSize(width: size.width, height: fittingSize.height)) - case (_, .infinity): - desiredSize = uiHostingController.sizeThatFits(in: CGSize(width: fittingSize.width, height: size.height)) - default: break - } - - // Ensure correct dimensions in non-self sizing axes - if !selfSizeHorizontal { desiredSize.width = size.width } - if !selfSizeVertical { desiredSize.height = size.height } - - return desiredSize.applyMaxSize(maxSize) - } -} diff --git a/Sources/ASCollectionView/ASSection+Initialisers.swift b/Sources/ASCollectionView/ASSection+Initialisers.swift new file mode 100644 index 0000000..199b878 --- /dev/null +++ b/Sources/ASCollectionView/ASSection+Initialisers.swift @@ -0,0 +1,175 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +// MARK: DYNAMIC CONTENT SECTION + +@available(iOS 13.0, *) +public extension ASSection +{ + /** + Initializes a section with data + + - Parameters: + - id: The id for this section + - data: The data to display in the section. This initialiser expects data that conforms to 'Identifiable' + - dataID: The keypath to a hashable identifier of each data item + - onCellEvent: Use this to respond to cell appearance/disappearance, and preloading events. + - onDragDropEvent: Define this closure to enable drag/drop and respond to events (default is nil: drag/drop disabled) + - contentBuilder: A closure returning a SwiftUI view for the given data item + */ + init( + id: SectionID, + data: DataCollection, + dataID dataIDKeyPath: KeyPath, + container: @escaping ((Content) -> Container), + selectedItems: Binding>? = nil, + shouldAllowSelection: ((_ index: Int) -> Bool)? = nil, + shouldAllowDeselection: ((_ index: Int) -> Bool)? = nil, + onCellEvent: OnCellEvent? = nil, + dragDropConfig: ASDragDropConfig = .disabled, + shouldAllowSwipeToDelete: ShouldAllowSwipeToDelete? = nil, + onSwipeToDelete: OnSwipeToDelete? = nil, + contextMenuProvider: ContextMenuProvider? = nil, + @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, ASCellContext) -> Content)) + where DataCollection.Index == Int + { + self.id = id + dataSource = ASSectionDataSource( + data: data, + dataIDKeyPath: dataIDKeyPath, + container: container, + content: contentBuilder, + selectedItems: selectedItems, + shouldAllowSelection: shouldAllowSelection, + shouldAllowDeselection: shouldAllowDeselection, + onCellEvent: onCellEvent, + dragDropConfig: dragDropConfig, + shouldAllowSwipeToDelete: shouldAllowSwipeToDelete, + onSwipeToDelete: onSwipeToDelete, + contextMenuProvider: contextMenuProvider) + } + + init( + id: SectionID, + data: DataCollection, + dataID dataIDKeyPath: KeyPath, + selectedItems: Binding>? = nil, + shouldAllowSelection: ((_ index: Int) -> Bool)? = nil, + shouldAllowDeselection: ((_ index: Int) -> Bool)? = nil, + onCellEvent: OnCellEvent? = nil, + dragDropConfig: ASDragDropConfig = .disabled, + shouldAllowSwipeToDelete: ShouldAllowSwipeToDelete? = nil, + onSwipeToDelete: OnSwipeToDelete? = nil, + contextMenuProvider: ContextMenuProvider? = nil, + @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, ASCellContext) -> Content)) + where DataCollection.Index == Int + { + self.init(id: id, data: data, dataID: dataIDKeyPath, container: { $0 }, selectedItems: selectedItems, shouldAllowSelection: shouldAllowSelection, shouldAllowDeselection: shouldAllowDeselection, onCellEvent: onCellEvent, dragDropConfig: dragDropConfig, shouldAllowSwipeToDelete: shouldAllowSwipeToDelete, onSwipeToDelete: onSwipeToDelete, contextMenuProvider: contextMenuProvider, contentBuilder: contentBuilder) + } +} + +// MARK: IDENTIFIABLE DATA SECTION + +@available(iOS 13.0, *) +public extension ASCollectionViewSection +{ + /** + Initializes a section with identifiable data + - Parameters: + - id: The id for this section + - data: The data to display in the section. This initialiser expects data that conforms to 'Identifiable' + - onCellEvent: Use this to respond to cell appearance/disappearance, and preloading events. + - onDragDropEvent: Define this closure to enable drag/drop and respond to events (default is nil: drag/drop disabled) + - contentBuilder: A closure returning a SwiftUI view for the given data item + */ + init( + id: SectionID, + data: DataCollection, + container: @escaping ((Content) -> Container), + selectedItems: Binding>? = nil, + shouldAllowSelection: ((_ index: Int) -> Bool)? = nil, + shouldAllowDeselection: ((_ index: Int) -> Bool)? = nil, + onCellEvent: OnCellEvent? = nil, + dragDropConfig: ASDragDropConfig = .disabled, + shouldAllowSwipeToDelete: ShouldAllowSwipeToDelete? = nil, + onSwipeToDelete: OnSwipeToDelete? = nil, + contextMenuProvider: ContextMenuProvider? = nil, + @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, ASCellContext) -> Content)) + where DataCollection.Index == Int, DataCollection.Element: Identifiable + { + self.init(id: id, data: data, dataID: \.id, container: container, selectedItems: selectedItems, shouldAllowSelection: shouldAllowSelection, shouldAllowDeselection: shouldAllowDeselection, onCellEvent: onCellEvent, dragDropConfig: dragDropConfig, shouldAllowSwipeToDelete: shouldAllowSwipeToDelete, onSwipeToDelete: onSwipeToDelete, contextMenuProvider: contextMenuProvider, contentBuilder: contentBuilder) + } + + init( + id: SectionID, + data: DataCollection, + selectedItems: Binding>? = nil, + shouldAllowSelection: ((_ index: Int) -> Bool)? = nil, + shouldAllowDeselection: ((_ index: Int) -> Bool)? = nil, + onCellEvent: OnCellEvent? = nil, + dragDropConfig: ASDragDropConfig = .disabled, + shouldAllowSwipeToDelete: ShouldAllowSwipeToDelete? = nil, + onSwipeToDelete: OnSwipeToDelete? = nil, + contextMenuProvider: ContextMenuProvider? = nil, + @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, ASCellContext) -> Content)) + where DataCollection.Index == Int, DataCollection.Element: Identifiable + { + self.init(id: id, data: data, container: { $0 }, selectedItems: selectedItems, shouldAllowSelection: shouldAllowSelection, shouldAllowDeselection: shouldAllowDeselection, onCellEvent: onCellEvent, dragDropConfig: dragDropConfig, shouldAllowSwipeToDelete: shouldAllowSwipeToDelete, onSwipeToDelete: onSwipeToDelete, contextMenuProvider: contextMenuProvider, contentBuilder: contentBuilder) + } +} + +// MARK: STATIC CONTENT SECTION + +@available(iOS 13.0, *) +public extension ASCollectionViewSection +{ + /** + Initializes a section with static content + + - Parameters: + - id: The id for this section + - content: A closure returning a number of SwiftUI views to display in the collection view + */ + init(id: SectionID, container: @escaping ((AnyView) -> Container), @ViewArrayBuilder content: () -> ViewArrayBuilder.Wrapper) + { + self.id = id + dataSource = ASSectionDataSource<[ASCollectionViewStaticContent], ASCollectionViewStaticContent.ID, AnyView, Container>( + data: content().flattened().enumerated().map + { + ASCollectionViewStaticContent(index: $0.offset, view: $0.element) + }, + dataIDKeyPath: \.id, + container: container, + content: { staticContent, _ in staticContent.view }, + dragDropConfig: .disabled) + } + + init(id: SectionID, @ViewArrayBuilder content: () -> ViewArrayBuilder.Wrapper) + { + self.init(id: id, container: { $0 }, content: content) + } + + /** + Initializes a section with a single view + + - Parameters: + - id: The id for this section + - content: A single SwiftUI views to display in the collection view + */ + init(id: SectionID, container: @escaping ((AnyView) -> Container), content: () -> Content) + { + self.id = id + dataSource = ASSectionDataSource<[ASCollectionViewStaticContent], ASCollectionViewStaticContent.ID, AnyView, Container>( + data: [ASCollectionViewStaticContent(index: 0, view: AnyView(content()))], + dataIDKeyPath: \.id, + container: container, + content: { staticContent, _ in staticContent.view }, + dragDropConfig: .disabled) + } + + init(id: SectionID, content: () -> Content) { + self.init(id: id, container: { $0 }, content: content) + } +} diff --git a/Sources/ASCollectionView/ASSection+Modifiers.swift b/Sources/ASCollectionView/ASSection+Modifiers.swift new file mode 100644 index 0000000..16df523 --- /dev/null +++ b/Sources/ASCollectionView/ASSection+Modifiers.swift @@ -0,0 +1,70 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +// MARK: SUPPLEMENTARY VIEWS - PUBLIC MODIFIERS + +@available(iOS 13.0, *) +public extension ASCollectionViewSection +{ + func sectionHeader(content: () -> Content?) -> Self + { + var section = self + section.setHeaderView(content()) + return section + } + + func sectionFooter(content: () -> Content?) -> Self + { + var section = self + section.setFooterView(content()) + return section + } + + func sectionSupplementary(ofKind kind: String, content: () -> Content?) -> Self + { + var section = self + section.setSupplementaryView(content(), ofKind: kind) + return section + } + + func tableViewSetEstimatedSizes(headerHeight: CGFloat? = nil, footerHeight: CGFloat? = nil) -> Self + { + var section = self + section.estimatedHeaderHeight = headerHeight + section.estimatedFooterHeight = footerHeight + return section + } + + func tableViewDisableDefaultTheming() -> Self + { + var section = self + section.disableDefaultTheming = true + return section + } + + func tableViewSeparatorInsets(leading: CGFloat = 0, trailing: CGFloat = 0) -> Self + { + var section = self + section.tableViewSeparatorInsets = UIEdgeInsets(top: 0, left: leading, bottom: 0, right: trailing) + return section + } + + // Use this modifier to make a section's cells be cached even when off-screen. This is useful for cells containing nested collection views + func cacheCells() -> Self + { + var section = self + section.shouldCacheCells = true + return section + } + + // MARK: Self-sizing config + + func selfSizingConfig(_ config: @escaping SelfSizingConfig) -> Self + { + var section = self + section.dataSource.setSelfSizingConfig(config: config) + return section + } +} diff --git a/Sources/ASCollectionView/ASSection.swift b/Sources/ASCollectionView/ASSection.swift deleted file mode 100644 index d4aa872..0000000 --- a/Sources/ASCollectionView/ASSection.swift +++ /dev/null @@ -1,307 +0,0 @@ -// ASCollectionView. Created by Apptek Studios 2019 - -import Foundation -import SwiftUI - -@available(iOS 13.0, *) -public struct ASCollectionViewStaticContent: Identifiable -{ - public var index: Int - var view: AnyView - - public var id: Int { index } -} - -@available(iOS 13.0, *) -public struct ASCollectionViewItemUniqueID: Hashable -{ - var sectionIDHash: Int - var itemIDHash: Int - init(sectionID: SectionID, itemID: ItemID) - { - sectionIDHash = sectionID.hashValue - itemIDHash = itemID.hashValue - } -} - -@available(iOS 13.0, *) -public typealias ASCollectionViewSection = ASSection - -@available(iOS 13.0, *) -public struct ASSection -{ - public var id: SectionID - - private var supplementaryViews: [String: AnyView] = [:] - - internal var dataSource: ASSectionDataSourceProtocol - - public var itemIDs: [ASCollectionViewItemUniqueID] - { - dataSource.getUniqueItemIDs(withSectionID: id) - } - - // Only relevant for ASTableView - var estimatedRowHeight: CGFloat? - var estimatedHeaderHeight: CGFloat? - var estimatedFooterHeight: CGFloat? - - var shouldCacheCells: Bool = false - - /** - Initializes a section with data - - - Parameters: - - id: The id for this section - - data: The data to display in the section. This initialiser expects data that conforms to 'Identifiable' - - dataID: The keypath to a hashable identifier of each data item - - onCellEvent: Use this to respond to cell appearance/disappearance, and preloading events. - - onDragDropEvent: Define this closure to enable drag/drop and respond to events (default is nil: drag/drop disabled) - - contentBuilder: A closure returning a SwiftUI view for the given data item - */ - public init( - id: SectionID, - data: DataCollection, - dataID dataIDKeyPath: KeyPath, - container: @escaping ((Content) -> Container), - selectedItems: Binding>? = nil, - shouldAllowSelection: ((_ index: Int) -> Bool)? = nil, - shouldAllowDeselection: ((_ index: Int) -> Bool)? = nil, - onCellEvent: OnCellEvent? = nil, - onDragDropEvent: OnDragDrop? = nil, - itemProvider: ItemProvider? = nil, - onSwipeToDelete: OnSwipeToDelete? = nil, - contextMenuProvider: ContextMenuProvider? = nil, - @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, CellContext) -> Content)) - where DataCollection.Index == Int - { - self.id = id - dataSource = ASSectionDataSource( - data: data, - dataIDKeyPath: dataIDKeyPath, - container: container, - content: contentBuilder, - selectedItems: selectedItems, - shouldAllowSelection: shouldAllowSelection, - shouldAllowDeselection: shouldAllowDeselection, - onCellEvent: onCellEvent, - onDragDrop: onDragDropEvent, - itemProvider: itemProvider, - onSwipeToDelete: onSwipeToDelete, - contextMenuProvider: contextMenuProvider) - } - - public init( - id: SectionID, - data: DataCollection, - dataID dataIDKeyPath: KeyPath, - selectedItems: Binding>? = nil, - shouldAllowSelection: ((_ index: Int) -> Bool)? = nil, - shouldAllowDeselection: ((_ index: Int) -> Bool)? = nil, - onCellEvent: OnCellEvent? = nil, - onDragDropEvent: OnDragDrop? = nil, - itemProvider: ItemProvider? = nil, - onSwipeToDelete: OnSwipeToDelete? = nil, - contextMenuProvider: ContextMenuProvider? = nil, - @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, CellContext) -> Content)) - where DataCollection.Index == Int - { - self.init(id: id, data: data, dataID: dataIDKeyPath, container: { $0 }, selectedItems: selectedItems, shouldAllowSelection: shouldAllowSelection, shouldAllowDeselection: shouldAllowDeselection, onCellEvent: onCellEvent, onDragDropEvent: onDragDropEvent, itemProvider: itemProvider, onSwipeToDelete: onSwipeToDelete, contextMenuProvider: contextMenuProvider, contentBuilder: contentBuilder) - } -} - -// MARK: SUPPLEMENTARY VIEWS - INTERNAL - -@available(iOS 13.0, *) -internal extension ASCollectionViewSection -{ - mutating func setHeaderView(_ view: Content?) - { - setSupplementaryView(view, ofKind: UICollectionView.elementKindSectionHeader) - } - - mutating func setFooterView(_ view: Content?) - { - setSupplementaryView(view, ofKind: UICollectionView.elementKindSectionFooter) - } - - mutating func setSupplementaryView(_ view: Content?, ofKind kind: String) - { - guard let view = view else - { - supplementaryViews.removeValue(forKey: kind) - return - } - - supplementaryViews[kind] = AnyView(view) - } - - var supplementaryKinds: Set - { - Set(supplementaryViews.keys) - } - - func supplementary(ofKind kind: String) -> AnyView? - { - supplementaryViews[kind] - } -} - -// MARK: SUPPLEMENTARY VIEWS - PUBLIC MODIFIERS - -@available(iOS 13.0, *) -public extension ASCollectionViewSection -{ - func sectionHeader(content: () -> Content?) -> Self - { - var section = self - section.setHeaderView(content()) - return section - } - - func sectionFooter(content: () -> Content?) -> Self - { - var section = self - section.setFooterView(content()) - return section - } - - func sectionSupplementary(ofKind kind: String, content: () -> Content?) -> Self - { - var section = self - section.setSupplementaryView(content(), ofKind: kind) - return section - } - - func tableViewSetEstimatedSizes(rowHeight: CGFloat? = nil, headerHeight: CGFloat? = nil, footerHeight: CGFloat? = nil) -> Self - { - var section = self - section.estimatedRowHeight = rowHeight - section.estimatedHeaderHeight = headerHeight - section.estimatedFooterHeight = footerHeight - return section - } - - // Use this modifier to make a section's cells be cached even when off-screen. This is useful for cells containing nested collection views - func cacheCells() -> Self - { - var section = self - section.shouldCacheCells = true - return section - } -} - -// MARK: STATIC CONTENT SECTION - -@available(iOS 13.0, *) -public extension ASCollectionViewSection -{ - /** - Initializes a section with static content - - - Parameters: - - id: The id for this section - - content: A closure returning a number of SwiftUI views to display in the collection view - */ - init(id: SectionID, container: @escaping ((AnyView) -> Container), @ViewArrayBuilder content: () -> ViewArrayBuilder.Wrapper) - { - self.id = id - dataSource = ASSectionDataSource<[ASCollectionViewStaticContent], ASCollectionViewStaticContent.ID, AnyView, Container>( - data: content().flattened().enumerated().map - { - ASCollectionViewStaticContent(index: $0.offset, view: $0.element) - }, - dataIDKeyPath: \.id, - container: container, - content: { staticContent, _ in staticContent.view }) - } - - init(id: SectionID, @ViewArrayBuilder content: () -> ViewArrayBuilder.Wrapper) - { - self.init(id: id, container: { $0 }, content: content) - } - - /** - Initializes a section with a single view - - - Parameters: - - id: The id for this section - - content: A single SwiftUI views to display in the collection view - */ - init(id: SectionID, container: @escaping ((AnyView) -> Container), content: () -> Content) - { - self.id = id - dataSource = ASSectionDataSource<[ASCollectionViewStaticContent], ASCollectionViewStaticContent.ID, AnyView, Container>( - data: [ASCollectionViewStaticContent(index: 0, view: AnyView(content()))], - dataIDKeyPath: \.id, - container: container, - content: { staticContent, _ in staticContent.view }) - } - - init(id: SectionID, content: () -> Content) { - self.init(id: id, container: { $0 }, content: content) - } -} - -// MARK: Self-sizing config - -@available(iOS 13.0, *) -public extension ASSection -{ - func selfSizingConfig(_ config: @escaping SelfSizingConfig) -> Self - { - var section = self - section.dataSource.setSelfSizingConfig(config: config) - return section - } -} - -// MARK: IDENTIFIABLE DATA SECTION - -@available(iOS 13.0, *) -public extension ASCollectionViewSection -{ - /** - Initializes a section with identifiable data - - Parameters: - - id: The id for this section - - data: The data to display in the section. This initialiser expects data that conforms to 'Identifiable' - - onCellEvent: Use this to respond to cell appearance/disappearance, and preloading events. - - onDragDropEvent: Define this closure to enable drag/drop and respond to events (default is nil: drag/drop disabled) - - contentBuilder: A closure returning a SwiftUI view for the given data item - */ - @inlinable init( - id: SectionID, - data: DataCollection, - container: @escaping ((Content) -> Container), - selectedItems: Binding>? = nil, - shouldAllowSelection: ((_ index: Int) -> Bool)? = nil, - shouldAllowDeselection: ((_ index: Int) -> Bool)? = nil, - onCellEvent: OnCellEvent? = nil, - onDragDropEvent: OnDragDrop? = nil, - itemProvider: ItemProvider? = nil, - onSwipeToDelete: OnSwipeToDelete? = nil, - contextMenuProvider: ContextMenuProvider? = nil, - @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, CellContext) -> Content)) - where DataCollection.Index == Int, DataCollection.Element: Identifiable - { - self.init(id: id, data: data, dataID: \.id, container: container, selectedItems: selectedItems, shouldAllowSelection: shouldAllowSelection, shouldAllowDeselection: shouldAllowDeselection, onCellEvent: onCellEvent, onDragDropEvent: onDragDropEvent, itemProvider: itemProvider, onSwipeToDelete: onSwipeToDelete, contextMenuProvider: contextMenuProvider, contentBuilder: contentBuilder) - } - - @inlinable init( - id: SectionID, - data: DataCollection, - selectedItems: Binding>? = nil, - shouldAllowSelection: ((_ index: Int) -> Bool)? = nil, - shouldAllowDeselection: ((_ index: Int) -> Bool)? = nil, - onCellEvent: OnCellEvent? = nil, - onDragDropEvent: OnDragDrop? = nil, - itemProvider: ItemProvider? = nil, - onSwipeToDelete: OnSwipeToDelete? = nil, - contextMenuProvider: ContextMenuProvider? = nil, - @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, CellContext) -> Content)) - where DataCollection.Index == Int, DataCollection.Element: Identifiable - { - self.init(id: id, data: data, container: { $0 }, selectedItems: selectedItems, shouldAllowSelection: shouldAllowSelection, shouldAllowDeselection: shouldAllowDeselection, onCellEvent: onCellEvent, onDragDropEvent: onDragDropEvent, itemProvider: itemProvider, onSwipeToDelete: onSwipeToDelete, contextMenuProvider: contextMenuProvider, contentBuilder: contentBuilder) - } -} diff --git a/Sources/ASCollectionView/ASTableView+Initialisers.swift b/Sources/ASCollectionView/ASTableView+Initialisers.swift new file mode 100644 index 0000000..f4aebd7 --- /dev/null +++ b/Sources/ASCollectionView/ASTableView+Initialisers.swift @@ -0,0 +1,83 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +@available(iOS 13.0, *) +public extension ASTableView +{ + /** + Initializes a table view with the given sections + + - Parameters: + - sections: An array of sections (ASTableViewSection) + */ + @inlinable init(style: UITableView.Style = .plain, sections: [Section]) + { + self.style = style + self.sections = sections + } + + @inlinable init(style: UITableView.Style = .plain, @SectionArrayBuilder sectionBuilder: () -> [Section]) + { + self.style = style + sections = sectionBuilder() + } +} + +@available(iOS 13.0, *) +public extension ASTableView where SectionID == Int +{ + /** + Initializes a table view with a single section. + + - Parameters: + - section: A single section (ASTableViewSection) + */ + init(style: UITableView.Style = .plain, section: Section) + { + self.style = style + sections = [section] + } + + /** + Initializes a table view with a single section. + */ + init( + style: UITableView.Style = .plain, + data: DataCollection, + dataID dataIDKeyPath: KeyPath, + @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, ASCellContext) -> Content)) + where DataCollection.Index == Int + { + self.style = style + let section = ASSection( + id: 0, + data: data, + dataID: dataIDKeyPath, + contentBuilder: contentBuilder) + sections = [section] + } + + /** + Initializes a table view with a single section of identifiable data + */ + init( + style: UITableView.Style = .plain, + data: DataCollection, + @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, ASCellContext) -> Content)) + where DataCollection.Index == Int, DataCollection.Element: Identifiable + { + self.init(style: style, data: data, dataID: \.id, contentBuilder: contentBuilder) + } + + /** + Initializes a table view with a single section of static content + */ + static func `static`(@ViewArrayBuilder staticContent: () -> ViewArrayBuilder.Wrapper) -> ASTableView + { + ASTableView( + style: .plain, + sections: [ASTableViewSection(id: 0, content: staticContent)]) + } +} diff --git a/Sources/ASCollectionView/ASTableView+Modifiers.swift b/Sources/ASCollectionView/ASTableView+Modifiers.swift new file mode 100644 index 0000000..aa35237 --- /dev/null +++ b/Sources/ASCollectionView/ASTableView+Modifiers.swift @@ -0,0 +1,108 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +// MARK: PUBLIC Modifier: OnScroll / OnReachedBottom + +@available(iOS 13.0, *) +public extension ASTableView +{ + /// Set a closure that is called whenever the tableView is scrolled + func onScroll(_ onScroll: @escaping OnScrollCallback) -> Self + { + var this = self + this.onScrollCallback = onScroll + return this + } + + /// Set a closure that is called whenever the tableView is scrolled to the bottom. + /// This is useful to enable loading more data when scrolling to bottom + func onReachedBottom(_ onReachedBottom: @escaping OnReachedBottomCallback) -> Self + { + var this = self + this.onReachedBottomCallback = onReachedBottom + return this + } + + /// Set whether to show separators between cells + func separatorsEnabled(_ isEnabled: Bool = true) -> Self + { + var this = self + this.separatorsEnabled = isEnabled + return this + } + + /// Set whether to show scroll indicator + func scrollIndicatorEnabled(_ isEnabled: Bool = true) -> Self + { + var this = self + this.scrollIndicatorEnabled = isEnabled + return this + } + + /// Set the content insets + func contentInsets(_ insets: UIEdgeInsets) -> Self + { + var this = self + this.contentInsets = insets + return this + } + + /// Set a closure that is called when the tableView is pulled to refresh + func onPullToRefresh(_ callback: ((_ endRefreshing: @escaping (() -> Void)) -> Void)?) -> Self + { + var this = self + this.onPullToRefresh = callback + return this + } + + /// Set whether the TableView should always allow bounce vertically + func alwaysBounce(_ alwaysBounce: Bool = true) -> Self + { + var this = self + this.alwaysBounce = alwaysBounce + return this + } + + /// Set whether the TableView should animate on data refresh + func animateOnDataRefresh(_ animate: Bool = true) -> Self + { + var this = self + this.animateOnDataRefresh = animate + return this + } +} + +// MARK: ASTableView specific header modifiers + +@available(iOS 13.0, *) +public extension ASTableViewSection +{ + func sectionHeaderInsetGrouped(content: () -> Content?) -> Self + { + var section = self + let insetGroupedContent = + content() + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: 12, leading: 0, bottom: 6, trailing: 0)) + + section.setHeaderView(insetGroupedContent) + return section + } +} + +@available(iOS 13.0, *) +public extension ASTableView +{ + func shrinkToContentSize(isEnabled: Bool = true) -> some View + { + SelfSizingWrapper(content: self, shrinkDirection: .vertical, isEnabled: isEnabled) + } + + func fitContentSize(isEnabled: Bool = true) -> some View + { + SelfSizingWrapper(content: self, shrinkDirection: .vertical, isEnabled: isEnabled, expandToFitMode: true) + } +} diff --git a/Sources/ASCollectionView/ASTableView.swift b/Sources/ASCollectionView/ASTableView.swift deleted file mode 100644 index 244a6c3..0000000 --- a/Sources/ASCollectionView/ASTableView.swift +++ /dev/null @@ -1,811 +0,0 @@ -// ASCollectionView. Created by Apptek Studios 2019 - -import Combine -import SwiftUI - -@available(iOS 13.0, *) -extension ASTableView where SectionID == Int -{ - /** - Initializes a table view with a single section. - - - Parameters: - - section: A single section (ASTableViewSection) - */ - public init(style: UITableView.Style = .plain, section: Section) - { - self.style = style - sections = [section] - } - - /** - Initializes a table view with a single section. - */ - public init( - style: UITableView.Style = .plain, - data: DataCollection, - dataID dataIDKeyPath: KeyPath, - @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, CellContext) -> Content)) - where DataCollection.Index == Int - { - self.style = style - let section = ASSection( - id: 0, - data: data, - dataID: dataIDKeyPath, - contentBuilder: contentBuilder) - sections = [section] - } - - /** - Initializes a table view with a single section of identifiable data - */ - public init( - style: UITableView.Style = .plain, - data: DataCollection, - @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, CellContext) -> Content)) - where DataCollection.Index == Int, DataCollection.Element: Identifiable - { - self.init(style: style, data: data, dataID: \.id, contentBuilder: contentBuilder) - } - - /** - Initializes a table view with a single section of static content - */ - public static func `static`(@ViewArrayBuilder staticContent: () -> ViewArrayBuilder.Wrapper) -> ASTableView - { - ASTableView( - style: .plain, - sections: [ASTableViewSection(id: 0, content: staticContent)]) - } -} - -@available(iOS 13.0, *) -public typealias ASTableViewSection = ASSection - -@available(iOS 13.0, *) -public struct ASTableView: UIViewControllerRepresentable, ContentSize -{ - // MARK: Type definitions - - public typealias Section = ASTableViewSection - - public typealias OnScrollCallback = ((_ contentOffset: CGPoint, _ contentSize: CGSize) -> Void) - public typealias OnReachedBottomCallback = (() -> Void) - - // MARK: Key variables - - public var sections: [Section] - public var style: UITableView.Style - - // MARK: Private vars set by public modifiers - - private var onScrollCallback: OnScrollCallback? - private var onReachedBottomCallback: OnReachedBottomCallback? - - private var scrollIndicatorEnabled: Bool = true - private var contentInsets: UIEdgeInsets = .zero - - private var separatorsEnabled: Bool = true - - private var onPullToRefresh: ((_ endRefreshing: @escaping (() -> Void)) -> Void)? - - private var alwaysBounce: Bool = false - private var animateOnDataRefresh: Bool = true - - // MARK: Environment variables - - @Environment(\.editMode) private var editMode - - // Other - var contentSizeTracker: ContentSizeTracker? - - /** - Initializes a table view with the given sections - - - Parameters: - - sections: An array of sections (ASTableViewSection) - */ - @inlinable public init(style: UITableView.Style = .plain, sections: [Section]) - { - self.style = style - self.sections = sections - } - - @inlinable public init(style: UITableView.Style = .plain, @SectionArrayBuilder sectionBuilder: () -> [Section]) - { - self.style = style - sections = sectionBuilder() - } - - public func makeUIViewController(context: Context) -> AS_TableViewController - { - context.coordinator.parent = self - - let tableViewController = AS_TableViewController(style: style) - tableViewController.coordinator = context.coordinator - - context.coordinator.tableViewController = tableViewController - context.coordinator.updateTableViewSettings(tableViewController.tableView) - - context.coordinator.setupDataSource(forTableView: tableViewController.tableView) - return tableViewController - } - - public func updateUIViewController(_ tableViewController: AS_TableViewController, context: Context) - { - context.coordinator.parent = self - context.coordinator.updateTableViewSettings(tableViewController.tableView) - context.coordinator.updateContent(tableViewController.tableView, transaction: context.transaction, refreshExistingCells: true) - context.coordinator.configureRefreshControl(for: tableViewController.tableView) - } - - public func makeCoordinator() -> Coordinator - { - Coordinator(self) - } - - public class Coordinator: NSObject, ASTableViewCoordinator, UITableViewDelegate, UITableViewDataSourcePrefetching - { - var parent: ASTableView - weak var tableViewController: AS_TableViewController? - - var dataSource: ASTableViewDiffableDataSource? - - let cellReuseID = UUID().uuidString - let supplementaryReuseID = UUID().uuidString - - // MARK: Private tracking variables - - private var hasDoneInitialSetup = false - private var lastSnapshot: NSDiffableDataSourceSnapshot? - - // MARK: Caching - - private var autoCachingHostingControllers = ASPriorityCache() - private var explicitlyCachedHostingControllers: [ASCollectionViewItemUniqueID: ASHostingControllerProtocol] = [:] - - typealias Cell = ASTableViewCell - - init(_ parent: ASTableView) - { - self.parent = parent - } - - func itemID(for indexPath: IndexPath) -> ASCollectionViewItemUniqueID? - { - guard - let sectionID = sectionID(fromSectionIndex: indexPath.section) - else { return nil } - return parent.sections[safe: indexPath.section]?.dataSource.getItemID(for: indexPath.item, withSectionID: sectionID) - } - - func sectionID(fromSectionIndex sectionIndex: Int) -> SectionID? - { - parent.sections[safe: sectionIndex]?.id - } - - func section(forItemID itemID: ASCollectionViewItemUniqueID) -> Section? - { - parent.sections - .first(where: { $0.id.hashValue == itemID.sectionIDHash }) - } - - func updateTableViewSettings(_ tableView: UITableView) - { - assignIfChanged(tableView, \.backgroundColor, newValue: (parent.style == .plain) ? .clear : .systemGroupedBackground) - assignIfChanged(tableView, \.separatorStyle, newValue: parent.separatorsEnabled ? .singleLine : .none) - assignIfChanged(tableView, \.contentInset, newValue: parent.contentInsets) - assignIfChanged(tableView, \.alwaysBounceVertical, newValue: parent.alwaysBounce) - assignIfChanged(tableView, \.showsVerticalScrollIndicator, newValue: parent.scrollIndicatorEnabled) - assignIfChanged(tableView, \.showsHorizontalScrollIndicator, newValue: parent.scrollIndicatorEnabled) - assignIfChanged(tableView, \.keyboardDismissMode, newValue: .onDrag) - - let isEditing = parent.editMode?.wrappedValue.isEditing ?? false - assignIfChanged(tableView, \.allowsSelection, newValue: isEditing) - assignIfChanged(tableView, \.allowsMultipleSelection, newValue: isEditing) - } - - func setupDataSource(forTableView tv: UITableView) - { - tv.delegate = self - tv.prefetchDataSource = self - tv.register(Cell.self, forCellReuseIdentifier: cellReuseID) - tv.register(ASTableViewSupplementaryView.self, forHeaderFooterViewReuseIdentifier: supplementaryReuseID) - - dataSource = .init(tableView: tv) - { [weak self] (tableView, indexPath, itemID) -> UITableViewCell? in - guard let self = self else { return nil } - guard - let cell = tableView.dequeueReusableCell(withIdentifier: self.cellReuseID, for: indexPath) as? Cell - else { return nil } - - guard let section = self.parent.sections[safe: indexPath.section] else { return cell } - - cell.backgroundColor = (self.parent.style == .plain) ? .clear : .secondarySystemGroupedBackground - - // Cell layout invalidation callback - cell.invalidateLayout = { [weak self] in - self?.reloadRow(indexPath) - } - - // Self Sizing Settings - let selfSizingContext = ASSelfSizingContext(cellType: .content, indexPath: indexPath) - cell.selfSizingConfig = - section.dataSource.getSelfSizingSettings(context: selfSizingContext) - ?? ASSelfSizingConfig(selfSizeHorizontally: false, selfSizeVertically: true) - - // Set itemID - cell.itemID = itemID - - // Update hostingController - let cachedHC = self.explicitlyCachedHostingControllers[itemID] ?? self.autoCachingHostingControllers[itemID] - cell.hostingController = section.dataSource.updateOrCreateHostController(forItemID: itemID, existingHC: cachedHC) - // Cache the HC - self.autoCachingHostingControllers[itemID] = cell.hostingController - if section.shouldCacheCells - { - self.explicitlyCachedHostingControllers[itemID] = cell.hostingController - } - - return cell - } - dataSource?.defaultRowAnimation = .none - } - - func populateDataSource(animated: Bool = true, isInitialLoad: Bool = false) - { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections(parent.sections.map { $0.id }) - parent.sections.forEach - { - snapshot.appendItems($0.itemIDs, toSection: $0.id) - } - lastSnapshot = snapshot - dataSource?.apply(snapshot, animatingDifferences: animated, isInitialLoad: isInitialLoad) - { - self.tableViewController.map { self.didUpdateContentSize($0.tableView.contentSize) } - } - } - - func updateContent(_ tv: UITableView, transaction: Transaction?, refreshExistingCells: Bool) - { - guard hasDoneInitialSetup else { return } - if refreshExistingCells - { - withAnimation(parent.animateOnDataRefresh ? transaction?.animation : nil) { - for case let cell as Cell in tv.visibleCells - { - guard - let itemID = cell.itemID, - let hc = cell.hostingController - else { return } - self.section(forItemID: itemID)?.dataSource.update(hc, forItemID: itemID) - } - - tv.visibleHeaderViews.forEach { sectionIndex, view in - configureHeader(view, forSection: sectionIndex) - } - - tv.visibleFooterViews.forEach { sectionIndex, view in - configureFooter(view, forSection: sectionIndex) - } - } - } - let transactionAnimationEnabled = (transaction?.animation != nil) && !(transaction?.disablesAnimations ?? false) - populateDataSource(animated: parent.animateOnDataRefresh && transactionAnimationEnabled) - updateSelectionBindings(tv) - } - - func reloadRow(_ indexPath: IndexPath) - { - guard - let itemID = itemID(for: indexPath), - var snapshot = lastSnapshot - else { return } - snapshot.reloadItems([itemID]) - dataSource?.apply(snapshot, animatingDifferences: true) - { - self.tableViewController.map { self.didUpdateContentSize($0.tableView.contentSize) } - } - } - - func onMoveToParent() - { - if !hasDoneInitialSetup - { - hasDoneInitialSetup = true - - // Populate data source - populateDataSource(animated: false, isInitialLoad: true) - - // Check if reached bottom already - tableViewController.map { checkIfReachedBottom($0.tableView) } - } - } - - func onMoveFromParent() - {} - - // MARK: Function for updating contentSize binding - - var lastContentSize: CGSize = .zero - func didUpdateContentSize(_ size: CGSize) - { - guard let tv = tableViewController?.tableView, tv.contentSize != lastContentSize else { return } - lastContentSize = tv.contentSize - parent.contentSizeTracker?.contentSize = size - } - - func configureRefreshControl(for tv: UITableView) - { - guard parent.onPullToRefresh != nil else - { - if tv.refreshControl != nil - { - tv.refreshControl = nil - } - return - } - if tv.refreshControl == nil - { - let refreshControl = UIRefreshControl() - refreshControl.addTarget(self, action: #selector(tableViewDidPullToRefresh), for: .valueChanged) - tv.refreshControl = refreshControl - } - } - - @objc - public func tableViewDidPullToRefresh() - { - guard let tableView = tableViewController?.tableView else { return } - let endRefreshing: (() -> Void) = { [weak tableView] in - tableView?.refreshControl?.endRefreshing() - } - parent.onPullToRefresh?(endRefreshing) - } - - public func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat - { - parent.sections[safe: indexPath.section]?.estimatedRowHeight ?? 50 - } - - public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) - { - tableViewController.map { (cell as? Cell)?.willAppear(in: $0) } - parent.sections[safe: indexPath.section]?.dataSource.onAppear(indexPath) - } - - public func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) - { - (cell as? Cell)?.didDisappear() - parent.sections[safe: indexPath.section]?.dataSource.onDisappear(indexPath) - } - - public func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) - { - (view as? ASTableViewSupplementaryView)?.willAppear(in: tableViewController) - } - - public func tableView(_ tableView: UITableView, didEndDisplayingHeaderView view: UIView, forSection section: Int) - { - (view as? ASTableViewSupplementaryView)?.didDisappear() - } - - public func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) - { - (view as? ASTableViewSupplementaryView)?.willAppear(in: tableViewController) - } - - public func tableView(_ tableView: UITableView, didEndDisplayingFooterView view: UIView, forSection section: Int) - { - (view as? ASTableViewSupplementaryView)?.didDisappear() - } - - public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) - { - let itemIDsToPrefetchBySection: [Int: [IndexPath]] = Dictionary(grouping: indexPaths) { $0.section } - itemIDsToPrefetchBySection.forEach - { - parent.sections[safe: $0.key]?.dataSource.prefetch($0.value) - } - } - - public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) - { - let itemIDsToCancelPrefetchBySection: [Int: [IndexPath]] = Dictionary(grouping: indexPaths) { $0.section } - itemIDsToCancelPrefetchBySection.forEach - { - parent.sections[safe: $0.key]?.dataSource.cancelPrefetch($0.value) - } - } - - // MARK: Swipe actions - - public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? - { - guard parent.sections[safe: indexPath.section]?.dataSource.supportsDelete(at: indexPath) == true else { return nil } - let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completionHandler in - self?.onDeleteAction(indexPath: indexPath, completionHandler: completionHandler) - } - return UISwipeActionsConfiguration(actions: [deleteAction]) - } - - public func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle - { - .none - } - - private func onDeleteAction(indexPath: IndexPath, completionHandler: (Bool) -> Void) - { - parent.sections[safe: indexPath.section]?.dataSource.onDelete(indexPath: indexPath, completionHandler: completionHandler) - } - - // MARK: Cell Selection - - public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) - { - updateContent(tableView, transaction: nil, refreshExistingCells: true) - } - - public func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) - { - updateContent(tableView, transaction: nil, refreshExistingCells: true) - } - - func updateSelectionBindings(_ tableView: UITableView) - { - let selected = tableView.indexPathsForSelectedRows ?? [] - let selectionBySection = Dictionary(grouping: selected) { $0.section } - .mapValues - { - Set($0.map { $0.item }) - } - parent.sections.enumerated().forEach { offset, section in - section.dataSource.updateSelection(selectionBySection[offset] ?? []) - } - } - - public func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? - { - guard parent.sections[safe: indexPath.section]?.dataSource.shouldSelect(indexPath) ?? false else - { - return nil - } - return indexPath - } - - public func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? - { - guard parent.sections[safe: indexPath.section]?.dataSource.shouldDeselect(indexPath) ?? false else - { - return nil - } - return indexPath - } - - public func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat - { - guard parent.sections[safe: section]?.supplementary(ofKind: UICollectionView.elementKindSectionHeader) != nil else - { - return CGFloat.leastNormalMagnitude - } - return parent.sections[safe: section]?.estimatedHeaderHeight ?? 50 - } - - public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat - { - guard parent.sections[safe: section]?.supplementary(ofKind: UICollectionView.elementKindSectionHeader) != nil else - { - return CGFloat.leastNormalMagnitude - } - return UITableView.automaticDimension - } - - public func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat - { - guard parent.sections[safe: section]?.supplementary(ofKind: UICollectionView.elementKindSectionFooter) != nil else - { - return CGFloat.leastNormalMagnitude - } - return parent.sections[safe: section]?.estimatedFooterHeight ?? 50 - } - - public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat - { - guard parent.sections[safe: section]?.supplementary(ofKind: UICollectionView.elementKindSectionFooter) != nil else - { - return CGFloat.leastNormalMagnitude - } - return UITableView.automaticDimension - } - - public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? - { - guard let reusableView = tableView.dequeueReusableHeaderFooterView(withIdentifier: supplementaryReuseID) else { return nil } - configureHeader(reusableView, forSection: section) - return reusableView - } - - func configureHeader(_ headerCell: UITableViewHeaderFooterView, forSection section: Int) - { - guard let reusableView = headerCell as? ASTableViewSupplementaryView - else { return } - if let supplementaryView = parent.sections[safe: section]?.supplementary(ofKind: UICollectionView.elementKindSectionHeader) - { - // Self Sizing Settings - let selfSizingContext = ASSelfSizingContext(cellType: .supplementary(UICollectionView.elementKindSectionHeader), indexPath: IndexPath(row: 0, section: section)) - reusableView.selfSizingConfig = - parent.sections[safe: section]?.dataSource.getSelfSizingSettings(context: selfSizingContext) - ?? ASSelfSizingConfig(selfSizeHorizontally: false, selfSizeVertically: true) - - // Cell Content Setup - reusableView.setupFor( - id: section, - view: supplementaryView) - } - else - { - reusableView.setupForEmpty(id: section) - } - } - - public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? - { - guard let reusableView = tableView.dequeueReusableHeaderFooterView(withIdentifier: supplementaryReuseID) else { return nil } - configureFooter(reusableView, forSection: section) - return reusableView - } - - func configureFooter(_ footerCell: UITableViewHeaderFooterView, forSection section: Int) - { - guard let reusableView = footerCell as? ASTableViewSupplementaryView - else { return } - if let supplementaryView = parent.sections[safe: section]?.supplementary(ofKind: UICollectionView.elementKindSectionFooter) - { - // Self Sizing Settings - let selfSizingContext = ASSelfSizingContext(cellType: .supplementary(UICollectionView.elementKindSectionFooter), indexPath: IndexPath(row: 0, section: section)) - reusableView.selfSizingConfig = - parent.sections[safe: section]?.dataSource.getSelfSizingSettings(context: selfSizingContext) - ?? ASSelfSizingConfig(selfSizeHorizontally: false, selfSizeVertically: true) - - // Cell Content Setup - reusableView.setupFor( - id: section, - view: supplementaryView) - } - else - { - reusableView.setupForEmpty(id: section) - } - } - - public func scrollViewDidScroll(_ scrollView: UIScrollView) - { - parent.onScrollCallback?(scrollView.contentOffset, scrollView.contentSizePlusInsets) - checkIfReachedBottom(scrollView) - } - - var hasAlreadyReachedBottom: Bool = false - func checkIfReachedBottom(_ scrollView: UIScrollView) - { - if (scrollView.contentSize.height - scrollView.contentOffset.y) <= scrollView.frame.size.height - { - if !hasAlreadyReachedBottom - { - hasAlreadyReachedBottom = true - parent.onReachedBottomCallback?() - } - } - else - { - hasAlreadyReachedBottom = false - } - } - } -} - -@available(iOS 13.0, *) -protocol ASTableViewCoordinator: AnyObject -{ - func onMoveToParent() - func onMoveFromParent() - func didUpdateContentSize(_ size: CGSize) -} - -// MARK: PUBLIC Modifier: OnScroll / OnReachedBottom - -@available(iOS 13.0, *) -public extension ASTableView -{ - /// Set a closure that is called whenever the tableView is scrolled - func onScroll(_ onScroll: @escaping OnScrollCallback) -> Self - { - var this = self - this.onScrollCallback = onScroll - return this - } - - /// Set a closure that is called whenever the tableView is scrolled to the bottom. - /// This is useful to enable loading more data when scrolling to bottom - func onReachedBottom(_ onReachedBottom: @escaping OnReachedBottomCallback) -> Self - { - var this = self - this.onReachedBottomCallback = onReachedBottom - return this - } - - /// Set whether to show separators between cells - func separatorsEnabled(_ isEnabled: Bool = true) -> Self - { - var this = self - this.separatorsEnabled = isEnabled - return this - } - - /// Set whether to show scroll indicator - func scrollIndicatorEnabled(_ isEnabled: Bool = true) -> Self - { - var this = self - this.scrollIndicatorEnabled = isEnabled - return this - } - - /// Set the content insets - func contentInsets(_ insets: UIEdgeInsets) -> Self - { - var this = self - this.contentInsets = insets - return this - } - - /// Set a closure that is called when the tableView is pulled to refresh - func onPullToRefresh(_ callback: ((_ endRefreshing: @escaping (() -> Void)) -> Void)?) -> Self - { - var this = self - this.onPullToRefresh = callback - return this - } - - /// Set whether the TableView should always allow bounce vertically - func alwaysBounce(_ alwaysBounce: Bool = true) -> Self - { - var this = self - this.alwaysBounce = alwaysBounce - return this - } - - /// Set whether the TableView should animate on data refresh - func animateOnDataRefresh(_ animate: Bool = true) -> Self - { - var this = self - this.animateOnDataRefresh = animate - return this - } -} - -// MARK: ASTableView specific header modifiers - -@available(iOS 13.0, *) -public extension ASTableViewSection -{ - func sectionHeaderInsetGrouped(content: () -> Content?) -> Self - { - var section = self - let insetGroupedContent = - HStack { - content() - Spacer() - } - .font(.headline) - .padding(EdgeInsets(top: 12, leading: 0, bottom: 6, trailing: 0)) - - section.setHeaderView(insetGroupedContent) - return section - } -} - -@available(iOS 13.0, *) -public class AS_TableViewController: UIViewController -{ - weak var coordinator: ASTableViewCoordinator? - { - didSet - { - guard viewIfLoaded != nil else { return } - tableView.coordinator = coordinator - } - } - - var style: UITableView.Style - - lazy var tableView: AS_UITableView = { - let tableView = AS_UITableView(frame: .zero, style: style) - tableView.coordinator = coordinator - tableView.tableHeaderView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: CGFloat.leastNormalMagnitude, height: CGFloat.leastNormalMagnitude))) // Remove unnecessary padding in Style.grouped/insetGrouped - tableView.tableFooterView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: CGFloat.leastNormalMagnitude, height: 10))) // Remove separators for non-existent cells - return tableView - }() - - public init(style: UITableView.Style) - { - self.style = style - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) - { - fatalError("init(coder:) has not been implemented") - } - - public override func loadView() - { - view = tableView - } - - public override func viewDidLoad() - { - super.viewDidLoad() - } - - public override func viewDidLayoutSubviews() - { - super.viewDidLayoutSubviews() - coordinator?.didUpdateContentSize(tableView.contentSize) - } - - public override func didMove(toParent parent: UIViewController?) - { - super.didMove(toParent: parent) - if parent != nil - { - coordinator?.onMoveToParent() - } - else - { - coordinator?.onMoveFromParent() - } - } -} - -@available(iOS 13.0, *) -class AS_UITableView: UITableView -{ - weak var coordinator: ASTableViewCoordinator? - - public override func didMoveToWindow() - { - super.didMoveToWindow() - - // Intended as a temporary workaround for a SwiftUI bug present in 13.3 -> the UIViewController is not moved to a parent when embedded in a list/scrollview - coordinator?.onMoveToParent() - } -} - -@available(iOS 13.0, *) -class ASTableViewDiffableDataSource: UITableViewDiffableDataSource where SectionIdentifierType: Hashable, ItemIdentifierType: Hashable -{ - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool - { - true - } - - override func apply(_ snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) - { - apply(snapshot, animatingDifferences: animatingDifferences, isInitialLoad: false, completion: completion) - } - - func apply(_ snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool = true, isInitialLoad: Bool, completion: (() -> Void)? = nil) - { - if animatingDifferences - { - super.apply(snapshot, animatingDifferences: !isInitialLoad, completion: completion) - } - else - { - UIView.performWithoutAnimation { - super.apply(snapshot, animatingDifferences: !isInitialLoad, completion: completion) // Animation must be true to get diffing. However we have disabled animation using .performWithoutAnimation - } - } - } -} diff --git a/Sources/ASCollectionView/ASTableViewCells.swift b/Sources/ASCollectionView/ASTableViewCells.swift deleted file mode 100644 index 8480bfb..0000000 --- a/Sources/ASCollectionView/ASTableViewCells.swift +++ /dev/null @@ -1,192 +0,0 @@ -// ASCollectionView. Created by Apptek Studios 2019 - -import Foundation -import SwiftUI - -@available(iOS 13.0, *) -class ASTableViewCell: UITableViewCell, ASDataSourceConfigurableCell -{ - var itemID: ASCollectionViewItemUniqueID? - var hostingController: ASHostingControllerProtocol? - { - didSet - { - guard hostingController !== oldValue, let hc = hostingController else { return } - if hc.viewController.view.superview != contentView - { - hc.viewController.view.removeFromSuperview() - contentView.subviews.forEach { $0.removeFromSuperview() } - contentView.addSubview(hc.viewController.view) - } - } - } - - var selfSizingConfig: ASSelfSizingConfig = .init(selfSizeHorizontally: false, selfSizeVertically: true) - - var invalidateLayout: (() -> Void)? - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) - { - super.init(style: style, reuseIdentifier: reuseIdentifier) - backgroundColor = nil - } - - required init?(coder: NSCoder) - { - fatalError("init(coder:) has not been implemented") - } - - func willAppear(in vc: UIViewController) - { - hostingController.map - { - $0.applyModifier( - ASHostingControllerModifier(invalidateCellLayout: { [weak self] in - self?.invalidateLayout?() - }) - ) - if $0.viewController.parent != vc - { - $0.viewController.removeFromParent() - vc.addChild($0.viewController) - } - hostingController?.viewController.didMove(toParent: vc) - } - } - - func didDisappear() - { - hostingController?.viewController.removeFromParent() - } - - override func prepareForReuse() - { - backgroundColor = .clear - hostingController = nil - isSelected = false - } - - override func layoutSubviews() - { - super.layoutSubviews() - - if hostingController?.viewController.view.frame != contentView.bounds - { - hostingController?.viewController.view.frame = contentView.bounds - hostingController?.viewController.view.layoutIfNeeded() - } - } - - var fittedSize: CGSize = .zero - - override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize - { - guard let hc = hostingController else { return .zero } - let size = hc.sizeThatFits( - in: targetSize, - maxSize: ASOptionalSize(), - selfSizeHorizontal: false, - selfSizeVertical: selfSizingConfig.selfSizeVertically) - layoutIfNeeded() // A hacky way to make cell size animations work correctly - return size - } - - override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize - { - systemLayoutSizeFitting(targetSize) - } -} - -@available(iOS 13.0, *) -class ASTableViewSupplementaryView: UITableViewHeaderFooterView -{ - var hostingController: ASHostingControllerProtocol? - private(set) var id: Int? - - var selfSizingConfig: ASSelfSizingConfig = .init(selfSizeHorizontally: false, selfSizeVertically: true) - - override init(reuseIdentifier: String?) - { - super.init(reuseIdentifier: reuseIdentifier) - backgroundView = UIView() - } - - required init?(coder: NSCoder) - { - fatalError("init(coder:) has not been implemented") - } - - func setupFor(id: Int, view: Content) - { - self.id = id - if let hc = hostingController as? ASHostingController - { - hc.setView(view) - } - else - { - hostingController = ASHostingController(view) - } - } - - func setupForEmpty(id: Int) - { - self.id = id - hostingController = nil - contentView.subviews.forEach { $0.removeFromSuperview() } - } - - func willAppear(in vc: UIViewController?) - { - hostingController.map - { - if $0.viewController.parent != vc - { - $0.viewController.removeFromParent() - vc?.addChild($0.viewController) - } - if $0.viewController.view.superview != contentView - { - $0.viewController.view.removeFromSuperview() - contentView.subviews.forEach { $0.removeFromSuperview() } - contentView.addSubview($0.viewController.view) - } - - setNeedsLayout() - - vc.map { hostingController?.viewController.didMove(toParent: $0) } - } - } - - func didDisappear() - { - hostingController?.viewController.removeFromParent() - } - - override func prepareForReuse() - { - hostingController = nil - } - - override func layoutSubviews() - { - super.layoutSubviews() - hostingController?.viewController.view.frame = contentView.bounds - } - - override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize - { - guard let hc = hostingController else { return CGSize(width: 1, height: 1) } - let size = hc.sizeThatFits( - in: targetSize, - maxSize: ASOptionalSize(), - selfSizeHorizontal: false, - selfSizeVertical: selfSizingConfig.selfSizeVertically) - return size - } - - override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize - { - systemLayoutSizeFitting(targetSize) - } -} diff --git a/Sources/ASCollectionView/Cells/ASCollectionViewCell.swift b/Sources/ASCollectionView/Cells/ASCollectionViewCell.swift new file mode 100644 index 0000000..75ccd12 --- /dev/null +++ b/Sources/ASCollectionView/Cells/ASCollectionViewCell.swift @@ -0,0 +1,117 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI +import UIKit + +@available(iOS 13.0, *) +class ASCollectionViewCell: UICollectionViewCell, ASDataSourceConfigurableCell +{ + var indexPath: IndexPath? + var itemID: ASCollectionViewItemUniqueID? + var hostingController: ASHostingControllerProtocol? + { + didSet + { + hostingController?.invalidateCellLayoutCallback = invalidateLayoutCallback + hostingController?.collectionViewScrollToCellCallback = scrollToCellCallback + } + } + + weak var collectionView: UICollectionView? + + var selfSizingConfig: ASSelfSizingConfig = .init(selfSizeHorizontally: true, selfSizeVertically: true) + + var invalidateLayoutCallback: ((_ animated: Bool) -> Void)? + var scrollToCellCallback: ((UICollectionView.ScrollPosition) -> Void)? + + func willAppear(in vc: UIViewController) + { + if hostingController?.viewController.parent != vc + { + hostingController?.viewController.removeFromParent() + hostingController.map { vc.addChild($0.viewController) } + attachView() + hostingController?.viewController.didMove(toParent: vc) + } + else + { + attachView() + } + } + + func didDisappear() + { + hostingController?.viewController.removeFromParent() + } + + private func attachView() + { + guard let hcView = hostingController?.viewController.view else + { + contentView.subviews.forEach { $0.removeFromSuperview() } + return + } + if hcView.superview != contentView + { + contentView.subviews.forEach { $0.removeFromSuperview() } + contentView.addSubview(hcView) + setNeedsLayout() + } + } + + override func prepareForReuse() + { + indexPath = nil + itemID = nil + isSelected = false + hostingController = nil + } + + override func layoutSubviews() + { + super.layoutSubviews() + + if hostingController?.viewController.view.frame != contentView.bounds + { + UIView.performWithoutAnimation { + hostingController?.viewController.view.frame = contentView.bounds + hostingController?.viewController.view.setNeedsLayout() + hostingController?.viewController.view.layoutIfNeeded() + } + } + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize + { + guard let hc = hostingController else + { + return CGSize(width: 1, height: 1) + } // Can't return .zero as UICollectionViewLayout will crash + + let size = hc.sizeThatFits( + in: targetSize, + maxSize: maxSizeForSelfSizing, + selfSizeHorizontal: selfSizingConfig.selfSizeHorizontally, + selfSizeVertical: selfSizingConfig.selfSizeVertically) + return size + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize + { + systemLayoutSizeFitting(targetSize) + } + + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes + { + layoutAttributes.size = systemLayoutSizeFitting(layoutAttributes.size) + return layoutAttributes + } + + var maxSizeForSelfSizing: ASOptionalSize + { + ASOptionalSize( + width: selfSizingConfig.canExceedCollectionWidth ? nil : collectionView.map { $0.contentSize.width - 0.001 }, + height: selfSizingConfig.canExceedCollectionHeight ? nil : collectionView.map { $0.contentSize.height - 0.001 }) + } +} diff --git a/Sources/ASCollectionView/ASCollectionViewDecoration.swift b/Sources/ASCollectionView/Cells/ASCollectionViewDecoration.swift similarity index 90% rename from Sources/ASCollectionView/ASCollectionViewDecoration.swift rename to Sources/ASCollectionView/Cells/ASCollectionViewDecoration.swift index 3563809..fa3492a 100644 --- a/Sources/ASCollectionView/ASCollectionViewDecoration.swift +++ b/Sources/ASCollectionView/Cells/ASCollectionViewDecoration.swift @@ -2,6 +2,7 @@ import Foundation import SwiftUI +import UIKit @available(iOS 13.0, *) public protocol Decoration: View @@ -16,7 +17,7 @@ class ASCollectionViewDecoration: ASCollectionViewSupplemen { super.init(frame: frame) let view = Content() - setupFor(id: 0, view: view) + hostingController = ASHostingController(view) willAppear(in: nil) } diff --git a/Sources/ASCollectionView/Cells/ASCollectionViewSupplementaryView.swift b/Sources/ASCollectionView/Cells/ASCollectionViewSupplementaryView.swift new file mode 100644 index 0000000..561819b --- /dev/null +++ b/Sources/ASCollectionView/Cells/ASCollectionViewSupplementaryView.swift @@ -0,0 +1,97 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI +import UIKit + +@available(iOS 13.0, *) +class ASCollectionViewSupplementaryView: UICollectionReusableView +{ + var hostingController: ASHostingControllerProtocol? + + var selfSizingConfig: ASSelfSizingConfig = .init(selfSizeHorizontally: true, selfSizeVertically: true) + var maxSizeForSelfSizing: ASOptionalSize = .none + + func setupForEmpty() + { + hostingController = nil + attachView() + } + + func willAppear(in vc: UIViewController?) + { + if hostingController?.viewController.parent != vc + { + hostingController?.viewController.removeFromParent() + hostingController.map { vc?.addChild($0.viewController) } + attachView() + hostingController?.viewController.didMove(toParent: vc) + } + else + { + attachView() + } + } + + func didDisappear() + { + hostingController?.viewController.removeFromParent() + } + + private func attachView() + { + guard let hcView = hostingController?.viewController.view else + { + subviews.forEach { $0.removeFromSuperview() } + return + } + if hcView.superview != self + { + subviews.forEach { $0.removeFromSuperview() } + addSubview(hcView) + setNeedsLayout() + } + } + + override func prepareForReuse() + { + hostingController = nil + } + + override func layoutSubviews() + { + super.layoutSubviews() + + if hostingController?.viewController.view.frame != bounds + { + UIView.performWithoutAnimation { + hostingController?.viewController.view.frame = bounds + hostingController?.viewController.view.setNeedsLayout() + hostingController?.viewController.view.layoutIfNeeded() + } + } + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize + { + guard let hc = hostingController else { return CGSize(width: 1, height: 1) } + let size = hc.sizeThatFits( + in: targetSize, + maxSize: maxSizeForSelfSizing, + selfSizeHorizontal: selfSizingConfig.selfSizeHorizontally, + selfSizeVertical: selfSizingConfig.selfSizeVertically) + + return size + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize + { + systemLayoutSizeFitting(targetSize) + } + + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes + { + layoutAttributes.size = systemLayoutSizeFitting(layoutAttributes.size) + return layoutAttributes + } +} diff --git a/Sources/ASCollectionView/Cells/ASSupplementaryCellID.swift b/Sources/ASCollectionView/Cells/ASSupplementaryCellID.swift new file mode 100644 index 0000000..a160659 --- /dev/null +++ b/Sources/ASCollectionView/Cells/ASSupplementaryCellID.swift @@ -0,0 +1,9 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation + +struct ASSupplementaryCellID: Hashable +{ + let sectionID: SectionID + let supplementaryKind: String +} diff --git a/Sources/ASCollectionView/Cells/ASTableViewCell.swift b/Sources/ASCollectionView/Cells/ASTableViewCell.swift new file mode 100644 index 0000000..3f84369 --- /dev/null +++ b/Sources/ASCollectionView/Cells/ASTableViewCell.swift @@ -0,0 +1,128 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI +import UIKit + +@available(iOS 13.0, *) +class ASTableViewCell: UITableViewCell, ASDataSourceConfigurableCell +{ + var indexPath: IndexPath? + var itemID: ASCollectionViewItemUniqueID? + var hostingController: ASHostingControllerProtocol? + { + didSet + { + hostingController?.invalidateCellLayoutCallback = invalidateLayoutCallback + hostingController?.tableViewScrollToCellCallback = scrollToCellCallback + } + } + + var invalidateLayoutCallback: ((_ animated: Bool) -> Void)? + var scrollToCellCallback: ((UITableView.ScrollPosition) -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) + { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + backgroundColor = nil + selectionStyle = .none + } + + required init?(coder: NSCoder) + { + fatalError("init(coder:) has not been implemented") + } + + func willAppear(in vc: UIViewController) + { + if hostingController?.viewController.parent != vc + { + hostingController?.viewController.removeFromParent() + hostingController.map { vc.addChild($0.viewController) } + attachView() + hostingController?.viewController.didMove(toParent: vc) + } + else + { + attachView() + } + } + + func didDisappear() + { + hostingController?.viewController.removeFromParent() + } + + private func attachView() + { + guard let hcView = hostingController?.viewController.view else + { + contentView.subviews.forEach { $0.removeFromSuperview() } + return + } + if hcView.superview != contentView + { + contentView.subviews.forEach { $0.removeFromSuperview() } + contentView.addSubview(hcView) + setNeedsLayout() + } + } + + override func prepareForReuse() + { + backgroundColor = nil + indexPath = nil + itemID = nil + hostingController = nil + isSelected = false + } + + func recalculateSize() + { + hostingController?.viewController.view.setNeedsLayout() + hostingController?.viewController.view.layoutIfNeeded() + } + + override func layoutSubviews() + { + super.layoutSubviews() + + if hostingController?.viewController.view.frame != contentView.bounds + { + UIView.performWithoutAnimation { + hostingController?.viewController.view.frame = contentView.bounds + hostingController?.viewController.view.setNeedsLayout() + hostingController?.viewController.view.layoutIfNeeded() + } + } + } + + var fittedSize: CGSize = .zero + { + didSet + { + if fittedSize != oldValue + { + setNeedsLayout() + layoutIfNeeded() + } + } + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize + { + guard let hc = hostingController else { return .zero } + let size = hc.sizeThatFits( + in: targetSize, + maxSize: ASOptionalSize(), + selfSizeHorizontal: false, + selfSizeVertical: true) + fittedSize = size + return size + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize + { + systemLayoutSizeFitting(targetSize) + } +} diff --git a/Sources/ASCollectionView/Cells/ASTableViewSupplementaryView.swift b/Sources/ASCollectionView/Cells/ASTableViewSupplementaryView.swift new file mode 100644 index 0000000..c73e193 --- /dev/null +++ b/Sources/ASCollectionView/Cells/ASTableViewSupplementaryView.swift @@ -0,0 +1,107 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI +import UIKit + +@available(iOS 13.0, *) +class ASTableViewSupplementaryView: UITableViewHeaderFooterView +{ + var hostingController: ASHostingControllerProtocol? + { + didSet + { + setNeedsLayout() + } + } + + var sectionIDHash: Int? + + override init(reuseIdentifier: String?) + { + super.init(reuseIdentifier: reuseIdentifier) + backgroundView = UIView() + } + + required init?(coder: NSCoder) + { + fatalError("init(coder:) has not been implemented") + } + + func setupForEmpty() + { + hostingController = nil + attachView() + } + + func willAppear(in vc: UIViewController) + { + if hostingController?.viewController.parent != vc + { + hostingController?.viewController.removeFromParent() + hostingController.map { vc.addChild($0.viewController) } + attachView() + hostingController?.viewController.didMove(toParent: vc) + } + else + { + attachView() + } + } + + func didDisappear() + { + hostingController?.viewController.removeFromParent() + } + + private func attachView() + { + guard let hcView = hostingController?.viewController.view else + { + contentView.subviews.forEach { $0.removeFromSuperview() } + return + } + if hcView.superview != contentView + { + contentView.subviews.forEach { $0.removeFromSuperview() } + contentView.addSubview(hcView) + setNeedsLayout() + } + } + + override func prepareForReuse() + { + hostingController = nil + sectionIDHash = nil + } + + override func layoutSubviews() + { + super.layoutSubviews() + + if hostingController?.viewController.view.frame != contentView.bounds + { + UIView.performWithoutAnimation { + hostingController?.viewController.view.frame = contentView.bounds + hostingController?.viewController.view.setNeedsLayout() + hostingController?.viewController.view.layoutIfNeeded() + } + } + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize + { + guard let hc = hostingController else { return CGSize(width: 1, height: 1) } + let size = hc.sizeThatFits( + in: targetSize, + maxSize: ASOptionalSize(), + selfSizeHorizontal: false, + selfSizeVertical: true) + return size + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize + { + systemLayoutSizeFitting(targetSize) + } +} diff --git a/Sources/ASCollectionView/Config/ASDragDropConfig.swift b/Sources/ASCollectionView/Config/ASDragDropConfig.swift new file mode 100644 index 0000000..cffb4fa --- /dev/null +++ b/Sources/ASCollectionView/Config/ASDragDropConfig.swift @@ -0,0 +1,25 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI +import UIKit + +@available(iOS 13.0, *) +public struct ASDragDropConfig +{ + var dataBinding: Binding<[Data]>? + + var dragEnabled: Bool = false + var dropEnabled: Bool = false + var reorderingEnabled: Bool = false + + var dragItemProvider: ((_ item: Data) -> NSItemProvider?)? + var shouldMoveItem: ((_ sourceIndexPath: IndexPath, _ destinationIndexPath: IndexPath) -> Bool)? + + /// An optional closure that you can use to decide what to do with a dropped item. + /// Return nil if you want to ignore the drop. + /// Return an item (of the same type as your section data) if you want to insert a row. + /// `sourceItem`: If the drop originated from a cell with the same data source, this will provide the original item that has been dragged + /// `dragItem`: This is the further information provided by UIKit. For example, if a drag came from another app, you could deal with that using this. + var dropItemProvider: ((_ sourceItem: Data?, _ dragItem: UIDragItem) -> Data?)? +} diff --git a/Sources/ASCollectionView/Config/ClosureTypeAliases.swift b/Sources/ASCollectionView/Config/ClosureTypeAliases.swift new file mode 100644 index 0000000..1637db1 --- /dev/null +++ b/Sources/ASCollectionView/Config/ClosureTypeAliases.swift @@ -0,0 +1,35 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import UIKit + +@available(iOS 13.0, *) +public enum CellEvent +{ + /// Respond by starting necessary prefetch operations for this data to be displayed soon (eg. download images) + case prefetchForData(data: [Data]) + + /// Called when its no longer necessary to prefetch this data + case cancelPrefetchForData(data: [Data]) + + /// Called when an item is appearing on the screen + case onAppear(item: Data) + + /// Called when an item is disappearing from the screen + case onDisappear(item: Data) +} + +@available(iOS 13.0, *) +public typealias OnCellEvent = ((_ event: CellEvent) -> Void) + +@available(iOS 13.0, *) +public typealias ShouldAllowSwipeToDelete = ((_ index: Int) -> Bool) + +@available(iOS 13.0, *) +public typealias OnSwipeToDelete = ((_ index: Int, _ item: Data, _ completionHandler: (Bool) -> Void) -> Void) + +@available(iOS 13.0, *) +public typealias ContextMenuProvider = ((_ index: Int, _ item: Data) -> UIContextMenuConfiguration?) + +@available(iOS 13.0, *) +public typealias SelfSizingConfig = ((_ context: ASSelfSizingContext) -> ASSelfSizingConfig?) diff --git a/Sources/ASCollectionView/Datasource/ASDiffableDataSource.swift b/Sources/ASCollectionView/Datasource/ASDiffableDataSource.swift new file mode 100644 index 0000000..7066680 --- /dev/null +++ b/Sources/ASCollectionView/Datasource/ASDiffableDataSource.swift @@ -0,0 +1,124 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import DifferenceKit +import Foundation +import UIKit + +@available(iOS 13.0, *) +class ASDiffableDataSource: NSObject +{ + var currentSnapshot = ASDiffableDataSourceSnapshot() + + func identifier(at indexPath: IndexPath) -> ASCollectionViewItemUniqueID + { + currentSnapshot.sections[indexPath.section].elements[indexPath.item].differenceIdentifier + } +} + +@available(iOS 13.0, *) +struct ASDiffableDataSourceSnapshot +{ + private(set) var sections: [Section] + private(set) var itemPositionMap: [ASCollectionViewItemUniqueID: ItemPosition] = [:] + + init(sections: [Section] = []) + { + self.sections = sections + sections.enumerated().forEach { sectionIndex, section in + section.elements.enumerated().forEach { itemIndex, item in itemPositionMap[item.differenceIdentifier] = ItemPosition(itemIndex: itemIndex, sectionIndex: sectionIndex) } + } + } + + mutating func appendSection(sectionID: SectionID, items: [ASCollectionViewItemUniqueID]) + { + let newSection = Section(id: sectionID, elements: items) + sections.append(newSection) + newSection.elements.enumerated().forEach { itemIndex, item in itemPositionMap[item.differenceIdentifier] = ItemPosition(itemIndex: itemIndex, sectionIndex: sections.endIndex - 1) } + } + + mutating func removeItems(fromSectionIndex sectionIndex: Int, atOffsets offsets: IndexSet) + { + guard sections.containsIndex(sectionIndex) else { return } + sections[sectionIndex].elements.remove(atOffsets: offsets) + } + + mutating func insertItems(_ items: [ASCollectionViewItemUniqueID], atSectionIndex sectionIndex: Int, atOffset offset: Int) + { + guard sections.containsIndex(sectionIndex) else { return } + sections[sectionIndex].elements.insert(contentsOf: items.map { Item(id: $0) }, at: offset) + } + + mutating func reloadItems(items: Set) + { + items.forEach { item in + guard let position = itemPositionMap[item] else { return } + sections[position.sectionIndex].elements[position.itemIndex].isReloaded = true + } + } + + struct ItemPosition + { + var itemIndex: Int + var sectionIndex: Int + } + + struct Section + { + var id: SectionID + var elements: [Item] + + var differenceIdentifier: SectionID + { + id + } + + func isContentEqual(to source: ASDiffableDataSourceSnapshot.Section) -> Bool + { + source.differenceIdentifier == differenceIdentifier + } + } + + struct Item: Differentiable + { + var differenceIdentifier: ASCollectionViewItemUniqueID + var isReloaded: Bool + + init(id: ASCollectionViewItemUniqueID, isReloaded: Bool) + { + differenceIdentifier = id + self.isReloaded = isReloaded + } + + init(id: ASCollectionViewItemUniqueID) + { + self.init(id: id, isReloaded: false) + } + + func isContentEqual(to source: Item) -> Bool + { + !isReloaded && differenceIdentifier == source.differenceIdentifier + } + } +} + +@available(iOS 13.0, *) +extension ASDiffableDataSourceSnapshot.Section +{ + init(id: SectionID, elements: [ASCollectionViewItemUniqueID]) + { + self.id = id + self.elements = elements.map { ASDiffableDataSourceSnapshot.Item(id: $0) } + } +} + +@available(iOS 13.0, *) +extension ASDiffableDataSourceSnapshot.Section: DifferentiableSection +{ + init(source: Self, elements: C) where C.Element == ASDiffableDataSourceSnapshot.Item + { + self.init(id: source.differenceIdentifier, elements: Array(elements)) + } +} + +@available(iOS 13.0, *) +extension ASCollectionViewItemUniqueID: Differentiable {} diff --git a/Sources/ASCollectionView/Datasource/ASDiffableDataSourceCollectionView.swift b/Sources/ASCollectionView/Datasource/ASDiffableDataSourceCollectionView.swift new file mode 100644 index 0000000..f5502bb --- /dev/null +++ b/Sources/ASCollectionView/Datasource/ASDiffableDataSourceCollectionView.swift @@ -0,0 +1,82 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import DifferenceKit +import UIKit + +@available(iOS 13.0, *) +class ASDiffableDataSourceCollectionView: ASDiffableDataSource, UICollectionViewDataSource +{ + /// The type of closure providing the cell. + public typealias Snapshot = ASDiffableDataSourceSnapshot + public typealias CellProvider = (UICollectionView, IndexPath, ASCollectionViewItemUniqueID) -> ASCollectionViewCell? + public typealias SupplementaryProvider = (UICollectionView, String, IndexPath) -> ASCollectionViewSupplementaryView? + + private weak var collectionView: UICollectionView? + var cellProvider: CellProvider + var supplementaryViewProvider: SupplementaryProvider? + + public init(collectionView: UICollectionView, cellProvider: @escaping CellProvider) + { + self.collectionView = collectionView + self.cellProvider = cellProvider + super.init() + + collectionView.dataSource = self + collectionView.register(ASCollectionViewSupplementaryView.self, forSupplementaryViewOfKind: supplementaryEmptyKind, withReuseIdentifier: supplementaryEmptyReuseID) + } + + private var firstLoad: Bool = true + + func applySnapshot(_ newSnapshot: Snapshot, animated: Bool = true, completion: (() -> Void)? = nil) + { + let changeset = StagedChangeset(source: currentSnapshot.sections, target: newSnapshot.sections) + + guard let collectionView = collectionView else { return } + + CATransaction.begin() + if firstLoad || !animated + { + firstLoad = false + CATransaction.setDisableActions(true) + } + CATransaction.setCompletionBlock(completion) + collectionView.reload(using: changeset, interrupt: { $0.changeCount > 100 }) { newSections in + self.currentSnapshot = .init(sections: newSections) + } + CATransaction.commit() + } + + func numberOfSections(in collectionView: UICollectionView) -> Int + { + currentSnapshot.sections.count + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int + { + currentSnapshot.sections[section].elements.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell + { + let itemIdentifier = identifier(at: indexPath) + guard let cell = cellProvider(collectionView, indexPath, itemIdentifier) else + { + fatalError("ASCollectionView dataSource returned a nil cell for row at index path: \(indexPath), collectionView: \(collectionView), itemIdentifier: \(itemIdentifier)") + } + return cell + } + + private let supplementaryEmptyKind = UUID().uuidString // Used to prevent crash if supplementaries defined in layout but not provided by the section + private let supplementaryEmptyReuseID = UUID().uuidString + + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView + { + guard let cell = supplementaryViewProvider?(collectionView, kind, indexPath) else + { + let empty = collectionView.dequeueReusableSupplementaryView(ofKind: supplementaryEmptyKind, withReuseIdentifier: supplementaryEmptyReuseID, for: indexPath) + (empty as? ASCollectionViewSupplementaryView)?.setupForEmpty() + return empty + } + return cell + } +} diff --git a/Sources/ASCollectionView/Datasource/ASDiffableDataSourceTableView.swift b/Sources/ASCollectionView/Datasource/ASDiffableDataSourceTableView.swift new file mode 100644 index 0000000..1bd6ce9 --- /dev/null +++ b/Sources/ASCollectionView/Datasource/ASDiffableDataSourceTableView.swift @@ -0,0 +1,92 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import DifferenceKit +import UIKit + +@available(iOS 13.0, *) +class ASDiffableDataSourceTableView: ASDiffableDataSource, UITableViewDataSource +{ + /// The type of closure providing the cell. + public typealias Snapshot = ASDiffableDataSourceSnapshot + public typealias CellProvider = (UITableView, IndexPath, ASCollectionViewItemUniqueID) -> ASTableViewCell? + + private weak var tableView: UITableView? + private let cellProvider: CellProvider + + public init(tableView: UITableView, cellProvider: @escaping CellProvider) + { + self.tableView = tableView + self.cellProvider = cellProvider + super.init() + + tableView.dataSource = self + } + + /// The default animation to updating the views. + public var defaultRowAnimation: UITableView.RowAnimation = .automatic + + private var firstLoad: Bool = true + private var canRefreshSizes: Bool = false + + func applySnapshot(_ newSnapshot: Snapshot, animated: Bool = true, completion: (() -> Void)? = nil) + { + guard let tableView = tableView else { return } + + firstLoad = false + + let changeset = StagedChangeset(source: currentSnapshot.sections, target: newSnapshot.sections) + let shouldDisableAnimation = firstLoad || !animated + + CATransaction.begin() + if shouldDisableAnimation + { + CATransaction.setDisableActions(true) + } + CATransaction.setCompletionBlock({ [weak self] in + self?.canRefreshSizes = true + completion?() + }) + tableView.reload(using: changeset, with: .none) { newSections in + self.currentSnapshot = .init(sections: newSections) + } + CATransaction.commit() + } + + func updateCellSizes(animated: Bool = true) + { + guard let tableView = tableView, canRefreshSizes, !tableView.visibleCells.isEmpty else { return } + CATransaction.begin() + if !animated + { + CATransaction.setDisableActions(true) + } + tableView.performBatchUpdates(nil, completion: nil) + + CATransaction.commit() + } + + func numberOfSections(in tableView: UITableView) -> Int + { + currentSnapshot.sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int + { + currentSnapshot.sections[section].elements.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell + { + let itemIdentifier = identifier(at: indexPath) + guard let cell = cellProvider(tableView, indexPath, itemIdentifier) else + { + fatalError("ASTableView dataSource returned a nil cell for row at index path: \(indexPath), tableView: \(tableView), itemIdentifier: \(itemIdentifier)") + } + return cell + } + + func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool + { + true + } +} diff --git a/Sources/ASCollectionView/Delegate/ASCollectionViewDelegate.swift b/Sources/ASCollectionView/Delegate/ASCollectionViewDelegate.swift new file mode 100644 index 0000000..64576bf --- /dev/null +++ b/Sources/ASCollectionView/Delegate/ASCollectionViewDelegate.swift @@ -0,0 +1,105 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +/// ASCollectionViewDelegate: Subclass this to create a custom delegate (eg. for supporting UICollectionViewLayouts that default to using the collectionView delegate) +@available(iOS 13.0, *) +open class ASCollectionViewDelegate: NSObject, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout +{ + weak var coordinator: ASCollectionViewCoordinator? + + public func getDataForItem(at indexPath: IndexPath) -> Any? + { + coordinator?.typeErasedDataForItem(at: indexPath) + } + + public func getDataForItem(at indexPath: IndexPath) -> T? + { + coordinator?.typeErasedDataForItem(at: indexPath) as? T + } + + open func collectionViewSelfSizingSettings(forContext: ASSelfSizingContext) -> ASSelfSizingConfig? + { + nil + } + + open var collectionViewContentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior + { + .scrollableAxes + } + + open func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) + { + coordinator?.collectionView(collectionView, willDisplay: cell, forItemAt: indexPath) + } + + open func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) + { + coordinator?.collectionView(collectionView, didEndDisplaying: cell, forItemAt: indexPath) + } + + open func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) + { + coordinator?.collectionView(collectionView, willDisplaySupplementaryView: view, forElementKind: elementKind, at: indexPath) + } + + open func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) + { + coordinator?.collectionView(collectionView, didEndDisplayingSupplementaryView: view, forElementOfKind: elementKind, at: indexPath) + } + + open func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) + { + coordinator?.collectionView(collectionView, didSelectItemAt: indexPath) + } + + open func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) + { + coordinator?.collectionView(collectionView, didDeselectItemAt: indexPath) + } + + /* + //REPLACED WITH CUSTOM PREFETCH SOLUTION AS PREFETCH API WAS NOT WORKING FOR COMPOSITIONAL LAYOUT + public func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) + public func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) + */ +} + +@available(iOS 13.0, *) +extension ASCollectionViewDelegate: UICollectionViewDragDelegate, UICollectionViewDropDelegate +{ + open func collectionView(_ collectionView: UICollectionView, dragSessionAllowsMoveOperation session: UIDragSession) -> Bool + { + true + } + + open func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] + { + coordinator?.collectionView(collectionView, itemsForBeginning: session, at: indexPath) ?? [] + } + + open func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal + { + coordinator?.collectionView(collectionView, dropSessionDidUpdate: session, withDestinationIndexPath: destinationIndexPath) ?? UICollectionViewDropProposal(operation: .cancel) + } + + open func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) + { + self.coordinator?.collectionView(collectionView, performDropWith: coordinator) + } + + open func scrollViewDidScroll(_ scrollView: UIScrollView) + { + coordinator?.scrollViewDidScroll(scrollView) + } +} + +@available(iOS 13.0, *) +extension ASCollectionViewDelegate +{ + open func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? + { + coordinator?.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPath, point: point) + } +} diff --git a/Sources/ASCollectionView/Environment/EnvironmentKeys.swift b/Sources/ASCollectionView/Environment/EnvironmentKeys.swift new file mode 100644 index 0000000..b863c57 --- /dev/null +++ b/Sources/ASCollectionView/Environment/EnvironmentKeys.swift @@ -0,0 +1,46 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +// MARK: Internal Key Definitions + +@available(iOS 13.0, *) +struct EnvironmentKeyInvalidateCellLayout: EnvironmentKey +{ + static let defaultValue: ((_ animated: Bool) -> Void)? = nil +} + +struct EnvironmentKeyTableViewScrollToCell: EnvironmentKey +{ + static let defaultValue: ((UITableView.ScrollPosition) -> Void)? = nil +} + +struct EnvironmentKeyCollectionViewScrollToCell: EnvironmentKey +{ + static let defaultValue: ((UICollectionView.ScrollPosition) -> Void)? = nil +} + +// MARK: Internal Helpers + +@available(iOS 13.0, *) +public extension EnvironmentValues +{ + var invalidateCellLayout: ((_ animated: Bool) -> Void)? + { + get { self[EnvironmentKeyInvalidateCellLayout.self] } + set { self[EnvironmentKeyInvalidateCellLayout.self] = newValue } + } + + var tableViewScrollToCell: ((UITableView.ScrollPosition) -> Void)? + { + get { self[EnvironmentKeyTableViewScrollToCell.self] } + set { self[EnvironmentKeyTableViewScrollToCell.self] = newValue } + } + + var collectionViewScrollToCell: ((UICollectionView.ScrollPosition) -> Void)? + { + get { self[EnvironmentKeyCollectionViewScrollToCell.self] } + set { self[EnvironmentKeyCollectionViewScrollToCell.self] = newValue } + } +} diff --git a/Sources/ASCollectionView/EnvironmentKeys.swift b/Sources/ASCollectionView/EnvironmentKeys.swift deleted file mode 100644 index 9b23f6d..0000000 --- a/Sources/ASCollectionView/EnvironmentKeys.swift +++ /dev/null @@ -1,24 +0,0 @@ -// ASCollectionView. Created by Apptek Studios 2019 - -import Foundation -import SwiftUI - -// MARK: Internal Key Definitions - -@available(iOS 13.0, *) -struct EnvironmentKeyInvalidateCellLayout: EnvironmentKey -{ - static let defaultValue: (() -> Void) = {} -} - -// MARK: Internal Helpers - -@available(iOS 13.0, *) -public extension EnvironmentValues -{ - var invalidateCellLayout: () -> Void - { - get { self[EnvironmentKeyInvalidateCellLayout.self] } - set { self[EnvironmentKeyInvalidateCellLayout.self] = newValue } - } -} diff --git a/Sources/ASCollectionView/FunctionBuilders/SectionArrayBuilder.swift b/Sources/ASCollectionView/FunctionBuilders/SectionArrayBuilder.swift index cd504cb..604cd3e 100644 --- a/Sources/ASCollectionView/FunctionBuilders/SectionArrayBuilder.swift +++ b/Sources/ASCollectionView/FunctionBuilders/SectionArrayBuilder.swift @@ -37,6 +37,12 @@ extension Array: Nestable } } +@available(iOS 13.0, *) +public func buildSectionArray(@SectionArrayBuilder _ sections: () -> [ASSection]) -> [ASSection] +{ + sections() +} + @available(iOS 13.0, *) @_functionBuilder public struct SectionArrayBuilder where SectionID: Hashable diff --git a/Sources/ASCollectionView/FunctionBuilders/ViewArrayBuilder.swift b/Sources/ASCollectionView/FunctionBuilders/ViewArrayBuilder.swift index a03f3c5..ca05a0e 100644 --- a/Sources/ASCollectionView/FunctionBuilders/ViewArrayBuilder.swift +++ b/Sources/ASCollectionView/FunctionBuilders/ViewArrayBuilder.swift @@ -83,6 +83,16 @@ public struct ViewArrayBuilder .group([Wrapper(item0), Wrapper(item1)]) } + public static func buildBlock(_ header: C0, _ array: [CX]) -> Output + { + .group([Wrapper(header), .group(array.map { Wrapper($0) })]) + } + + public static func buildBlock(_ header: C0, _ array: [CX], _ footer: C1) -> Output + { + .group([Wrapper(header), .group(array.map { Wrapper($0) }), Wrapper(footer)]) + } + public static func buildBlock(_ item0: C0, _ item1: C1, _ item2: C2) -> Output { .group([Wrapper(item0), Wrapper(item1), Wrapper(item2)]) diff --git a/Sources/ASCollectionView/ASCollectionView.swift b/Sources/ASCollectionView/Implementation/ASCollectionView.swift similarity index 57% rename from Sources/ASCollectionView/ASCollectionView.swift rename to Sources/ASCollectionView/Implementation/ASCollectionView.swift index 34ef460..4f6fd3e 100644 --- a/Sources/ASCollectionView/ASCollectionView.swift +++ b/Sources/ASCollectionView/Implementation/ASCollectionView.swift @@ -3,59 +3,6 @@ import Combine import SwiftUI -// MARK: Init for single-section CV - -@available(iOS 13.0, *) -extension ASCollectionView where SectionID == Int -{ - /** - Initializes a collection view with a single section. - - - Parameters: - - section: A single section (ASCollectionViewSection) - */ - public init(section: Section) - { - sections = [section] - } - - /** - Initializes a collection view with a single section of static content - */ - public init(@ViewArrayBuilder staticContent: () -> ViewArrayBuilder.Wrapper) - { - self.init(sections: [ASCollectionViewSection(id: 0, content: staticContent)]) - } - - /** - Initializes a collection view with a single section. - */ - public init( - data: DataCollection, - dataID dataIDKeyPath: KeyPath, - @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, CellContext) -> Content)) - where DataCollection.Index == Int - { - let section = ASCollectionViewSection( - id: 0, - data: data, - dataID: dataIDKeyPath, - contentBuilder: contentBuilder) - sections = [section] - } - - /** - Initializes a collection view with a single section with identifiable data - */ - public init( - data: DataCollection, - @ViewBuilder contentBuilder: @escaping ((DataCollection.Element, CellContext) -> Content)) - where DataCollection.Index == Int, DataCollection.Element: Identifiable - { - self.init(data: data, dataID: \.id, contentBuilder: contentBuilder) - } -} - @available(iOS 13.0, *) public struct ASCollectionView: UIViewControllerRepresentable, ContentSize { @@ -74,56 +21,40 @@ public struct ASCollectionView: UIViewControllerRepresentab // MARK: Internal variables modified by modifier functions - private var delegateInitialiser: (() -> ASCollectionViewDelegate) = ASCollectionViewDelegate.init + internal var delegateInitialiser: (() -> ASCollectionViewDelegate) = ASCollectionViewDelegate.init internal var contentSizeTracker: ContentSizeTracker? - private var onScrollCallback: OnScrollCallback? - private var onReachedBoundaryCallback: OnReachedBoundaryCallback? + internal var onScrollCallback: OnScrollCallback? + internal var onReachedBoundaryCallback: OnReachedBoundaryCallback? - private var horizontalScrollIndicatorEnabled: Bool = true - private var verticalScrollIndicatorEnabled: Bool = true - private var contentInsets: UIEdgeInsets = .zero + internal var horizontalScrollIndicatorEnabled: Bool = true + internal var verticalScrollIndicatorEnabled: Bool = true + internal var contentInsets: UIEdgeInsets = .zero - private var onPullToRefresh: ((_ endRefreshing: @escaping (() -> Void)) -> Void)? + internal var onPullToRefresh: ((_ endRefreshing: @escaping (() -> Void)) -> Void)? - private var alwaysBounceVertical: Bool = false - private var alwaysBounceHorizontal: Bool = false + internal var alwaysBounceVertical: Bool = false + internal var alwaysBounceHorizontal: Bool = false - private var initialScrollPosition: ASCollectionViewScrollPosition? + internal var initialScrollPosition: ASCollectionViewScrollPosition? - private var animateOnDataRefresh: Bool = true + internal var animateOnDataRefresh: Bool = true - private var maintainScrollPositionOnOrientationChange: Bool = true + internal var maintainScrollPositionOnOrientationChange: Bool = true - private var shouldInvalidateLayoutOnStateChange: Bool = false - private var shouldAnimateInvalidatedLayoutOnStateChange: Bool = false + internal var shouldInvalidateLayoutOnStateChange: Bool = false + internal var shouldAnimateInvalidatedLayoutOnStateChange: Bool = false - private var shouldRecreateLayoutOnStateChange: Bool = false - private var shouldAnimateRecreatedLayoutOnStateChange: Bool = false + internal var shouldRecreateLayoutOnStateChange: Bool = false + internal var shouldAnimateRecreatedLayoutOnStateChange: Bool = false // MARK: Environment variables // SwiftUI environment @Environment(\.editMode) private var editMode - // MARK: Init for multi-section CVs - - /** - Initializes a collection view with the given sections - - - Parameters: - - sections: An array of sections (ASCollectionViewSection) - */ - @inlinable public init(sections: [Section]) - { - self.sections = sections - } - - @inlinable public init(@SectionArrayBuilder sectionBuilder: () -> [Section]) - { - sections = sectionBuilder() - } + @Environment(\.invalidateCellLayout) var invalidateParentCellLayout // Call this if using content size binding (nested inside another ASCollectionView) public func makeUIViewController(context: Context) -> AS_CollectionViewController { @@ -150,9 +81,12 @@ public struct ASCollectionView: UIViewControllerRepresentab { context.coordinator.parent = self context.coordinator.updateCollectionViewSettings(collectionViewController.collectionView) - context.coordinator.updateLayout() context.coordinator.updateContent(collectionViewController.collectionView, transaction: context.transaction, refreshExistingCells: true) + context.coordinator.updateLayout() context.coordinator.configureRefreshControl(for: collectionViewController.collectionView) +#if DEBUG + debugOnly_checkHasUniqueSections() +#endif } public func makeCoordinator() -> Coordinator @@ -160,6 +94,25 @@ public struct ASCollectionView: UIViewControllerRepresentab Coordinator(self) } +#if DEBUG + func debugOnly_checkHasUniqueSections() + { + var sectionIDs: Set = [] + var conflicts: Set = [] + sections.forEach { + let (inserted, _) = sectionIDs.insert($0.id) + if !inserted + { + conflicts.insert($0.id) + } + } + if !conflicts.isEmpty + { + print("ASCOLLECTIONVIEW: The following section IDs are used more than once, please use unique section IDs to avoid unexpected behaviour:", conflicts) + } + } +#endif + // MARK: Coordinator Class public class Coordinator: ASCollectionViewCoordinator @@ -169,23 +122,24 @@ public struct ASCollectionView: UIViewControllerRepresentab weak var collectionViewController: AS_CollectionViewController? - var dataSource: ASCollectionViewDiffableDataSource? + var dataSource: ASDiffableDataSourceCollectionView? let cellReuseID = UUID().uuidString let supplementaryReuseID = UUID().uuidString - let supplementaryEmptyKind = UUID().uuidString // Used to prevent crash if supplementaries defined in layout but not provided by the section // MARK: Private tracking variables - private var hasDoneInitialSetup = false - private var hasFiredBoundaryNotificationForBoundary: Set = [] + private var hasMovedToParent = true + private var hasSetInitialScrollPosition = false + private var hasFiredBoundaryNotificationForBoundary: Set = [] private var haveRegisteredForSupplementaryOfKind: Set = [] // MARK: Caching private var autoCachingHostingControllers = ASPriorityCache() private var explicitlyCachedHostingControllers: [ASCollectionViewItemUniqueID: ASHostingControllerProtocol] = [:] + private var autoCachingSupplementaryHostControllers = ASPriorityCache, ASHostingControllerProtocol>() typealias Cell = ASCollectionViewCell @@ -207,8 +161,7 @@ public struct ASCollectionView: UIViewControllerRepresentab func supplementaryKinds() -> Set { - let emptyKindSet: Set = [supplementaryEmptyKind] // Used to prevent crash if supplementaries defined in layout but not provided by the section - return parent.sections.reduce(into: emptyKindSet) { result, section in + parent.sections.reduce(into: Set()) { result, section in result.formUnion(section.supplementaryKinds) } } @@ -244,10 +197,9 @@ public struct ASCollectionView: UIViewControllerRepresentab cv.dropDelegate = delegate cv.register(Cell.self, forCellWithReuseIdentifier: cellReuseID) - registerSupplementaries(forCollectionView: cv) dataSource = .init(collectionView: cv) - { [weak self] (collectionView, indexPath, itemID) -> UICollectionViewCell? in + { [weak self] collectionView, indexPath, itemID in guard let self = self else { return nil } guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: self.cellReuseID, for: indexPath) as? Cell @@ -257,8 +209,11 @@ public struct ASCollectionView: UIViewControllerRepresentab cell.collectionView = collectionView - cell.invalidateLayout = { [weak collectionView] in - collectionView?.collectionViewLayout.invalidateLayout() + cell.invalidateLayoutCallback = { [weak self] animated in + self?.invalidateLayout(animated: animated) + } + cell.scrollToCellCallback = { [weak self] position in + self?.scrollToItem(indexPath: indexPath, position: position) } // Self Sizing Settings @@ -271,6 +226,7 @@ public struct ASCollectionView: UIViewControllerRepresentab // Set itemID cell.itemID = itemID + cell.indexPath = indexPath // Update hostingController let cachedHC = self.explicitlyCachedHostingControllers[itemID] ?? self.autoCachingHostingControllers[itemID] @@ -285,104 +241,112 @@ public struct ASCollectionView: UIViewControllerRepresentab return cell } - dataSource?.supplementaryViewProvider = { [weak self] (cv, kind, indexPath) -> UICollectionReusableView? in + dataSource?.supplementaryViewProvider = { [weak self] cv, kind, indexPath in guard let self = self else { return nil } guard self.supplementaryKinds().contains(kind) else { - let emptyView = cv.dequeueReusableSupplementaryView(ofKind: self.supplementaryEmptyKind, withReuseIdentifier: self.supplementaryReuseID, for: indexPath) as? ASCollectionViewSupplementaryView - emptyView?.setupAsEmptyView() - return emptyView + return nil } guard let reusableView = cv.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: self.supplementaryReuseID, for: indexPath) as? ASCollectionViewSupplementaryView else { return nil } + let ifEmpty = { + reusableView.setupForEmpty() + } + + guard let section = self.parent.sections[safe: indexPath.section] else { ifEmpty(); return reusableView } + let supplementaryID = ASSupplementaryCellID(sectionID: section.id, supplementaryKind: kind) + // Self Sizing Settings let selfSizingContext = ASSelfSizingContext(cellType: .supplementary(kind), indexPath: indexPath) reusableView.selfSizingConfig = - self.parent.sections[safe: indexPath.section]?.dataSource.getSelfSizingSettings(context: selfSizingContext) - ?? self.delegate?.collectionViewSelfSizingSettings(forContext: selfSizingContext) + section.dataSource.getSelfSizingSettings(context: selfSizingContext) ?? ASSelfSizingConfig(selfSizeHorizontally: true, selfSizeVertically: true) - if let supplementaryView = self.parent.sections[safe: indexPath.section]?.supplementary(ofKind: kind) - { - reusableView.setupFor( - id: indexPath.section, - view: supplementaryView) - } - else - { - reusableView.setupAsEmptyView() - } + // Update hostingController + let cachedHC = self.autoCachingSupplementaryHostControllers[supplementaryID] + reusableView.hostingController = section.dataSource.updateOrCreateHostController(forSupplementaryKind: kind, existingHC: cachedHC) + // Cache the HC + self.autoCachingSupplementaryHostControllers[supplementaryID] = reusableView.hostingController return reusableView } setupPrefetching() } - func populateDataSource(animated: Bool = true, isInitialLoad: Bool = false) + func populateDataSource(animated: Bool = true) { - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections(parent.sections.map { $0.id }) - parent.sections.forEach - { - snapshot.appendItems($0.itemIDs, toSection: $0.id) - } - dataSource?.apply(snapshot, animatingDifferences: animated, isInitialLoad: isInitialLoad) - { - self.collectionViewController.map { self.didUpdateContentSize($0.collectionView.contentSize) } - } + guard hasMovedToParent else { return } + collectionViewController.map { registerSupplementaries(forCollectionView: $0.collectionView) } // New sections might involve new types of supplementary... + let snapshot = ASDiffableDataSourceSnapshot(sections: + parent.sections.map { + ASDiffableDataSourceSnapshot.Section(id: $0.id, elements: $0.itemIDs) + } + ) + dataSource?.applySnapshot(snapshot, animated: animated) + collectionViewController.map { self.didUpdateContentSize($0.collectionView.contentSize) } } func updateContent(_ cv: UICollectionView, transaction: Transaction?, refreshExistingCells: Bool) { - guard hasDoneInitialSetup else { return } - registerSupplementaries(forCollectionView: cv) // New sections might involve new types of supplementary... + guard hasMovedToParent else { return } + + let transactionAnimationEnabled = (transaction?.animation != nil) && !(transaction?.disablesAnimations ?? false) + populateDataSource(animated: parent.animateOnDataRefresh && transactionAnimationEnabled) + if refreshExistingCells { withAnimation(parent.animateOnDataRefresh ? transaction?.animation : nil) { - for case let cell as Cell in cv.visibleCells - { - guard - let itemID = cell.itemID, - let hc = cell.hostingController - else { return } - self.section(forItemID: itemID)?.dataSource.update(hc, forItemID: itemID) - } + refreshVisibleCells() } + } + updateSelectionBindings(cv) + } - supplementaryKinds().forEach - { kind in - cv.indexPathsForVisibleSupplementaryElements(ofKind: kind).forEach - { - guard let supplementaryView = parent.sections[safe: $0.section]?.supplementary(ofKind: kind) else { return } - (cv.supplementaryView(forElementKind: kind, at: $0) as? ASCollectionViewSupplementaryView)? - .updateView(supplementaryView) - } + func refreshVisibleCells() + { + guard let cv = collectionViewController?.collectionView else { return } + for case let cell as Cell in cv.visibleCells + { + guard + let itemID = cell.itemID, + let hc = cell.hostingController + else { return } + self.section(forItemID: itemID)?.dataSource.update(hc, forItemID: itemID) + } + + supplementaryKinds().forEach + { kind in + cv.indexPathsForVisibleSupplementaryElements(ofKind: kind).forEach + { + guard let view = (cv.supplementaryView(forElementKind: kind, at: $0) as? ASCollectionViewSupplementaryView) else { return } + view.hostingController = parent.sections[safe: $0.section]?.dataSource.updateOrCreateHostController(forSupplementaryKind: kind, existingHC: view.hostingController) } } - let transactionAnimationEnabled = (transaction?.animation != nil) && !(transaction?.disablesAnimations ?? false) - populateDataSource(animated: parent.animateOnDataRefresh && transactionAnimationEnabled) - updateSelectionBindings(cv) } func onMoveToParent() { - if !hasDoneInitialSetup - { - hasDoneInitialSetup = true + guard !hasMovedToParent else { return } + + hasMovedToParent = true + populateDataSource(animated: false) + } - // Populate data source - populateDataSource(animated: false, isInitialLoad: true) + func onMoveFromParent() {} - // Set initial scroll position - parent.initialScrollPosition.map { scrollToPosition($0, animated: false) } + func invalidateLayout(animated: Bool) + { + CATransaction.begin() + if !animated + { + CATransaction.setDisableActions(true) } + collectionViewController?.collectionViewLayout.invalidateLayout() + CATransaction.commit() } - func onMoveFromParent() - {} - func configureRefreshControl(for cv: UICollectionView) { guard parent.onPullToRefresh != nil else @@ -413,6 +377,13 @@ public struct ASCollectionView: UIViewControllerRepresentab // MARK: Functions for determining scroll position (on appear, and also on orientation change) + func scrollToItem(indexPath: IndexPath, position: UICollectionView.ScrollPosition = []) + { + CATransaction.begin() + collectionViewController?.collectionView.scrollToItem(at: indexPath, at: position, animated: true) + CATransaction.commit() + } + func scrollToPosition(_ scrollPosition: ASCollectionViewScrollPosition, animated: Bool = false) { switch scrollPosition @@ -491,7 +462,9 @@ public struct ASCollectionView: UIViewControllerRepresentab func updateLayout() { - guard let collectionViewController = collectionViewController else { return } + guard + hasMovedToParent, + let collectionViewController = collectionViewController else { return } // Configure any custom layout parent.layout.configureLayout(layoutObject: collectionViewController.collectionView.collectionViewLayout) @@ -499,16 +472,15 @@ public struct ASCollectionView: UIViewControllerRepresentab if parent.shouldRecreateLayoutOnStateChange { let newLayout = parent.layout.makeLayout(withCoordinator: self) - collectionViewController.collectionView.setCollectionViewLayout(newLayout, animated: parent.shouldAnimateRecreatedLayoutOnStateChange && hasDoneInitialSetup) + collectionViewController.collectionView.setCollectionViewLayout(newLayout, animated: parent.shouldAnimateRecreatedLayoutOnStateChange && hasMovedToParent) } // If enabled, invalidate the layout else if parent.shouldInvalidateLayoutOnStateChange { let changes = { collectionViewController.collectionViewLayout.invalidateLayout() - collectionViewController.collectionView.layoutIfNeeded() } - if parent.shouldAnimateInvalidatedLayoutOnStateChange, hasDoneInitialSetup + if parent.shouldAnimateInvalidatedLayoutOnStateChange, hasMovedToParent { UIView.animate( withDuration: 0.4, @@ -597,28 +569,124 @@ public struct ASCollectionView: UIViewControllerRepresentab } } - func dragItem(for indexPath: IndexPath) -> UIDragItem? - { - guard !indexPath.isEmpty else { return nil } - return parent.sections[safe: indexPath.section]?.dataSource.getDragItem(for: indexPath) - } - func canDrop(at indexPath: IndexPath) -> Bool { guard !indexPath.isEmpty else { return false } return parent.sections[safe: indexPath.section]?.dataSource.dropEnabled ?? false } - func removeItem(from indexPath: IndexPath) + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - guard !indexPath.isEmpty else { return } - parent.sections[safe: indexPath.section]?.dataSource.removeItem(from: indexPath) + guard !indexPath.isEmpty else { return [] } + guard let dragItem = parent.sections[safe: indexPath.section]?.dataSource.getDragItem(for: indexPath) else { return [] } + return [dragItem] } - func insertItems(_ items: [UIDragItem], at indexPath: IndexPath) + func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { - guard !indexPath.isEmpty else { return } - parent.sections[safe: indexPath.section]?.dataSource.insertDragItems(items, at: indexPath) + if collectionView.hasActiveDrag + { + if let destination = destinationIndexPath + { + guard canDrop(at: destination) else + { + return UICollectionViewDropProposal(operation: .cancel) + } + } + return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) + } + else + { + return UICollectionViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath) + } + } + + func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) + { + guard + let destinationIndexPath = coordinator.destinationIndexPath, + !destinationIndexPath.isEmpty, + let destinationSection = parent.sections[safe: destinationIndexPath.section] + else { return } + + guard canDrop(at: destinationIndexPath) else { return } + + guard let oldSnapshot = dataSource?.currentSnapshot else { return } + var dragSnapshot = oldSnapshot + + switch coordinator.proposal.operation + { + case .move: + guard destinationSection.dataSource.reorderingEnabled else { return } + let itemsBySourceSection = Dictionary(grouping: coordinator.items) { item -> Int? in + if let sourceIndex = item.sourceIndexPath, !sourceIndex.isEmpty + { + return sourceIndex.section + } + else + { + return nil + } + } + + let sourceSections = itemsBySourceSection.keys.sorted { a, b in + guard let a = a else { return false } + guard let b = b else { return true } + return a < b + } + + var itemsToInsert: [UICollectionViewDropItem] = [] + + for sourceSectionIndex in sourceSections + { + guard let items = itemsBySourceSection[sourceSectionIndex] else { continue } + + if + let sourceSectionIndex = sourceSectionIndex, + let sourceSection = parent.sections[safe: sourceSectionIndex] + { + guard sourceSection.dataSource.reorderingEnabled else { continue } + + let sourceIndices = items.compactMap { $0.sourceIndexPath?.item } + + // Remove from source section + dragSnapshot.removeItems(fromSectionIndex: sourceSectionIndex, atOffsets: IndexSet(sourceIndices)) + sourceSection.dataSource.applyRemove(atOffsets: IndexSet(sourceIndices)) + } + + // Add to insertion array (regardless whether sourceSection is nil) + itemsToInsert.append(contentsOf: items) + } + + let itemsToInsertIDs: [ASCollectionViewItemUniqueID] = itemsToInsert.compactMap { item in + if let sourceIndexPath = item.sourceIndexPath + { + return oldSnapshot.sections[sourceIndexPath.section].elements[sourceIndexPath.item].differenceIdentifier + } + else + { + return destinationSection.dataSource.getItemID(for: item.dragItem, withSectionID: destinationSection.id) + } + } + dragSnapshot.insertItems(itemsToInsertIDs, atSectionIndex: destinationIndexPath.section, atOffset: destinationIndexPath.item) + destinationSection.dataSource.applyInsert(items: itemsToInsert.map { $0.dragItem }, at: destinationIndexPath.item) + + case .copy: + destinationSection.dataSource.applyInsert(items: coordinator.items.map { $0.dragItem }, at: destinationIndexPath.item) + + default: break + } + + dataSource?.applySnapshot(dragSnapshot) + refreshVisibleCells() + + if let dragItem = coordinator.items.first, let destination = coordinator.destinationIndexPath + { + if dragItem.sourceIndexPath != nil + { + coordinator.drop(dragItem.dragItem, toItemAt: destination) + } + } } func typeErasedDataForItem(at indexPath: IndexPath) -> Any? @@ -632,9 +700,19 @@ public struct ASCollectionView: UIViewControllerRepresentab var lastContentSize: CGSize = .zero func didUpdateContentSize(_ size: CGSize) { - guard let cv = collectionViewController?.collectionView, cv.contentSize != lastContentSize else { return } + guard let cv = collectionViewController?.collectionView, cv.contentSize != lastContentSize, cv.contentSize.width != 0, cv.contentSize.height != 0 else { return } + let firstSize = lastContentSize == .zero lastContentSize = cv.contentSize parent.contentSizeTracker?.contentSize = size + if !hasSetInitialScrollPosition + { + hasSetInitialScrollPosition = true + parent.initialScrollPosition.map { scrollToPosition($0, animated: false) } + } + + DispatchQueue.main.async { + self.parent.invalidateParentCellLayout?(!firstSize) + } } // MARK: Variables used for the custom prefetching implementation @@ -723,10 +801,9 @@ internal protocol ASCollectionViewCoordinator: AnyObject func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? - func dragItem(for indexPath: IndexPath) -> UIDragItem? - func canDrop(at indexPath: IndexPath) -> Bool - func removeItem(from indexPath: IndexPath) - func insertItems(_ items: [UIDragItem], at indexPath: IndexPath) + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] + func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal + func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) func didUpdateContentSize(_ size: CGSize) func scrollViewDidScroll(_ scrollView: UIScrollView) func onMoveToParent() @@ -740,7 +817,7 @@ extension ASCollectionView.Coordinator { func setupPrefetching() { - let numberToPreload = 8 + let numberToPreload = 5 prefetchSubscription = queuePrefetch .collect(.byTime(DispatchQueue.main, 0.1)) // .throttle CRASHES on 13.1, fixed from 13.3 but still using .collect for 13.1 compatibility .compactMap @@ -793,21 +870,21 @@ extension ASCollectionView.Coordinator } .sink { [weak self] prefetch in - guard let self = self else { return } prefetch.forEach { sectionIndex, toPrefetch in if !toPrefetch.isEmpty { - self.parent.sections[safe: sectionIndex]?.dataSource.prefetch(toPrefetch) + self?.parent.sections[safe: sectionIndex]?.dataSource.prefetch(toPrefetch) } - let toCancel = self.currentlyPrefetching.filter { $0.section == sectionIndex }.subtracting(toPrefetch) - if !toCancel.isEmpty + if + let toCancel = self?.currentlyPrefetching.filter({ $0.section == sectionIndex }).subtracting(toPrefetch), + !toCancel.isEmpty { - self.parent.sections[safe: sectionIndex]?.dataSource.cancelPrefetch(Array(toCancel)) + self?.parent.sections[safe: sectionIndex]?.dataSource.cancelPrefetch(Array(toCancel)) } } - self.currentlyPrefetching = Set(prefetch.flatMap { $0.value }) + self?.currentlyPrefetching = Set(prefetch.flatMap { $0.value }) } } } @@ -821,318 +898,3 @@ public enum ASCollectionViewScrollPosition case right case centerOnIndexPath(_: IndexPath) } - -@available(iOS 13.0, *) -public class AS_CollectionViewController: UIViewController -{ - weak var coordinator: ASCollectionViewCoordinator? - { - didSet - { - guard viewIfLoaded != nil else { return } - collectionView.coordinator = coordinator - } - } - - var collectionViewLayout: UICollectionViewLayout - lazy var collectionView: AS_UICollectionView = { - let cv = AS_UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) - cv.coordinator = coordinator - return cv - }() - - public init(collectionViewLayout layout: UICollectionViewLayout) - { - collectionViewLayout = layout - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) - { - fatalError("init(coder:) has not been implemented") - } - - public override func didMove(toParent parent: UIViewController?) - { - super.didMove(toParent: parent) - if parent != nil - { - coordinator?.onMoveToParent() - } - else - { - coordinator?.onMoveFromParent() - } - } - - public override func loadView() - { - view = collectionView - } - - public override func viewDidLoad() - { - super.viewDidLoad() - view.backgroundColor = .clear - } - - public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) - { - // Get current central cell - self.coordinator?.prepareForOrientationChange() - - super.viewWillTransition(to: size, with: coordinator) - // The following is a workaround to fix the interface rotation animation under SwiftUI - view.frame = CGRect(origin: view.frame.origin, size: size) - - coordinator.animate(alongsideTransition: { _ in - self.view.setNeedsLayout() - self.view.layoutIfNeeded() - if - let desiredOffset = self.coordinator?.getContentOffsetForOrientationChange(), - self.collectionView.contentOffset != desiredOffset - { - self.collectionView.contentOffset = desiredOffset - } - }) - { _ in - // Completion - self.coordinator?.completedOrientationChange() - } - } - - public override func viewSafeAreaInsetsDidChange() - { - super.viewSafeAreaInsetsDidChange() - // The following is a workaround to fix the interface rotation animation under SwiftUI - collectionViewLayout.invalidateLayout() - } - - public override func viewDidLayoutSubviews() - { - super.viewDidLayoutSubviews() - coordinator?.didUpdateContentSize(collectionView.contentSize) - } -} - -@available(iOS 13.0, *) -class AS_UICollectionView: UICollectionView -{ - weak var coordinator: ASCollectionViewCoordinator? - - public override func didMoveToWindow() - { - super.didMoveToWindow() - guard window != nil else { return } - // Intended as a temporary workaround for a SwiftUI bug present in 13.3 -> the UIViewController is not moved to a parent when embedded in a list/scrollview - coordinator?.onMoveToParent() - } -} - -// MARK: Modifer: Custom Delegate - -@available(iOS 13.0, *) -public extension ASCollectionView -{ - /// Use this modifier to assign a custom delegate type (subclass of ASCollectionViewDelegate). This allows support for old UICollectionViewLayouts that require a delegate. - func customDelegate(_ delegateInitialiser: @escaping (() -> ASCollectionViewDelegate)) -> Self - { - var cv = self - cv.delegateInitialiser = delegateInitialiser - return cv - } -} - -// MARK: Modifer: Layout Invalidation - -@available(iOS 13.0, *) -public extension ASCollectionView -{ - /// For use in cases where you would like to change layout settings in response to a change in variables referenced by your layout closure. - /// Note: this ensures the layout is invalidated - /// - For UICollectionViewCompositionalLayout this means that your SectionLayout closure will be called again - /// - closures capture value types when created, therefore you must refer to a reference type in your layout closure if you want it to update. - func shouldInvalidateLayoutOnStateChange(_ shouldInvalidate: Bool, animated: Bool = true) -> Self - { - var this = self - this.shouldInvalidateLayoutOnStateChange = shouldInvalidate - this.shouldAnimateInvalidatedLayoutOnStateChange = animated - return this - } - - /// For use in cases where you would like to recreate the layout object in response to a change in state. Eg. for changing layout types completely - /// If not changing the type of layout (eg. to a different class) t is preferable to invalidate the layout and update variables in the `configureCustomLayout` closure - func shouldRecreateLayoutOnStateChange(_ shouldRecreate: Bool, animated: Bool = true) -> Self - { - var this = self - this.shouldRecreateLayoutOnStateChange = shouldRecreate - this.shouldAnimateRecreatedLayoutOnStateChange = animated - return this - } -} - -// MARK: Modifer: Other Modifiers - -@available(iOS 13.0, *) -public extension ASCollectionView -{ - /// Set a closure that is called whenever the collectionView is scrolled - func onScroll(_ onScroll: @escaping OnScrollCallback) -> Self - { - var this = self - this.onScrollCallback = onScroll - return this - } - - /// Set a closure that is called whenever the collectionView is scrolled to a boundary. eg. the bottom. - /// This is useful to enable loading more data when scrolling to bottom - func onReachedBoundary(_ onReachedBoundary: @escaping OnReachedBoundaryCallback) -> Self - { - var this = self - this.onReachedBoundaryCallback = onReachedBoundary - return this - } - - /// Set whether to show scroll indicators - func scrollIndicatorsEnabled(horizontal: Bool = true, vertical: Bool = true) -> Self - { - var this = self - this.horizontalScrollIndicatorEnabled = horizontal - this.verticalScrollIndicatorEnabled = vertical - return this - } - - /// Set the content insets - func contentInsets(_ insets: UIEdgeInsets) -> Self - { - var this = self - this.contentInsets = insets - return this - } - - /// Set a closure that is called when the collectionView is pulled to refresh - func onPullToRefresh(_ callback: ((_ endRefreshing: @escaping (() -> Void)) -> Void)?) -> Self - { - var this = self - this.onPullToRefresh = callback - return this - } - - /// Set whether the ASCollectionView should always allow bounce vertically - func alwaysBounceVertical(_ alwaysBounce: Bool = true) -> Self - { - var this = self - this.alwaysBounceVertical = alwaysBounce - return this - } - - /// Set whether the ASCollectionView should always allow bounce horizontally - func alwaysBounceHorizontal(_ alwaysBounce: Bool = true) -> Self - { - var this = self - this.alwaysBounceHorizontal = alwaysBounce - return this - } - - /// Set an initial scroll position for the ASCollectionView - func initialScrollPosition(_ position: ASCollectionViewScrollPosition?) -> Self - { - var this = self - this.initialScrollPosition = position - return this - } - - /// Set whether the ASCollectionView should animate on data refresh - func animateOnDataRefresh(_ animate: Bool = true) -> Self - { - var this = self - this.animateOnDataRefresh = animate - return this - } - - /// Set whether the ASCollectionView should attempt to maintain scroll position on orientation change, default is true - func shouldAttemptToMaintainScrollPositionOnOrientationChange(maintainPosition: Bool) -> Self - { - var this = self - this.maintainScrollPositionOnOrientationChange = true - return this - } -} - -// MARK: PUBLIC layout modifier functions - -@available(iOS 13.0, *) -public extension ASCollectionView -{ - func layout(_ layout: Layout) -> Self - { - var this = self - this.layout = layout - return this - } - - func layout( - scrollDirection: UICollectionView.ScrollDirection = .vertical, - interSectionSpacing: CGFloat = 10, - layoutPerSection: @escaping CompositionalLayout) -> Self - { - var this = self - this.layout = Layout( - scrollDirection: scrollDirection, - interSectionSpacing: interSectionSpacing, - layoutPerSection: layoutPerSection) - return this - } - - func layout( - scrollDirection: UICollectionView.ScrollDirection = .vertical, - interSectionSpacing: CGFloat = 10, - layout: @escaping CompositionalLayoutIgnoringSections) -> Self - { - var this = self - this.layout = Layout( - scrollDirection: scrollDirection, - interSectionSpacing: interSectionSpacing, - layout: layout) - return this - } - - func layout(customLayout: @escaping (() -> UICollectionViewLayout)) -> Self - { - var this = self - this.layout = Layout(customLayout: customLayout) - return this - } - - func layout(createCustomLayout: @escaping (() -> LayoutClass), configureCustomLayout: @escaping ((LayoutClass) -> Void)) -> Self - { - var this = self - this.layout = Layout(createCustomLayout: createCustomLayout, configureCustomLayout: configureCustomLayout) - return this - } -} - -@available(iOS 13.0, *) -class ASCollectionViewDiffableDataSource: UICollectionViewDiffableDataSource where SectionIdentifierType: Hashable, ItemIdentifierType: Hashable -{ - // private let updateQueue = DispatchQueue(label: "ASCollectionViewUpdateQueue", qos: .userInitiated) - - override func apply(_ snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) - { - apply(snapshot, animatingDifferences: animatingDifferences, isInitialLoad: false, completion: completion) - } - - func apply(_ snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool = true, isInitialLoad: Bool, completion: (() -> Void)? = nil) - { - if animatingDifferences - { - super.apply(snapshot, animatingDifferences: !isInitialLoad, completion: completion) - } - else - { - UIView.performWithoutAnimation { - super.apply(snapshot, animatingDifferences: !isInitialLoad, completion: completion) // Animation must be true to get diffing. However we have disabled animation using .performWithoutAnimation - } - } - } -} diff --git a/Sources/ASCollectionView/Implementation/ASSection.swift b/Sources/ASCollectionView/Implementation/ASSection.swift new file mode 100644 index 0000000..3cd8101 --- /dev/null +++ b/Sources/ASCollectionView/Implementation/ASSection.swift @@ -0,0 +1,86 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +@available(iOS 13.0, *) +public struct ASCollectionViewStaticContent: Identifiable +{ + public var index: Int + var view: AnyView + + public var id: Int { index } +} + +@available(iOS 13.0, *) +public struct ASCollectionViewItemUniqueID: Hashable +{ + var sectionIDHash: Int + var itemIDHash: Int + init(sectionID: SectionID, itemID: ItemID) + { + sectionIDHash = sectionID.hashValue + itemIDHash = itemID.hashValue + } +} + +@available(iOS 13.0, *) +public typealias ASCollectionViewSection = ASSection + +@available(iOS 13.0, *) +public struct ASSection +{ + public var id: SectionID + + internal var dataSource: ASSectionDataSourceProtocol + + public var itemIDs: [ASCollectionViewItemUniqueID] + { + dataSource.getUniqueItemIDs(withSectionID: id) + } + + var shouldCacheCells: Bool = false + + // Only relevant for ASTableView + var disableDefaultTheming: Bool = false + var tableViewSeparatorInsets: UIEdgeInsets? + var estimatedHeaderHeight: CGFloat? + var estimatedFooterHeight: CGFloat? +} + +// MARK: SUPPLEMENTARY VIEWS - INTERNAL + +@available(iOS 13.0, *) +internal extension ASCollectionViewSection +{ + mutating func setHeaderView(_ view: Content?) + { + setSupplementaryView(view, ofKind: UICollectionView.elementKindSectionHeader) + } + + mutating func setFooterView(_ view: Content?) + { + setSupplementaryView(view, ofKind: UICollectionView.elementKindSectionFooter) + } + + mutating func setSupplementaryView(_ view: Content?, ofKind kind: String) + { + guard let view = view else + { + dataSource.supplementaryViews.removeValue(forKey: kind) + return + } + + dataSource.supplementaryViews[kind] = AnyView(view) + } + + var supplementaryKinds: Set + { + Set(dataSource.supplementaryViews.keys) + } + + func supplementary(ofKind kind: String) -> AnyView? + { + dataSource.supplementaryViews[kind] + } +} diff --git a/Sources/ASCollectionView/ASSectionDataSource.swift b/Sources/ASCollectionView/Implementation/ASSectionDataSource.swift similarity index 58% rename from Sources/ASCollectionView/ASSectionDataSource.swift rename to Sources/ASCollectionView/Implementation/ASSectionDataSource.swift index 3a74570..bc5a526 100644 --- a/Sources/ASCollectionView/ASSectionDataSource.swift +++ b/Sources/ASCollectionView/Implementation/ASSectionDataSource.swift @@ -6,19 +6,25 @@ import SwiftUI @available(iOS 13.0, *) internal protocol ASSectionDataSourceProtocol { + var endIndex: Int { get } func getIndexPaths(withSectionIndex sectionIndex: Int) -> [IndexPath] func getItemID(for index: Int, withSectionID sectionID: SectionID) -> ASCollectionViewItemUniqueID? func getUniqueItemIDs(withSectionID sectionID: SectionID) -> [ASCollectionViewItemUniqueID] func updateOrCreateHostController(forItemID itemID: ASCollectionViewItemUniqueID, existingHC: ASHostingControllerProtocol?) -> ASHostingControllerProtocol? func update(_ hc: ASHostingControllerProtocol, forItemID itemID: ASCollectionViewItemUniqueID) + func updateOrCreateHostController(forSupplementaryKind supplementaryKind: String, existingHC: ASHostingControllerProtocol?) -> ASHostingControllerProtocol? + func update(_ hc: ASHostingControllerProtocol, forSupplementaryKind supplementaryKind: String) + var supplementaryViews: [String: AnyView] { get set } func getTypeErasedData(for indexPath: IndexPath) -> Any? func onAppear(_ indexPath: IndexPath) func onDisappear(_ indexPath: IndexPath) func prefetch(_ indexPaths: [IndexPath]) func cancelPrefetch(_ indexPaths: [IndexPath]) + func willAcceptDropItem(from dragItem: UIDragItem) -> Bool func getDragItem(for indexPath: IndexPath) -> UIDragItem? - func removeItem(from indexPath: IndexPath) - func insertDragItems(_ items: [UIDragItem], at indexPath: IndexPath) + func getItemID(for dragItem: UIDragItem, withSectionID sectionID: SectionID) -> ASCollectionViewItemUniqueID? + func applyRemove(atOffsets offsets: IndexSet) + func applyInsert(items: [UIDragItem], at index: Int) func supportsDelete(at indexPath: IndexPath) -> Bool func onDelete(indexPath: IndexPath, completionHandler: (Bool) -> Void) func getContextMenu(for indexPath: IndexPath) -> UIContextMenuConfiguration? @@ -31,57 +37,15 @@ internal protocol ASSectionDataSourceProtocol var dragEnabled: Bool { get } var dropEnabled: Bool { get } + var reorderingEnabled: Bool { get } mutating func setSelfSizingConfig(config: @escaping SelfSizingConfig) } @available(iOS 13.0, *) -public enum CellEvent +protocol ASDataSourceConfigurableCell { - /// Respond by starting necessary prefetch operations for this data to be displayed soon (eg. download images) - case prefetchForData(data: [Data]) - - /// Called when its no longer necessary to prefetch this data - case cancelPrefetchForData(data: [Data]) - - /// Called when an item is appearing on the screen - case onAppear(item: Data) - - /// Called when an item is disappearing from the screen - case onDisappear(item: Data) -} - -@available(iOS 13.0, *) -public enum DragDrop -{ - case onRemoveItem(indexPath: IndexPath) - case onAddItems(items: [Data], atIndexPath: IndexPath) -} - -@available(iOS 13.0, *) -public typealias OnCellEvent = ((_ event: CellEvent) -> Void) - -@available(iOS 13.0, *) -public typealias OnDragDrop = ((_ event: DragDrop) -> Void) - -@available(iOS 13.0, *) -public typealias ItemProvider = ((_ item: Data) -> NSItemProvider) - -@available(iOS 13.0, *) -public typealias OnSwipeToDelete = ((Data, _ completionHandler: (Bool) -> Void) -> Void) - -@available(iOS 13.0, *) -public typealias ContextMenuProvider = ((_ item: Data) -> UIContextMenuConfiguration?) - -@available(iOS 13.0, *) -public typealias SelfSizingConfig = ((_ context: ASSelfSizingContext) -> ASSelfSizingConfig?) - -@available(iOS 13.0, *) -public struct CellContext -{ - public var isSelected: Bool - public var isFirstInSection: Bool - public var isLastInSection: Bool + var hostingController: ASHostingControllerProtocol? { get set } } @available(iOS 13.0, *) @@ -91,33 +55,37 @@ internal struct ASSectionDataSource var container: (Content) -> Container - var content: (DataCollection.Element, CellContext) -> Content + var content: (DataCollection.Element, ASCellContext) -> Content var selectedItems: Binding>? var shouldAllowSelection: ((_ index: Int) -> Bool)? var shouldAllowDeselection: ((_ index: Int) -> Bool)? var onCellEvent: OnCellEvent? - var onDragDrop: OnDragDrop? - var itemProvider: ItemProvider? + var dragDropConfig: ASDragDropConfig + var shouldAllowSwipeToDelete: ShouldAllowSwipeToDelete? var onSwipeToDelete: OnSwipeToDelete? var contextMenuProvider: ContextMenuProvider? var selfSizingConfig: (SelfSizingConfig)? var supplementaryViews: [String: AnyView] = [:] - var dragEnabled: Bool { onDragDrop != nil } - var dropEnabled: Bool { onDragDrop != nil } + var dragEnabled: Bool { dragDropConfig.dragEnabled } + var dropEnabled: Bool { dragDropConfig.dropEnabled } + var reorderingEnabled: Bool { dragDropConfig.reorderingEnabled } + + var endIndex: Int { data.endIndex } func getIndex(of itemID: ASCollectionViewItemUniqueID) -> Int? { data.firstIndex(where: { $0[keyPath: dataIDKeyPath].hashValue == itemID.itemIDHash }) } - func cellContext(for index: Int) -> CellContext + func cellContext(for index: Int) -> ASCellContext { - CellContext( + ASCellContext( isSelected: isSelected(index: index), + index: index, isFirstInSection: index == data.startIndex, isLastInSection: index == data.endIndex - 1) } @@ -125,22 +93,48 @@ internal struct ASSectionDataSource ASHostingControllerProtocol? { guard let content = getContent(forItemID: itemID) else { return nil } + return updateOrCreateHostController(content: content, existingHC: existingHC) + } - if let hc = (existingHC as? ASHostingController) + func update(_ hc: ASHostingControllerProtocol, forItemID itemID: ASCollectionViewItemUniqueID) + { + guard let content = getContent(forItemID: itemID) else { return } + update(hc, withContent: content) + } + + func updateOrCreateHostController(forSupplementaryKind supplementaryKind: String, existingHC: ASHostingControllerProtocol?) -> ASHostingControllerProtocol? + { + guard let content = supplementaryViews[supplementaryKind] else { return nil } + return updateOrCreateHostController(content: content, existingHC: existingHC) + } + + func update(_ hc: ASHostingControllerProtocol, forSupplementaryKind supplementaryKind: String) + { + guard let content = supplementaryViews[supplementaryKind] else { return } + update(hc, withContent: content) + } + + private func updateOrCreateHostController(content: Wrapped, existingHC: ASHostingControllerProtocol?) -> ASHostingControllerProtocol + { + if let hc = (existingHC as? ASHostingController) { hc.setView(content) + hc.disableSwiftUIDropInteraction = dropEnabled + hc.disableSwiftUIDragInteraction = dragEnabled return hc } else { - return ASHostingController(content) + let newHC = ASHostingController(content) + newHC.disableSwiftUIDropInteraction = dropEnabled + newHC.disableSwiftUIDragInteraction = dragEnabled + return newHC } } - func update(_ hc: ASHostingControllerProtocol, forItemID itemID: ASCollectionViewItemUniqueID) + private func update(_ hc: ASHostingControllerProtocol, withContent content: Wrapped) { - guard let hc = hc as? ASHostingController else { return } - guard let content = getContent(forItemID: itemID) else { return } + guard let hc = hc as? ASHostingController else { return } hc.setView(content) } @@ -164,7 +158,12 @@ internal struct ASSectionDataSource(for index: Int, withSectionID sectionID: SectionID) -> ASCollectionViewItemUniqueID? { - data[safe: index].map { ASCollectionViewItemUniqueID(sectionID: sectionID, itemID: $0[keyPath: dataIDKeyPath]) } + data[safe: index].map { getItemID(for: $0, withSectionID: sectionID) } + } + + func getItemID(for item: Data, withSectionID sectionID: SectionID) -> ASCollectionViewItemUniqueID + { + ASCollectionViewItemUniqueID(sectionID: sectionID, itemID: item[keyPath: dataIDKeyPath]) } func getUniqueItemIDs(withSectionID sectionID: SectionID) -> [ASCollectionViewItemUniqueID] @@ -207,13 +206,14 @@ internal struct ASSectionDataSource Bool { - onSwipeToDelete != nil + guard onSwipeToDelete != nil else { return false } + return shouldAllowSwipeToDelete?(indexPath.item) ?? true } func onDelete(indexPath: IndexPath, completionHandler: (Bool) -> Void) { guard let item = data[safe: indexPath.item] else { return } - onSwipeToDelete?(item, completionHandler) + onSwipeToDelete?(indexPath.item, item, completionHandler) } func getDragItem(for indexPath: IndexPath) -> UIDragItem? @@ -221,29 +221,46 @@ internal struct ASSectionDataSource Bool { - guard data.containsIndex(indexPath.item) else { return } - onDragDrop?(.onRemoveItem(indexPath: indexPath)) + getDropItem(from: dragItem) != nil } - func insertDragItems(_ items: [UIDragItem], at indexPath: IndexPath) + func getDropItem(from dragItem: UIDragItem) -> Data? { - guard dropEnabled else { return } - let index = max(data.startIndex, min(indexPath.item, data.endIndex)) - let indexPath = IndexPath(item: index, section: indexPath.section) - let dataItems = items.compactMap - { (dragItem) -> Data? in - guard let item = dragItem.localObject as? Data else { return nil } - return item - } - onDragDrop?(.onAddItems(items: dataItems, atIndexPath: indexPath)) + guard dropEnabled else { return nil } + + let sourceItem = dragItem.localObject as? Data + return dragDropConfig.dropItemProvider?(sourceItem, dragItem) ?? sourceItem + } + + func getItemID(for dragItem: UIDragItem, withSectionID sectionID: SectionID) -> ASCollectionViewItemUniqueID? + { + guard let item = getDropItem(from: dragItem) else { return nil } + return getItemID(for: item, withSectionID: sectionID) + } + + func applyRemove(atOffsets offsets: IndexSet) + { + dragDropConfig.dataBinding?.wrappedValue.remove(atOffsets: offsets) + } + + func applyInsert(items: [UIDragItem], at index: Int) + { + let actualItems = items.compactMap(getDropItem(from:)) + let allDataIDs = Set(dragDropConfig.dataBinding?.wrappedValue.map { $0[keyPath: dataIDKeyPath] } ?? []) + let noDuplicates = actualItems.filter { !allDataIDs.contains($0[keyPath: dataIDKeyPath]) } +#if DEBUG + // Notify during debug build if IDs are not unique (programmer error) + if noDuplicates.count != actualItems.count { print("ASCOLLECTIONVIEW/ASTABLEVIEW: Attempted to insert an item with the same ID as one already in the section. This may cause unexpected behaviour.") } +#endif + dragDropConfig.dataBinding?.wrappedValue.insert(contentsOf: noDuplicates, at: index) } func getContextMenu(for indexPath: IndexPath) -> UIContextMenuConfiguration? @@ -253,7 +270,7 @@ internal struct ASSectionDataSource ASSelfSizingConfig? diff --git a/Sources/ASCollectionView/Implementation/ASTableView.swift b/Sources/ASCollectionView/Implementation/ASTableView.swift new file mode 100644 index 0000000..c1bdd23 --- /dev/null +++ b/Sources/ASCollectionView/Implementation/ASTableView.swift @@ -0,0 +1,674 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Combine +import SwiftUI + +@available(iOS 13.0, *) +public typealias ASTableViewSection = ASSection + +@available(iOS 13.0, *) +public struct ASTableView: UIViewControllerRepresentable, ContentSize +{ + // MARK: Type definitions + + public typealias Section = ASTableViewSection + + public typealias OnScrollCallback = ((_ contentOffset: CGPoint, _ contentSize: CGSize) -> Void) + public typealias OnReachedBottomCallback = (() -> Void) + + // MARK: Key variables + + public var sections: [Section] + public var style: UITableView.Style + + // MARK: Private vars set by public modifiers + + internal var onScrollCallback: OnScrollCallback? + internal var onReachedBottomCallback: OnReachedBottomCallback? + + internal var scrollIndicatorEnabled: Bool = true + internal var contentInsets: UIEdgeInsets = .zero + + internal var separatorsEnabled: Bool = true + + internal var onPullToRefresh: ((_ endRefreshing: @escaping (() -> Void)) -> Void)? + + internal var alwaysBounce: Bool = false + internal var animateOnDataRefresh: Bool = true + + // MARK: Environment variables + + @Environment(\.editMode) private var editMode + + @Environment(\.invalidateCellLayout) var invalidateParentCellLayout // Call this if using content size binding (nested inside another ASCollectionView) + + // Other + var contentSizeTracker: ContentSizeTracker? + + public func makeUIViewController(context: Context) -> AS_TableViewController + { + context.coordinator.parent = self + + let tableViewController = AS_TableViewController(style: style) + tableViewController.coordinator = context.coordinator + + context.coordinator.tableViewController = tableViewController + context.coordinator.updateTableViewSettings(tableViewController.tableView) + + context.coordinator.setupDataSource(forTableView: tableViewController.tableView) + return tableViewController + } + + public func updateUIViewController(_ tableViewController: AS_TableViewController, context: Context) + { + context.coordinator.parent = self + context.coordinator.updateTableViewSettings(tableViewController.tableView) + context.coordinator.updateContent(tableViewController.tableView, transaction: context.transaction, refreshExistingCells: true) + context.coordinator.configureRefreshControl(for: tableViewController.tableView) +#if DEBUG + debugOnly_checkHasUniqueSections() +#endif + } + + public func makeCoordinator() -> Coordinator + { + Coordinator(self) + } + +#if DEBUG + func debugOnly_checkHasUniqueSections() + { + var sectionIDs: Set = [] + var conflicts: Set = [] + sections.forEach { + let (inserted, _) = sectionIDs.insert($0.id) + if !inserted + { + conflicts.insert($0.id) + } + } + if !conflicts.isEmpty + { + print("ASTABLEVIEW: The following section IDs are used more than once, please use unique section IDs to avoid unexpected behaviour:", conflicts) + } + } +#endif + + public class Coordinator: NSObject, ASTableViewCoordinator, UITableViewDelegate, UITableViewDataSourcePrefetching, UITableViewDragDelegate, UITableViewDropDelegate + { + var parent: ASTableView + weak var tableViewController: AS_TableViewController? + + var dataSource: ASDiffableDataSourceTableView? + + let cellReuseID = UUID().uuidString + let supplementaryReuseID = UUID().uuidString + + // MARK: Private tracking variables + + private var hasMovedToParent = false + + private var visibleSupplementaries: [ASSupplementaryCellID: ASTableViewSupplementaryView] = [:] + + // MARK: Caching + + private var autoCachingHostingControllers = ASPriorityCache() + private var explicitlyCachedHostingControllers: [ASCollectionViewItemUniqueID: ASHostingControllerProtocol] = [:] + private var autoCachingSupplementaryHostControllers = ASPriorityCache, ASHostingControllerProtocol>() + + typealias Cell = ASTableViewCell + + init(_ parent: ASTableView) + { + self.parent = parent + } + + func itemID(for indexPath: IndexPath) -> ASCollectionViewItemUniqueID? + { + guard + let sectionID = sectionID(fromSectionIndex: indexPath.section) + else { return nil } + return parent.sections[safe: indexPath.section]?.dataSource.getItemID(for: indexPath.item, withSectionID: sectionID) + } + + func sectionID(fromSectionIndex sectionIndex: Int) -> SectionID? + { + parent.sections[safe: sectionIndex]?.id + } + + func section(forItemID itemID: ASCollectionViewItemUniqueID) -> Section? + { + parent.sections + .first(where: { $0.id.hashValue == itemID.sectionIDHash }) + } + + func updateTableViewSettings(_ tableView: UITableView) + { + assignIfChanged(tableView, \.backgroundColor, newValue: (parent.style == .plain) ? .clear : .systemGroupedBackground) + assignIfChanged(tableView, \.separatorStyle, newValue: parent.separatorsEnabled ? .singleLine : .none) + assignIfChanged(tableView, \.contentInset, newValue: parent.contentInsets) + assignIfChanged(tableView, \.alwaysBounceVertical, newValue: parent.alwaysBounce) + assignIfChanged(tableView, \.showsVerticalScrollIndicator, newValue: parent.scrollIndicatorEnabled) + assignIfChanged(tableView, \.showsHorizontalScrollIndicator, newValue: parent.scrollIndicatorEnabled) + assignIfChanged(tableView, \.keyboardDismissMode, newValue: .onDrag) + + let isEditing = parent.editMode?.wrappedValue.isEditing ?? false + assignIfChanged(tableView, \.allowsMultipleSelection, newValue: isEditing) + if assignIfChanged(tableView, \.allowsSelection, newValue: isEditing) + { + updateSelectionBindings(tableView) + } + } + + func setupDataSource(forTableView tv: UITableView) + { + tv.delegate = self + tv.prefetchDataSource = self + + tv.dragDelegate = self + tv.dropDelegate = self + tv.dragInteractionEnabled = true + + tv.register(Cell.self, forCellReuseIdentifier: cellReuseID) + tv.register(ASTableViewSupplementaryView.self, forHeaderFooterViewReuseIdentifier: supplementaryReuseID) + + dataSource = .init(tableView: tv) + { [weak self] tableView, indexPath, itemID in + guard let self = self else { return nil } + guard + let cell = tableView.dequeueReusableCell(withIdentifier: self.cellReuseID, for: indexPath) as? Cell + else { return nil } + + guard let section = self.parent.sections[safe: indexPath.section] else { return cell } + + cell.backgroundColor = (self.parent.style == .plain || section.disableDefaultTheming) ? .clear : .secondarySystemGroupedBackground + + cell.separatorInset = section.tableViewSeparatorInsets ?? UIEdgeInsets(top: 0, left: UITableView.automaticDimension, bottom: 0, right: UITableView.automaticDimension) + + // Cell layout invalidation callback + cell.invalidateLayoutCallback = { [weak self, weak cell] animated in + cell.map { self?.invalidateLayout(animated: animated, cell: $0) } + } + cell.scrollToCellCallback = { [weak self] position in + self?.scrollToRow(indexPath: indexPath, position: position) + } + + // Set itemID + cell.indexPath = indexPath + cell.itemID = itemID + + // Update hostingController + let cachedHC = self.explicitlyCachedHostingControllers[itemID] ?? self.autoCachingHostingControllers[itemID] + cell.hostingController = section.dataSource.updateOrCreateHostController(forItemID: itemID, existingHC: cachedHC) + // Cache the HC + self.autoCachingHostingControllers[itemID] = cell.hostingController + if section.shouldCacheCells + { + self.explicitlyCachedHostingControllers[itemID] = cell.hostingController + } + + return cell + } + } + + func populateDataSource(animated: Bool = true) + { + guard hasMovedToParent else { return } + let snapshot = ASDiffableDataSourceSnapshot(sections: + parent.sections.map { + ASDiffableDataSourceSnapshot.Section(id: $0.id, elements: $0.itemIDs) + } + ) + dataSource?.applySnapshot(snapshot, animated: animated) + } + + func updateContent(_ tv: UITableView, transaction: Transaction?, refreshExistingCells: Bool) + { + guard hasMovedToParent else { return } + + let transactionAnimationEnabled = (transaction?.animation != nil) && !(transaction?.disablesAnimations ?? false) + populateDataSource(animated: parent.animateOnDataRefresh && transactionAnimationEnabled) + + if refreshExistingCells + { + withAnimation(parent.animateOnDataRefresh ? transaction?.animation : nil) { + refreshVisibleCells() + } + dataSource?.updateCellSizes(animated: transactionAnimationEnabled) + } + updateSelectionBindings(tv) + } + + func refreshVisibleCells() + { + guard let tv = tableViewController?.tableView else { return } + for case let cell as Cell in tv.visibleCells + { + guard + let itemID = cell.itemID, + let hc = cell.hostingController + else { return } + self.section(forItemID: itemID)?.dataSource.update(hc, forItemID: itemID) + } + + visibleSupplementaries.forEach { key, view in + guard let section = self.parent.sections.first(where: { $0.id.hashValue == key.sectionID.hashValue }) else { return } + view.hostingController = section.dataSource.updateOrCreateHostController(forSupplementaryKind: key.supplementaryKind, existingHC: view.hostingController) + } + } + + func invalidateLayout(animated: Bool, cell: ASTableViewCell) + { + cell.recalculateSize() + dataSource?.updateCellSizes(animated: animated) + } + + func scrollToRow(indexPath: IndexPath, position: UITableView.ScrollPosition = .none) + { + tableViewController?.tableView.scrollToRow(at: indexPath, at: position, animated: true) + } + + func onMoveToParent() + { + guard !hasMovedToParent else { return } + + hasMovedToParent = true + populateDataSource(animated: false) + tableViewController.map { checkIfReachedBottom($0.tableView) } + } + + func onMoveFromParent() {} + + // MARK: Function for updating contentSize binding + + var lastContentSize: CGSize = .zero + func didUpdateContentSize(_ size: CGSize) + { + guard let tv = tableViewController?.tableView, tv.contentSize != lastContentSize, tv.contentSize.height != 0 else { return } + let firstSize = lastContentSize == .zero + lastContentSize = tv.contentSize + parent.contentSizeTracker?.contentSize = size + DispatchQueue.main.async { + self.parent.invalidateParentCellLayout?(!firstSize) + } + } + + func configureRefreshControl(for tv: UITableView) + { + guard parent.onPullToRefresh != nil else + { + if tv.refreshControl != nil + { + tv.refreshControl = nil + } + return + } + if tv.refreshControl == nil + { + let refreshControl = UIRefreshControl() + refreshControl.addTarget(self, action: #selector(tableViewDidPullToRefresh), for: .valueChanged) + tv.refreshControl = refreshControl + } + } + + @objc + public func tableViewDidPullToRefresh() + { + guard let tableView = tableViewController?.tableView else { return } + let endRefreshing: (() -> Void) = { [weak tableView] in + tableView?.refreshControl?.endRefreshing() + } + parent.onPullToRefresh?(endRefreshing) + } + + public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) + { + tableViewController.map { (cell as? Cell)?.willAppear(in: $0) } + parent.sections[safe: indexPath.section]?.dataSource.onAppear(indexPath) + } + + public func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) + { + (cell as? Cell)?.didDisappear() + parent.sections[safe: indexPath.section]?.dataSource.onDisappear(indexPath) + } + + public func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) + { + guard let view = (view as? ASTableViewSupplementaryView) else { return } + if let section = parent.sections[safe: section] + { + let supplementaryID = ASSupplementaryCellID(sectionID: section.id, supplementaryKind: UICollectionView.elementKindSectionHeader) + visibleSupplementaries[supplementaryID] = view + } + tableViewController.map { view.willAppear(in: $0) } + } + + public func tableView(_ tableView: UITableView, didEndDisplayingHeaderView view: UIView, forSection section: Int) + { + guard let view = (view as? ASTableViewSupplementaryView) else { return } + if let section = parent.sections[safe: section] + { + let supplementaryID = ASSupplementaryCellID(sectionID: section.id, supplementaryKind: UICollectionView.elementKindSectionHeader) + visibleSupplementaries.removeValue(forKey: supplementaryID) + } + view.didDisappear() + } + + public func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) + { + guard let view = (view as? ASTableViewSupplementaryView) else { return } + if let section = parent.sections[safe: section] + { + let supplementaryID = ASSupplementaryCellID(sectionID: section.id, supplementaryKind: UICollectionView.elementKindSectionFooter) + visibleSupplementaries[supplementaryID] = view + } + tableViewController.map { view.willAppear(in: $0) } + } + + public func tableView(_ tableView: UITableView, didEndDisplayingFooterView view: UIView, forSection section: Int) + { + guard let view = (view as? ASTableViewSupplementaryView) else { return } + if let section = parent.sections[safe: section] + { + let supplementaryID = ASSupplementaryCellID(sectionID: section.id, supplementaryKind: UICollectionView.elementKindSectionFooter) + visibleSupplementaries.removeValue(forKey: supplementaryID) + } + view.didDisappear() + } + + public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) + { + let itemIDsToPrefetchBySection: [Int: [IndexPath]] = Dictionary(grouping: indexPaths) { $0.section } + itemIDsToPrefetchBySection.forEach + { + parent.sections[safe: $0.key]?.dataSource.prefetch($0.value) + } + } + + public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) + { + let itemIDsToCancelPrefetchBySection: [Int: [IndexPath]] = Dictionary(grouping: indexPaths) { $0.section } + itemIDsToCancelPrefetchBySection.forEach + { + parent.sections[safe: $0.key]?.dataSource.cancelPrefetch($0.value) + } + } + + // MARK: Swipe actions + + public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? + { + guard parent.sections[safe: indexPath.section]?.dataSource.supportsDelete(at: indexPath) == true else { return nil } + let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completionHandler in + self?.onDeleteAction(indexPath: indexPath, completionHandler: completionHandler) + } + return UISwipeActionsConfiguration(actions: [deleteAction]) + } + + public func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle + { + .none + } + + private func onDeleteAction(indexPath: IndexPath, completionHandler: (Bool) -> Void) + { + parent.sections[safe: indexPath.section]?.dataSource.onDelete(indexPath: indexPath, completionHandler: completionHandler) + } + + // MARK: Cell Selection + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) + { + updateContent(tableView, transaction: nil, refreshExistingCells: true) + } + + public func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) + { + updateContent(tableView, transaction: nil, refreshExistingCells: true) + } + + func updateSelectionBindings(_ tableView: UITableView) + { + let selected = tableView.allowsSelection ? (tableView.indexPathsForSelectedRows ?? []) : [] + let selectionBySection = Dictionary(grouping: selected) { $0.section } + .mapValues + { + Set($0.map { $0.item }) + } + parent.sections.enumerated().forEach { offset, section in + section.dataSource.updateSelection(selectionBySection[offset] ?? []) + } + } + + public func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? + { + guard parent.sections[safe: indexPath.section]?.dataSource.shouldSelect(indexPath) ?? false else + { + return nil + } + return indexPath + } + + public func tableView(_ tableView: UITableView, willDeselectRowAt indexPath: IndexPath) -> IndexPath? + { + guard parent.sections[safe: indexPath.section]?.dataSource.shouldDeselect(indexPath) ?? false else + { + return nil + } + return indexPath + } + + public func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] + { + guard !indexPath.isEmpty else { return [] } + guard let dragItem = parent.sections[safe: indexPath.section]?.dataSource.getDragItem(for: indexPath) else { return [] } + return [dragItem] + } + + func canDrop(at indexPath: IndexPath) -> Bool + { + guard !indexPath.isEmpty else { return false } + return parent.sections[safe: indexPath.section]?.dataSource.dropEnabled ?? false + } + + public func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal + { + if tableView.hasActiveDrag + { + if let destination = destinationIndexPath + { + guard canDrop(at: destination) else + { + return UITableViewDropProposal(operation: .cancel) + } + } + return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) + } + else + { + return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath) + } + } + + public func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) + { + guard + let destinationIndexPath = coordinator.destinationIndexPath, + !destinationIndexPath.isEmpty, + let destinationSection = parent.sections[safe: destinationIndexPath.section] + else { return } + + guard canDrop(at: destinationIndexPath) else { return } + + guard let oldSnapshot = dataSource?.currentSnapshot else { return } + var dragSnapshot = oldSnapshot + + switch coordinator.proposal.operation + { + case .move: + guard destinationSection.dataSource.reorderingEnabled else { return } + let itemsBySourceSection = Dictionary(grouping: coordinator.items) { item -> Int? in + if let sourceIndex = item.sourceIndexPath, !sourceIndex.isEmpty + { + return sourceIndex.section + } + else + { + return nil + } + } + + let sourceSections = itemsBySourceSection.keys.sorted { a, b in + guard let a = a else { return false } + guard let b = b else { return true } + return a < b + } + + var itemsToInsert: [UITableViewDropItem] = [] + + for sourceSectionIndex in sourceSections + { + guard let items = itemsBySourceSection[sourceSectionIndex] else { continue } + + if + let sourceSectionIndex = sourceSectionIndex, + let sourceSection = parent.sections[safe: sourceSectionIndex] + { + guard sourceSection.dataSource.reorderingEnabled else { continue } + + let sourceIndices = items.compactMap { $0.sourceIndexPath?.item } + + // Remove from source section + dragSnapshot.removeItems(fromSectionIndex: sourceSectionIndex, atOffsets: IndexSet(sourceIndices)) + sourceSection.dataSource.applyRemove(atOffsets: IndexSet(sourceIndices)) + } + + // Add to insertion array (regardless whether sourceSection is nil) + itemsToInsert.append(contentsOf: items) + } + + let itemsToInsertIDs: [ASCollectionViewItemUniqueID] = itemsToInsert.compactMap { item in + if let sourceIndexPath = item.sourceIndexPath + { + return oldSnapshot.sections[sourceIndexPath.section].elements[sourceIndexPath.item].differenceIdentifier + } + else + { + return destinationSection.dataSource.getItemID(for: item.dragItem, withSectionID: destinationSection.id) + } + } + let safeDestinationIndex = min(destinationIndexPath.item, dragSnapshot.sections[destinationIndexPath.section].elements.endIndex) + dragSnapshot.insertItems(itemsToInsertIDs, atSectionIndex: destinationIndexPath.section, atOffset: destinationIndexPath.item) + destinationSection.dataSource.applyInsert(items: itemsToInsert.map { $0.dragItem }, at: safeDestinationIndex) + + case .copy: + destinationSection.dataSource.applyInsert(items: coordinator.items.map { $0.dragItem }, at: destinationIndexPath.item) + + default: break + } + + dataSource?.applySnapshot(dragSnapshot) + refreshVisibleCells() + + if let dragItem = coordinator.items.first, let destination = coordinator.destinationIndexPath + { + if dragItem.sourceIndexPath != nil + { + coordinator.drop(dragItem.dragItem, toRowAt: destination) + } + } + } + + public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat + { + guard parent.sections[safe: section]?.supplementary(ofKind: UICollectionView.elementKindSectionHeader) != nil else + { + return CGFloat.leastNormalMagnitude + } + return UITableView.automaticDimension + } + + public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat + { + guard parent.sections[safe: section]?.supplementary(ofKind: UICollectionView.elementKindSectionFooter) != nil else + { + return CGFloat.leastNormalMagnitude + } + return UITableView.automaticDimension + } + + public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? + { + guard let reusableView = tableView.dequeueReusableHeaderFooterView(withIdentifier: supplementaryReuseID) else { return nil } + configureSupplementary(reusableView, supplementaryKind: UICollectionView.elementKindSectionHeader, forSection: section) + return reusableView + } + + public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? + { + guard let reusableView = tableView.dequeueReusableHeaderFooterView(withIdentifier: supplementaryReuseID) else { return nil } + configureSupplementary(reusableView, supplementaryKind: UICollectionView.elementKindSectionFooter, forSection: section) + return reusableView + } + + func configureSupplementary(_ cell: UITableViewHeaderFooterView, supplementaryKind: String, forSection sectionIndex: Int) + { + guard let reusableView = cell as? ASTableViewSupplementaryView + else { return } + + let ifEmpty = { + reusableView.setupForEmpty() + } + + guard let section = parent.sections[safe: sectionIndex] else { ifEmpty(); return } + reusableView.sectionIDHash = section.id.hashValue + let supplementaryID = ASSupplementaryCellID(sectionID: section.id, supplementaryKind: supplementaryKind) + + // Update hostingController + let cachedHC = autoCachingSupplementaryHostControllers[supplementaryID] + reusableView.hostingController = section.dataSource.updateOrCreateHostController(forSupplementaryKind: supplementaryKind, existingHC: cachedHC) + // Cache the HC + autoCachingSupplementaryHostControllers[supplementaryID] = reusableView.hostingController + } + + // MARK: Context Menu Support + + public func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? + { + guard !indexPath.isEmpty else { return nil } + return parent.sections[safe: indexPath.section]?.dataSource.getContextMenu(for: indexPath) + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) + { + parent.onScrollCallback?(scrollView.contentOffset, scrollView.contentSizePlusInsets) + checkIfReachedBottom(scrollView) + } + + var hasAlreadyReachedBottom: Bool = false + func checkIfReachedBottom(_ scrollView: UIScrollView) + { + if (scrollView.contentSize.height - scrollView.contentOffset.y) <= scrollView.frame.size.height + { + if !hasAlreadyReachedBottom + { + hasAlreadyReachedBottom = true + parent.onReachedBottomCallback?() + } + } + else + { + hasAlreadyReachedBottom = false + } + } + } +} + +@available(iOS 13.0, *) +protocol ASTableViewCoordinator: AnyObject +{ + func onMoveToParent() + func onMoveFromParent() + func didUpdateContentSize(_ size: CGSize) +} diff --git a/Sources/ASCollectionView/ASCollectionViewLayout.swift b/Sources/ASCollectionView/Layout/ASCollectionViewLayout.swift similarity index 100% rename from Sources/ASCollectionView/ASCollectionViewLayout.swift rename to Sources/ASCollectionView/Layout/ASCollectionViewLayout.swift diff --git a/Sources/ASCollectionView/ASWaterfallLayout.swift b/Sources/ASCollectionView/Layout/ASWaterfallLayout.swift similarity index 99% rename from Sources/ASCollectionView/ASWaterfallLayout.swift rename to Sources/ASCollectionView/Layout/ASWaterfallLayout.swift index 2e392ff..d0d3ddd 100644 --- a/Sources/ASCollectionView/ASWaterfallLayout.swift +++ b/Sources/ASCollectionView/Layout/ASWaterfallLayout.swift @@ -245,7 +245,6 @@ public class ASWaterfallLayout: UICollectionViewLayout, ASCollectionViewLayoutPr public override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool { guard !hasDelegate else { return false } - print(preferredAttributes.indexPath) if originalAttributes.indexPath.item == -1 { guard diff --git a/Sources/ASCollectionView/Binding+Sequence.swift b/Sources/ASCollectionView/Support/Binding+Sequence.swift similarity index 100% rename from Sources/ASCollectionView/Binding+Sequence.swift rename to Sources/ASCollectionView/Support/Binding+Sequence.swift diff --git a/Sources/ASCollectionView/Support/GlobalConvenienceFunctions.swift b/Sources/ASCollectionView/Support/GlobalConvenienceFunctions.swift index 026d8fb..5fa1120 100644 --- a/Sources/ASCollectionView/Support/GlobalConvenienceFunctions.swift +++ b/Sources/ASCollectionView/Support/GlobalConvenienceFunctions.swift @@ -2,7 +2,10 @@ import Foundation -func assignIfChanged(_ object: Object, _ keyPath: ReferenceWritableKeyPath, newValue: T) { - guard newValue != object[keyPath: keyPath] else { return } +@discardableResult +func assignIfChanged(_ object: Object, _ keyPath: ReferenceWritableKeyPath, newValue: T) -> Bool +{ + guard newValue != object[keyPath: keyPath] else { return false } object[keyPath: keyPath] = newValue + return true } diff --git a/Sources/ASCollectionView/RandomAccessCollection+Safe.swift b/Sources/ASCollectionView/Support/RandomAccessCollection+Safe.swift similarity index 100% rename from Sources/ASCollectionView/RandomAccessCollection+Safe.swift rename to Sources/ASCollectionView/Support/RandomAccessCollection+Safe.swift diff --git a/Sources/ASCollectionView/ASCollectionView+ShrinkToFit.swift b/Sources/ASCollectionView/Support/ShrinkToFitWrapper.swift similarity index 69% rename from Sources/ASCollectionView/ASCollectionView+ShrinkToFit.swift rename to Sources/ASCollectionView/Support/ShrinkToFitWrapper.swift index fc02b70..99d9012 100644 --- a/Sources/ASCollectionView/ASCollectionView+ShrinkToFit.swift +++ b/Sources/ASCollectionView/Support/ShrinkToFitWrapper.swift @@ -33,6 +33,7 @@ struct SelfSizingWrapper: View var content: Content var shrinkDirection: ShrinkDimension var isEnabled: Bool = true + var expandToFitMode: Bool = false var modifiedContent: Content { @@ -43,7 +44,7 @@ struct SelfSizingWrapper: View var body: some View { - SubWrapper(contentSizeTracker: contentSizeTracker, content: modifiedContent, shrinkDirection: shrinkDirection, isEnabled: isEnabled) + SubWrapper(contentSizeTracker: contentSizeTracker, content: modifiedContent, shrinkDirection: shrinkDirection, isEnabled: isEnabled, expandToFitMode: expandToFitMode) } } @@ -56,15 +57,18 @@ struct SubWrapper: View var content: Content var shrinkDirection: ShrinkDimension var isEnabled: Bool + var expandToFitMode: Bool var body: some View { content .frame( + minWidth: isEnabled && expandToFitMode && shrinkDirection.shrinkHorizontal ? contentSizeTracker.contentSize?.width : nil, idealWidth: isEnabled && shrinkDirection.shrinkHorizontal ? contentSizeTracker.contentSize?.width : nil, - maxWidth: isEnabled && shrinkDirection.shrinkHorizontal ? contentSizeTracker.contentSize?.width : nil, + maxWidth: expandToFitMode ? .infinity : (isEnabled && shrinkDirection.shrinkHorizontal ? contentSizeTracker.contentSize?.width : nil), + minHeight: isEnabled && expandToFitMode && shrinkDirection.shrinkVertical ? contentSizeTracker.contentSize?.height : nil, idealHeight: isEnabled && shrinkDirection.shrinkVertical ? contentSizeTracker.contentSize?.height : nil, - maxHeight: isEnabled && shrinkDirection.shrinkVertical ? contentSizeTracker.contentSize?.height : nil, + maxHeight: expandToFitMode ? .infinity : (isEnabled && shrinkDirection.shrinkVertical ? contentSizeTracker.contentSize?.height : nil), alignment: .topLeading) } } @@ -75,21 +79,3 @@ class ContentSizeTracker: ObservableObject @Published var contentSize: CGSize? } - -@available(iOS 13.0, *) -public extension ASCollectionView -{ - func shrinkToContentSize(isEnabled: Bool = true, dimension: ShrinkDimension) -> some View - { - SelfSizingWrapper(content: self, shrinkDirection: dimension, isEnabled: isEnabled) - } -} - -@available(iOS 13.0, *) -public extension ASTableView -{ - func shrinkToContentSize(isEnabled: Bool = true) -> some View - { - SelfSizingWrapper(content: self, shrinkDirection: .vertical, isEnabled: isEnabled) - } -} diff --git a/Sources/ASCollectionView/UIKit/ASHostingController.swift b/Sources/ASCollectionView/UIKit/ASHostingController.swift new file mode 100644 index 0000000..cbacc29 --- /dev/null +++ b/Sources/ASCollectionView/UIKit/ASHostingController.swift @@ -0,0 +1,195 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +@available(iOS 13.0, *) +internal struct ASHostingControllerWrapper: View, ASHostingControllerWrapperProtocol +{ + var invalidateCellLayoutCallback: ((_ animated: Bool) -> Void)? + var collectionViewScrollToCellCallback: ((UICollectionView.ScrollPosition) -> Void)? + var tableViewScrollToCellCallback: ((UITableView.ScrollPosition) -> Void)? + + var content: Content + var body: some View + { + content + .edgesIgnoringSafeArea(.all) + .environment(\.invalidateCellLayout, invalidateCellLayoutCallback) + .environment(\.collectionViewScrollToCell, collectionViewScrollToCellCallback) + .environment(\.tableViewScrollToCell, tableViewScrollToCellCallback) + } +} + +protocol ASHostingControllerWrapperProtocol +{ + var invalidateCellLayoutCallback: ((_ animated: Bool) -> Void)? { get set } + var collectionViewScrollToCellCallback: ((UICollectionView.ScrollPosition) -> Void)? { get set } + var tableViewScrollToCellCallback: ((UITableView.ScrollPosition) -> Void)? { get set } +} + +@available(iOS 13.0, *) +internal protocol ASHostingControllerProtocol: AnyObject, ASHostingControllerWrapperProtocol +{ + var viewController: UIViewController { get } + func sizeThatFits(in size: CGSize, maxSize: ASOptionalSize, selfSizeHorizontal: Bool, selfSizeVertical: Bool) -> CGSize +} + +@available(iOS 13.0, *) +internal class ASHostingController: ASHostingControllerProtocol +{ + init(_ view: ViewType) + { + uiHostingController = .init(rootView: ASHostingControllerWrapper(content: view)) + } + + private let uiHostingController: AS_UIHostingController> + var viewController: UIViewController + { + uiHostingController.view.backgroundColor = .clear + uiHostingController.view.insetsLayoutMarginsFromSafeArea = false + return uiHostingController as UIViewController + } + + var disableSwiftUIDropInteraction: Bool + { + get { uiHostingController.shouldDisableDrop } + set { uiHostingController.shouldDisableDrop = newValue } + } + + var disableSwiftUIDragInteraction: Bool + { + get { uiHostingController.shouldDisableDrag } + set { uiHostingController.shouldDisableDrag = newValue } + } + + var hostedView: ViewType + { + get + { + uiHostingController.rootView.content + } + set + { + uiHostingController.rootView.content = newValue + } + } + + var invalidateCellLayoutCallback: ((_ animated: Bool) -> Void)? + { + get + { + uiHostingController.rootView.invalidateCellLayoutCallback + } + set + { + uiHostingController.rootView.invalidateCellLayoutCallback = newValue + } + } + + var collectionViewScrollToCellCallback: ((UICollectionView.ScrollPosition) -> Void)? + { + get + { + uiHostingController.rootView.collectionViewScrollToCellCallback + } + set + { + uiHostingController.rootView.collectionViewScrollToCellCallback = newValue + } + } + + var tableViewScrollToCellCallback: ((UITableView.ScrollPosition) -> Void)? + { + get + { + uiHostingController.rootView.tableViewScrollToCellCallback + } + set + { + uiHostingController.rootView.tableViewScrollToCellCallback = newValue + } + } + + func setView(_ view: ViewType) + { + hostedView = view + } + + func sizeThatFits(in size: CGSize, maxSize: ASOptionalSize, selfSizeHorizontal: Bool, selfSizeVertical: Bool) -> CGSize + { + let fittingSize = CGSize( + width: selfSizeHorizontal ? .infinity : size.width, + height: selfSizeVertical ? .infinity : size.height).applyMaxSize(maxSize) + + // Find the desired size + var desiredSize = uiHostingController.sizeThatFits(in: fittingSize) + + // Accounting for 'greedy' swiftUI views that take up as much space as they can + switch (desiredSize.width, desiredSize.height) + { + case (.infinity, .infinity): + desiredSize = uiHostingController.sizeThatFits(in: size) + case (.infinity, _): + desiredSize = uiHostingController.sizeThatFits(in: CGSize(width: size.width, height: fittingSize.height)) + case (_, .infinity): + desiredSize = uiHostingController.sizeThatFits(in: CGSize(width: fittingSize.width, height: size.height)) + default: break + } + + // Ensure correct dimensions in non-self sizing axes + if !selfSizeHorizontal { desiredSize.width = size.width } + if !selfSizeVertical { desiredSize.height = size.height } + + return desiredSize.applyMaxSize(maxSize) + } +} + +@available(iOS 13.0, *) +private class AS_UIHostingController: UIHostingController +{ + var shouldDisableDrop: Bool = false + { + didSet + { + disableInteractionsIfNeeded() + } + } + + var shouldDisableDrag: Bool = false + { + didSet + { + disableInteractionsIfNeeded() + } + } + + private func disableInteractionsIfNeeded() + { + guard let view = viewIfLoaded else { return } + if shouldDisableDrop + { + if let dropInteraction = view.interactions.first(where: { + $0.isKind(of: UIDropInteraction.self) + }) as? UIDropInteraction + { + view.removeInteraction(dropInteraction) + } + } + if shouldDisableDrag + { + if let contextInteraction = view.interactions.first(where: { + $0.isKind(of: UIDragInteraction.self) + }) as? UIDragInteraction + { + view.removeInteraction(contextInteraction) + } + } + } + + override func loadView() + { + super.loadView() + disableInteractionsIfNeeded() + } +} diff --git a/Sources/ASCollectionView/UIKit/AS_UICollectionView.swift b/Sources/ASCollectionView/UIKit/AS_UICollectionView.swift new file mode 100644 index 0000000..30de499 --- /dev/null +++ b/Sources/ASCollectionView/UIKit/AS_UICollectionView.swift @@ -0,0 +1,93 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +@available(iOS 13.0, *) +public class AS_CollectionViewController: UIViewController +{ + weak var coordinator: ASCollectionViewCoordinator? + + var collectionViewLayout: UICollectionViewLayout + lazy var collectionView: AS_UICollectionView = { + let cv = AS_UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + return cv + }() + + public init(collectionViewLayout layout: UICollectionViewLayout) + { + collectionViewLayout = layout + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) + { + fatalError("init(coder:) has not been implemented") + } + + public override func viewWillAppear(_ animated: Bool) + { + super.viewWillAppear(animated) + // NOTE: Due to some SwiftUI bugs currently, we've chosen to call this here instead of actual parent call + coordinator?.onMoveToParent() + } + + public override func viewDidDisappear(_ animated: Bool) + { + super.viewDidDisappear(animated) + // NOTE: Due to some SwiftUI bugs currently, we've chosen to call this here instead of actual parent call + coordinator?.onMoveFromParent() + } + + public override func loadView() + { + view = collectionView + } + + public override func viewDidLoad() + { + super.viewDidLoad() + view.backgroundColor = .clear + } + + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) + { + // Get current central cell + self.coordinator?.prepareForOrientationChange() + + super.viewWillTransition(to: size, with: coordinator) + // The following is a workaround to fix the interface rotation animation under SwiftUI + view.frame = CGRect(origin: view.frame.origin, size: size) + + coordinator.animate(alongsideTransition: { _ in + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + if + let desiredOffset = self.coordinator?.getContentOffsetForOrientationChange(), + self.collectionView.contentOffset != desiredOffset + { + self.collectionView.contentOffset = desiredOffset + } + }) + { _ in + // Completion + self.coordinator?.completedOrientationChange() + } + } + + public override func viewSafeAreaInsetsDidChange() + { + super.viewSafeAreaInsetsDidChange() + // The following is a workaround to fix the interface rotation animation under SwiftUI + collectionViewLayout.invalidateLayout() + } + + public override func viewDidLayoutSubviews() + { + super.viewDidLayoutSubviews() + coordinator?.didUpdateContentSize(collectionView.contentSize) + } +} + +@available(iOS 13.0, *) +class AS_UICollectionView: UICollectionView {} diff --git a/Sources/ASCollectionView/UIKit/AS_UITableView.swift b/Sources/ASCollectionView/UIKit/AS_UITableView.swift new file mode 100644 index 0000000..dfc7321 --- /dev/null +++ b/Sources/ASCollectionView/UIKit/AS_UITableView.swift @@ -0,0 +1,63 @@ +// ASCollectionView. Created by Apptek Studios 2019 + +import Foundation +import SwiftUI + +@available(iOS 13.0, *) +public class AS_TableViewController: UIViewController +{ + weak var coordinator: ASTableViewCoordinator? + + var style: UITableView.Style + + lazy var tableView: AS_UITableView = { + let tableView = AS_UITableView(frame: .zero, style: style) + tableView.tableHeaderView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: CGFloat.leastNormalMagnitude, height: CGFloat.leastNormalMagnitude))) // Remove unnecessary padding in Style.grouped/insetGrouped + tableView.tableFooterView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: CGFloat.leastNormalMagnitude, height: CGFloat.leastNormalMagnitude))) // Remove separators for non-existent cells + return tableView + }() + + public init(style: UITableView.Style) + { + self.style = style + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) + { + fatalError("init(coder:) has not been implemented") + } + + public override func loadView() + { + view = tableView + } + + public override func viewDidLoad() + { + super.viewDidLoad() + } + + public override func viewDidLayoutSubviews() + { + super.viewDidLayoutSubviews() + coordinator?.didUpdateContentSize(tableView.contentSize) + } + + public override func viewWillAppear(_ animated: Bool) + { + super.viewWillAppear(animated) + // NOTE: Due to some SwiftUI bugs currently, we've chosen to call this here instead of actual parent call + coordinator?.onMoveToParent() + } + + public override func viewDidDisappear(_ animated: Bool) + { + super.viewDidDisappear(animated) + // NOTE: Due to some SwiftUI bugs currently, we've chosen to call this here instead of actual parent call + coordinator?.onMoveFromParent() + } +} + +@available(iOS 13.0, *) +class AS_UITableView: UITableView {} diff --git a/Sources/ASCollectionView/UICollectionView+Convenience.swift b/Sources/ASCollectionView/UIKitExtensions/UICollectionView+Convenience.swift similarity index 100% rename from Sources/ASCollectionView/UICollectionView+Convenience.swift rename to Sources/ASCollectionView/UIKitExtensions/UICollectionView+Convenience.swift diff --git a/Sources/ASCollectionView/UIScrollView+Convenience.swift b/Sources/ASCollectionView/UIKitExtensions/UIScrollView+Convenience.swift similarity index 100% rename from Sources/ASCollectionView/UIScrollView+Convenience.swift rename to Sources/ASCollectionView/UIKitExtensions/UIScrollView+Convenience.swift diff --git a/Sources/ASCollectionView/UITableView+Convenience.swift b/Sources/ASCollectionView/UIKitExtensions/UITableView+Convenience.swift similarity index 62% rename from Sources/ASCollectionView/UITableView+Convenience.swift rename to Sources/ASCollectionView/UIKitExtensions/UITableView+Convenience.swift index 73e6316..c0e4229 100644 --- a/Sources/ASCollectionView/UITableView+Convenience.swift +++ b/Sources/ASCollectionView/UIKitExtensions/UITableView+Convenience.swift @@ -5,18 +5,18 @@ import UIKit extension UITableView { /// The section header views that are visible in the table view. - var visibleHeaderViews: [(sectionIndex: Int, view: UITableViewHeaderFooterView)] + var visibleHeaderViews: [UITableViewHeaderFooterView] { visibleSections.compactMap { index in - headerView(forSection: index).map { (sectionIndex: index, view: $0) } + headerView(forSection: index) } } /// The section footer views that are visible in the table view. - var visibleFooterViews: [(sectionIndex: Int, view: UITableViewHeaderFooterView)] + var visibleFooterViews: [UITableViewHeaderFooterView] { visibleSections.compactMap { index in - footerView(forSection: index).map { (sectionIndex: index, view: $0) } + footerView(forSection: index) } }