Calculate height of UITableViewCell offscreen with size classes - uitableview

I've been working on a set of classes that allow for UITableView's and their associated classes to uses auto layout and dynamic type. It's based around an answer on Stack Overflow that aims to add auto layout support.
So far, it works well, but I've run in to a couple of problems when using size classes. The first is directly related to the table calculating the height:
When I create a new UITableViewCell, but don't add it to any views, the size class is AnyxAny, so when I've got some subviews or constraints that change based on the size class, they're always working as if they're in an AnyxAny situation. So far, my very hacky solution is to create a new UIWindow that I add the cells to by:
UIWindow(frame: UIScreen.mainScreen().applicationFrame)
This functions correctly, but I've got a couple of issues with it:
I'm now creating an entire new UIWindow object, which seems inefficient
Each cell has to be added to the same window
When the screen rotates, the size class may change (i.e., iPhone 6 Plus), so I need to listen for changes to the application frame and update my window's frame (not implemented yet)
Is there an easy/more efficient way to ensure the UITableViewCell knows its size class, without having to create a new UIWindow, or do that more efficiently? Maybe I can add a
Most of the current code can be found via the GitHub page, but the most relevant methods are:
DynamicTableViewController
private var cachedClassesForCellReuseIdentifiers = [String : UITableViewCell.Type]()
private var cachedNibsForCellReuseIdentifiers = [String : UINib]()
private var offscreenCellRowsForReuseIdentifiers = [String : UITableViewCell]()
private var offScreenWindow: UIWindow = {
return UIWindow(frame: UIScreen.mainScreen().applicationFrame)
}()
override public func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
// This method is called with an NSMutableIndexPath, which is not compatible with an imutable NSIndexPath,
// so we create an imutable NSIndexPath to be passed to the following methods
let imutableIndexPath = NSIndexPath(forRow: indexPath.row, inSection: indexPath.section)
if let reuseIdentifier = self.cellReuseIdentifierForIndexPath(imutableIndexPath) {
if let cell = self.cellForReuseIdentifier(reuseIdentifier) {
self.configureCell(cell, forIndexPath: indexPath)
if let dynamicCell = cell as? DynamicTableViewCell {
let height = dynamicCell.heightInTableView(tableView)
return height
} else {
// Fallback for non-DynamicTableViewCell cells
let size = cell.contentView.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
let cellBoundsHeight = CGRectGetHeight(cell.bounds)
if size.height > 0 && size.height >= cellBoundsHeight {
// +1 for the cell separator
return size.height + 1
} else {
// In some situations (such as the content view not having any/enough constraints to get a height), the
// size from the systemLayoutSizeFittingSize: will be 0. However, because this can _sometimes_ be intended
// (e.g., when adding to a default style; see: DynamicSubtitleTableViewCell), we just return
// the height of the cell as-is. This may make some cells look wrong, but overall will also prevent 0 being returned,
// hopefully stopping some things from breaking.
return cellBoundsHeight + 1
}
}
}
}
return UITableViewAutomaticDimension
}
private func cellForReuseIdentifier(reuseIdentifier: String) -> UITableViewCell? {
if self.offscreenCellRowsForReuseIdentifiers[reuseIdentifier] == nil {
if let cellClass = self.cachedClassesForCellReuseIdentifiers[reuseIdentifier] {
let cell = cellClass()
self.offScreenWindow.addSubview(cell)
self.offscreenCellRowsForReuseIdentifiers[reuseIdentifier] = cell
} else if let cellNib = self.cachedNibsForCellReuseIdentifiers[reuseIdentifier] {
if let cell = cellNib.instantiateWithOwner(nil, options: nil).first as? UITableViewCell {
self.offScreenWindow.addSubview(cell)
self.offscreenCellRowsForReuseIdentifiers[reuseIdentifier] = cell
}
}
}
return self.offscreenCellRowsForReuseIdentifiers[reuseIdentifier]
}
DynamicTableViewCell
public func heightInTableView(tableView: UITableView) -> CGFloat {
var height: CGFloat!
if self.calculateHeight {
self.setNeedsUpdateConstraints()
self.updateConstraintsIfNeeded()
self.bounds = CGRectMake(0, 0, CGRectGetWidth(tableView.bounds), CGRectGetHeight(self.bounds))
self.setNeedsLayout()
self.layoutIfNeeded()
let size = self.contentView.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
let boundsHeight = CGRectGetHeight(self.bounds)
if size.height > 0 && size.height >= boundsHeight {
// +1 for the cell separator
height = size.height + 1
} else {
// In some situations (such as the content view not having any/enough constraints to get a height), the
// size from the systemLayoutSizeFittingSize: will be 0. However, because this can _sometimes_ be intended
// (e.g., when adding to a default style; see: DynamicSubtitleTableViewCell), we just return
// the height of the cell as-is. This may make some cells look wrong, but overall will also prevent 0 being returned,
// hopefully stopping some things from breaking.
height = boundsHeight + 1
}
} else {
height = self.cellHeight
}
if height < self.minimumHeight && self.minimumHeight != nil {
return self.minimumHeight!
} else {
return height
}
}
This solutions works for iOS 7 and 8, and so should any future solutions. This restriction has also removed the use of UITraitCollection, so I've not gone down that route (I'm not even sure it'd help)

