How to subclass UICollectionViewFlowLayout so that layoutAttributesForElementsInRect could be overridden - ios

I want to create a vertical infinite scroll and after reading so many tutorials have understood that I need to subclass UICollectionViewFlowLayout. The problem is that I don't fully understand how to do so.
I've tried following:
1. Created a new class newView and assigned it to my view controller in attribute inspector custom class section.
class newView: UICollectionViewController,
UICollectionViewDataSource, UICollectionViewDelegate {
Implemented (override) cellForItemAtIndexPath and sizeForItemAtIndexPath in this class which works fine. I have a vertical scrolling view so far containing 2 items in 1 row. But I have unequal spaces between 2 rows. After laying out first 2 items, the third one's vertical position is below the longer of the previous 2 items as shown below:
I've read many SO threads discussing and suggesting to subclass UICollectionViewFlowLayout and override layoutAttributesForElementsInRect method for desired display. But when I try to add flow layout in my view controller like below it gives me errors:
class DiscoverView: UICollectionViewController, UICollectionViewFlowLayout,
UICollectionViewDataSource, UICollectionViewDelegate {
I then thought that it's may be my view layout that needs to be subclassed instead of controller, so I tried to create a separate class like below:
class newViewLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
return super.layoutAttributesForElementsInRect(rect)
}
And then I tried to assign this class to my view layout. But it doesn't appear under custom class section (attribute inspector). Neither does it appear in Attribute inspector > collection view > layout > set custom > Class
I know it's some very basic and silly mistake but not sure what I'm doing wrong conceptually.

Though this is old, I wan´t to add the solution that made it work for me :)
You should add the subclass in viewDidLoad like:
collectionView?.collectionViewLayout = YourCustomClass

You need to override the flowlayout you declared in your main class
let flowLayout = flowLayoutClass()
override func viewDidLoad() {
super.viewDidLoad()
collectionView.collectionViewFlowLayout = flowLayout
}
class flowLayoutClass: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let arr = super.layoutAttributesForElements(in: rect)
for atts:UICollectionViewLayoutAttributes in arr! {
if nil == atts.representedElementKind {
let ip = atts.indexPath
atts.frame = (self.layoutAttributesForItem(at: ip)?.frame)!
}
}
return arr
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let atts = super.layoutAttributesForItem(at: indexPath)
if indexPath.item == 0 || indexPath.item == 1 {
var frame = atts?.frame;
frame?.origin.y = sectionInset.top;
atts?.frame = frame!;
return atts
}
let ipPrev = IndexPath(item: indexPath.item - 2, section: indexPath.section)
let fPrev = self.layoutAttributesForItem(at: ipPrev)?.frame
let rightPrev = (fPrev?.origin.y)! + (fPrev?.size.height)! + 10
if (atts?.frame.origin.y)! <= rightPrev {
return atts
}
var f = atts?.frame
f?.origin.y = rightPrev
atts?.frame = f!
return atts
}
}

Related

UICollectionViewLayout: add DecorationView only to specific cells

I've developed a custom CollectionViewLayout which uses DecorationView to show shadows behind the cells.
However, I'd like to add this decoration only to some cells. The UICollectionView is vertical, but it may contain an embedded horizontal UICollectionView inside the cell. The cells with an embedded UICollectionView should not be decorated, as shown on the image:
Here is the code I'm using to add a shadow. The UICollectionViewLayout does not provide a method how to retrieve a cell's class, so it could decide whether to add a shadow or not:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let parent = super.layoutAttributesForElements(in: rect)
guard let attributes = parent, !attributes.isEmpty else {
return parent
}
let sections = attributes.map{$0.indexPath.section}
let unique = Array(Set(sections))
// Need to detect, which sections contain an embedded UICollectionView and exclude them from the UNIQUE set
let backgroundShadowAttributes: [UICollectionViewLayoutAttributes] = unique.compactMap{ section in
let indexPath = IndexPath(item: 0, section: section)
return self.layoutAttributesForDecorationView(ofKind: backgroundViewClass.reuseIdentifier(),
at: indexPath)
}
return attributes + backgroundShadowAttributes + separators
}
Is there any way to conditionally specify, which views should be decorated?
Finished with this code:
A protocol to directly ask the DataSource, whether to show a shadow for a particular section:
protocol SectionBackgroundFlowLayoutDataSource {
func shouldDisplayBackgroundFor(section: Int) -> Bool
}
And leverage the protocol in the func layoutAttributesForElements(in rect: CGRect) method:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let parent = super.layoutAttributesForElements(in: rect)
guard let attributes = parent, !attributes.isEmpty else {
return parent
}
attributes.forEach(configureRoundCornersAttributes)
// Display shadows for every section by default
var sectionsWithShadow = Set(attributes.map{$0.indexPath.section})
if let dataSource = collectionView?.dataSource as? SectionBackgroundFlowLayoutDataSource {
// Ask DataSource for sections with shadows, if it supports the protocol
sectionsWithShadow = sectionsWithShadow.filter{dataSource.shouldDisplayBackgroundFor(section: $0)}
}
let backgroundShadowAttributes: [UICollectionViewLayoutAttributes] = sectionsWithShadow.compactMap{ section in
let indexPath = IndexPath(item: 0, section: section)
return self.layoutAttributesForDecorationView(ofKind: backgroundViewClass.reuseIdentifier(),
at: indexPath)
}
return attributes + backgroundShadowAttributes + separators
}
func shouldDisplayBackgroundFor(section: Int) -> Bool may return faster, than cellForItemAtIndexPath, as it doesn't require full cell configuration.

