How to call function when collection view is reloaded? - ios

I have a collection view and want to run some things when it is reloaded by the user. This reloading is caused by the user dragging down on the view until the reload icon comes out. Putting this function at anything else other than when the user does an interaction to reload the collection, will make this an unfeasible solution for me.

What about the following solution. I implemented it for collection-, table- and scroll views, in case other people have similar issues:
final class ScrollViewRefreshDemo: UIViewController {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
let tableView = UITableView()
let scrollView = UIScrollView()
override func viewDidLoad() {
super.viewDidLoad()
attachRefreshListener(to: collectionView)
attachRefreshListener(to: tableView)
attachRefreshListener(to: scrollView)
}
func attachRefreshListener(to scrollView: UIScrollView) {
scrollView.refreshControl?.addTarget(self, action: #selector(scrollViewDidRefresh), for: .valueChanged)
}
#objc
func scrollViewDidRefresh() {
// Execute any task while the scroll view is refreshing
}
}

Related

UIScrollView delegate methods not called when UICollectionViewCompositionalLayout is set

I currently have a UICollectionView using UICollectionViewCompositionalLayout. I would like to animate some views within the current visible cells while scrolling / scrolling stops.
Unfortunately it seems setting orthogonalScrollingBehavior on a section to anything but .none hijacks the UICollectionView accompanying UIScrollView delegate methods.
Was wondering if there're any current workaround for this? To get the paging behaviour and UIScrollView delegate?
Setup layout
enum Section {
case main
}
override func awakeFromNib() {
super.awakeFromNib()
collectionView.collectionViewLayout = createLayout()
collectionView.delegate = self
}
func configure() {
snapshot.appendSections([.main])
snapshot.appendItems(Array(0..<10))
dataSource.apply(snapshot, animatingDifferences: false)
}
private func createLayout() -> UICollectionViewLayout {
let leadingItem = NSCollectionLayoutItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0))
)
leadingItem.contentInsets = .zero
let containerGroup = NSCollectionLayoutGroup.horizontal(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0)
),
subitems: [leadingItem])
let section = NSCollectionLayoutSection(group: containerGroup)
section.orthogonalScrollingBehavior = .groupPaging // WOULD LIKE PAGING & UISCROLLVIEW TO ALSO BE FIRED
let config = UICollectionViewCompositionalLayoutConfiguration()
config.scrollDirection = .horizontal
let layout = UICollectionViewCompositionalLayout(section: section, configuration: config)
return layout
}
UICollectionViewDelegate
extension SlidingCardView: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
// THIS IS FIRED BUT UISCROLLVIEW METHODS NOT
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
print(111)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
print("1111111")
}
}
Setting orthogonalScrollingBehavior to a section, embeds an internal _UICollectionViewOrthogonalScrollerEmbeddedScrollView which handles the scrolling in a section. This internal scrollview is added as a subview to your collection view.
When you set yourself as a delegate to your collection view you should receive the scroll view delegate callbacks BUT ONLY for the main collection view, that scrolls between the sections and not the items in a section. Since the internal scrollviews (which may also be collectionViews, not sure) are completely different instances and you are not setting yourself as a delegate to them, you are not receiving their callbacks.
So as far as i know, there should not be an official way to receive these callbacks from the internal scrollviews that handle the scrolling in sections.
but if you are curious and you want to experiment with that you could use this 'hacked' collectionView class:
import UIKit
final class OrtogonalScrollingCollectionView: UICollectionView {
override var delegate: UICollectionViewDelegate? {
get { super.delegate }
set {
super.delegate = newValue
subviews.forEach { (view) in
guard String(describing: type(of: view)) == "_UICollectionViewOrthogonalScrollerEmbeddedScrollView" else { return }
guard let scrollView = view as? UIScrollView else { return }
scrollView.delegate = newValue
}
}
}
}
that would set your delegate to all internal scrollview that come with the orthogonal sections. You should not be using this in production environment, because there is no guarantee that Apple will keep the inner workings of the collection views the same way so this hack may not work in the future, plus you might get rejected for using private APIs in UIKit when you submit a build for release.
You may just want to use visibleItemsInvalidationHandler callback of your NSCollectionLayoutSection it acts like the UIScrollViewDelegate it will be invoked each time the section scrolls
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPagingCentered
section.visibleItemsInvalidationHandler = { (visibleItems, point, env) -> Void in
print(point)
}
Following #Stoyan answer, I fine tuned the class to be compatible with producition code by not looking for private APIs. Simply looking at all UIScrollView subclasses.
Also I think it's better to update the delegates during collection reload as you might not have the full view hierarchy yet when setting the delegate.
Finally, the class now recursively looks for UIScrollView so nothing is ever missed.
final class OrthogonalScrollingCollectionView: UICollectionView {
override func reloadData() {
super.reloadData()
scrollViews(in: self).forEach { scrollView in
scrollView.delegate = delegate
}
}
override func reloadSections(_ sections: IndexSet) {
super.reloadSections(sections)
scrollViews(in: self).forEach { scrollView in
scrollView.delegate = delegate
}
}
fileprivate func scrollViews(in subview: UIView) -> [UIScrollView] {
var scrollViews: [UIScrollView] = []
subview.subviews.forEach { view in
if let scrollView = view as? UIScrollView {
scrollViews.append(scrollView)
} else {
scrollViews.append(contentsOf: self.scrollViews(in: view))
}
}
return scrollViews
}
}
Here is a solution for determining which cell is in the center of the screen:
section.visibleItemsInvalidationHandler = { [weak self] visibleItems, point, environment in
guard let self = self else { return }
for visibleCell in self.collectionView.visibleCells {
let collectionViewCenterPoint = self.collectionView.center
if let relativePoint = visibleCell.superview?.convert(collectionViewCenterPoint, from: nil),
visibleCell.frame.contains(relativePoint)
{
// visibleCell is in the center of the view.
} else {
// visibleCell is outside the center of the view.
}
}
}