With IOS 8 and Autolayout You can set an estimated row height and set constraints for inner views with content view so it will scale according to the content in it. Here's my answer Which is the best approach among Autolayout or calculating the height using NSAttributedString, to implement dynamic height of an UITableViewCell?
Update: for IOS7 you need to calculate height when it is about to arrive you can do it in
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath Delegate method
#define HEIGHT_FOR_ONE_LINE 15; //define it according to your fonts
#define HEIGHT_FOR_SINGLE_ROWCELL 32;
#pragma mark - Table view data source
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
NSString *textToView= [self.yourDataSourceArray objectAtIndex:indexPath.row];
CGFloat screenWidth=[[UIScreen mainScreen]bounds].size.width;
CGFloat characterPerPoint= 10;
CGFloat widthTaken= characterPerPoint * textToView.length;
NSInteger numberOfLines= ceil(widthTaken/screenWidth);
CGFloat height=numberOfLines* HEIGHT_FOR_ONE_LINE;
height = height + HEIGHT_FOR_SINGLE_ROWCELL;
return height;
}

Related

iOS - Custom view: Intrinsic content size ignored after updated in layoutSubviews()

I have a tableview cell containing a custom view among other views and autolayout is used.
The purpose of my custom view is to layout its subviews in rows and breaks into a new row if the current subview does not fit in the current line. It kind of works like a multiline label but with views. I achieved this through exact positioning instead of autolayout.
Since I only know the width of my view in layoutSubviews(), I need to calculate the exact positions and number of lines there. This worked out well, but the frame(zero) of my view didn't match because of missing intrinsicContentSize.
So I added a check to the end of my calculation if my height changed since the last layout pass. If it did I update the height property which is used in my intrinsicContentSize property and call invalidateIntrinsicContentSize().
I observed that initially layoutSubviews() is called twice. The first pass works well and the intrinsicContentSize is taken into account even though the width of the cell is smaller than it should be. The second pass uses the actual width and also updates the intrinsicContentSize. However the parent(contentView in tableview cell) ignores this new intrinsicContentSize.
So basically the result is that the subviews are layout and drawn correctly but the frame of the custom view is not updated/used in parent.
The question:
Is there a way to notify the parent about the change of the intrinsic size or a designated place to update the size calculated in layoutSubviews() so the new size is used in the parent?
Edit:
Here is the code in my custom view.
FYI: 8 is just the vertical and horizontal space between two subviews
class WrapView : UIView {
var height = CGFloat.zero
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: height)
}
override func layoutSubviews() {
super.layoutSubviews()
guard frame.size.width != .zero else { return }
// Make subviews calc their size
subviews.forEach { $0.sizeToFit() }
// Check if there is enough space in a row to fit at least one view
guard subviews.map({ $0.frame.size.width }).max() ?? .zero <= frame.size.width else { return }
let width = frame.size.width
var row = [UIView]()
// rem is the remaining space in the current row
var rem = width
var y: CGFloat = .zero
var i = 0
while i < subviews.count {
let view = subviews[i]
let sizeNeeded = view.frame.size.width + (row.isEmpty ? 0 : 8)
let last = i == subviews.count - 1
let fit = rem >= sizeNeeded
if fit {
row.append(view)
rem -= sizeNeeded
i += 1
guard last else { continue }
}
let rowWidth = row.map { $0.frame.size.width + 8 }.reduce(-8, +)
var x = (width - rowWidth) * 0.5
for vw in row {
vw.frame.origin = CGPoint(x: x, y: y)
x += vw.frame.width + 8
}
y += row.map { $0.frame.size.height }.max()! + 8
rem = width
row = []
}
if height != y - 8 {
height = y - 8
invalidateIntrinsicContentSize()
}
}
}
After a lot of trying and research I finally solved the bug.
As #DonMag mentioned in the comments the new size of the cell wasn't recognized until a new layout pass. This could be verified by scrolling the cell off-screen and back in which showed the correct layout. Unfortunately it is harder than expected to trigger new pass as .beginUpdates() + .endUpdates()didn't
do the job.
Anyway I didn't find a way to trigger it but I followed the instructions described in this answer. Especially the part with the prototype cell for the height calculation provided a value which can be returned in tableview(heightForRowAt:).
Swift 5:
This is the code used for calculation:
let fitSize = CGSize(width: view.frame.size.width, height: .zero)
/* At this point populate the cell with the exact same data as the actual cell in the tableview */
cell.setNeedsUpdateConstraints()
cell.updateConstraintsIfNeeded()
cell.bounds = CGRect(x: .zero, y: .zero, width: view.frame.size.width, height: cell.bounds.height)
cell.setNeedsLayout()
cell.layoutIfNeeded()
height = headerCell.contentView.systemLayoutSizeFitting(fitSize).height + 1
The value is only calculated once and the cached as the size doesn't change anymore in my case.
Then the value can be returned in the delegate:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
indexPath.row == 0 ? height : UITableView.automaticDimension
}
I only used for the first cell as it is my header cell and there is only one section.

