I have collectionview with multiple section and vertical scrolling.
Each have only one item.
Screen flow is like below:
I call an api which provides me feedId and url for to fetch section data.
When user scrolls collectionview api gets called for section whichever section is currently visible on screen.
Inside each collectionview cell (or section) there is another collectionview with grid layout which renders grid image like below
Outer Collectionview
I am using Diffable datasource with compositional layout and while applying the snapshot the code is like below.
if #available(iOS 15.0, *) {
updateSnapshot.reconfigureItems([item])
} else {
updateSnapshot.reloadItems([item])
// Fallback on earlier versions
}
if #available(iOS 15.0, *) {
dataSource.applySnapshotUsingReloadData(updateSnapshot)
} else {
dataSource.apply(updateSnapshot, animatingDifferences: false, completion: nil)
// Fallback on earlier versions
}
Inner collectionview code is like below
var dataSource = RxCollectionViewSectionedReloadDataSource<SectionModel<String, GlobalGalleryViewModel>> {[] (ds, cv, indexPath, vm) -> UICollectionViewCell in
let cell = cv.dequeueReusableCell(withReuseIdentifier: GlobalGalleryCell.className, for: indexPath) as! GlobalGalleryCell
cell.configureCell(vm)
return cell
}
Issue
When I scroll the outer collectionview there is lots of glitches and flickering when inner gallery renders on outer collectionview cell.
Layout code for Outer collectionview
let parentItemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension:feedInterface.isHaveActualFeed ? .estimated(height) : .fractionalHeight(1)
)
let largeItem = NSCollectionLayoutItem(layoutSize: parentItemSize)
largeItem.contentInsets = NSDirectionalEdgeInsets(top: parentInset, leading: parentInset, bottom: parentInset, trailing: parentInset)
// Outer Group
let outerGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension:.estimated(height))
let outerGroup = NSCollectionLayoutGroup.vertical(layoutSize: outerGroupSize, subitems: [largeItem])
Also added the cell prefetching like this
func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
self.updateVisibleCellRemotely(indexPaths)
if let lastIndexPath = indexPaths.last,
lastIndexPath.section == (self.feeds.count - 1),
self.feeds.count > 0 {
self.viewModel.fetchBottomFeedData()
}///load more vertical or main feed or parent feed
}
Any solution will be appreciated.
Related
I have the following layout for my compositional layout
func createLayout() -> UICollectionViewLayout {
let layout = UICollectionViewCompositionalLayout {
(sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let dayItemSize = NSCollectionLayoutSize(widthDimension: .absolute(74), heightDimension: .fractionalHeight(1))
// 2. Setup media group
let dayItem = NSCollectionLayoutItem(layoutSize: dayItemSize)
let dayGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),heightDimension: .fractionalHeight(1.0))
let dayGroup = NSCollectionLayoutGroup.horizontal(layoutSize: dayGroupSize, subitems: [dayItem])
let interitemSpacing = CGFloat(10)
dayGroup.interItemSpacing = .fixed(interitemSpacing)
let section = NSCollectionLayoutSection(group: dayGroup)
section.orthogonalScrollingBehavior = .continuous
return section
}
return layout
}
Then there are 30 "day" items in my collectionView.
fileprivate func makeDataSource() -> DataSource {
let dataSource = DataSource(
collectionView: collectionView,
cellProvider: { (collectionView, indexPath, YearMonthDay) ->
UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TimelineDayCell.identifier, for: indexPath) as? TimelineDayCell
cell?.configure(with: YearMonthDay)
cell?.dayLabel.text = String(indexPath.section)+","+String(indexPath.row)
return cell
})
return dataSource
}
func configureDataSource() {
self.collectionView!.register(TimelineDayCell.nib, forCellWithReuseIdentifier: TimelineDayCell.identifier)
}
func applySnapshot(animatingDifferences: Bool = true) {
// 2
var snapshot = DataSourceSnapshot()
snapshot.appendSections([.main])
snapshot.appendItems(days) # 30 of these
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
My UIcollectionViewController covers the entire width of the screen. My cells in the screen look like this:
The text on the cells is their index path. You can see that between 0-4, 5-9 the spacing is uniform, then on the edges 4-5 and 9-10, its different. I believe this is because its the "edge" between screens. But what can I do about it?
I've also tried using no orthogonal scroll behavior and just setting the layout config's scroll direction
let config = UICollectionViewCompositionalLayoutConfiguration()
config.scrollDirection = .horizontal
layout.configuration = config
return layout
It seems to achieve the same thing. Any suggestions here?
EDIT: It seems that this behavior is based on the sizing of the group. For instance when I use let dayGroup = NSCollectionLayoutGroup.horizontal(layoutSize: dayGroupSize, subitem: dayItem, count: 30), it makes all of the items lie next to each other like so:
Now there are 30 separate items. Notice how it has auto-spaced the distance between them so that it can fit 30 into 1 screen width. Thus it is still trying to force all of the items in 1 group into a single screen which is the behavior I don't want. I want the group to be the width of the entire content size and to be scrollable.
Ok I solved it. It has to do with the group sizing. I had let dayGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),heightDimension: .fractionalHeight(1.0))
The inter-item spacing is between the group items. And the group size was the size of my window. So I need to make the group size the size of the entire content (bigger than the window for instance.
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 am implementing a UICollectionViewCompositionalLayout and using
section.orthogonalScrollingBehavior = .groupPagingCentered
to scroll a section horizontally.
This lets a user scroll from page to page in a section horizontally.
I use
section.visibleItemsInvalidationHandler = {...}
to get the page a user scrolls to.
How can I scroll to a page in this section programmatically?
I actually got it working. This code works XCode 12 Beta 2 / Target iOS14 / Swift 5.1.
In this piece of code, pay special attention to spacing, insets and orthogonalScrollingBehavior !!!
fileprivate func configureCollectionViewLayout() {
func createLayout() -> UICollectionViewLayout {
self.collectionView.canCancelContentTouches = false
let layout = UICollectionViewCompositionalLayout {
(sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
let spacing = CGFloat(10)
switch sectionKind {
case .someOtherSection:
// configure the layout for this section
case .yetAnotherSection:
// configure the layout for this section
case .sectionWithOrthogonalScrollingBehaviour:
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(1.0*9.0/16.0))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
group.interItemSpacing = .fixed(0) // should (probably) be 0 otherwise there is an offset when scrollingToIndex
section.interGroupSpacing = 0 // should (probably) be 0 otherwise there is an offset when scrollingToIndex
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 0) // leading and trailing should (certainly) be 0 otherwise the pages are offsetted in relation to the view
section.orthogonalScrollingBehavior = .continuous // should be continous, otherwise it won't work
return section
}
}
return layout
}
collectionView.collectionViewLayout = createLayout()
}
To scroll programatically, I implemented a segmented control. When user taps on a segment, the orthogonally scrolling section scrolls automatically to the cell corresponding with the selected segment.
#IBAction func segmentedControlValueChanged(_ sender: UISegmentedControl) {
// determine which Item belongs to the selected segment. In my case Items in a Section are represented by an enum called 'Item'
let dataSourceIndexPath = self.dataSource.indexPath(for: .itemCase)
let pressentationIndexPath = self.collectionView.presentationIndexPath(forDataSourceIndexPath: dataSourceIndexPath)! // This step is necessary, otherwise the next line does not have have wanted behaviour (I'm force unwrapping here to simplify. Don't do that in your final code)
self.collectionView.scrollToItem(at: pressentationIndexPath, at: .centeredHorizontally, animated: true) // make sure you use .centeredHorizontally
}
I'm not sure this is how Apple intended this to be used. It might brake again in next Xcode beta versions.
Sidenote: In case you were wondering why I need/want a segmented
control to scroll a collection view: It's because the content of the
cells in the orthogonally scrolling section need to consume the
horizontal pan gestures (the cells are all charts that you can move your finger
along and then display the chart value at the touched location).
Yes... I know this can also be done in other ways, but I wanted to use
the compositional layout because it fits together nicely with the
other sections, and I wanted to keep this sweet built in animation of
the orthogonalscrollbehavior (even though this is now partially lost because orthogonalScrollingBehavior needs to be .continues for it to work) and I just wanted to see if it can be done :)
Environment:
UICollectionView that looks like UITableView
Custom UICollectionViewFlowLayout subclass to define the frame of the DecorationView
Self-Sizing cells enabled
Expected behavior:
A DecorationView that should be placed as a background for every section of the UICollectionView
Observed Behavior:
The DecorationView collapses to an arbitrary size:
Seems that UICollectionView tries to calculate an automatic size for the DecorationView. If I disable Self-Sizing cells, the decoration view is being placed exactly at the expected place.
Is there any way to disable Self-Sizing for DecorationView ?
In my UICollectionViewFlowLayout subclass I simply take the first and last cells in the section and stretch the background to fill the space underneath them. The problem is that UICollectionView does not respect the size calculated there:
override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let collectionView = collectionView else {
return nil
}
let section = indexPath.section
let attrs = UICollectionViewLayoutAttributes(forDecorationViewOfKind: backgroundViewClass.reuseIdentifier(),
with: indexPath)
let numberOfItems = collectionView.numberOfItems(inSection: section)
let lastIndex = numberOfItems - 1
guard let firstItemAttributes = layoutAttributesForItem(at: IndexPath(indexes: [section, 0])),
let lastItemAttributes = layoutAttributesForItem(at: IndexPath(indexes: [section, lastIndex])) else {
return nil
}
let startFrame = firstItemAttributes.frame
let endFrame = lastItemAttributes.frame
let origin = startFrame.origin
let size = CGSize(width: startFrame.width,
height: -startFrame.minY + endFrame.maxY)
let frame = CGRect(origin: origin, size: size)
attrs.frame = frame
attrs.zIndex = -1
return attrs
}
It's possible that the frames of your decoration views are not being updated (i.e. invalidated) after the frames of your cells have been self-sized. The result is that the width of each decoration view remains at its default size.
Try implementing this function, which should invalidate the layout of the decoration view for each section every time the layout of an item in that section is invalidated:
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
let invalidatedSections = context.invalidatedItemIndexPaths?.map { $0.section } ?? []
let decorationIndexPaths = invalidatedSections.map { IndexPath(item: 0, section: $0) }
context.invalidateDecorationElements(ofKind: backgroundViewClass.reuseIdentifier(), at: decorationIndexPaths)
super.invalidateLayout(with: context)
}
Hi I am trying to make a home feed like facebook using UICollectionView But in each cell i want to put another collectionView that have 3 cells.
you can clone the project here
I have two bugs the first is when i scroll on the inner collection View the bounce do not bring back the cell to center. when i created the collection view i enabled the paging and set the minimumLineSpacing to 0
i could not understand why this is happening. when i tried to debug I noticed that this bug stops when i remove this line
layout.estimatedItemSize = CGSize(width: cv.frame.width, height: 1)
but removing that line brings me this error
The behavior of the UICollectionViewFlowLayout is not defined because: the item height must be less than the height of the UICollectionView minus the section insets top and bottom values, minus the content insets top and bottom values
because my cell have a dynamic Height
here is an example
my second problem is the text on each inner cell dosent display the good text i have to scroll until the last cell of the inner collection view to see the good text displayed here is an example
You first issue will be solved by setting the minimumInteritemSpacing for the innerCollectionView in the OuterCell. So the definition for innerCollectionView becomes this:
let innerCollectionView : UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
let cv = UICollectionView(frame :.zero , collectionViewLayout: layout)
cv.translatesAutoresizingMaskIntoConstraints = false
cv.backgroundColor = .orange
layout.estimatedItemSize = CGSize(width: cv.frame.width, height: 1)
cv.isPagingEnabled = true
cv.showsHorizontalScrollIndicator = false
return cv
}()
The second issue is solved by adding calls to reloadData and layoutIfNeeded in the didSet of the post property of OuterCell like this:
var post: Post? {
didSet {
if let numLikes = post?.numLikes {
likesLabel.text = "\(numLikes) Likes"
}
if let numComments = post?.numComments {
commentsLabel.text = "\(numComments) Comments"
}
innerCollectionView.reloadData()
self.layoutIfNeeded()
}
}
What you are seeing is related to cell reuse. You can see this in effect if you scroll to the yellow bordered text on the first item and then scroll down. You will see others are also on the yellow bordered text (although at least with the correct text now).
EDIT
As a bonus here is one method to remember the state of the cells.
First you need to track when the position changes so in OuterCell.swft add a new protocol like this:
protocol OuterCellProtocol: class {
func changed(toPosition position: Int, cell: OutterCell)
}
then add an instance variable for a delegate of that protocol to the OuterCell class like this:
public weak var delegate: OuterCellProtocol?
then finally you need to add the following method which is called when the scrolling finishes, calculates the new position and calls the delegate method to let it know. Like this:
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if let index = self.innerCollectionView.indexPathForItem(at: CGPoint(x: self.innerCollectionView.contentOffset.x + 1, y: self.innerCollectionView.contentOffset.y + 1)) {
self.delegate?.changed(toPosition: index.row, cell: self)
}
}
So that's each cell detecting when the collection view cell changes and informing a delegate. Let's see how to use that information.
The OutterCellCollectionViewController is going to need to keep track the position for each cell in it's collection view and update them when they become visible.
So first make the OutterCellCollectionViewController conform to the OuterCellProtocol so it is informed when one of its
class OutterCellCollectionViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout, OuterCellProtocol {
then add a class instance variable to record the cell positions to OuterCellCollectionViewController like this:
var positionForCell: [Int: Int] = [:]
then add the required OuterCellProtocol method to record the cell position changes like this:
func changed(toPosition position: Int, cell: OutterCell) {
if let index = self.collectionView?.indexPath(for: cell) {
self.positionForCell[index.row] = position
}
}
and finally update the cellForItemAt method to set the delegate for a cell and to use the new cell positions like this:
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "OutterCardCell", for: indexPath) as! OutterCell
cell.post = posts[indexPath.row]
cell.delegate = self
let cellPosition = self.positionForCell[indexPath.row] ?? 0
cell.innerCollectionView.scrollToItem(at: IndexPath(row: cellPosition, section: 0), at: .left, animated: false)
print (cellPosition)
return cell
}
If you managed to get that all setup correctly it should track the positions when you scroll up and down the list.