How to add self sizing child UICollectionViewController?

I have a view controller which has a container view.
I would like to add a UICollectionViewController as a child view controller i.e., add the view of the UICollectionViewController as a subview of the container view and call didMove(toParent:).
I would like the height of the container view to be dynamic so that it depends on the height of the UICollectionViewController's view. I want the height UICollectionView to be equal to its content height.
Can anyone point out how to do this?
This is what I have done till now-
While adding the UICollectionViewController, set translatesAutoresizingMaskIntoConstraints to false, as given in https://stackoverflow.com/a/35431534.
let viewController = CustomCollectionViewController()
viewController.view.translatesAutoresizingMaskIntoConstraints = false
addChild(viewController)
containerView.addSubview(viewController.view)
viewController.didMove(toParent: self)
viewController.view.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
viewController.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true
viewController.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true
viewController.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true
I created a custom UICollectionView such that its height depends on its content size as given in https://stackoverflow.com/a/49297382.
class CustomCollectionView: UICollectionView {
override func reloadData() {
super.reloadData()
invalidateIntrinsicContentSize()
}
override var intrinsicContentSize: CGSize {
return collectionViewLayout.collectionViewContentSize
}
}
Set the above custom UICollectionView as the collectionView of the CustomCollectionViewController using the answer given in https://stackoverflow.com/a/41404946
class CustomCollectionViewController: UICollectionViewController {
override func loadView() {
collectionView = CustomCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
}
}
What I have done does not work. Can anyone point out how to do this?

Is it possible to add a drag to reload horizontally for a collection view?

