In the video Advances in Collection View Layout - WWDC 2019, Apple introduces a new 'orthogonal scrolling behavior' feature. I have a view controller almost identical to OrthogonalScrollingViewController in their example code. In particular my collection view is laid out vertically, and each section can scroll horizontally (I use section.orthogonalScrollingBehavior = .groupPaging).
I want to have all my sections scroll horizontally in unison. Previously, I listened for scrollViewDidScroll on each horizontal collection view, then manually set the content offset of the others. However, with the new orthogonalScrollingBehavior implementation, scrollViewDidScroll never gets called on the delegate when I scroll horizontally. How can I detect horizontal scrolling events with the new API?
If there's another way to make the sections scroll together horizontally, I'm also open to other suggestions.
You can use this callback:
let section = NSCollectionLayoutSection(group: group)
section.visibleItemsInvalidationHandler = { [weak self] (visibleItems, offset, env) in
}
As mentioned you can use visibleItemsInvalidationHandler which provides the location of the scroll offset.
You can detect if a page changed by getting the modulus of the page width. You need to additionally supply a tolerance to ignore halfway scroll changes.
Im using this:
class CollectionView: UICollectionViewController {
private var currentPage: Int = 0 {
didSet {
if oldValue != currentPage {
print("The page changed to \(currentPage)")
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
// Configure layout...
let itemSize = NSCollectionLayoutSize...
let item = NSCollectionLayoutItem...
let groupSize = NSCollectionLayoutSize...
let group = NSCollectionLayoutGroup.horizontal...
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPaging
// Use visibleItemsInvalidationHandler to make calculations
section.visibleItemsInvalidationHandler = { [weak self] items, location, environment in
guard let self = self else { return }
let width = self.collectionView.bounds.width
let scrollOffset = location.x
let modulo = scrollOffset.truncatingRemainder(dividingBy: width)
let tolerance = width/5
if modulo < tolerance {
self.currentPage = Int(scrollOffset/width)
}
}
self.collectionView.collectionViewLayout = UICollectionViewCompositionalLayout(section: section)
}
}
Here's a hacky solution. Once you render your orthogonal section, you can access it via the subviews on your collectionView. You can then check if the subview is subclass of UIScrollView and replace the delegate.
collectionView.subviews.forEach { (subview) in
if let v = subview as? UIScrollView {
customDelegate.originalDelegate = v.delegate!
v.delegate = customDelegate
}
}
One tricky bit is that you want to capture its original delegate. The reason for this is because I notice that you must call originalDelegate.scrollViewDidScroll(scrollView) otherwise the section doesn't render out completely.
In other word something like:
class CustomDelegate: NSObject, UIScrollViewDelegate {
var originalDelegate: UIScrollViewDelegate!
func scrollViewDidScroll(_ scrollView: UIScrollView) {
originalDelegate.scrollViewDidScroll?(scrollView)
}
}
You can do this:
section.visibleItemsInvalidationHandler = { [weak self] visibleItems, point, environment in
let indexPath = visibleItems.last!.indexPath
self?.pageControl.currentPage = indexPath.row
}
The collectionView delegate willDisplay method will tell you when a cell is added to the collectionView (e.g. is displayed on screen, as they are removed when they go offscreen).
That should let you know that panning has effectively occurred (and in most cases the important part is not the pan gesture or animation but how it affects the displayed content).
In that delegate method, collectionView.visibleCells can be used to determine what cells are displayed and from that one can derive the position.
I have found one convenient way to handle this issue, you can avoid setting orthogonal scrolling and use configuration instead this way:
let config = UICollectionViewCompositionalLayoutConfiguration()
config.scrollDirection = .horizontal
let layout = UICollectionViewCompositionalLayout(sectionProvider:sectionProvider,configuration: config)
This will call all scroll delegates for collectionview. Hope this will be helpful for someone.
Related
I have been working on a UI screen where I need to fill up screen with fetched data from API and show view at very bottom of the screen.
My screen contains data in scrollable fashion so I have to use UICollectionView to manage this scrollable content properly.
i.e. If more content then footer will be shown at end of UICollectionView after whole list scroll is over, if less content then footer will be show at very bottom of the screen leaving blank space between footer and content.
Following is my code where I have dynamic flow layout which changes based on data I get.
API Function:
private func fetchEventDetails(_ eventId: Int) {
{
API Call - Success
}
self.contentItem = response
} onFailure: { error in
{
API Call - Failure
}
}
ContentItem is a variable stores API data and loads them into UICollectionView. So, whenever contentItem is set, new flowLayout is generated and Collection view is being re-rendered.
var contentItem: LSTEventDetails? {
didSet {
updateCollectionViewLayout()
}
}
private func updateCollectionViewLayout() {
CollectionView.collectionViewLayout = <generateNewFlowLayout()>
CollectionView.reloadData()
CollectionView.collectionViewLayout.invalidateLayout()
CollectionView.layoutIfNeeded()
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
}
Above function assigns new Flow layout to collection view and we have written resize footer view related function in viewDidLayoutSubviews, so, layout methods are called here. I don't know best place where to call this resizeFooter function which can be executed once data is loaded.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if CollectionView.numberOfSections > 0 {
//DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.CollectionView.resizeStickyFooter(for: IndexPath(item: 0, section: 0), andSectionLayout: self.sectionLayouts, andView: self.view)
//}
}
}
In the above function, if I add asyncAfter I get some jerky animation, like all data is loaded and then after sometime, the footer jumps to correct position which seems a bit weird.
sectionLayouts holds reference to all NSCollectionLayoutSection sections which I create while generating flow layout.
The main resizeFooter function is added as extension to UICollectionView:
extension UICollectionView {
func resizeStickyFooter(for indexPath: IndexPath = IndexPath(item: 0, section: 0), andSectionLayout sectionLayouts: [NSCollectionLayoutSection]?, andView view: UIView) {
let size = self.bounds.size.height - self.contentInset.bottom//- (sectionLayouts?.reduce(0, { $0 + $1.contentInsets.top + $1.contentInsets.bottom }) ?? 0) // - LSTTheme.UI.TabBarSize - no need as tab is hidden
let layoutViewSize = self.collectionViewLayout.collectionViewContentSize
if layoutViewSize.height < size {
if let footerView = self.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) {
let footerHeight = size - layoutViewSize.height
let footerSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(max(0, footerHeight) + footerView.bounds.height)
)
let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: footerSize,
elementKind: UICollectionView.elementKindSectionFooter,
alignment: .bottom
)
if (indexPath.section < (sectionLayouts?.count ?? 0)),
let layouts = sectionLayouts {
let section = layouts[indexPath.section]
section.boundarySupplementaryItems.removeAll(where: {$0.elementKind == UICollectionView.elementKindSectionFooter} )
section.boundarySupplementaryItems.append(sectionFooter)
let layout = UICollectionViewCompositionalLayout { sectionIndex, env in
return layouts[sectionIndex]
}
self.collectionViewLayout = layout
self.collectionViewLayout.invalidateLayout()
self.layoutIfNeeded()
}
}
}
}
}
Here, when this function gets executed at first time, I am not getting proper footer size and collectionViewContentSize.
Like, for the first time when this screen is loaded, I get following data listed on left-side while when I do some action on that particular screen by tapping button and which in turn calls updateCollectionViewLayout() again to create new flow layout and execute resize footer from viewDidLayoutSubviews(), I get following data listed on right-side.
size = 656 VS size = 656
layoutViewSize = (375.0, 414.0) | layoutViewSize = (375.0, 576.33)
- width : 375.0 | - width : 375.0
- height : 414.0 | - height : 576.33
|
footerView = (0 394; 375 20); | footerView = (0 499.333; 375 77);
- height : 20 | - height : 77
|
So, footerSize = {1, 262} | So, footerSize = {1, 156.66}
- height : 262 | - height : 156.66
Here, you will be able to see difference in layoutViewSize and footerView both which in turn affects footerSize.
Would anyone suggest me better option where to call resizeFooter method, so, I get proper contentViewSize once data is loaded? But I don't want that kind of jumpy solution where footer jumps to its original size post loading of all data.
ViewDidLoad() method just has call to fetchData API and nothing else, so, anything in viewDidLoad does not affect the collectionview.
I want it to be seamless so UX can not be affected.
Please provide me any suggestion.
I’ve been trying to create a UICollectionView header that would stick on top of my collection view. I’m using UICollectionViewCompositionalLayout.
I’ve tried multiple approaches: using a cell, using a section header and try to mess with insets and offsets to position it correctly relative to my content… And even adding a view on top of the collection view that would listen to the collection view’s scroll view’s contentOffset to position itself at the right place. But none of these approaches are satisfying. They all feel like a hack.
I’ve been doing some research and apparently you’d have to sublcass UICollectionViewLayout which is super tedious and seems overkill to just have a header, but one that is global to the whole collection view.
TL;DR
UICollectionViewCompositionalLayout has a configuration property which you can set by creating an UICollectionViewCompositionalLayoutConfiguration object. This object has some really nice and useful functionality such as the boundarySupplementaryItems property.
From the docs:
An array of the supplementary items that are associated with the boundary edges of the entire layout, such as global headers and footers.
Bingo. Set this property and do the necessary wiring in your datasource and you should have your global header.
Code Example
Here, I'm declaring a global header in my layout. The header is a segmented control inside a visual effect view, but yours can be any subclass of UICollectionReusableView.
enum SectionLayoutKind: Int, CaseIterable {
case description
}
private var collectionView: UICollectionView! = nil
override func viewDidLoad() {
super.viewDidLoad()
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
}
static func descriptionSection() -> NSCollectionLayoutSection {
// Instantiate and return a `NSCollectionLayoutSection` object.
}
func createLayout() -> UICollectionViewLayout {
let layout = UICollectionViewCompositionalLayout {
(sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
// Create your section
// add supplementaries such as header and footers that are relative to the section…
guard let layoutKind = SectionLayoutKind(rawValue: sectionIndex) else { return nil }
let section: NSCollectionLayoutSection
switch layoutKind {
case .description:
section = Self.descriptionSection()
}
return section
}
/*
✨ Magic starts HERE:
*/
let globalHeaderSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(44))
Constants.HeaderKind.globalSegmentedControl, alignment: .top)
let globalHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: globalHeaderSize, elementKind: Constants.HeaderKind.space, alignment: .top)
// Set true or false depending on the desired behavior
globalHeader.pinToVisibleBounds = true
let config = UICollectionViewCompositionalLayoutConfiguration()
/*
If you want to do spacing between sections.
That's another big thing this config object does.
If you try to define section spacing at the section level with insets,
the spacing is between the items and the standard headers.
*/
config.interSectionSpacing = 20
config.boundarySupplementaryItems = [globalHeader]
layout.configuration = config
/*
End of magic. ✨
*/
return layout
}
struct Constants {
struct HeaderKind {
static let space = "SpaceCollectionReusableView"
static let globalSegmentedControl = "segmentedControlHeader"
}
}
Supplementary code for the data source part:
let globalHeaderRegistration = UICollectionView.SupplementaryRegistration<SegmentedControlReusableView>(elementKind: Constants.HeaderKind.globalSegmentedControl) { (header, elementKind, indexPath) in
// Opportunity to further configure the header
header.segmentedControl.addTarget(self, action: #selector(self.onSegmentedControlValueChanged(_:)), for: .valueChanged)
}
dataSource.supplementaryViewProvider = { (view, kind, indexPath) in
if kind == Constants.HeaderKind.globalSegmentedControl {
return self.collectionView.dequeueConfiguredReusableSupplementary(using: globalHeaderRegistration, for: indexPath)
} else {
// return another registration object
}
}
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.
}
}
}
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()
}
}
}
Hi I'm trying to add feedback when scrolling through the collectionview Items. Where should I add code for feedback in collectionview delegates. If I add in willDisplay then add cell that will be displayed initially will call feedback which is not good. I need to provide feedback only when the user scrolls and selects a item.
Assuming that you only scroll in one direction (like vertically) and that all rows of items have the same height, you can use scrollViewDidScroll(_:) to detect selections like UIPickerView.
class ViewController {
var lastOffsetWithSound: CGFloat = 0
}
extension ViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let flowLayout = ((scrollView as? UICollectionView)?.collectionViewLayout as? UICollectionViewFlowLayout) {
let lineHeight = flowLayout.itemSize.height + flowLayout.minimumLineSpacing
let offset = scrollView.contentOffset.y
let roundedOffset = offset - offset.truncatingRemainder(dividingBy: lineHeight)
if abs(lastOffsetWithSound - roundedOffset) > lineHeight {
lastOffsetWithSound = roundedOffset
print("play sound feedback here")
}
}
}
}
Remember that UICollectionViewDelegateFlowLayout inherits UICollectionViewDelegate, which itself inherits UIScrollViewDelegate, so you can declare scrollViewDidScroll in any of them.
You can add it in view controller methods
touchesBegan(_:with:)
touchesMoved(_:with:)
So that whenever the user interacts with your view controller anywhere you can provide a feedback and it will only be limited to user interaction and not when you add a cell programetically or call update on your table view.
If you have other UI components as well in your controller and you want to limit the feedback to your collection view and not other components then you can check the view in these methods.
let touch: UITouch = touches.first as! UITouch
if (touch.view == collectionView){
println("This is your CollectionView")
}else{
println("This is not your CollectionView")
}
Don't forget to call super to give system a chance to react to the methods.
Hope this helps.