How to change UICollectionView zIndex?

Check the image below. I need to highlight the focused cell such that it is above all the cells in collectionView.
I know I have to change the zIndex of the focused cell. How to do that?
I need the logic. My code is in objective-c.
This is for tvOS but iOS code will also work here I guess.
Have you tried setting value for cell.layer.zPosition?
Try manually applying layer.zPosition to be the zIndex in applyLayoutAttributes: method of UICollectionViewCell subclass:
- (void)applyLayoutAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
{
[super applyLayoutAttributes:layoutAttributes];
self.layer.zPosition = layoutAttributes.zIndex;
}
You need to create a custom collection view flow layout. Compute the zIndex based on the collection view's scroll position or visible rect. The sample class is shown below.
final class CustomCollectionViewLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributes = super.layoutAttributesForElements(in: rect)
attributes?.forEach {
$0.zIndex = 0 //Compute and set
}
return attributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let attribute = super.layoutAttributesForItem(at: indexPath)
attribute?.zIndex = 0 //Compute and set
return attribute
}
}
Maybe set the z-position of collection view to -2, unhighlight to -1 and highlight to 0

UICollectionView remove section breaks with UICollectionViewFlowLayout

I have a dataset that is divided into multiple sections, however, I'd like to display this in a collectionView without breaks between sections. Here's an illustration of what I want to achieve:
Instead of:
0-0 0-1 0-2
0-3
1-0 1-1
2-0
3-0
I want:
0-0 0-1 0-2
0-3 1-0 1-1
2-0 3-0
I realize the solution likely lies with a custom UICollectionViewLayout subclass, but I'm not sure how to achieve something like this.
Thanks
You are correct that you need to subclass UICollectionViewLayout.
The essence to understand before starting is that you need to calculate at least position and size for every cell in the collection view. UICollectionViewLayout is just a structured way to provide that information. You get the structure, but you have to provide everything else yourself.
There are 4 methods you need to override:
prepare
invalidateLayout
layoutAttributesForItemAtIndexPath
layoutAttributesForElementsInRect
One trick is to cache the layout attributes in a lookup table (dictionary):
var cachedItemAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
In prepare, you calculate the layout attributes for each indexPath in your collectionView:
override func prepare() {
super.prepare()
calculateAttributes()
}
In invalidateLayout you reset the cached layout attributes and recalculate them:
override func invalidateLayout() {
super.invalidateLayout()
cachedItemAttributes = [:]
calculateAttributes()
}
In layoutAttributesForItemAtIndexPath you use the lookup table to return the right layout attributes:
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cachedItemAttributes[indexPath]
}
In layoutAttributesForElementsInRect you filter your lookup table for the elements within the specified rect:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return cachedItemAttributes.filter { rect.intersects($0.value.frame) }.map { $0.value }
}
The final piece of the puzzle is the actual calculation of the layout attributes. Here I will provide only pseudo-code:
func calculateAttributes() {
// For each indexpath (you can get this from the collectionView property using numberOfSections and numberOfItems:inSection )
// calculate the frame, i.e the origin point and size of each cell in your collectionView and set it with UICollectionViewLayoutAttributes.frame
// There are many other properties on UICollectionViewLayoutAttributes that you can tweak for your layout, but frame is a good starting point from which you can start experimenting.
// Add the layout attributes to the lookup table
// end loop
}
To answer your question, here is pseudo-code to calculate the position of each cell:
// If width of cell + current width of row + spacing, insets and margins exceeds the available width
// move to next row.
// else
// cell origin.x = current width of row + interitem spacing
// cell origin.y = number of rows * (row height + spacing)
// endif
If you need your custom layout to be configurable, then either use UICollectionViewDelegateFlowLayout if the available signatures are sufficient, or define your own that inherits from UICollectionViewDelegateFlowLayout or UICollectionViewDelegate. Because your protocol inherits from UICollectionViewDelegateFlowLayout, which itself inherits from UICollectionViewDelegate, you can set it directly as the collectionView delegate in your viewcontroller. In your custom collection view layout you just need to cast the delegate from UICollectionViewDelegate to your custom protocol to use it. Remember to handle cases where the casting fails or where the protocol methods are not implemented by the delegate.
I found that for me, Marmoy's answer is missing one additional element:
overriding collectionViewContentSize.
Otherwise, depending on the size of your collectionView, you may get a call to layoutAttributesForElements(in rect: CGRect) which has a zero width or height, which will miss many of the cells. This is especially true if you're trying to dynamically size items in the collection view.
So a more complete version of Marmoy's answer would be:
import UIKit
class NoBreakSectionCollectionViewLayout: UICollectionViewLayout {
var cachedItemAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
var cachedContentSize = CGSize.zero
override func prepare() {
super.prepare()
calculateAttributes()
}
override func invalidateLayout() {
super.invalidateLayout()
cachedItemAttributes = [:]
calculateAttributes()
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cachedItemAttributes[indexPath]
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return cachedItemAttributes.filter { rect.intersects($0.value.frame) }.map { $0.value }
}
override var collectionViewContentSize: CGSize {
return cachedContentSize
}
func calculateAttributes() {
var y = CGFloat(0)
var x = CGFloat(0)
var lastHeight = CGFloat(0)
let xSpacing = CGFloat(5)
let ySpacing = CGFloat(2)
if let collectionView = collectionView, let datasource = collectionView.dataSource, let sizeDelegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout {
let sections = datasource.numberOfSections?(in: collectionView) ?? 1
for section in 0..<sections {
for item in 0..<datasource.collectionView(collectionView, numberOfItemsInSection: section){
let indexPath = IndexPath(item: item, section: section)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
if let size = sizeDelegate.collectionView?(collectionView, layout: self, sizeForItemAt: indexPath) {
if x > 0 && (x + size.width + xSpacing) > collectionView.bounds.width {
y += size.height + ySpacing
x = CGFloat(0)
}
attributes.frame = CGRect(x: x, y: y, width: size.width, height: size.height)
lastHeight = size.height
x += size.width + xSpacing
}
cachedItemAttributes[indexPath] = attributes
}
}
cachedContentSize = CGSize(width: collectionView.bounds.width, height: y + lastHeight)
}
}
}
Additionally, it's important for your delegate to implement UICollectionViewDelegateFlowLayout in the example above... Alternately, you can just calculate item sizes in the Layout if you know them without knowing about the cell content.