Update:
I belive it may not be possible given the folowing line in apples documentation:
When the user drags the top of the scrollable content area downward
Found here.
Let me know if there is a way to do this.
I am trying to make it so that when the user swipe left (the way you swipe up in many apps with tableViews to reload) in a collection view it will show a loading icon and reload data (the reload data part I can handle myself).
How can I detect this so I can call a reloadData() method?
Note: I am using a UICollectionView which only has one column and x rows. At the first cell if the user swipes left it should show a loading icon and reload data.
I am looking for a way to detect the slide left intended to reload.
What I have tried:
let refreshControl = UIRefreshControl()
override func viewDidLoad() {
super.viewDidLoad()
viewDidLoadMethods()
refreshControl.tintColor = .black
refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged)
collectionView.addSubview(refreshControl)
collectionView.alwaysBounceHorizontal = true
But this only works vertically.
I solved this problem with the following, but I should note that there is no default fucntionality like there is for vertical refresh:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset
let inset = scrollView.contentInset
let y: CGFloat = offset.x - inset.left
let reload_distance: CGFloat = -80
if y < reload_distance{
shouldReload = true
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if let _ = scrollView as? UICollectionView {
currentlyScrolling = false
if shouldReload {
baseVC.showReloading()
reloadCollectionView()
}
}
}

ReloadData with Protocol on custom cell with Collectionview?

I attempted a bunch of answers on here with no luck.
I am attempting to reload my collectionview within my custom cell after fetching data from firebase. I am unable to even get the print statement to show from the custom cell. Not sure what I'm doing wrong.
On my ViewController I have the following delegate and call, I made a button to test to see if the delegate print statement on my custom cell will appear but no luck.
protocol DiscoverControllerDelegate {
func didFetchData()
}
class DiscoverController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
let cellId = "cellId"
let headerId = "headerId"
let database = FirebaseData.sharedInstance
var delegate: DiscoverControllerDelegate?
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
title = "Discover"
collectionView?.backgroundColor = .white
collectionView?.register(CategoriesCell.self, forCellWithReuseIdentifier: cellId)
collectionView?.register(HeaderCell.self, forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, withReuseIdentifier: headerId)
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Refresh", style: .plain, target: self, action: #selector(reloadData))
database.retrieveData {
print("fetching data")
self.delegate?.didFetchData()
}
}
#objc func reloadData () {
print("attemping to reload")
self.delegate?.didFetchData()
}
*edit to add on my override init
On my custom cell:
class CategoriesCell: UICollectionViewCell, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout, DiscoverControllerDelegate {
let itemCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.backgroundColor = .clear
return collectionView
}()
func didFetchData() {
print("RELOADING NOW!")
itemCollectionView.reloadData()
}
override init(frame: CGRect) {
super.init(frame: frame)
itemCollectionView.dataSource = self
itemCollectionView.delegate = self
itemCollectionView.register(ItemCell.self, forCellWithReuseIdentifier: cellId)
itemCollectionView.reloadData()
createLayout()
}
Many things wrong here. The reason "RELOADING NOW!" never logs is because you never set the delegate in your view controller. If you inspect self.delegate in DiscoverController it is probably nil. However, your entire setup right now seems flawed unless I'm misunderstanding what you're doing.
What does your UI look like? Why does CategoriesCell have a UICollectionView property? It's itemCollectionView property doesn't even have a datasource or delegate set and its not an #IBOutlet yet you're calling reloadData() on it. Not sure what it's even displaying or what you're tyring to do.
I feel like there is probably a lot more wrong here than just a single thing. You probably want to go through some UITableView and UICollectionView tutorials to learn about delegates and datasources in iOS.
I dont know about collection view but I use similar protocol structures with a table view. They are not much different. your protocol should be in the cell class not in the controller.
I've gone through the steps for a table view in the following post:
Firebase update
Maybe you can adapt it for your needs...

Hand off parent container's pan gesture to nested UICollectionView

