UICollectionView Custom Layout Middle column starting higher - ios

I have a UICollectionView scrolling vertically right now. I would like to have it look like this =, where the middle row starts a little higher than the other two to create a cool and interesting effect.
Any ideas on how to achieve this?

The Collection view layout can be achieved by creating a custom layout class.
Theory:
Basically, collection view works directly with the custom layout object to manage the overall layout process, asking for the required layout information.
During the layout process, the collection view calls specific methods of the layout object. These methods provide a chance to calculate the position of items and to provide the collection view with the primary information it needs.
Following methods are always called in order during the layout process:
prepare(): Perform the up-front calculations needed to provide layout information
collectionViewContentSize: Return the overall size of the entire content area based on your initial calculations
layoutAttributesForElements(in:): Return the attributes for cells and views that are in the specified rectangle
Practical:
Prerequisite: Assuming we have a Collection view in place and configured with the
datasource and delegates, let's create a UICollectionViewLayout
subclass. I named it HiveLayout. I also assigned it to the
collectionView in the storyboard. We also need some variable that will be useful in the process
// Properties for configuring the layout: the number of columns and the cell padding.
fileprivate var numberOfColumns = 3
fileprivate var cellPadding: CGFloat = 10
// Cache the calculated attributes. When you call prepare(), you’ll calculate the attributes for all items and add them to the cache. You can be efficient and query the cache instead of recalculating them every time.
fileprivate var cache = [UICollectionViewLayoutAttributes]()
// Properties to store the content size.
fileprivate var contentHeight: CGFloat = 0
fileprivate var contentWidth: CGFloat {
guard let collectionView = collectionView else {
return 0
}
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
prepare(): This is where we will actually calculate the attributes of the cells
override func prepare() {
// If cache is empty and the collection view exists – calculate the layout attributes
guard cache.isEmpty == true, let collectionView = collectionView else {
return
}
// xOffset: array with the x-coordinate for every column based on the column widths
// yOffset: array with the y-position for every column, Using odd-even logic to push the even cell upwards and odd cells down.
let columnWidth = contentWidth / CGFloat(numberOfColumns)
var xOffset = [CGFloat]()
for column in 0 ..< numberOfColumns {
xOffset.append(CGFloat(column) * columnWidth)
}
var column = 0
var yOffset = [CGFloat]()
for i in 0..<numberOfColumns {
yOffset.append((i % 2 == 0) ? (columnWidth / 2) : 0)
}
for item in 0 ..< collectionView.numberOfItems(inSection: 0) {
let indexPath = IndexPath(item: item, section: 0)
// Calculate insetFrame that can be set to the attribute
let cellHeight = columnWidth - (cellPadding * 2)
let height = cellPadding * 2 + cellHeight
let frame = CGRect(x: xOffset[column], y: yOffset[column], width: columnWidth, height: height)
let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding)
// Create an instance of UICollectionViewLayoutAttribute, sets its frame using insetFrame and appends the attributes to cache.
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = insetFrame
cache.append(attributes)
// Update the contentHeight to account for the frame of the newly calculated item. It then advances the yOffset for the current column based on the frame
contentHeight = max(contentHeight, frame.maxY)
yOffset[column] = yOffset[column] + height
column = column < (numberOfColumns - 1) ? (column + 1) : 0
}
}
collectionViewContentSize:
// Using contentWidth and contentHeight, calculate collectionViewContentSize.
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
layoutAttributesForElements(in:):
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()
for attributes in cache {
if attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
layoutAttributesForItem(at:): To return attributes for particular cell
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cache[indexPath.item]
}
Please check out the gist for the Layout Class.
Here it is in action!!

Related

How to animate collection view layout change while using `layoutAttributesForElements`?

I made a custom collection view flow layout that can toggle (with animation) between "film-strip" and "list" layouts. But after adding some fancy animations to the edge cells, the toggle animation broke. Here's what it looks like currently, without those changes:
The animation is nice and smooth, right? Here's the current, working code (full demo project here):
enum LayoutType {
case strip
case list
}
class FlowLayout: UICollectionViewFlowLayout {
var layoutType: LayoutType
var layoutAttributes = [UICollectionViewLayoutAttributes]() /// store the frame of each item
var contentSize = CGSize.zero /// the scrollable content size of the collection view
override var collectionViewContentSize: CGSize { return contentSize } /// pass scrollable content size back to the collection view
/// pass attributes to the collection view flow layout
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return layoutAttributes[indexPath.item]
}
// MARK: - Problem is here
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
/// edge cells don't shrink, but the animation is perfect
return layoutAttributes.filter { rect.intersects($0.frame) } /// try deleting this line
/// edge cells shrink (yay!), but the animation glitches out
return shrinkingEdgeCellAttributes(in: rect)
}
/// makes the edge cells slowly shrink as you scroll
func shrinkingEdgeCellAttributes(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView else { return nil }
let rectAttributes = layoutAttributes.filter { rect.intersects($0.frame) }
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size) /// rect of the visible collection view cells
let leadingCutoff: CGFloat = 50 /// once a cell reaches here, start shrinking it
let trailingCutoff: CGFloat
let paddingInsets: UIEdgeInsets /// apply shrinking even when cell has passed the screen's bounds
if layoutType == .strip {
trailingCutoff = CGFloat(collectionView.bounds.width - leadingCutoff)
paddingInsets = UIEdgeInsets(top: 0, left: -50, bottom: 0, right: -50)
} else {
trailingCutoff = CGFloat(collectionView.bounds.height - leadingCutoff)
paddingInsets = UIEdgeInsets(top: -50, left: 0, bottom: -50, right: 0)
}
for attributes in rectAttributes where visibleRect.inset(by: paddingInsets).contains(attributes.center) {
/// center of each cell, converted to a point inside `visibleRect`
let center = layoutType == .strip
? attributes.center.x - visibleRect.origin.x
: attributes.center.y - visibleRect.origin.y
var offset: CGFloat?
if center <= leadingCutoff {
offset = leadingCutoff - center /// distance from the cutoff, 0 if exactly on cutoff
} else if center >= trailingCutoff {
offset = center - trailingCutoff
}
if let offset = offset {
let scale = 1 - (pow(offset, 1.1) / 200) /// gradually shrink the cell
attributes.transform = CGAffineTransform(scaleX: scale, y: scale)
}
}
return rectAttributes
}
/// initialize with a LayoutType
init(layoutType: LayoutType) {
self.layoutType = layoutType
super.init()
}
/// make the layout (strip vs list) here
override func prepare() { /// configure the cells' frames
super.prepare()
guard let collectionView = collectionView else { return }
var offset: CGFloat = 0 /// origin for each cell
let cellSize = layoutType == .strip ? CGSize(width: 100, height: 50) : CGSize(width: collectionView.frame.width, height: 50)
for itemIndex in 0..<collectionView.numberOfItems(inSection: 0) {
let indexPath = IndexPath(item: itemIndex, section: 0)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
let origin: CGPoint
let addedOffset: CGFloat
if layoutType == .strip {
origin = CGPoint(x: offset, y: 0)
addedOffset = cellSize.width
} else {
origin = CGPoint(x: 0, y: offset)
addedOffset = cellSize.height
}
attributes.frame = CGRect(origin: origin, size: cellSize)
layoutAttributes.append(attributes)
offset += addedOffset
}
self.contentSize = layoutType == .strip /// set the collection view's `collectionViewContentSize`
? CGSize(width: offset, height: cellSize.height) /// if strip, height is fixed
: CGSize(width: cellSize.width, height: offset) /// if list, width is fixed
}
/// boilerplate code
required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
return context
}
}
class ViewController: UIViewController {
var data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
var isExpanded = false
lazy var listLayout = FlowLayout(layoutType: .list)
lazy var stripLayout = FlowLayout(layoutType: .strip)
#IBOutlet weak var collectionView: UICollectionView!
#IBOutlet weak var collectionViewHeightConstraint: NSLayoutConstraint!
#IBAction func toggleExpandPressed(_ sender: Any) {
isExpanded.toggle()
if isExpanded {
collectionView.setCollectionViewLayout(listLayout, animated: true)
} else {
collectionView.setCollectionViewLayout(stripLayout, animated: true)
}
}
override func viewDidLoad() {
super.viewDidLoad()
collectionView.collectionViewLayout = stripLayout /// start with the strip layout
collectionView.dataSource = self
collectionViewHeightConstraint.constant = 300
}
}
/// sample data source
extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return data.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ID", for: indexPath) as! Cell
cell.label.text = "\(data[indexPath.item])"
cell.contentView.layer.borderWidth = 5
cell.contentView.layer.borderColor = UIColor.red.cgColor
return cell
}
}
class Cell: UICollectionViewCell {
#IBOutlet weak var label: UILabel!
}
Again, everything works perfectly, including the animation. So then, I tried to make the cells shrink as they neared the screen's edge. I overrode layoutAttributesForElements to do this.
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return layoutAttributes.filter { rect.intersects($0.frame) } /// delete this line
return shrinkingEdgeCellAttributes(in: rect) /// replace with this
}
Film-strip
List
The scale/shrink animation is great. However, when I toggle between the layouts, the transition animation is broken.
Before (return layoutAttributes.filter...)
After (return shrinkingEdgeCellAttributes(in: rect))
How can I fix this animation? Should I be using a custom UICollectionViewTransitionLayout, and if so, how?
Whew! This was a workout. I was able to modify your FlowLayout so that there are no hiccups in animation. See below.
It works!
Problem
This is what was happening. When you change layouts, the layoutAttributesForElements method in FlowLayout is called twice if the content offset of the collection view is anything but (0, 0).
This is because you have overridden 'shouldInvalidateLayout' to return true regardless of whether it is actually needed. I believe the UICollectionView calls this method on the layout before and after the layout change (as per the observation).
The side effect of this is that your scale transform is applied twice - before and after the animations to the visible layout attributes.
Unfortunately, the scale transform is applied based on the contentOffset of the collection view (link)
let visibleRect = CGRect(
origin: collectionView.contentOffset,
size: collectionView.frame.size
)
During layout changes the contentOffset is not consistent. Before the animation starts contentOffset is applicable to the previous layout. After the animation, it is relative to the new layout. Here I also noticed that without a good reason, the contentOffset "jumps" around (see note 1)
Since you use the visibleRect to query the set of Layout Attributes to apply the scale on, it introduces further errors.
Solution
I was able to find a solution by applying these changes.
Write helpers methods to transform the content offset (and dependent visibleRect) left by the previous layout to values meaningful for this layout.
Prevent redundant layout attribute calculates in prepare method
Track when and when not the layout is animating
// In Flow Layout
class FlowLayout: UICollectionViewFlowLayout {
var animating: Bool = false
// ...
}
// In View Controller,
isExpanded.toggle()
if isExpanded {
listLayout.reset()
listLayout.animating = true // <--
// collectionView.setCollectionViewLayout(listLayout)
} else {
stripLayout.reset()
stripLayout.animating = true // <--
// collectionView.setCollectionViewLayout(stripLayout)
}
Override targetContentOffset method to handle content offset changes (prevent jumps)
// In Flow Layout
class FlowLayout: UICollectionViewFlowLayout {
var animating: Bool = false
var layoutType: LayoutType
// ...
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
guard animating else {
// return super
}
// Use our 'graceful' content content offset
// instead of arbitrary "jump"
switch(layoutType){
case .list: return transformCurrentContentOffset(.fromStripToList)
case .strip: return transformCurrentContentOffset(.fromListToStrip)
}
}
// ...
The implementation of content offset transforming is as follows.
/**
Transforms this layouts content offset, to the other layout
as specified in the layout transition parameter.
*/
private func transformCurrentContentOffset(_ transition: LayoutTransition) -> CGPoint{
let stripItemWidth: CGFloat = 100.0
let listItemHeight: CGFloat = 50.0
switch(transition){
case .fromStripToList:
let numberOfItems = collectionView!.contentOffset.x / stripItemWidth // from strip
var newPoint = CGPoint(x: 0, y: numberOfItems * CGFloat(listItemHeight)) // to list
if (newPoint.y + collectionView!.frame.height) >= contentSize.height{
newPoint = CGPoint(x: 0, y: contentSize.height - collectionView!.frame.height)
}
return newPoint
case .fromListToStrip:
let numberOfItems = collectionView!.contentOffset.y / listItemHeight // from list
var newPoint = CGPoint(x: numberOfItems * CGFloat(stripItemWidth), y: 0) // to strip
if (newPoint.x + collectionView!.frame.width) >= contentSize.width{
newPoint = CGPoint(x: contentSize.width - collectionView!.frame.width, y: 0)
}
return newPoint
}
}
There are some minor details I left out in the comments and as a pull request to OP's demo project so anyone interested can study it.
The key take-aways are,
Use targetContentOffset when arbitrary changes in content offset occur in response to layout changes.
Be careful about incorrect query of layout attributes in layoutAttributesForElements. Debug your rects!
Remember to clear your cached layout attributes on the prepare() method.
Notes
The "jump" behavior is evident even before you introduced scale transforms as seen in your gif.
I sincerely apologize if the answer is lengthy. Or, The solution is not quite what you wanted. The question was interesting which is why I spent the whole day trying to find a way to help.
Fork and Pull request.
Thanks for your detailed investigation #Thisura Dodangoda
– it was instrumental in helping me solve a similar problem. For folks who end up here, I want to add a tiny detail in case you run into another issue that I did.
The UICollectionViewLayout API has 2 very similar methods:
func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint
This method Retrieves the point at which to stop scrolling
and
func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint
This method Retrieves the content offset to use after an animated layout update or change
I had already implemented the first for some custom behaviour during scrolling, and I was trying to implement the solution posted by #Thisura Dodangoda in that method.
However, these are used for completely different purposes. You need to use the second method (without the velocity parameter) to implement the solution for layout changes.

Resizing UICollectionView with Custom Layout after deleting cells

I downloaded this Pintrest-esc Custom Layout for my CollectionView from : https://www.raywenderlich.com/164608/uicollectionview-custom-layout-tutorial-pinterest-2 (modified it a little bit to do with cache code)
So the layout works fine, i got it set up so when you scroll to the bottom, i make another API call, create and populate additional cells.
Problem: If a User scrolls down and loads some more cells (lets say 20 for example) and then uses the "Search Feature" i have implemented at the top, it will then filter some of the data via price from MinRange - MaxRange leaving you with (lets say 5 results), thus deleting some cells and repopulating those. BUT - All that space and scrollable space that existed for the first 20 cells still exists as blank white space.... How can i remove this space ? and why can it resize when adding more cells, but not when removing them ?
Code of my API Call:
DispatchQueue.global(qos: .background).async {
WebserviceController.GetSearchPage(parameters: params) { (success, data) in
if success
{
if let products = data!["MESSAGE"] as? [[String : AnyObject]]
{
self.productList = products
}
}
DispatchQueue.main.async(execute: { () -> Void in
self.pintrestCollectionView.reloadData()
self.pintrestCollectionView.collectionViewLayout.invalidateLayout()
self.pintrestCollectionView.layoutSubviews()
})
}
}
Code Of Custom Layout Class & protocol:
protocol PinterestLayoutDelegate: class {
func collectionView(_ collectionView:UICollectionView, heightForPhotoAtIndexPath indexPath:IndexPath) -> CGFloat
}
class PintrestLayout: UICollectionViewFlowLayout {
// 1
weak var delegate : PinterestLayoutDelegate!
// 2
fileprivate var numberOfColumns = 2
fileprivate var cellPadding: CGFloat = 10
// 3
fileprivate var cache = [UICollectionViewLayoutAttributes]()
// 4
fileprivate var contentHeight: CGFloat = 0
fileprivate var contentWidth: CGFloat {
guard let collectionView = collectionView else {
return 0
}
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
// 5
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
override func prepare() {
// 1
//You only calculate the layout attributes if cache is empty and the collection view exists.
/*guard cache.isEmpty == true, let collectionView = collectionView else {
return
}*/
cache.removeAll()
guard cache.isEmpty == true || cache.isEmpty == false, let collectionView = collectionView else {
return
}
// 2
//This declares and fills the xOffset array with the x-coordinate for every column based on the column widths. The yOffset array tracks the y-position for every column.
let columnWidth = contentWidth / CGFloat(numberOfColumns)
var xOffset = [CGFloat]()
for column in 0 ..< numberOfColumns {
xOffset.append(CGFloat(column) * columnWidth)
}
var column = 0
var yOffset = [CGFloat](repeating: 0, count: numberOfColumns)
// 3
//This loops through all the items in the first section, as this particular layout has only one section.
for item in 0 ..< collectionView.numberOfItems(inSection: 0) {
if collectionView.numberOfItems(inSection: 0) < 3
{
collectionView.isScrollEnabled = false
} else {
collectionView.isScrollEnabled = true
}
let indexPath = IndexPath(item: item, section: 0)
// 4
//This is where you perform the frame calculation. width is the previously calculated cellWidth, with the padding between cells removed.
let photoHeight = delegate.collectionView(collectionView, heightForPhotoAtIndexPath: indexPath)
let height = cellPadding * 2 + photoHeight
let frame = CGRect(x: xOffset[column], y: yOffset[column], width: columnWidth, height: height)
let insetFrame = frame.insetBy(dx: 7, dy: 4)
collectionView.contentInset = UIEdgeInsets.init(top: 15, left: 12, bottom: 0, right: 12)
// 5
//This creates an instance of UICollectionViewLayoutAttribute, sets its frame using insetFrame and appends the attributes to cache.
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = insetFrame
cache.append(attributes)
// 6
//This expands contentHeight to account for the frame of the newly calculated item.
contentHeight = max(contentHeight, frame.maxY)
yOffset[column] = yOffset[column] + height
column = column < (numberOfColumns - 1) ? (column + 1) : 0
}
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()
// Loop through the cache and look for items in the rect
for attributes in cache {
if attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
FYI: In MainStoryboard, i disabled "Adjust Scroll View Insets" as many of other threads and forums have suggested...
I'm guessing you've already found the answer, but I'm posting here for the next person since I ran into this issue.
The trick here is contentHeight actually never gets smaller.
contentHeight = max(contentHeight, frame.maxY)
This is a nondecreasing function.
To fix it, just set contentHeight to 0 if prepare is called:
guard cache.isEmpty == true || cache.isEmpty == false, let
collectionView = collectionView else {
return
}
contentHeight = 0

Vertical offset on a UICollectionView column

Image on right is what I try to achieve
Does anyone know how I could achieve this on a two column UICollectionView ?
I'm able to discern my columns by testing if (indexPath.row % 2 = 0) but I can't manage to achieve this result.
Is their a simple way to do so ? Or would I need a custom UICollectionViewFlowLayout ?
Note : All my cells are the same size.
Yes, you need to create a custom UICollectionViewLayout and that's simple to achieve. All you need is the following:
prepare(): Perform the up-front calculations needed to provide layout information
collectionViewContentSize: Return the overall size of the entire content area based on your initial calculations
layoutAttributesForElements(in:): Return the attributes for cells and views that are in the specified rectangle
Now that we have cells of the same size it's pretty easy to achieve.
Let's declare few variables to use:
// Adjust this as you need
fileprivate var offset: CGFloat = 50
// Properties for configuring the layout: the number of columns and the cell padding.
fileprivate var numberOfColumns = 2
fileprivate var cellPadding: CGFloat = 10
// Cache the calculated attributes. When you call prepare(), you’ll calculate the attributes for all items and add them to the cache. You can be efficient and
fileprivate var cache = [UICollectionViewLayoutAttributes]()
// Properties to store the content size.
fileprivate var contentHeight: CGFloat = 0
fileprivate var contentWidth: CGFloat {
guard let collectionView = collectionView else {
return 0
}
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
Next let's override the prepare() method
override func prepare() {
// If cache is empty and the collection view exists – calculate the layout attributes
guard cache.isEmpty == true, let collectionView = collectionView else {
return
}
// xOffset: array with the x-coordinate for every column based on the column widths
// yOffset: array with the y-position for every column, Using odd-even logic to push the even cell upwards and odd cells down.
let columnWidth = contentWidth / CGFloat(numberOfColumns)
var xOffset = [CGFloat]()
for column in 0 ..< numberOfColumns {
xOffset.append(CGFloat(column) * columnWidth)
}
var column = 0
var yOffset = [CGFloat]()
for i in 0..<numberOfColumns {
yOffset.append((i % 2 == 0) ? 0 : offset)
}
for item in 0 ..< collectionView.numberOfItems(inSection: 0) {
let indexPath = IndexPath(item: item, section: 0)
// Calculate insetFrame that can be set to the attribute
let cellHeight = columnWidth - (cellPadding * 2)
let height = cellPadding * 2 + cellHeight
let frame = CGRect(x: xOffset[column], y: yOffset[column], width: columnWidth, height: height)
let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding)
// Create an instance of UICollectionViewLayoutAttribute, sets its frame using insetFrame and appends the attributes to cache.
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = insetFrame
cache.append(attributes)
// Update the contentHeight to account for the frame of the newly calculated item. It then advances the yOffset for the current column based on the frame
contentHeight = max(contentHeight, frame.maxY)
yOffset[column] = yOffset[column] + height
column = column < (numberOfColumns - 1) ? (column + 1) : 0
}
}
Last we need collectionViewContentSize and layoutAttributesForElements(in:)
// Using contentWidth and contentHeight from previous steps, calculate collectionViewContentSize.
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()
for attributes in cache {
if attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cache[indexPath.item]
}
You can find gist with the class.
This is how it appears:
Note: It is just a demo, you may have to tweak it according to your needs.
Hope It Helps!

How to set UICollectionView bottom padding and scrolling size

Hello I have working uicollectionview custom layout and i have issuesfor bottom padding and scrolling sizes. Under below picture you can see;
Also top side first cell always stays on top when scrolling ( Left side cell must be always stay right now no problem there , but top side cell must be scroll when scrolling )
I try to set in viewDidLoad under below codes but dont work
self.automaticallyAdjustsScrollViewInsets = false
self.collectionView.contentInset = UIEdgeInsetsMake(20, 20, 120, 100);
My custom collectionview layout file
import UIKit
public var CELL_HEIGHT = CGFloat(22)
protocol CustomCollectionViewDelegateLayout: NSObjectProtocol {
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, widthForItemAtIndexPath indexPath: NSIndexPath) -> CGFloat
}
class CustomCollectionViewLayout: UICollectionViewLayout {
// Used for calculating each cells CGRect on screen.
// CGRect will define the Origin and Size of the cell.
let STATUS_BAR = UIApplication.sharedApplication().statusBarFrame.height
// Dictionary to hold the UICollectionViewLayoutAttributes for
// each cell. The layout attribtues will define the cell's size
// and position (x, y, and z index). I have found this process
// to be one of the heavier parts of the layout. I recommend
// holding onto this data after it has been calculated in either
// a dictionary or data store of some kind for a smooth performance.
var cellAttrsDictionary = Dictionary<NSIndexPath, UICollectionViewLayoutAttributes>()
// Defines the size of the area the user can move around in
// within the collection view.
var contentSize = CGSize.zero
// Used to determine if a data source update has occured.
// Note: The data source would be responsible for updating
// this value if an update was performed.
var dataSourceDidUpdate = true
weak var delegate: CustomCollectionViewDelegateLayout?
override func collectionViewContentSize() -> CGSize {
return self.contentSize
}
override func prepareLayout() {
// Only update header cells.
if !dataSourceDidUpdate {
// Determine current content offsets.
let xOffset = collectionView!.contentOffset.x
let yOffset = collectionView!.contentOffset.y
if collectionView?.numberOfSections() > 0 {
for section in 0...collectionView!.numberOfSections()-1 {
// Confirm the section has items.
if collectionView?.numberOfItemsInSection(section) > 0 {
// Update all items in the first row.
if section == 0 {
for item in 0...collectionView!.numberOfItemsInSection(section)-1 {
// Build indexPath to get attributes from dictionary.
let indexPath = NSIndexPath(forItem: item, inSection: section)
// Update y-position to follow user.
if let attrs = cellAttrsDictionary[indexPath] {
var frame = attrs.frame
// Also update x-position for corner cell.
if item == 0 {
frame.origin.x = xOffset
}
frame.origin.y = yOffset
attrs.frame = frame
}
}
// For all other sections, we only need to update
// the x-position for the fist item.
} else {
// Build indexPath to get attributes from dictionary.
let indexPath = NSIndexPath(forItem: 0, inSection: section)
// Update y-position to follow user.
if let attrs = cellAttrsDictionary[indexPath] {
var frame = attrs.frame
frame.origin.x = xOffset
attrs.frame = frame
}
}
}
}
}
// Do not run attribute generation code
// unless data source has been updated.
return
}
// Acknowledge data source change, and disable for next time.
dataSourceDidUpdate = false
var maxWidthInASection = CGFloat(0)
// Cycle through each section of the data source.
if collectionView?.numberOfSections() > 0 {
for section in 0...collectionView!.numberOfSections()-1 {
// Cycle through each item in the section.
if collectionView?.numberOfItemsInSection(section) > 0 {
var prevCellAttributes: UICollectionViewLayoutAttributes?
for item in 0...collectionView!.numberOfItemsInSection(section)-1 {
let cellIndex = NSIndexPath(forItem: item, inSection: section)
guard let width = delegate?.collectionView(collectionView!, layout: self, widthForItemAtIndexPath: cellIndex) else {
print("Please comform to CustomCollectionViewDelegateLayout protocol")
return
}
// Build the UICollectionVieLayoutAttributes for the cell.
var xPos = CGFloat(0)
let yPos = CGFloat(section) * CELL_HEIGHT
if let prevCellAttributes = prevCellAttributes {
xPos = CGRectGetMaxX(prevCellAttributes.frame)
}
let cellAttributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: cellIndex)
cellAttributes.frame = CGRect(x: xPos, y: yPos, width: width, height: CELL_HEIGHT)
// Determine zIndex based on cell type.
if section == 0 && item == 0 {
cellAttributes.zIndex = 4
} else if section == 0 {
cellAttributes.zIndex = 3
} else if item == 0 {
cellAttributes.zIndex = 2
} else {
cellAttributes.zIndex = 1
}
// Save the attributes.
cellAttrsDictionary[cellIndex] = cellAttributes
prevCellAttributes = cellAttributes
let maxX = CGRectGetMaxX(cellAttributes.frame)
if maxWidthInASection < maxX {
maxWidthInASection = maxX
}
}
}
}
}
// Update content size.
let contentWidth = maxWidthInASection
let contentHeight = Double(collectionView!.numberOfSections())
self.contentSize = CGSize(width: Double(contentWidth), height: contentHeight)
self.contentSize.height = CGFloat(Double(collectionView!.numberOfSections()))
}
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// Create an array to hold all elements found in our current view.
var attributesInRect = [UICollectionViewLayoutAttributes]()
// Check each element to see if it should be returned.
for cellAttributes in cellAttrsDictionary.values.elements {
if CGRectIntersectsRect(rect, cellAttributes.frame) {
attributesInRect.append(cellAttributes)
}
}
// Return list of elements.
return attributesInRect
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
return cellAttrsDictionary[indexPath]!
}
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
return true
}
}
I try change collecionview properties in storyboard but doesnt works also i attached current collectionview object properties picture under below
Also i selected zoom 4 but zoom feature doesnt work ? why ? it must be work also zoom feature.
I know this is too late to answer, but this could be useful to other users.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 50, right: 0)
}
This what worked for me:
messagesCollectionView.contentInset.top = 100
messagesCollectionView.contentInset.bottom = 80
This can be also done on IB under section insets...

Visible lag while scrolling two way scrolling UICollectionView with large number of cells (250,000 or more)

I am subclassing UICollectionViewFlowLayout in order to get two way scrolling in a UICollectionView. The scrolling works fine for smaller number of row and section count (100-200 rows and sections) but there is visible lag while scrolling when I increase row and section count over 500 i.e 250,000 or more cells in the UICollectionView. I have traced the source of the lag to be for in loop in the layoutAttributesForElementsInRect. I am using a Dictionary to hold UICollectionViewLayoutAttributes of each cell to avoid recalculating it and looping through it to return attributes of cells from layoutAttributesForElementsInRect
import UIKit
class LuckGameCollectionViewLayout: UICollectionViewFlowLayout {
// Used for calculating each cells CGRect on screen.
// CGRect will define the Origin and Size of the cell.
let CELL_HEIGHT = 70.0
let CELL_WIDTH = 70.0
// Dictionary to hold the UICollectionViewLayoutAttributes for
// each cell. The layout attribtues will define the cell's size
// and position (x, y, and z index). I have found this process
// to be one of the heavier parts of the layout. I recommend
// holding onto this data after it has been calculated in either
// a dictionary or data store of some kind for a smooth performance.
var cellAttrsDictionary = Dictionary<NSIndexPath, UICollectionViewLayoutAttributes>()
// Defines the size of the area the user can move around in
// within the collection view.
var contentSize = CGSize.zero
override func collectionViewContentSize() -> CGSize {
return self.contentSize
}
override func prepareLayout() {
// Cycle through each section of the data source.
if collectionView?.numberOfSections() > 0 {
for section in 0...collectionView!.numberOfSections()-1 {
// Cycle through each item in the section.
if collectionView?.numberOfItemsInSection(section) > 0 {
for item in 0...collectionView!.numberOfItemsInSection(section)-1 {
// Build the UICollectionVieLayoutAttributes for the cell.
let cellIndex = NSIndexPath(forItem: item, inSection: section)
let xPos = Double(item) * CELL_WIDTH
let yPos = Double(section) * CELL_HEIGHT
let cellAttributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: cellIndex)
cellAttributes.frame = CGRect(x: xPos, y: yPos, width: CELL_WIDTH, height: CELL_HEIGHT)
// Save the attributes.
cellAttrsDictionary[cellIndex] = cellAttributes
}
}
}
}
// Update content size.
let contentWidth = Double(collectionView!.numberOfItemsInSection(0)) * CELL_WIDTH
let contentHeight = Double(collectionView!.numberOfSections()) * CELL_HEIGHT
self.contentSize = CGSize(width: contentWidth, height: contentHeight)
}
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// Create an array to hold all elements found in our current view.
var attributesInRect = [UICollectionViewLayoutAttributes]()
// Check each element to see if it should be returned.
for (_,cellAttributes) in cellAttrsDictionary {
if CGRectIntersectsRect(rect, cellAttributes.frame) {
attributesInRect.append(cellAttributes)
}
}
// Return list of elements.
return attributesInRect
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
return cellAttrsDictionary[indexPath]!
}
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
return false
}
}
Edit:
Following are the changes that I have come up with in the layoutAttributesForElementsInRect method.
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// Create an array to hold all elements found in our current view.
var attributesInRect = [UICollectionViewLayoutAttributes]()
let xOffSet = self.collectionView?.contentOffset.x
let yOffSet = self.collectionView?.contentOffset.y
let totalColumnCount = self.collectionView?.numberOfSections()
let totalRowCount = self.collectionView?.numberOfItemsInSection(0)
let startRow = Int(Double(xOffSet!)/CELL_WIDTH) - 10 //include 10 rows towards left
let endRow = Int(Double(xOffSet!)/CELL_WIDTH + Double(Utils.getScreenWidth())/CELL_WIDTH) + 10 //include 10 rows towards right
let startCol = Int(Double(yOffSet!)/CELL_HEIGHT) - 10 //include 10 rows towards top
let endCol = Int(Double(yOffSet!)/CELL_HEIGHT + Double(Utils.getScreenHeight())/CELL_HEIGHT) + 10 //include 10 rows towards bottom
for(var i = startRow ; i <= endRow; i = i + 1){
for (var j = startCol ; j <= endCol; j = j + 1){
if (i < 0 || i > (totalRowCount! - 1) || j < 0 || j > (totalColumnCount! - 1)){
continue
}
let indexPath: NSIndexPath = NSIndexPath(forRow: i, inSection: j)
attributesInRect.append(cellAttrsDictionary[indexPath]!)
}
}
// Return list of elements.
return attributesInRect
}
I have calculated the offset of the collectionView and used it to calculate the cells that will be visible on screen(using height/width of each cell). I had to add extra cells on each side so that when user scrolls there are no missing cells. I have tested this and the performance is fine.
By taking advantage of the layoutAttributesForElementsInRect(rect: CGRect) with your known cell size you don't need to cache your attributes and could just calculate them for a given rect as the collectionView requests them. You would still need to check for the boundary cases of 0 and the maximum section/row counts to avoid calculating unneeded or invalid attributes but that can be easily done in where clauses around the loops. Here's a working example that I've tested with 1000 sections x 1000 rows and it works just fine without lagging on the device:
Edit: I've added the biggerRect so that attributes can be pre-calculated for before the scrolling gets there. From your edit it looks like you're still caching the attributes which I don't think is needed for performance. Also it's going to lead to a much larger memory footprint with the more scrolling you do. Also is there a reason your don't want to use the supplied CGRect from the callback rather than manually calculate one from the contentOffset?
class LuckGameCollectionViewLayout: UICollectionViewFlowLayout {
let CELL_HEIGHT = 50.0
let CELL_WIDTH = 50.0
override func collectionViewContentSize() -> CGSize {
let contentWidth = Double(collectionView!.numberOfItemsInSection(0)) * CELL_WIDTH
let contentHeight = Double(collectionView!.numberOfSections()) * CELL_HEIGHT
return CGSize(width: contentWidth, height: contentHeight)
}
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let biggerRect = rect.insetBy(dx: -2048, dy: -2048)
let startIndexY = Int(Double(biggerRect.origin.y) / CELL_HEIGHT)
let startIndexX = Int(Double(biggerRect.origin.x) / CELL_WIDTH)
let numberOfVisibleCellsInRectY = Int(Double(biggerRect.height) / CELL_HEIGHT) + startIndexY
let numberOfVisibleCellsInRectX = Int(Double(biggerRect.width) / CELL_WIDTH) + startIndexX
var attributes: [UICollectionViewLayoutAttributes] = []
for section in startIndexY..<numberOfVisibleCellsInRectY
where section >= 0 && section < self.collectionView!.numberOfSections() {
for item in startIndexX..<numberOfVisibleCellsInRectX
where item >= 0 && item < self.collectionView!.numberOfItemsInSection(section) {
let cellIndex = NSIndexPath(forItem: item, inSection: section)
if let attrs = self.layoutAttributesForItemAtIndexPath(cellIndex) {
attributes.append(attrs)
}
}
}
return attributes
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
let xPos = Double(indexPath.row) * CELL_WIDTH
let yPos = Double(indexPath.section) * CELL_HEIGHT
let cellAttributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
cellAttributes.frame = CGRect(x: xPos, y: yPos, width: CELL_WIDTH, height: CELL_HEIGHT)
return cellAttributes
}
}
You need to come up with a data structure for your cells which is ordered by dimension(s), in order to come up with some algorithm using those dimension(s) to narrow down the search range.
Let's take the case of a table with cells 100 pixels tall by the full width of the view, and 250_000 elements, asked for the cells intersecting { 0, top, 320, bottom }. Then your data structure would be an array ordered by top coordinate, and the accompanying algorithm would be like
let start: Int = top / 100
let end: Int = bottom / 100 + 1
return (start...end).map { cellAttributes[$0] }
Add as much complexity as your actual layout requires.
cellAttrsDictionary is inappropriate to store the UICollectionViewLayoutAttributes. It's indexed by NSIndexPath but in layoutAttributesForElementsInRect you are searching for an area. If you need cellAttrsDictionary for something else then you could leave it but store each cellAttribute additionally in another data structure which is faster to search.
For example a nested array:
var allCellAttributes = [[UICollectionViewLayoutAttributes]]()
The first level array describes an area, let's say it's in chunks of 1000 pixel high and full screen width. So all cellAttributes which intersect with the rectangle { 0, 0, screenwidth, 1000 } go into:
allCellAttributes[0].append(cellAttributes)
All cellAttributes which intersect with the rectangle { 0, 1000, screenwidth, 2000 } go into:
allCellAttributes[1].append(cellAttributes)
And so on...
Then, in layoutAttributesForElementsInRect you can search in this data-structure by jumping directly into the array depending on the given CGRect. If the rect is e.g. {0, 5500, 100, 6700} then you just need to search in this range:
allCellAttributes[5]
allCellAttributes[6]
This should give you the basic idea, I hope you understand what I mean.

Resources