How do I create a parallax effect in UITableView with UIImageView in their prototype cells

I'm building an app in iOS 8.4 with Swift.
I have a UITableView with a custom UITableViewCell that includes a UILabel and UIImageView. This is all fairly straight forward and everything renders fine.
I'm trying to create a parallax effect similar to the one demonstrated in this demo.
I currently have this code in my tableView.cellForRowAtIndexPath
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
var cell = self.tableView.dequeueReusableCellWithIdentifier("myitem", forIndexPath: indexPath) as! MixTableViewCell
cell.img.backgroundColor = UIColor.blackColor()
cell.title.text = self.items[indexPath.row]["title"]
cell.img.image = UIImage(named: "Example.png")
// ideally it would be cool to have an extension allowing the following
// cell.img.addParallax(50) // or some other configurable offset
return cell
}
That block exists inside a class that looks like class HomeController: UIViewController, UITableViewDelegate, UITableViewDataSource { ... }
I am also aware that I can listen to scroll events in my class via func scrollViewDidScroll.
Other than that, help is appreciated!
I figured it out! The idea was to do this without implementing any extra libraries especially given the simplicity of the implementation.
First... in the custom table view Cell class, you have to create an wrapper view. You can select your UIImageView in the Prototype cell, then choose Editor > Embed in > View. Drag the two into your Cell as outlets, then set clipToBounds = true for the containing view. (also remember to set the constraints to the same as your image.
class MyCustomCell: UITableViewCell {
#IBOutlet weak var img: UIImageView!
#IBOutlet weak var imgWrapper: UIView!
override func awakeFromNib() {
self.imgWrapper.clipsToBounds = true
}
}
Then in your UITableViewController subclass (or delegate), implement the scrollViewDidScroll — from here you'll continually update the UIImageView's .frame property. See below:
override func scrollViewDidScroll(scrollView: UIScrollView) {
let offsetY = self.tableView.contentOffset.y
for cell in self.tableView.visibleCells as! [MyCustomCell] {
let x = cell.img.frame.origin.x
let w = cell.img.bounds.width
let h = cell.img.bounds.height
let y = ((offsetY - cell.frame.origin.y) / h) * 25
cell.img.frame = CGRectMake(x, y, w, h)
}
}
See this in action.
I wasn't too happy with #ded's solution requiring a wrapper view, so I came up with another one that uses autolayout and is simple enough.
In the storyboard, you just have to add your imageView and set 4 constraints on the ImageView:
Leading to ContentView (ie Superview) = 0
Trailing to ContentView (ie Superview) = 0
Top Space to ContentView (ie Superview) = 0
ImageView Height (set to 200 here but this is recalculated based on the cell height anyway)
The last two constraints (top and height) need referencing outlets to your custom UITableViewCell (in the above pic, double click on the constraint in the rightmost column, and then Show the connection inspector - the icon is an arrow in a circle)
Your UITableViewCell should look something like this:
class ParallaxTableViewCell: UITableViewCell {
#IBOutlet weak var parallaxImageView: UIImageView!
// MARK: ParallaxCell
#IBOutlet weak var parallaxHeightConstraint: NSLayoutConstraint!
#IBOutlet weak var parallaxTopConstraint: NSLayoutConstraint!
override func awakeFromNib() {
super.awakeFromNib()
clipsToBounds = true
parallaxImageView.contentMode = .ScaleAspectFill
parallaxImageView.clipsToBounds = false
}
}
So basically, we tell the image to take as much space as possible, but we clip it to the cell frame.
Now your TableViewController should look like this:
class ParallaxTableViewController: UITableViewController {
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return cellHeight
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("CellIdentifier", forIndexPath: indexPath) as! ParallaxTableViewCell
cell.parallaxImageView.image = … // Set your image
cell.parallaxHeightConstraint.constant = parallaxImageHeight
cell.parallaxTopConstraint.constant = parallaxOffsetFor(tableView.contentOffset.y, cell: cell)
return cell
}
// Change the ratio or enter a fixed value, whatever you need
var cellHeight: CGFloat {
return tableView.frame.width * 9 / 16
}
// Just an alias to make the code easier to read
var imageVisibleHeight: CGFloat {
return cellHeight
}
// Change this value to whatever you like (it sets how "fast" the image moves when you scroll)
let parallaxOffsetSpeed: CGFloat = 25
// This just makes sure that whatever the design is, there's enough image to be displayed, I let it up to you to figure out the details, but it's not a magic formula don't worry :)
var parallaxImageHeight: CGFloat {
let maxOffset = (sqrt(pow(cellHeight, 2) + 4 * parallaxOffsetSpeed * tableView.frame.height) - cellHeight) / 2
return imageVisibleHeight + maxOffset
}
// Used when the table dequeues a cell, or when it scrolls
func parallaxOffsetFor(newOffsetY: CGFloat, cell: UITableViewCell) -> CGFloat {
return ((newOffsetY - cell.frame.origin.y) / parallaxImageHeight) * parallaxOffsetSpeed
}
override func scrollViewDidScroll(scrollView: UIScrollView) {
let offsetY = tableView.contentOffset.y
for cell in tableView.visibleCells as! [MyCustomTableViewCell] {
cell.parallaxTopConstraint.constant = parallaxOffsetFor(offsetY, cell: cell)
}
}
}
Notes:
it is important to use tableView.dequeueReusableCellWithIdentifier("CellIdentifier", forIndexPath: indexPath) and not tableView.dequeueReusableCellWithIdentifier("CellIdentifier"), otherwise the image won't be offset until you start scrolling
So there you have it, parallax UITableViewCells that should work with any layout, and can also be adapted to CollectionViews.
This method works with table view and collection view.
first of all create the cell for the tableview and put the image view in it.
set the image height slightly more than the cell height. if cell height = 160 let the image height be 200 (to make the parallax effect and you can change it accordingly)
put this two variable in your viewController or any class where your tableView delegate is extended
let imageHeight:CGFloat = 150.0
let OffsetSpeed: CGFloat = 25.0
add the following code in the same class
func scrollViewDidScroll(scrollView: UIScrollView) {
// print("inside scroll")
if let visibleCells = seriesTabelView.visibleCells as? [SeriesTableViewCell] {
for parallaxCell in visibleCells {
var yOffset = ((seriesTabelView.contentOffset.y - parallaxCell.frame.origin.y) / imageHeight) * OffsetSpeedTwo
parallaxCell.offset(CGPointMake(0.0, yOffset))
}
}
}
where seriesTabelView is my UItableview
and now lets goto the cell of this tableView and add the following code
func offset(offset: CGPoint) {
posterImage.frame = CGRectOffset(self.posterImage.bounds, offset.x, offset.y)
}
were posterImage is my UIImageView
If you want to implement this to collectionView just change the tableView vairable to your collectionView variable
and thats it. i am not sure if this is the best way. but it works for me. hope it works for you too. and let me know if there is any problem
After combining answers from #ded and #Nycen I came to this solution, which uses embedded view, but changes layout constraint (only one of them):
In Interface Builder embed the image view into a UIView. For that view make [√] Clips to bounds checked in View > Drawing
Add the following constraints from the image to view: left and right, center Vertically, height
Adjust the height constraint so that the image is slightly higher than the view
For the Align Center Y constraint make an outlet into your UITableViewCell
Add this function into your view controller (which is either UITableViewController or UITableViewControllerDelegate)
private static let screenMid = UIScreen.main.bounds.height / 2
private func adjustParallax(for cell: MyTableCell) {
cell.imageCenterYConstraint.constant = -(cell.frame.origin.y - MyViewController.screenMid - self.tableView.contentOffset.y) / 10
}
Note: by editing the magic number 10 you can change how hard the effect will be applied, and by removing the - symbol from equation you can change the effect's direction
Call the function from when the cell is reused:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "myCellId", for: indexPath) as! MyTableCell
adjustParallax(for: cell)
return cell
}
And also when scroll happens:
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
(self.tableView.visibleCells as! [MyTableCell]).forEach { cell in
adjustParallax(for: cell)
}
}