I'm trying to build a complex split view container controller that facilitates two variable height containers, each with their own nested view controller. There's a global pan gesture on the parent controller that allows the user to drag anywhere in the view container and slide the "divider" between views up and down. It also has some intelligent position threshold detection logic that will expand either view (or reset the divider position):
This works fine. There's also a lot of code to construct this, which I'm happy to share, but I don't think it's relevant, so I'll omit it for the time being.
I'm now trying to complicate things by adding a collection view to the bottom view:
I've been able to work it out so that I can scroll the split view up with a decisive pan gesture, and scroll the collection view with a quick flick of the finger (a swipe gesture, I suppose it is?), but this is a really sub-par experience: you can't pan the view and scroll the collection view at the same time, and expecting a user to consistently replicate similar, yet different gestures in order to control the view is too difficult of an interaction.
To attempt to solve this, I've tried several delegate/protocol solutions in which I detect the position of the divider in the split view and enable/disable canCancelTouchesInView and/or isUserInteractionEnable on the collection view based on whether the bottom view is fully expanded. This works to a point, but not in the following two scenarios:
When the split view divider is in its default position, if the user pans up to where the bottom view is fully expanded, then keeps on panning up, the collection view should begin scrolling until the gesture ends.
When the split view divider is at the top (bottom container view is fully expanded) and the collection view is not at the top, if the user pans down, the collection view should scroll instead of the split view divider moving, until the collection view reaches its top position, at which point the split view should return to its default position.
Here is an animation that illustrates this behavior:
Given this, I'm starting to think the only way to solve the problem is by creating a delegate method on the split view that tells the collection view when the bottom view is at maximum height, which then can intercept the parent's pan gesture or forward the screen touches to the collection view instead? But, I'm not sure how to do that. If I'm on the right track with a solution, then my question is simply: How can I forward or hand off a pan gesture to a collection view and have the collection view interact the same way it would if the touches had been captured by it in the first place? can I do something with pointInside or touches____ methods?
If I can't do it this way, how else can I solve this problem?
Update for bounty hunters: I've had some fragmented luck creating a delegate method on the collection view, and calling it on the split view container to set a property shouldScroll, by which I use some pan direction and positioning information to determine whether or not the scroll view should scroll. I then return this value in UIGestureRecognizerDelegate's gestureRecognizer:shouldReceive touch: delegate method:
// protocol delegate
protocol GalleryCollectionViewDelegate {
var shouldScroll: Bool? { get }
}
// shouldScroll property
private var _shouldScroll: Bool? = nil
var shouldScroll: Bool {
get {
// Will attempt to retrieve delegate value, or self set value, or return false
return self.galleryDelegate?.shouldScroll ?? self._shouldScroll ?? false
}
set {
self._shouldScroll = newValue
}
}
// UIGestureRecognizerDelegate method
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
return shouldScroll
}
// ----------------
// Delegate property/getter called on the split view controller and the logic:
var shouldScroll: Bool? {
get {
return panTarget != self
}
}
var panTarget: UIViewController! {
get {
// Use intelligent position detection to determine whether the pan should be
// captured by the containing splitview or the gallery's collectionview
switch (viewState.currentPosition,
viewState.pan?.directionTravelled,
galleryScene.galleryCollectionView.isScrolled) {
case (.top, .up?, _), (.top, .down?, true): return galleryScene
default: return self
}
}
}
This works OK for when you begin scrolling, but doesn't perform well once scrolling is enabled on the collection view, because the scroll gesture almost always overrides the pan gesture. I'm wondering if I can wire something up with gestureRecognizer:shouldRecognizeSimultaneouslyWith:, but I'm not there yet.
What about making the child view for bottom view actually takes up the entire screen and set the collection view's contentInset.top to top view height. And then add the other child view controller above the bottom view. Then the only thing you need to do is make the parent view controller the delegate to listen to the bottom view's collection view's scroll offset and change the top view's position. No complicated gesture recognizer stuff. Only one scroll view(collection view)
Update: Try this!!
import Foundation
import UIKit
let topViewHeight: CGFloat = 250
class SplitViewController: UIViewController, BottomViewControllerScrollDelegate {
let topViewController: TopViewController = TopViewController()
let bottomViewController: BottomViewController = BottomViewController()
override func viewDidLoad() {
super.viewDidLoad()
automaticallyAdjustsScrollViewInsets = false
bottomViewController.delegate = self
addViewController(bottomViewController, frame: view.bounds, completion: nil)
addViewController(topViewController, frame: CGRect(x: 0, y: 0, width: view.frame.size.width, height: topViewHeight), completion: nil)
}
func bottomViewScrollViewDidScroll(_ scrollView: UIScrollView) {
print("\(scrollView.contentOffset.y)")
let offset = (scrollView.contentOffset.y + topViewHeight)
if offset < 0 {
topViewController.view.frame.origin.y = 0
topViewController.view.frame.size.height = topViewHeight - offset
} else {
topViewController.view.frame.origin.y = -(scrollView.contentOffset.y + topViewHeight)
topViewController.view.frame.size.height = topViewHeight
}
}
}
class TopViewController: UIViewController {
let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
automaticallyAdjustsScrollViewInsets = false
view.backgroundColor = UIColor.red
label.text = "Top View"
view.addSubview(label)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
label.sizeToFit()
label.center = view.center
}
}
protocol BottomViewControllerScrollDelegate: class {
func bottomViewScrollViewDidScroll(_ scrollView: UIScrollView)
}
class BottomViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
var collectionView: UICollectionView!
weak var delegate: BottomViewControllerScrollDelegate?
let cellPadding: CGFloat = 5
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.yellow
automaticallyAdjustsScrollViewInsets = false
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = cellPadding
layout.minimumLineSpacing = cellPadding
layout.scrollDirection = .vertical
layout.sectionInset = UIEdgeInsets(top: cellPadding, left: 0, bottom: cellPadding, right: 0)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.contentInset.top = topViewHeight
collectionView.scrollIndicatorInsets.top = topViewHeight
collectionView.alwaysBounceVertical = true
collectionView.backgroundColor = .clear
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: String(describing: UICollectionViewCell.self))
view.addSubview(collectionView)
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 30
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: UICollectionViewCell.self), for: indexPath)
cell.backgroundColor = UIColor.darkGray
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = floor((collectionView.frame.size.width - 2 * cellPadding) / 3)
return CGSize(width: width, height: width)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.bottomViewScrollViewDidScroll(scrollView)
}
}
extension UIViewController {
func addViewController(_ viewController: UIViewController, frame: CGRect, completion: (()-> Void)?) {
viewController.willMove(toParentViewController: self)
viewController.beginAppearanceTransition(true, animated: false)
addChildViewController(viewController)
viewController.view.frame = frame
viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(viewController.view)
viewController.didMove(toParentViewController: self)
viewController.endAppearanceTransition()
completion?()
}
}
You can't "hand off" a gesture, because the gesture recognizer remains the same object and its view is unvarying — it's the view to which the gesture recognizer is attached.
However, nothing stops you from telling some other view what to do in response to a gesture. The collection view is a scroll view, so you know how it is being scrolled at every instant and can do something else in parallel.
You should be able to achieve what you're looking for with a single collection view using UICollectionViewDelegateFlowLayout. If you need any special scrolling behavior for your top view such as parallax, you can still achieve that in a single collection view by implementing a custom layout object that inherits from UICollectionViewLayout.
Using the UICollectionViewDelegateFlowLayout approach is a little more straightforward than implementing a custom layout, so if you want to give that a shot, try the following:
Create your top view as a subclass of UICollectionViewCell and register it with your collection view.
Create your "divider" view as a subclass of UICollectionViewCell and register it with your collection view as a supplementary view using func register(_ viewClass: AnyClass?,
forSupplementaryViewOfKind elementKind: String,
withReuseIdentifier identifier: String)
Have your collection view controller conform to UICollectionViewDelegateFlowLayout, create a layout object as an instance of UICollectionViewFlowLayout assign your collection view controller as the delegate of your flow layout instance, and init your collection view with your flow layout.
Implement optional func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize returning the desired size of each of your diffrent views in your collecton view controller.

Resources