Why aren't the cells in my UICollectionView updating their size when I update the flow layout?

I have a grid of cells in a UICollectionViewController:
Pretty standard 3 column grid (based on approximate cell size, wider layouts have more cells)
The overall idea of the app is that a user taps a cell which counts down the specified time, and once the timer is complete it will unlock the next time. The first time is always unlocked:
After the first 3 buttons are tapped and have counted down
Tapping the same cell twice in succession removes all cells except the first and the one which was tapped twice, to allow the user to loop around.
Doing this should also update the collectionViewLayout to be only 2 columns of equal width which fill the screen, but at the moment the first and second cells are not resizing:
[After a button is tapped twice in succession](http:// i.imgur.com/cLvaApG.png)
The layout is definitely updated / invalidated because rotating the device updates the cells as expected:
[Rotated to landscape mode](http:// i.imgur.com/rngwzre.png)
[And rotated back to portrait](http:// i.imgur.com/ajwPr1J.png)
I'm sizing the cells as follows:
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
if repeating {
let target = collectionView.frame.width
let targetFraction = target / 2
return CGSize(width: targetFraction, height: targetFraction)
}
let target = CGFloat(125)
let frameRef = collectionView.frame.width
let frameWidthRemainder = frameRef % target
let frameWidthRounded = frameRef - frameWidthRemainder
let columnCount = frameWidthRounded / target
let columnWidth = frameRef / columnCount
return CGSize(width: columnWidth, height: columnWidth)
}
Repeating is a bool which is set / reset by tapping on the cells as part of an action:
#IBAction func circleProgressButtonTapped(sender: CircleProgressButton) {
...
if previouslySelectedButton == sender && !repeating {
repeating = true
collectionViewLayout.invalidateLayout()
buttons = [buttons[0], buttons[row]]
collectionView?.reloadData()
for button in buttons {
button.circleProgressButton.setNeedsDisplay()
}
}
...
}
When the device rotates, I'm resetting the layout with:
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
for button in buttons {
button.circleProgressButton.setNeedsDisplay()
}
collectionViewLayout.invalidateLayout()
}
Buttons is an array of the cells, which is populated / reset with:
func refreshView() {
if let min = defaults.valueForKey(userDefaultsKeys.min.rawValue) as? Int {
self.min = min
}
if let max = defaults.valueForKey(userDefaultsKeys.max.rawValue) as? Int {
self.max = max
}
buttons = []
collectionView?.reloadData()
for i in 0..<buttonCount {
guard let collectionView = collectionView else { break }
let indexPath = NSIndexPath(forRow: i, inSection: 0)
let multiplier = i + 1
let time = min * multiplier
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as! CircleProgressButtonCollectionViewCell
cell.circleProgressButton.delegate = self
cell.circleProgressButton.value = time
cell.circleProgressButton.resetText()
cell.circleProgressButton.indexPath = indexPath
cell.circleProgressButton.progress = 0
cell.circleProgressButton.activeProgressColour = theme
cell.circleProgressButton.tintColor = theme
cell.circleProgressButton.setNeedsDisplay()
if i > 0 {
cell.circleProgressButton.enabled = false
}
buttons.append(cell)
}
}
I know this is probably not the best way to be making the cells, but any other way caused problems with the cell re-use. I.e. the progress in a cell would also be re-used.
I'm not even sure if the issue is collectionViewLayout.invalidateLayout() is not invalidating properly or what. It could just be the collectionView needs to be updated in some other way. I don't even know if it's the collectionView or the cell I should be focusing on. It's just weird that the first two cells are the only ones not updating, especially as I'm not doing anything different to them.
I would list everything I've tried but I'd be here a while. Among other things, I've tried:
if previouslySelectedButton == sender && !repeating {
...
collectionViewLayout.prepareLayout()
if let collectionView = collectionView {
collectionView.setNeedsDisplay()
collectionView.setNeedsLayout()
}
if let collectionViewLayoutCollectionView = collectionViewLayout.collectionView {
collectionViewLayoutCollectionView.setNeedsDisplay()
collectionViewLayoutCollectionView.setNeedsLayout()
}
collectionView?.setNeedsDisplay()
collectionViewLayout.collectionView?.setNeedsDisplay()
collectionView?.setNeedsLayout()
collectionViewLayout.collectionView?.setNeedsLayout()
collectionView?.reloadData()
buttons = [buttons[0], buttons[row]]
for button in buttons {
button.setNeedsLayout()
button.setNeedsDisplay()
}
I've tried setting the buttons array to empty, reloading, then populating it (generating entirely new cells) and reloading it again but even that doesn't work.
If I have to radically alter my entire approach, so be it.

UICollectionViewFlowLayout Place Section Headers over Section Left Inset

I am trying to modify UICollectionViewFlowLayout (vertical scroll) in order to place each section header to the left of all items of that section (as opposed to on top, which is the default).
That is, this is the default behaviour:
...and this is what I want:
So I subclassed UICollectionViewFlowLayout:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributesToReturn = super.layoutAttributesForElementsInRect(rect) else {
return nil
}
// Copy to avoid the dreadded "Cached frame mismatch" runtime warning:
var copiedAttributes = [UICollectionViewLayoutAttributes]()
for attribute in attributesToReturn {
copiedAttributes.append(attribute.copy() as! UICollectionViewLayoutAttributes)
}
for attributes in copiedAttributes {
if let kind = attributes.representedElementKind {
// Non nil: It is a supplementary View
if kind == UICollectionElementKindSectionHeader {
// HEADER
var frame = attributes.frame
frame.origin.y = frame.origin.y + frame.size.height
frame.size.width = sectionInset.left
attributes.frame = frame
}
}
else{
// Nil: It is an item
}
}
return copiedAttributes
}
Also, for good measure (?), I adopted the protocol UICollectionViewDelegateFlowLayout and implemented this method (although it is not clear what takes precedence. And then, there is the settings in the storyboard file, but those seem to be overriden by their runtime counterparts):
func collectionView(
collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize {
let left = (collectionViewLayout as! UICollectionViewFlowLayout).sectionInset.left
return CGSizeMake(left, 1)
}
...and I succeed in lowering the header to the first row of its section; However, the space originally occupied by the header stays open:
...and the only way I can accomplish that is by setting the header view height to 1 and "Clips Subviews" to false, so that the label is displayed (If I set the height to 0, the label is not drawn), but this is definitely not the most elegant solution (and will likely break in -say- iOS 9.2)
That is, the actual height of the header is linked to the vertical space between sections: I can not set the space to zero while keeping the header view at a reasonable size for display.
Perhaps I should also move all section items up (by the same amount as my header height) instead, to fill the hole?
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect
{
NSArray *originalAttributes = [super layoutAttributesForElementsInRect:rect];
NSMutableArray *allAttributes = [NSMutableArray new];
for (UICollectionViewLayoutAttributes* attributes in originalAttributes) {
[allAttributes addObject:[attributes copy]];
}
for (UICollectionViewLayoutAttributes *attributes in allAttributes) {
NSString *kind = attributes.representedElementKind;
if (kind == UICollectionElementKindSectionHeader) {
CGRect frame = attributes.frame;
UICollectionViewLayoutAttributes *cellAttrs = [super layoutAttributesForItemAtIndexPath:attributes.indexPath];
frame.origin.x = frame.origin.x;
frame.size.height = self.sectionInset.top;
frame.size.width = cellAttrs.frame.size.width;
attributes.frame = frame;
}
}
return allAttributes;
}
OK, so this is what I did:
First of all, I subclassed UICollectionViewFlowLayout based on this github project, to get the left alignment I was looking for (I had to convert it from Objective-C to swift, but other than than it's pretty much the same).
Since I am already subclassing the layout object, I can implement any modifications in this subclass.
I declared a property to store the width of my "side-headers" (this quantity also doubles as the left inset of each section):
import UIKit
class LeftAlignedFlowLayout: UICollectionViewFlowLayout
{
let customHeaderWidth:CGFloat = 150.0
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
sectionInset.left = customHeaderWidth
}
Then, in the implementation of layoutAttributesForElementsInRect() I did this:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
guard let attributesToReturn = super.layoutAttributesForElementsInRect(rect) else {
return nil
}
var copiedAttributes = [UICollectionViewLayoutAttributes]()
for attribute in attributesToReturn {
// Must copy attributes to avoid runtime warning:
copiedAttributes.append(attribute.copy() as! UICollectionViewLayoutAttributes)
}
for attributes in copiedAttributes {
if let kind = attributes.representedElementKind {
// Non nil : Supplementary View
if kind == UICollectionElementKindSectionHeader {
// [A] HEADER
var frame = attributes.frame
frame.origin.y = frame.origin.y + frame.size.height
frame.size.width = sectionInset.left
frame.size.height = 60 // Hard-coded - header height
attributes.frame = frame
}
else if kind == UICollectionElementKindSectionFooter {
// [B] FOOTER
var frame = attributes.frame
// My footer view is a "hairline" taking most of the width
// (save for 8 points of inset margin on each side):
frame.origin.x += 8
frame.size.width -= 16
attributes.frame = frame
}
}
else{
// Kind is nil : Item (cell)
if let attributesForItem = self.layoutAttributesForItemAtIndexPath(attributes.indexPath){
attributes.frame = attributesForItem.frame
}
}
}
return copiedAttributes
}
There is also the implementation of layoutAttributesForItemAtIndexPath(), but that pertains more to the left alignment of the items-part, so I omitted it (if anyone is interested, please check the above-linked github project).
I will create a repository with a demo project of my implementation as soon as I have time.
These are the Size Inspector settings for the collection view and the flow layout object, respectively:
(By the way, item size is variable and determined at runtime by the delegate method, so ignore those values)
I also adopted the UICollectionViewDelegateFlowLayout protocol, like this:
func collectionView(collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize
{
let left = (collectionViewLayout as! UICollectionViewFlowLayout).sectionInset.left
return CGSizeMake(left, 1)
}
Although, to be honest, some of the layout attributes can be specified in many places (Interface Builder, properties of layout object, return value from delegate methods, etc... ) and I don't remember exactly which takes precendence in each case, so I need to clean up my code a bit.

Populating UICollectionView in reverse order

I would like to populate UICollectionView in reverse order so that the last item of the UICollectionView fills first and then the second last and so on. Actually I'm applying animation and items are showing up one by one. Therefore, I want the last item to show up first.
Swift 4.2
I found a simple solution and worked for me to show last item first of a collection view:
Inside viewDidLoad() method:
collectionView.transform = CGAffineTransform.init(rotationAngle: (-(CGFloat)(Double.pi)))
and inside collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) method before returning the cell:
cell.transform = CGAffineTransform(rotationAngle: CGFloat.pi)
(optional) Below lines will be necessary to auto scroll and show new item with smooth scroll.
Add below lines after loading new data:
if self.dataCollection.count > 0 {
self.collectionView.scrollToItem(at: //scroll collection view to indexpath
NSIndexPath.init(row:(self.collectionView?.numberOfItems(inSection: 0))!-1, //get last item of self collectionview (number of items -1)
section: 0) as IndexPath //scroll to bottom of current section
, at: UICollectionView.ScrollPosition.bottom, //right, left, top, bottom, centeredHorizontally, centeredVertically
animated: true)
}
I'm surprised that Apple scares people away from writing their own UICollectionViewLayout in the documentation. It's really very straightforward. Here's an implementation that I just used in an app that will do exactly what are asking. New items appear at the bottom, and the while there is not enough content to fill up the screen the the items are bottom justified, like you see in message apps. In other words item zero in your data source is the lowest item in the stack.
This code assumes that you have multiple sections, each with items of a fixed height and no spaces between items, and the full width of the collection view. If your layout is more complicated, such as different spacing between sections and items, or variable height items, Apple's intention is that you use the prepare() callback to do the heavy lifting and cache size information for later use.
This code uses Swift 3.0.
//
// Created by John Lyon-Smith on 1/7/17.
// Copyright © 2017 John Lyon-Smith. All rights reserved.
//
import Foundation
import UIKit
class InvertedStackLayout: UICollectionViewLayout {
let cellHeight: CGFloat = 100.00 // Your cell height here...
override func prepare() {
super.prepare()
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var layoutAttrs = [UICollectionViewLayoutAttributes]()
if let collectionView = self.collectionView {
for section in 0 ..< collectionView.numberOfSections {
if let numberOfSectionItems = numberOfItemsInSection(section) {
for item in 0 ..< numberOfSectionItems {
let indexPath = IndexPath(item: item, section: section)
let layoutAttr = layoutAttributesForItem(at: indexPath)
if let layoutAttr = layoutAttr, layoutAttr.frame.intersects(rect) {
layoutAttrs.append(layoutAttr)
}
}
}
}
}
return layoutAttrs
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let layoutAttr = UICollectionViewLayoutAttributes(forCellWith: indexPath)
let contentSize = self.collectionViewContentSize
layoutAttr.frame = CGRect(
x: 0, y: contentSize.height - CGFloat(indexPath.item + 1) * cellHeight,
width: contentSize.width, height: cellHeight)
return layoutAttr
}
func numberOfItemsInSection(_ section: Int) -> Int? {
if let collectionView = self.collectionView,
let numSectionItems = collectionView.dataSource?.collectionView(collectionView, numberOfItemsInSection: section)
{
return numSectionItems
}
return 0
}
override var collectionViewContentSize: CGSize {
get {
var height: CGFloat = 0.0
var bounds = CGRect.zero
if let collectionView = self.collectionView {
for section in 0 ..< collectionView.numberOfSections {
if let numItems = numberOfItemsInSection(section) {
height += CGFloat(numItems) * cellHeight
}
}
bounds = collectionView.bounds
}
return CGSize(width: bounds.width, height: max(height, bounds.height))
}
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
if let oldBounds = self.collectionView?.bounds,
oldBounds.width != newBounds.width || oldBounds.height != newBounds.height
{
return true
}
return false
}
}
Just click on UICollectionView in storyboard,
in inspector menu under view section change semantic to Force Right-to-Left
I have attach an image to show how to do it in the inspector menu:
I'm assuming you are using UICollectionViewFlawLayout, and this doesn't have logic to do that, it only works in a TOP-LEFT BOTTOM-RIGHT order. To do that you have to build your own layout, which you can do creating a new object that inherits from UICollectionViewLayout.
It seems like a lot of work but is not really that much, you have to implement 4 methods, and since your layout is just bottom-up should be easy to know the frames of each cell.
Check the apple tutorial here: https://developer.apple.com/library/prerelease/ios/documentation/WindowsViews/Conceptual/CollectionViewPGforIOS/CreatingCustomLayouts/CreatingCustomLayouts.html
The data collection does not actually have to be modified but that will produce the expected result. Since you control the following method:
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
Simply return cells created from inverting the requested index. The index path is the cell's index in the collection, not necessarily the index in the source data set. I used this for a reversed display from a CoreData set.
let desiredIndex = dataProfile!.itemEntries!.count - indexPath[1] - 1;
Don't know if this still would be useful but I guess it might be quite useful for others.
If your collection view's cells are of the same height there is actually a much less complicated solution for your problem than building a custom UICollectionViewLayout.
Firstly, just make an outlet of your collection view's top constraint and add this code to the view controller:
-(void)viewWillAppear:(BOOL)animated {
[self.view layoutIfNeeded]; //for letting the compiler know the actual height and width of your collection view before we start to operate with it
if (self.collectionView.frame.size.height > self.collectionView.collectionViewLayout.collectionViewContentSize.height) {
self.collectionViewTopConstraint.constant = self.collectionView.frame.size.height - self.collectionView.collectionViewLayout.collectionViewContentSize.height;
}
So basically you calculate the difference between collection view's height and its content only if the view's height is bigger. Then you adjust it to the constraint's constant. Pretty simple. But if you need to implement cell resizing as well, this code won't be enough. But I guess this approach may be quite useful. Hope this helps.
A simple working solution is here!
// Change the collection view layer transform.
collectionView.transform3D = CATransform3DMakeScale(1, -1, 1)
// Change the cell layer transform.
cell.transform3D = CATransform3DMakeScale(1, -1, 1)
It is as simple as:
yourCollectionView.inverted = true
PS : Same for Texture/IGListKit..

Preventing "wrapping" of items in UICollectionView

I need a UICollectionView to display a grid that is potentially larger than the visible frame in both width and height, while maintaining row and column integrity. The default UICollectionViewFlowLayout allows sections to scroll off-screen, but it wraps items within a section to keep them all on-screen, which screws up my grid.
Recognizing that UICollectionView is a subclass of UIScrollView, I tried just manually setting the collection view's content size property in viewDidLoad:
self.collectionView.contentSize = CGSizeMake((columns * (cellWidth + itemSpacingX), (rows * (cellHeight + itemSpacingY));
But this had no effect. Questions:
Is there an easy way to accomplish this without building a custom layout?
Would using a custom layout and overriding the collectionViewContentSize method succeed in getting the collection view to stop wrapping items and scroll in both directions?
If I do have to build a custom layout--which I'll have to invest some time to learn--do I subclass UICollectionViewLayout, or would subclassing UICollectionViewFlowLayout save time?
UPDATE:
I tried embedding the UICollectionView as a subview of a UIScrollView. The collection view itself is behaving correctly--the rows aren't wrapping at the edge of the scroll view, telling me that it is filling the UIScrollView content size that I set. But the scroll view doesn't pan in the horizontal direction, i.e. it only scrolls vertically, which the collection view does by itself anyway. So stuck again. Could there be an issue with the responder chain?
Figured out two ways to do this. Both required a custom layout. The problem is that the default flow layout--and I now know from the Collection View Programming Guide this is partly the definition of a flow layout--generates the cell layout attributes based on the bounds of the superview, and will wrap items in a section to keep them in bounds so that scrolling occurs in only one axis. Will skip the code details, as it isn't hard, and my problem was mainly confusion on what approach to take.
Easy way: use a UIScrollView and subclass 'UICollectionViewFlowLayout'. Embed the UICollectionView in a UIScrollView. Set the contentSize property of the scroll view in viewDiDLoad to match the full size that your collection view will occupy (this will let the default flow layout place items in a single line within a section without wrapping). Subclass UICollectionViewFlowLayout, and set that object as your custom layout for the collection view. In the custom flow layout, override collectionViewContentSize to return the full size of the collection view matrix. With this approach, you'll be using a flow layout, but will be able to scroll in both directions to view un-wrapped sections. The disadvantage is that you still have a flow layout that is pretty limited. Plus, it seems clunky to put a UICollectionView inside an instance of its own superclass just to get the functionality that the collection view by itself should have.
Harder way, but more versatile and elegant: subclass UICollectionViewLayout. I used this tutorial to learn how to implement a complete custom layout. You don't need a UIScrollView here. If you forego the flow layout, subclass UICollectionViewLayout, and set that as the custom layout, you can build out the matrix and get the right behavior from the collection view itself. It's more work because you have to generate all the layout attributes, but you'll be positioned to make the collection view do whatever you want.
In my opinion, Apple should add a property to the default flow layout that suppresses wrapping. Getting a device to display a 2D matrix with intact rows and columns isn't an exotic functionality and it seems like it should be easier to do.
Here is a version of AndrewK's code updated to Swift 4:
import UIKit
class CollectionViewMatrixLayout: UICollectionViewLayout {
var itemSize: CGSize
var interItemSpacingY: CGFloat
var interItemSpacingX: CGFloat
var layoutInfo: [IndexPath: UICollectionViewLayoutAttributes]
override init() {
itemSize = CGSize(width: 50, height: 50)
interItemSpacingY = 1
interItemSpacingX = 1
layoutInfo = [IndexPath: UICollectionViewLayoutAttributes]()
super.init()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepare() {
guard let collectionView = self.collectionView else {
return
}
var cellLayoutInfo = [IndexPath: UICollectionViewLayoutAttributes]()
var indexPath = IndexPath(item: 0, section: 0)
let sectionCount = collectionView.numberOfSections
for section in 0..<sectionCount {
let itemCount = collectionView.numberOfItems(inSection: section)
for item in 0..<itemCount {
indexPath = IndexPath(item: item, section: section)
let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
itemAttributes.frame = frameForCell(at: indexPath)
cellLayoutInfo[indexPath] = itemAttributes
}
self.layoutInfo = cellLayoutInfo
}
}
func frameForCell(at indexPath: IndexPath) -> CGRect {
let row = indexPath.section
let column = indexPath.item
let originX = (itemSize.width + interItemSpacingX) * CGFloat(column)
let originY = (itemSize.height + interItemSpacingY) * CGFloat(row)
return CGRect(x: originX, y: originY, width: itemSize.width, height: itemSize.height)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
var allAttributes = Array<UICollectionViewLayoutAttributes>()
for (_, attributes) in self.layoutInfo {
if (rect.intersects(attributes.frame)) {
allAttributes.append(attributes)
}
}
return allAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return self.layoutInfo[indexPath]
}
override var collectionViewContentSize: CGSize {
guard let collectionView = self.collectionView else {
return .zero
}
let sectionCount = collectionView.numberOfSections
let height = (itemSize.height + interItemSpacingY) * CGFloat(sectionCount)
let itemCount = Array(0..<sectionCount)
.map { collectionView.numberOfItems(inSection: $0) }
.max() ?? 0
let width = (itemSize.width + interItemSpacingX) * CGFloat(itemCount)
return CGSize(width: width, height: height)
}
}
Here is complete matrix customLayout:
import UIKit
class MatrixLayout: UICollectionViewLayout {
var itemSize: CGSize!
var interItemSpacingY: CGFloat!
var interItemSpacingX: CGFloat!
var layoutInfo: Dictionary<NSIndexPath, UICollectionViewLayoutAttributes>!
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
itemSize = CGSizeMake(50.0, 50.0)
interItemSpacingY = 1.0
interItemSpacingX = 1.0
}
override func prepareLayout() {
var cellLayoutInfo = Dictionary<NSIndexPath, UICollectionViewLayoutAttributes>()
let sectionCount = self.collectionView?.numberOfSections()
var indexPath = NSIndexPath(forItem: 0, inSection: 0)
for (var section = 0; section < sectionCount; section += 1)
{
let itemCount = self.collectionView?.numberOfItemsInSection(section)
for (var item = 0; item < itemCount; item += 1)
{
indexPath = NSIndexPath(forItem:item, inSection: section)
let itemAttributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
itemAttributes.frame = frameForCellAtIndexPath(indexPath)
cellLayoutInfo[indexPath] = itemAttributes
}
self.layoutInfo = cellLayoutInfo
}
}
func frameForCellAtIndexPath(indexPath: NSIndexPath) -> CGRect
{
let row = indexPath.section
let column = indexPath.item
let originX = (self.itemSize.width + self.interItemSpacingX) * CGFloat(column)
let originY = (self.itemSize.height + self.interItemSpacingY) * CGFloat(row)
return CGRectMake(originX, originY, self.itemSize.width, self.itemSize.height)
}
override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
var allAttributes = Array<UICollectionViewLayoutAttributes>()
for (index, attributes) in self.layoutInfo
{
if (CGRectIntersectsRect(rect, attributes.frame))
{
allAttributes.append(attributes)
}
}
return allAttributes
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes? {
return self.layoutInfo[indexPath]
}
override func collectionViewContentSize() -> CGSize {
let width:CGFloat = (self.itemSize.width + self.interItemSpacingX) * CGFloat((self.collectionView?.numberOfItemsInSection(0))!)
let height:CGFloat = (self.itemSize.height + self.interItemSpacingY) * CGFloat((self.collectionView?.numberOfSections())!)
return CGSizeMake(width, height)
}
}
sections are rows,
items are columns

Resources