Unverified Commit 14a5d095 authored by Apptek Studios's avatar Apptek Studios Committed by GitHub
Browse files

Add support for `onReachedBoundary` closure, support for RandomAccessCollection, more... (#64)

* `onReachedBoundary` support
* Implement DataSource support for RandomAccessCollection (allows use of FetchedResults directly)
* Improve initial loading of data
parent be65d275
Showing with 157 additions and 52 deletions
+157 -52
......@@ -124,7 +124,7 @@
B86C6F16234B078600522AEF /* AppDelegate.swift */,
B86C6F18234B078600522AEF /* SceneDelegate.swift */,
B86C6F42234B0B9D00522AEF /* MainView.swift */,
B86C6F35234B0B9D00522AEF /* Models */,
B86C6F35234B0B9D00522AEF /* SharedModels */,
B86C6F30234B0B9D00522AEF /* Support */,
B86C6F38234B0B9D00522AEF /* Screens */,
B86C6F1C234B078800522AEF /* Assets.xcassets */,
......@@ -161,12 +161,12 @@
path = Support;
sourceTree = "<group>";
};
B86C6F35234B0B9D00522AEF /* Models */ = {
B86C6F35234B0B9D00522AEF /* SharedModels */ = {
isa = PBXGroup;
children = (
B86C6F36234B0B9D00522AEF /* Post.swift */,
);
path = Models;
path = SharedModels;
sourceTree = "<group>";
};
B86C6F38234B0B9D00522AEF /* Screens */ = {
......
......@@ -6,7 +6,7 @@ import UIKit
struct InstaFeedScreen: View
{
@State var data: [[Post]] = (0...3).map { DataSource.postsForInstaSection($0) }
@State var data: [[Post]] = (0...1).map { DataSource.postsForInstaSection($0) }
var sections: [ASTableViewSection<Int>]
{
......@@ -30,6 +30,10 @@ struct InstaFeedScreen: View
}
.frame(height: 100)
.scrollIndicatorsEnabled(false)
.onCollectionViewReachedBoundary
{ boundary in
print("Reached the \(boundary) boundary")
}
}
}
else
......
......@@ -46,6 +46,10 @@ struct MagazineLayoutScreen: View
.customDelegate(ASCollectionViewMagazineLayoutDelegate.init)
.edgesIgnoringSafeArea(.all)
.navigationBarTitle("Magazine Layout (custom delegate)", displayMode: .inline)
.onCollectionViewReachedBoundary
{ boundary in
print("Reached the \(boundary) boundary")
}
}
func onCellEvent(_ event: CellEvent<Post>)
......
......@@ -53,7 +53,7 @@ struct DataSource
}
}
static func postsForInstaSection(_ sectionID: Int, number: Int = 12) -> [Post]
static func postsForInstaSection(_ sectionID: Int, number: Int = 5) -> [Post]
{
(0..<number).map
{ b -> Post in
......
......@@ -70,19 +70,24 @@ public struct ASCollectionView<SectionID: Hashable>: UIViewControllerRepresentab
var delegateInitialiser: (() -> ASCollectionViewDelegate) = ASCollectionViewDelegate.init
var shouldInvalidateLayoutOnStateChange: Bool = false
var shouldAnimateInvalidatedLayoutOnStateChange: Bool = false
var shouldRecreateLayoutOnStateChange: Bool = false
var shouldAnimateRecreatedLayoutOnStateChange: Bool = false
// MARK: Environment variables
@Environment(\.scrollIndicatorsEnabled) private var scrollIndicatorsEnabled
@Environment(\.contentInsets) private var contentInsets
@Environment(\.alwaysBounceHorizontal) private var alwaysBounceHorizontal
@Environment(\.alwaysBounceVertical) private var alwaysBounceVertical
@Environment(\.initialScrollPosition) private var initialScrollPosition
@Environment(\.collectionViewOnReachedBoundary) private var onReachedBoundary
@Environment(\.editMode) private var editMode
// MARK: Internal variables modified by modifier functions
var shouldInvalidateLayoutOnStateChange: Bool = false
var shouldAnimateInvalidatedLayoutOnStateChange: Bool = false
var shouldRecreateLayoutOnStateChange: Bool = false
var shouldAnimateRecreatedLayoutOnStateChange: Bool = false
// MARK: Init for multi-section CVs
/**
......@@ -171,7 +176,10 @@ public struct ASCollectionView<SectionID: Hashable>: UIViewControllerRepresentab
var hostingControllerCache = ASFIFODictionary<ASCollectionViewItemUniqueID, ASHostingControllerProtocol>()
var hasSetInitialScrollPosition = false
// MARK: Private tracking variables
private var hasDoneInitialSetup = false
private var hasFiredBoundaryNotificationForBoundary: Set<Boundary> = []
typealias Cell = ASCollectionViewCell
......@@ -272,7 +280,8 @@ public struct ASCollectionView<SectionID: Hashable>: UIViewControllerRepresentab
func updateContent(_ cv: UICollectionView, animated: Bool, refreshExistingCells: Bool)
{
if refreshExistingCells, collectionViewController?.parent != nil
guard collectionViewController?.parent != nil else { return }
if refreshExistingCells
{
cv.visibleCells.forEach
{ cell in
......@@ -294,12 +303,20 @@ public struct ASCollectionView<SectionID: Hashable>: UIViewControllerRepresentab
}
}
}
populateDataSource(animated: collectionViewController?.parent != nil)
populateDataSource(animated: animated)
updateSelectionBindings(cv)
if !hasSetInitialScrollPosition
{
}
func onMoveToParent(_ parentController: AS_CollectionViewController)
{
if !hasDoneInitialSetup {
hasDoneInitialSetup = true
// Populate data source
populateDataSource(animated: false)
// Set initial scroll position
parent.initialScrollPosition.map { scrollToPosition($0, animated: false) }
hasSetInitialScrollPosition = true
}
}
......@@ -310,11 +327,11 @@ public struct ASCollectionView<SectionID: Hashable>: UIViewControllerRepresentab
case .top, .left:
collectionViewController?.collectionView.setContentOffset(.zero, animated: animated)
case .bottom:
guard let contentSize = collectionViewController?.collectionView.contentSizePlusInsets else { return }
collectionViewController?.collectionView.setContentOffset(.init(x: 0, y: contentSize.height), animated: animated)
guard let maxOffset = collectionViewController?.collectionView.maxContentOffset else { return }
collectionViewController?.collectionView.setContentOffset(.init(x: 0, y: maxOffset.y), animated: animated)
case .right:
guard let contentSize = collectionViewController?.collectionView.contentSizePlusInsets else { return }
collectionViewController?.collectionView.setContentOffset(.init(x: contentSize.width, y: 0), animated: animated)
guard let maxOffset = collectionViewController?.collectionView.maxContentOffset else { return }
collectionViewController?.collectionView.setContentOffset(.init(x: maxOffset.x, y: 0), animated: animated)
case let .centerOnIndexPath(indexPath):
guard let offset = getContentOffsetToCenterCell(at: indexPath) else { return }
collectionViewController?.collectionView.setContentOffset(offset, animated: animated)
......@@ -404,7 +421,7 @@ public struct ASCollectionView<SectionID: Hashable>: UIViewControllerRepresentab
public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath)
{
(cell as? Cell)?.willAppear(in: collectionViewController)
collectionViewController.map { (cell as? Cell)?.willAppear(in: $0) }
currentlyPrefetching.remove(indexPath)
guard !indexPath.isEmpty, indexPath.section < parent.sections.endIndex else { return }
parent.sections[indexPath.section].dataSource.onAppear(indexPath)
......@@ -525,6 +542,54 @@ public struct ASCollectionView<SectionID: Hashable>: UIViewControllerRepresentab
}
}
// MARK: OnReachedEnd support
extension ASCollectionView.Coordinator
{
public func scrollViewDidScroll(_ scrollView: UIScrollView)
{
checkIfReachedBoundary(scrollView)
}
func checkIfReachedBoundary(_ scrollView: UIScrollView)
{
let scrollableHorizontally = scrollView.contentSizePlusInsets.width > scrollView.frame.size.width
let scrollableVertically = scrollView.contentSizePlusInsets.height > scrollView.frame.size.height
for boundary in Boundary.allCases
{
let hasReachedBoundary: Bool = {
switch boundary
{
case .left:
return scrollableHorizontally && scrollView.contentOffset.x <= 0
case .top:
return scrollableVertically && scrollView.contentOffset.y <= -scrollView.adjustedContentInset.top
case .right:
return scrollableHorizontally && (scrollView.contentSizePlusInsets.width - scrollView.contentOffset.x) <= scrollView.frame.size.width
case .bottom:
return scrollableVertically && (scrollView.contentSizePlusInsets.height - scrollView.contentOffset.y) <= scrollView.frame.size.height
}
}()
if hasReachedBoundary
{
// If we haven't already fired the notification, send it now
if !hasFiredBoundaryNotificationForBoundary.contains(boundary)
{
hasFiredBoundaryNotificationForBoundary.insert(boundary)
parent.onReachedBoundary(boundary)
}
}
else
{
// No longer at this boundary, reset so it can fire again if needed
hasFiredBoundaryNotificationForBoundary.remove(boundary)
}
}
}
}
// MARK: Modifer: Custom Delegate
public extension ASCollectionView
......@@ -564,7 +629,7 @@ public extension ASCollectionView
return this
}
}
// MARK: Coordinator Protocol
internal protocol ASCollectionViewCoordinator: AnyObject
{
func typeErasedDataForItem(at indexPath: IndexPath) -> Any?
......@@ -582,6 +647,8 @@ internal protocol ASCollectionViewCoordinator: AnyObject
func removeItem(from indexPath: IndexPath)
func insertItems(_ items: [UIDragItem], at indexPath: IndexPath)
func didUpdateContentSize(_ size: CGSize)
func scrollViewDidScroll(_ scrollView: UIScrollView)
func onMoveToParent(_ collectionViewController: AS_CollectionViewController)
}
// MARK: Custom Prefetching Implementation
......@@ -590,9 +657,9 @@ extension ASCollectionView.Coordinator
{
func setupPrefetching()
{
let numberToPreload = 10
let numberToPreload = 8
prefetchSubscription = queuePrefetch
.collect(.byTime(DispatchQueue.main, 0.1)) // Wanted to use .throttle(for: 0.1, scheduler: DispatchQueue(label: "ASCollectionView PREFETCH"), latest: true) -> THIS CRASHES?? BUG??
.collect(.byTime(DispatchQueue.main, 0.1)) // .throttle CRASHES on 13.1, fixed from 13.3 but still using .collect for 13.1 compatibility
.compactMap
{ _ in
self.collectionViewController?.collectionView.indexPathsForVisibleItems
......@@ -689,6 +756,12 @@ public class AS_CollectionViewController: UIViewController
fatalError("init(coder:) has not been implemented")
}
public override func didMove(toParent parent: UIViewController?)
{
super.didMove(toParent: parent)
coordinator?.onMoveToParent(self)
}
public override func viewDidLoad()
{
super.viewDidLoad()
......
......@@ -28,17 +28,17 @@ class ASCollectionViewCell: UICollectionViewCell
self.id = id
}
func willAppear(in vc: UIViewController?)
func willAppear(in vc: UIViewController)
{
hostingController.map
{
$0.viewController.removeFromParent()
vc?.addChild($0.viewController)
vc.addChild($0.viewController)
contentView.addSubview($0.viewController.view)
setNeedsLayout()
vc.map { hostingController?.viewController.didMove(toParent: $0) }
hostingController?.viewController.didMove(toParent: vc)
}
}
......
......@@ -140,4 +140,9 @@ extension ASCollectionViewDelegate: UICollectionViewDragDelegate, UICollectionVi
return
}
}
public func scrollViewDidScroll(_ scrollView: UIScrollView)
{
self.coordinator?.scrollViewDidScroll(scrollView)
}
}
......@@ -49,17 +49,18 @@ public struct ASCollectionViewSection<SectionID: Hashable>: Hashable
- 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<Data, DataID: Hashable, Content: View>(
public init<DataCollection: RandomAccessCollection, DataID: Hashable, Content: View> (
id: SectionID,
data: [Data],
dataID dataIDKeyPath: KeyPath<Data, DataID>,
onCellEvent: OnCellEvent<Data>? = nil,
onDragDropEvent: OnDragDrop<Data>? = nil,
itemProvider: ItemProvider<Data>? = nil,
@ViewBuilder contentBuilder: @escaping ((Data, CellContext) -> Content))
data: DataCollection,
dataID dataIDKeyPath: KeyPath<DataCollection.Element, DataID>,
onCellEvent: OnCellEvent<DataCollection.Element>? = nil,
onDragDropEvent: OnDragDrop<DataCollection.Element>? = nil,
itemProvider: ItemProvider<DataCollection.Element>? = nil,
@ViewBuilder contentBuilder: @escaping ((DataCollection.Element, CellContext) -> Content))
where DataCollection.Index == Int
{
self.id = id
dataSource = ASSectionDataSource<Data, DataID, Content>(
dataSource = ASSectionDataSource<DataCollection, DataID, Content>(
data: data,
dataIDKeyPath: dataIDKeyPath,
onCellEvent: onCellEvent,
......@@ -164,7 +165,7 @@ public extension ASCollectionViewSection
init(id: SectionID, @ViewArrayBuilder content: () -> [AnyView])
{
self.id = id
dataSource = ASSectionDataSource<ASCollectionViewStaticContent, ASCollectionViewStaticContent.ID, AnyView>(
dataSource = ASSectionDataSource<[ASCollectionViewStaticContent], ASCollectionViewStaticContent.ID, AnyView>(
data: content().enumerated().map
{
ASCollectionViewStaticContent(id: $0.offset, view: $0.element)
......@@ -183,7 +184,7 @@ public extension ASCollectionViewSection
init<Content: View>(id: SectionID, content: () -> Content)
{
self.id = id
dataSource = ASSectionDataSource<ASCollectionViewStaticContent, ASCollectionViewStaticContent.ID, AnyView>(
dataSource = ASSectionDataSource<[ASCollectionViewStaticContent], ASCollectionViewStaticContent.ID, AnyView>(
data: [ASCollectionViewStaticContent(id: 0, view: AnyView(content()))],
dataIDKeyPath: \.id,
content: { staticContent, _ in staticContent.view })
......
......@@ -52,9 +52,10 @@ public struct CellContext
public var isLastInSection: Bool
}
internal struct ASSectionDataSource<Data, DataID, Content>: ASSectionDataSourceProtocol where DataID: Hashable, Content: View
internal struct ASSectionDataSource<DataCollection: RandomAccessCollection, DataID, Content>: ASSectionDataSourceProtocol where DataID: Hashable, Content: View, DataCollection.Index == Int
{
var data: [Data]
typealias Data = DataCollection.Element
var data: DataCollection
var dataIDKeyPath: KeyPath<Data, DataID>
var onCellEvent: OnCellEvent<Data>?
var onDragDrop: OnDragDrop<Data>?
......
......@@ -197,16 +197,6 @@ public struct ASTableView<SectionID: Hashable>: UIViewControllerRepresentable
return cell
}
dataSource?.defaultRowAnimation = .fade
/* self.dataSource?. = { (cv, kind, indexPath) -> UICollectionReusableView? in
guard
let reusableView = cv.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: self.supplementaryReuseID, for: indexPath) as? ASCollectionViewSupplementaryView
else { return nil }
let headerView = self.parent.sections[indexPath.section].header
reusableView.setupFor(id: indexPath.section,
view: headerView)
return reusableView
} */
}
func updateContent(_ tv: UITableView, refreshExistingCells: Bool)
......
......@@ -28,6 +28,11 @@ struct EnvironmentKeyASTableViewOnReachedBottom: EnvironmentKey
static let defaultValue: (() -> Void) = {}
}
struct EnvironmentKeyASCollectionViewOnReachedBoundary: EnvironmentKey
{
static let defaultValue: ((Boundary) -> Void) = { _ in }
}
struct EnvironmentKeyASAlwaysBounceVertical: EnvironmentKey
{
static let defaultValue: Bool = false
......@@ -75,6 +80,12 @@ public extension EnvironmentValues
set { self[EnvironmentKeyASTableViewOnReachedBottom.self] = newValue }
}
var collectionViewOnReachedBoundary: (Boundary) -> Void
{
get { self[EnvironmentKeyASCollectionViewOnReachedBoundary.self] }
set { self[EnvironmentKeyASCollectionViewOnReachedBoundary.self] = newValue }
}
var alwaysBounceVertical: Bool
{
get { return self[EnvironmentKeyASAlwaysBounceVertical.self] }
......@@ -116,6 +127,11 @@ public extension View
environment(\.tableViewOnReachedBottom, onReachedBottom)
}
func onCollectionViewReachedBoundary(_ onReachedBoundary: @escaping ((Boundary) -> Void)) -> some View
{
environment(\.collectionViewOnReachedBoundary, onReachedBoundary)
}
func alwaysBounceHorizontal(_ alwaysBounce: Bool = true) -> some View
{
environment(\.alwaysBounceHorizontal, alwaysBounce)
......
......@@ -70,18 +70,29 @@ extension UICollectionView
else { return nil }
return IndexPath(item: itemCount - 1, section: sectionCount - 1)
}
}
extension UIScrollView
{
var contentSizePlusInsets: CGSize
{
CGSize(
width: contentSize.width + contentInset.left + contentInset.right,
height: contentSize.height + contentInset.top + contentInset.bottom)
width: contentSize.width + adjustedContentInset.left + adjustedContentInset.right,
height: contentSize.height + adjustedContentInset.bottom + contentInset.top) // NOTE: the adjusted top inset intentionally left out, as SwiftUI uses a negative contentOffset to display the nav bar (doesn't affect content size)
}
var maxContentOffset: CGPoint
{
CGPoint(
x: max(0, contentSizePlusInsets.width - bounds.width),
y: max(0, contentSizePlusInsets.height - bounds.height))
y: max(0, contentSizePlusInsets.height + safeAreaInsets.top - bounds.height))
}
}
public enum Boundary: CaseIterable
{
case left
case right
case top
case bottom
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment