I'm facing a challenge when using UICollectionView and a related Layout. I'm trying to create a vertical UICollectionView with two columns, where as the cells in the left and right columns are positioned as displayed in the picture:
I do manage without problems to create the two columns but struggle finding the correct settings in my Layout in order to have the right column be offset by half the height of one cell. Note, all cells have the same calculated dimension based on the width of the screen (minus the spacing)...
Every cell in my data array has got a position index, I can thus easily find out whether a cell is positioned right or left based base on it (odd / even)
Any help is gladly appreciated
Here's how I would implement a subclass of UICollectionViewFlowLayout to achieve what you want.
class OffsetFlowLayout: UICollectionViewFlowLayout {
var verticalOffset: CGFloat = 0
override func prepare() {
super.prepare()
verticalOffset = 100 // Calculate offset here
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attributes = super.layoutAttributesForItem(at: indexPath) else { return nil }
guard remainder(Double(indexPath.row), 2) != 0 else { return attributes }
// For each item in the right column, offset the y value of it's origin
attributes.frame.origin.y += verticalOffset
return attributes
}
}
As you can see, on our implementation of layoutAttributesForItem the first thing we do is to call super and store the value it returns. Then we check the index and if we are on the left column, we return what super gave us, which would be the "default" value.
If we're on the right column, we modify the attributes object to move the frame of the cell by the offset we want, and then return that one.
NOTE: I haven't tested this. There's a chance the UICollectionViewFlowLayout uses the previous cells to layout the next cells, in which case you would only need to modify the first element in the right column, just change guard remainder(Double(indexPath.row), 2) != 0 else { return attributes } to guard indexPath.row == 1 else { return attributes }
Related
I am trying to implement a collection view, in which items have:
automatic height based on constraints
the full available width of the collection view.
I'm aware that this is pretty easy to accomplish UICollectionViewCompositionalLayout, but I'm looking to solve it for iOS 11+. I've decided to implement a custom UICollectionViewFlowLayout:
class SingleColumnFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView,
let layoutAttributes = super.layoutAttributesForElements(in: rect) else { return [] }
layoutAttributes
.filter { $0.representedElementCategory == .cell }
.forEach { attributes in
let availableWidth = collectionView.bounds
.inset(by: collectionView.contentInset)
.width
attributes.frame.origin.x = sectionInset.left
attributes.frame.size.width = availableWidth
}
return layoutAttributes
}
}
The result isn't quite what I have imagined:
The cell I'm using is pretty simple:
Interestingly if I add a fixed width constraint to the label, it works correctly, so my theory is that
for some reason the collection view fails to infer the width of the label correctly
for that reason, it thinks that it can fit multiple items in the same row
because of this, it calculates incorrect y values for some of the items.
I would like to make this work without fixed-width labels, so my question would be: am I missing anything obvious? Is there a way to fix this?
For anyone interested, I've uploaded the entire project to GitHub.
As it turns out, the issue was caused by the fixed trailing constraint of the label. The label's intrinsic width was smaller (due to short text), and since the entire cell was constrained horizontally, the cell width also became small. I fixed it by changing the trailing constraint from 'equal' to 'greater than'.
I am loading a number of remote images with Kingfisher and having significant difficulty getting them to load correctly into a Tableview with cells of dynamic heights. My goal is to have the images always be the full width of the screen and of a dynamic height, how can this be achieved?
I asked a related question previously which led to understanding the basic layout using a stack view: SnapKit: How to set layout constraints for items in a TableViewCell programatically
So I've built something like the following:
With the following code (some parts removed for brevity):
// CREATE VIEWS
let containerStack = UIStackView()
let header = UIView()
let headerStack = UIStackView()
let title = UILabel()
let author = UILabel()
var previewImage = UIImageView()
...
// KINGFISHER
let url = URL(string: article.imageUrl)
previewImage.kf.indicatorType = .activity
previewImage.kf.setImage(
with: url,
options: [
.transition(.fade(0.2)),
.scaleFactor(UIScreen.main.scale),
.cacheOriginalImage
]) { result in
switch result {
case .success(_):
self.setNeedsLayout()
UIView.performWithoutAnimation {
self.tableView()?.beginUpdates()
self.tableView()?.endUpdates()
}
case .failure(let error):
print(error)
}
}
...
// LAYOUT
containerStack.axis = .vertical
headerStack.axis = .vertical
headerStack.spacing = 6
headerStack.addArrangedSubview(title)
headerStack.addArrangedSubview(author)
header.addSubview(headerStack)
containerStack.addArrangedSubview(header)
containerStack.addSubview(previewImage)
addSubview(containerStack)
headerStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(20)
}
containerStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
Without a constraint for imageView, the image does not appear.
With the following constraint, the image does not appear either:
previewImage.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.top.equalTo(headerView.snp.bottom).offset(20)
}
With other attempts, the image is completely skewed or overlaps the labels/other cells and images.
Finally, following this comment: With Auto Layout, how do I make a UIImageView's size dynamic depending on the image? and this gist: https://gist.github.com/marcc-orange/e309d86275e301466d1eecc8e400ad00 and with these constraints make.edges.equalToSuperview() I am able to get the images to display at their correct scales, but they completely cover the labels.
Ideally it would look something like this:
100 % working solution with Sample Code
I just managed to acheive the same layout with dynamic label contents and dynamic image dimensions. I did it through constraints and Autolayout. Take a look at the demo project at this GitHub Repository
As matt pointed out, we have to calculate the height of each cell after image is downloaded (when we know its width and height). Note that the height of each cell is calculated by tableView's delegate method heightForRowAt IndexPath
So after each image is downloaded, save the image in array at this indexPath and reload that indexPath so height is calculated again, based on image dimensions.
Some key points to note are as follows
Use 3 types of cells. One for label, one for subtitle and one for Image. Inside cellForRowAt initialize and return the appropriate
cell. Each cell has a unique cellIdentifier but class is same
number of sections in tableView == count of data source
number of rows in section == 3
First row corresponds to title, second row corresponds to subtitle and the 3rd corresponds to the image.
number of lines for labels should be 0 so that height should be calculated based on content
Inside cellForRowAt download the image asynchrounously, store it in array and reload that row.
By reloading the row, heightForRowAt gets called, calculates the required cell height based on image dimensions and returns the height.
So each cell's height is calculated dynamically based on image dimensions
Take a look at Some code
override func numberOfSections(in tableView: UITableView) -> Int {
return arrayListItems.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
//Title, SubTitle, and Image
return 3
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch indexPath.row {
case 0:
//configure and return Title Cell. See code in Github Repo
case 1:
//configure and return SubTitle Cell. See code in Github Repo
case 2:
let cellImage = tableView.dequeueReusableCell(withIdentifier: cellIdentifierImage) as! TableViewCell
let item = arrayListItems[indexPath.section]
//if we already have the image, just show
if let image = arrayListItems[indexPath.section].image {
cellImage.imageViewPicture.image = image
}else {
if let url = URL.init(string: item.imageUrlStr) {
cellImage.imageViewPicture.kf.setImage(with: url) { [weak self] result in
guard let strongSelf = self else { return } //arc
switch result {
case .success(let value):
print("=====Image Size \(value.image.size)" )
//store image in array so that `heightForRowAt` can use image width and height to calculate cell height
strongSelf.arrayListItems[indexPath.section].image = value.image
DispatchQueue.main.async {
//reload this row so that `heightForRowAt` runs again and calculates height of cell based on image height
self?.tableView.reloadRows(at: [indexPath], with: .automatic)
}
case .failure(let error):
print(error) // The error happens
}
}
}
}
return cellImage
default:
print("this should not be called")
}
//this should not be executed
return .init()
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
//calculate the height of label cells automatically in each section
if indexPath.row == 0 || indexPath.row == 1 { return UITableView.automaticDimension }
// calculating the height of image for indexPath
else if indexPath.row == 2, let image = arrayListItems[indexPath.section].image {
print("heightForRowAt indexPath : \(indexPath)")
//image
let imageWidth = image.size.width
let imageHeight = image.size.height
guard imageWidth > 0 && imageHeight > 0 else { return UITableView.automaticDimension }
//images always be the full width of the screen
let requiredWidth = tableView.frame.width
let widthRatio = requiredWidth / imageWidth
let requiredHeight = imageHeight * widthRatio
print("returned height \(requiredHeight) at indexPath: \(indexPath)")
return requiredHeight
}
else { return UITableView.automaticDimension }
}
Related.
Another approach that we can follow is return the image dimensions from the API request. If that can be done, it will simplify things a lot. Take a look at this similar question (for collectionView).
Self sizing Collection view cells with async image downloading.
Placholder.com Used for fetching images asynchronously
Self Sizing Cells: (A Good read)
Sample
It’s relatively easy to do what you’re describing: your image view needs a width constraint that is equal to the width of the “screen” (as you put it) and a height constraint that is proportional to the width constraint (multiplier) based on the proportions of the downloaded image (aka “aspect ratio”). This value cannot be set in advance; you need to configure it once you have the downloaded image, as you do not know its proportions until then. So you need an outlet to the height constraint so that you can remove it and replace it with one that has the correct multiplier when you know it. If your other constraints are correct in relation to the top and bottom of the image view, everything else will follow as desired.
These screen shots show that this approach works:
(Scrolling further down the table view:)
It isn’t 100% identical to your desired interface, but the idea is the same. In each cell we have two labels and an image, and the images can have different aspect ratios but those aspect ratios are correctly displayed - and the cells themselves have different heights depending upon that.
This is the key code I used:
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! Cell
// in real life you’d set the labels here too
// in real life you’d be fetching the image from the network...
// ...and probably supplying it asynchronously later
let im = UIImage(named:self.pix[indexPath.row])!
cell.iv.image = im
let con = cell.heightConstraint!
con.isActive = false
let ratio = im.size.width/im.size.height
let newcon = NSLayoutConstraint(item: con.firstItem, attribute: con.firstAttribute, relatedBy: con.relation, toItem: con.secondItem, attribute: con.secondAttribute, multiplier: ratio, constant: 0)
newcon.isActive = true
cell.heightConstraint = newcon
return cell
There's a straight forward solution for your problem if you don't want to change your layout.
1- define your cell
2- put the UIImageView and other UI elements you like inside your cell and add these constraints for the image view:
-top,leading,trailing,bottom to superview
-height constraints and add outlet to your code (for example :heightConstraint)
3-Change the content fill to : aspect fit
4- Load your images via kingfisher or any other way you like, once you pass your image, check the size and calculate the ratio : imageAspectRatio = height/width
5-Set the heightConstraint.constant = screenWidth * imageAspectRatio
6-call layoutIfNeeded() for the cell and you should be ok!
*This solution works with any UI layout composition including stack views, the point is having a constraint on the images and letting the tableview figure it out how to calculate and draw constraints.
class CustomTableViewCell: UITableViewCell {
#IBOutlet weak var heightConstraint: NSLayoutConstraint!
#IBOutlet weak var sampleImageView: UIImageView!
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
func configure(image:UIImage) {
let hRatio = image.size.height / image.size.width
let newImageHeight = hRatio * UIScreen.main.bounds.width
heightConstraint.constant = newImageHeight
sampleImageView.image = image
sampleImageView.layoutIfNeeded()
}
}
Result :
I'm trying to update some constraints on a cell depending on the scroll progress inside my TableView. The goal is to recreate this effect : https://youtu.be/VMyNHq3CO04?t=416 (at 6:56)
I'm currently having all my cell with spaces and rounded corner. For now, I get the position of the offset of the scrollView of my tableView and run the following function :
func updateCellState(position: CGFloat) {
let numberOfItems = tableView.numberOfRows(inSection: 0)
for i in 0..<numberOfItems {
guard let cell = tableView(tableView, cellForRowAt: IndexPath(row: i, section: 0)) as? FMReceiptReviewCellWithContextualActions else {
break
}
tableView.beginUpdates()
self.stretch = position / 1000 > 1 ? 1 : position / 1000
cell.delegate?.updateCellState(position: position)
cell.setNeedsLayout()
cell.layoutIfNeeded()
tableView.endUpdates()
}
}
The updateCellState take the position of the scrollView to update my constraint proportionally.
But I've some major issues with this code :
In term of performance, the function is called way to often because of the scrollView (even when calling the updateCellState only by 30,40,... offset point)
My cells are not always updated and they need to be dequeue to be reload and display my cells with new parameters (a reloadData() of the tableView work, but not suitable for a smooth flow)
Do you have any idea how I could replicate the effect I mentioned above.
Currently, I have a calendar grid system. I am trying to implement a feature, where I can show event blocks within the calendar. So, I want to be able to implement where it is possible to add the "Untitled" blocks anywhere within the grid system by the user tapping a cell. My first thought is to add a view outside of the UICollectionView, but then when I scroll away on the calendar. The "Untitled" block would still exist, and would still be on the screen. I need to add a cell within the collection layout in order for it to stay within the flow of the collection view. To build the grid system, I had to make a custom subclass of UICollectionViewLayout, so I am not using a UICollectionViewFlowLayout. I am still a little lost how to add a cell above another cell, any ideas on the best way to implement this feature?
I ended up figuring out the answer to my own question. And, I was able to do it without having to hack the collectionView with a bunch of inner scroll views. Here's what I did. I already had my grid system in place, so I had to add an extra section to my collection view. This section's number of items was dependent upon my events data source array as such:
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
if section == Constants.numberOfSections {
//custom event section
return events.count
}
//calender grid items
return Constants.numberOfColumns
}
But, my custom layout for the collection view needed to be updated about the cellAttributes. If I did not tell it that a new cell was added, then it would either crash because it couldn't find a corresponding grid member or add the cell to the grid, like it was another part of the grid. So, I had to update the cellAttributes in my custom layout class, and then manually calculate where the cell should be placed within the layout. So, basically every time I add an event cell, the layout has to manually calculate where that cell should be within the grid. This is the function where I manually calculate the events coordinates in my custom layout subclass (the relevant parts is in the custom events comments):
fileprivate func setCellAttributes(item: Int, section: Int) {
// Build the UICollectionVieLayoutAttributes for the cell.
let cellIndex = IndexPath(item: item, section: section)
var cellWidth: Double = CELL_WIDTH
var cellHeight: Double = CELL_HEIGHT
var xPos: Double = 0
var yPos = Double(section) * CELL_HEIGHT
if section == collectionView!.numberOfSections - 1 {
//custom event items
let rect = getCustomEventRect(item: item)
xPos = Double(rect.x)
yPos = Double(rect.y)
cellHeight = Double(rect.height)
cellWidth = Double(rect.width)
} else if item == 0 {
//the y axis cells
cellWidth = yAxisCellWidth
} else {
//all other cells
xPos = calculateXPos(item: item)
}
let cellAttributes = UICollectionViewLayoutAttributes(forCellWith: cellIndex)
cellAttributes.frame = CGRect(x: xPos, y: yPos, width: cellWidth, height: cellHeight)
// Determine zIndex based on cell type.
if section == 0 && item == 0 {
//top left corner cell
cellAttributes.zIndex = 5
} else if section == 0 {
//y axis cells
cellAttributes.zIndex = 4
} else if section == collectionView!.numberOfSections - 1 {
//custom event cells
cellAttributes.zIndex = 2
} else if item == 0 {
//top x axis cells
cellAttributes.zIndex = 3
} else {
//all background schedule cells
cellAttributes.zIndex = 1
}
// Save the attributes.
cellAttrsDictionary[cellIndex] = cellAttributes
}
Also, every time I updated the events in my viewController, I had to update the events in my layout. So, I implemented this in my viewController:
var events: [CustomEvent] = [] {
didSet {
if let layout = theCollectionView.collectionViewLayout as? ScheduleCollectionViewLayout {
layout.events = events
}
}
}
And, when a user taps to add a new event, I make sure to update the layout accordingly:
func removeEventCell(at indexPath: IndexPath) {
let eventSection: Int = collectionView!.numberOfSections - 1
let totalEventItems: Int = collectionView!.numberOfItems(inSection: eventSection)
//decrementing all indexPaths above the deleted event cell, so the attribute dictionary will be up to date, when reloadSections is run by the collectionView.
for item in 0..<totalEventItems where item > indexPath.item {
let targetIndexPath = IndexPath(item: item - 1, section: eventSection)
let cellAttr = cellAttrsDictionary[IndexPath(item: item, section: eventSection)]
cellAttr?.indexPath = targetIndexPath
cellAttrsDictionary[targetIndexPath] = cellAttr
}
let lastIndexPath = IndexPath(item: totalEventItems - 1, section: eventSection)
cellAttrsDictionary.removeValue(forKey: lastIndexPath)
}
fileprivate func addEventCellAttributes(numOfEventsToAdd: Int) {
for num in 1...numOfEventsToAdd {
setCellAttributes(item: events.count - num, section: collectionView!.numberOfSections - 1)
}
}
This is a very manual process of calculating where to put the cells, but it keeps the collectionView working smoothly and allows the functionality of having cells above cells. The summary of my answer is that I added a new section for my custom events, and I had to manually calculate where the positions of these cells should be, rather than just having them flow with the grid system. My interactive calendar now works perfectly. If you're having trouble trying to even create a grid system, I used this tutorial:
https://www.credera.com/blog/mobile-applications-and-web/building-a-multi-directional-uicollectionview-in-swift/
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..