UICollectionViewFlowLayout Subclass causes some cells to not appear

I have a vertically scrolling UICollectionView that uses a subclass of UICollectionViewFlowLayout to try and eliminate inter-item spacing. This would result in something that looks similar to a UITableView, but I need the CollectionView for other purposes. There is a problem in my implementation of the FlowLayout subclass that causes cells to disappear when scrolling fast. Here is the code for my FlowLayout subclass:
EDIT: See Comments For Update
class ListLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
if var answer = super.layoutAttributesForElementsInRect(rect) {
for attr in (answer as [UICollectionViewLayoutAttributes]) {
let ip = attr.indexPath
attr.frame = self.layoutAttributesForItemAtIndexPath(ip).frame
}
return answer;
}
return nil
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! {
let currentItemAtts = super.layoutAttributesForItemAtIndexPath(indexPath) as UICollectionViewLayoutAttributes
if indexPath.item == 0 {
var frame = currentItemAtts.frame
frame.origin.y = 0
currentItemAtts.frame = frame
return currentItemAtts
}
let prevIP = NSIndexPath(forItem: indexPath.item - 1, inSection: indexPath.section)
let prevFrame = self.layoutAttributesForItemAtIndexPath(prevIP).frame
let prevFrameTopPoint = prevFrame.origin.y + prevFrame.size.height
var frame = currentItemAtts.frame
frame.origin.y = prevFrameTopPoint
currentItemAtts.frame = frame
return currentItemAtts
}
}
One other thing to note: My cells are variable height. Their height is set by overriding preferredLayoutAttributesFittingAttributes in the subclass of the custom cell:
override func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes! {
let attr: UICollectionViewLayoutAttributes = layoutAttributes.copy() as UICollectionViewLayoutAttributes
attr.frame.size = CGSizeMake(self.frame.size.width, myHeight)
return attr
}
And I set the layout's estimated size on initialization:
flowLayout.estimatedItemSize = CGSize(width: UIScreen.mainScreen().bounds.size.width, height: 60)
Here is a GIF that demonstrates this problem:
Does anybody have an idea as to what's going on? Your help is much appreciated.
Thanks!

Resources