How to reduce memory usage on UICollectionView with large quantity of items? - ios

I'm trying to use a UICollectionView instead of a UITableView to display a very large number of items (>15000) and it seems the UICollectionView pre-allocates a pixel buffer for the entire contentView size required by the collection. Depending on the item sizes, the simulator shows up to 6.75GB of memory required.
I was hoping the collection view, based on its protocol being very similar to UITableView, wouldn't allocate any pixel buffer and rely solely on the cells' backing/rendering.
I'm using a storyboard file to define the collection view and cell, both have Opaque = false. I've looked at numerous articles on Stack Overflow, most have to do with memory leaks so I'm a little stumped as to how to fix the issue.
For the curious, here's the entire code base (other than the Storyboard):
MyCollectionViewCell.swift
import UIKit
class MyCollectionViewCell: UICollectionViewCell {
static let identifier = "myCellIdentifier"
}
UIColor+Random.swift
import UIKit
extension UIColor {
static func randomColor() -> UIColor {
let red = CGFloat(drand48())
let green = CGFloat(drand48())
let blue = CGFloat(drand48())
return UIColor(red: red, green: green, blue: blue, alpha: 1.0)
}
}
ViewController.swift
import UIKit
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 10000
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 30000
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCollectionViewCell.identifier, for: indexPath) as! MyCollectionViewCell
cell.backgroundColor = UIColor.randomColor()
return cell
}
#IBOutlet weak var collectionView: UICollectionView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
}
}
Memory usage under certain use cases:

The UICollectionView is suited to manage a limited number of sections. Using very large amount of sections and items will invariably lead to large amounts of memory being used as UIKit attempts to keep track of the positioning of every elements in the sections to scale the contentView appropriately.
Instead, create a custom UICollectionViewLayout to manage the relationship between the very large number of items, where they should be positioned and coordinate with the UICollectionViewDataSource to map a relatively small set of items as a "window" to a much larger set.
For example, say UICollectionView items are as per the question about 100x100; UIKit would render approximately 80~100 on the screen at once. Assuming UICollectionView caches a few entries on all side as the user scrolls, having 1024 items in a "cache" to rotate through should be more than sufficient. UICollectionView has no issue at all managing 1 section with 1024 items.
Next, using a custom UICollectionViewLayout, define a custom contentViewSize large enough to hold all the items. UICollectionView will be inquiring the layout about which items are within a visible rectangle via layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
Make sure to return a new set of item's every time the collection view is inquiring. Using a 1024 items cache start with item index 0 for the first element displayed on screen, 1 for second, 2 for 3rd, etc. Coordinating with your UICollectionViewDataSource, every time a coordinate in your layout is associated with a item index, inform the data source about the true section/item that should be associated with the index associated.
First, lets define a protocol & data source to map the true size of the large data source to something reasonable UICollectionView can handle.
LargeDataSourceCoordinator.swift:
import UIKit
protocol LargeDataSourceProtocol {
func largeNumberOfSections() -> Int
func largeNumberOfItems(in section: Int) -> Int
func largeNumberToCollectionViewCacheSize() -> Int
func associateLargeIndexPath(_ largeIndexPath: IndexPath) -> IndexPath
}
class LargeDataSourceCoordinator: NSObject, UICollectionViewDataSource, LargeDataSourceProtocol {
var cachedMapEntries: [IndexPath: IndexPath] = [:]
var rotatingCacheIndex: Int = 0
func largeNumberToCollectionViewCacheSize() -> Int {
return 1024 // arbitrary number, increase if rendering issues are visible like cells not appearing when scrolling
}
func largeNumberOfSections() -> Int {
// To do: implement logic to find the number of sections
return 10000 // simplified arbitrary number for sake of demo
}
func largeNumberOfItems(in section: Int) -> Int {
// To do: implement logic to find the number of items in each section
return 30000 // simplified arbitrary number for sake of demo
}
func associateLargeIndexPath(_ largeIndexPath: IndexPath) -> IndexPath {
for existingPath in cachedMapEntries where existingPath.value == largeIndexPath {
return existingPath.key
}
let collectionViewIndexPath = IndexPath(item: rotatingCacheIndex, section: 0)
cachedMapEntries[collectionViewIndexPath] = largeIndexPath
rotatingCacheIndex = (rotatingCacheIndex + 1) % self.largeNumberToCollectionViewCacheSize()
return collectionViewIndexPath
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.largeNumberToCollectionViewCacheSize()
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = MyCollectionViewCell.dequeue(from: collectionView, for: indexPath)
guard let largeIndexPath = cachedMapEntries[indexPath] else { return cell }
// retrieve the data at largeIndexPath.section, largeIndexPath.item
// configure cell accordingly
cell.addDebugText("section: \(largeIndexPath.section)\nitem: \(largeIndexPath.item)")
return cell
}
}
Next, let's create UICollectionViewLayout to help position and coordinate with the data source. For sake of simplicity, cell use fixed size of 100x100, each section is displayed immediately after the other with cells packed to the left on each row.
LargeDataSourceLayout.swift:
import UIKit
class LargeDataSourceLayout: UICollectionViewLayout {
let cellSize = CGSize(width: 100, height: 100)
var cellsPerRow: CGFloat {
guard let collectionView = self.collectionView else { return 1.0 }
return (collectionView.frame.size.width / cellSize.width).rounded(.towardZero)
}
var cacheNumberOfItems: [Int] = []
private func refreshNumberOfItemsCache() {
guard
let largeDataSource = self.collectionView?.dataSource as? LargeDataSourceProtocol
else { return }
cacheNumberOfItems.removeAll()
for section in 0 ..< largeDataSource.largeNumberOfSections() {
let itemsInSection: Int = largeDataSource.largeNumberOfItems(in: section)
cacheNumberOfItems.append(itemsInSection)
}
}
var cacheRowsPerSection: [Int] = []
private func refreshRowsPerSection() {
let itemsPerRow = Float(self.cellsPerRow)
cacheRowsPerSection.removeAll()
for section in 0 ..< cacheNumberOfItems.count {
let numberOfItems = Float(cacheNumberOfItems[section])
let numberOfRows = (numberOfItems / itemsPerRow).rounded(.awayFromZero)
cacheRowsPerSection.append(Int(numberOfRows))
}
}
override var collectionViewContentSize: CGSize {
// To do: update logic as per your requirements
refreshNumberOfItemsCache()
refreshRowsPerSection()
let totalRows = cacheRowsPerSection.reduce(0, +)
return CGSize(width: self.cellsPerRow * cellSize.width,
height: CGFloat(totalRows) * cellSize.height)
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// To do: implement logic to compute the attributes for a specific item
return nil
}
private func originForRow(_ row: Int) -> CGFloat {
return CGFloat(row) * cellSize.height
}
private func pathsInRow(_ row: Int) -> [IndexPath] {
let itemsPerRow = Int(self.cellsPerRow)
var subRowIndex = row
for section in 0 ..< cacheRowsPerSection.count {
let rowsInSection = cacheRowsPerSection[section]
if subRowIndex < rowsInSection {
let firstItem = subRowIndex * itemsPerRow
let lastItem = min(cacheNumberOfItems[section],firstItem+itemsPerRow) - 1
var paths: [IndexPath] = []
for item in firstItem ... lastItem {
paths.append(IndexPath(item: item, section: section))
}
return paths
} else {
guard rowsInSection <= subRowIndex else { return [] }
subRowIndex -= rowsInSection
}
}
// if caches are properly updated, we should never reach here
return []
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let largeDataSource = self.collectionView?.dataSource as? LargeDataSourceProtocol else { return nil }
let firstRow = max(0,Int((rect.minY / cellSize.height).rounded(.towardZero)))
var row = firstRow
var attributes: [UICollectionViewLayoutAttributes] = []
repeat {
let originY = originForRow(row)
if originY > rect.maxY {
return attributes
}
var originX: CGFloat = 0.0
for largeIndexPath in pathsInRow(row) {
let indexPath = largeDataSource.associateLargeIndexPath(largeIndexPath)
let itemAttribute = UICollectionViewLayoutAttributes(forCellWith: indexPath)
itemAttribute.frame = CGRect(x: originX, y: originY, width: cellSize.width, height: cellSize.height)
attributes.append(itemAttribute)
originX += cellSize.width
}
row += 1
} while true
}
}
There are a few things happening in there, and there's tons of room for optimization based on use-case, but the concept is there. For sake of completion, below is associated code and app preview.
MyCollectionViewCell.swift:
import UIKit
class MyCollectionViewCell: UICollectionViewCell {
static let identifier = "MyCollectionViewCell"
static func dequeue(from collectionView: UICollectionView, for indexPath: IndexPath) -> MyCollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath) as? MyCollectionViewCell ?? MyCollectionViewCell()
cell.contentView.backgroundColor = UIColor.random()
return cell
}
override func prepareForReuse() {
super.prepareForReuse()
removeDebugLabel()
}
private func removeDebugLabel() {
self.contentView.subviews.first?.removeFromSuperview()
}
func addDebugText(_ text: String) {
removeDebugLabel()
let debugLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
debugLabel.text = text
debugLabel.numberOfLines = 2
debugLabel.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize)
debugLabel.textColor = UIColor.black
debugLabel.textAlignment = .center
self.contentView.addSubview(debugLabel)
}
}
UIColor+random.swift:
import UIKit
extension UIColor {
static func random() -> UIColor {
//random color
let hue = CGFloat(arc4random() % 256) / 256.0
let saturation = (CGFloat(arc4random() % 128) / 256.0) + 0.5 // 0.5 to 1.0, away from white
let brightness = (CGFloat(arc4random() % 128) / 256.0 ) + 0.5 // 0.5 to 1.0, away from black
return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
}
}

Related

UICollection View "Reload Data" After Scrolling Crash

I have made a collection view with cells arranged in rows in columns using a great tutorial. I have added a button to a toolbar in the main view controller that calls collectionView.reloadData() as I want a user to be able to edit values which will in turn update the datasource and then reload the collection view to show the updates.
Running this on a simulator it works, but if any scrolling takes place it causes this crash *** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[__NSArrayM objectAtIndex:]: index 0 beyond bounds for empty array'. If no scrolling has taken place then calling collectionView.reloadData() works. I can't find where this empty array is which is causing the crash. I have tried printing all the arrays that are used in the code in the console but none appear to be empty. Have tried commenting out various lines of code to try and narrow down where the problem is, it seems to be something in the override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? block. I have also tried reseting the collection view frame co-ordinates to 0 before reload data is called but that also didn't work. Have been stuck going round in circles for a few days which no luck. Any suggestions as to where I am going wrong would be hugely appreciated! My code so far is below (please excuse the long winded explanation and code);
View Controller
import UIKit
class ViewController: UIViewController {
// variable to contain cell indexpaths sent from collectionViewFlowLayout.
var cellIndexPaths = [IndexPath] ()
#IBOutlet weak var collectionView: UICollectionView!
#IBOutlet weak var collectionViewFlowLayout: UICollectionViewFlowLayout! {
didSet {
collectionViewFlowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
}
}
#IBAction func editButtonPressed(_ sender: UIButton) {
collectionView.reloadData()
}
#IBAction func doneButtonPressed(_ sender: UIButton) {
}
override func viewDidLoad() {
super.viewDidLoad()
collectionView.delegate = self
collectionView.dataSource = self
}
}
extension ViewController:UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
2
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
5
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! collectionViewCell
cell.backgroundColor = .green
cell.textLabel.text = "\(indexPath) - AAAABBBBCCCC"
return cell
}
}
extension ViewController:UICollectionViewDelegate, IndexPathDelegate {
func getIndexPaths(indexPathArray: Array<IndexPath>) {
cellIndexPaths = indexPathArray.uniqueValues
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
var cellArray = [UICollectionViewCell] ()
print(cellIndexPaths)
for indexPathItem in cellIndexPaths {
if let cell = collectionView.cellForItem(at: indexPathItem) {
if indexPathItem.section == indexPath.section {
cellArray.append(cell)
}
}
for cells in cellArray {
cells.backgroundColor = .red
Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { (timer) in
cells.backgroundColor = .green
}
}
}
}
}
extension ViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let cell = collectionView.cellForItem(at: indexPath)
if let safeCell = cell {
let cellSize = CGSize(width: safeCell.frame.width, height: safeCell.frame.height)
return cellSize
} else {
return CGSize (width: 300, height: 100)
}
}
}
extension Array where Element: Hashable {
var uniqueValues: [Element] {
var allowed = Set(self)
return compactMap { allowed.remove($0) }
}
}
Flow Layout
import UIKit
protocol IndexPathDelegate {
func getIndexPaths(indexPathArray: Array<IndexPath>)
}
class collectionViewFlowLayout: UICollectionViewFlowLayout {
override var collectionViewContentSize: CGSize {
return CGSize(width: 10000000, height: 100000)
}
override func prepare() {
setupAttributes()
indexItemDelegate()
}
// MARK: - ATTRIBUTES FOR ALL CELLS
private var allCellAttributes: [[UICollectionViewLayoutAttributes]] = []
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var layoutAttributes = [UICollectionViewLayoutAttributes]()
for rowAttrs in allCellAttributes {
for itemAttrs in rowAttrs where rect.intersects(itemAttrs.frame) {
layoutAttributes.append(itemAttrs)
}
}
return layoutAttributes
}
// MARK: - SETUP ATTRIBUTES
var cellIndexPaths = [IndexPath] ()
private func setupAttributes() {
allCellAttributes = []
var xOffset: CGFloat = 0
var yOffset: CGFloat = 0
for row in 0..<rowsCount {
var rowAttrs: [UICollectionViewLayoutAttributes] = []
xOffset = 0
for col in 0..<columnsCount(in: row) {
let itemSize = size(forRow: row, column: col)
let indexPath = IndexPath(row: row, column: col)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = CGRect(x: xOffset, y: yOffset, width: itemSize.width, height: itemSize.height).integral
rowAttrs.append(attributes)
xOffset += itemSize.width
cellIndexPaths.append(indexPath)
}
yOffset += rowAttrs.last?.frame.height ?? 0.0
allCellAttributes.append(rowAttrs)
}
}
// MARK: - CONVERT SECTIONS TO ROWS, ITEMS TO COLUMNS
private var rowsCount: Int {
return collectionView!.numberOfSections
}
private func columnsCount(in row: Int) -> Int {
return collectionView!.numberOfItems(inSection: row)
}
// MARK: - GET CELL SIZE
private func size(forRow row: Int, column: Int) -> CGSize {
guard let delegate = collectionView?.delegate as? UICollectionViewDelegateFlowLayout,
let size = delegate.collectionView?(collectionView!, layout: self, sizeForItemAt: IndexPath(row: row, column: column)) else {
assertionFailure("Implement collectionView(_,layout:,sizeForItemAt: in UICollectionViewDelegateFlowLayout")
return .zero
}
return size
}
private func indexItemDelegate () {
let delegate = collectionView?.delegate as? IndexPathDelegate
delegate?.getIndexPaths(indexPathArray: cellIndexPaths)
}
}
// MARK: - INDEX PATH EXTENSION
//creates index path with rows and columns instead of sections and items
private extension IndexPath {
init(row: Int, column: Int) {
self = IndexPath(item: column, section: row)
}
}
Collection Cell
import UIKit
class collectionViewCell: UICollectionViewCell {
#IBOutlet weak var textLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
contentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentView.leftAnchor.constraint(equalTo: leftAnchor),
contentView.rightAnchor.constraint(equalTo: rightAnchor),
contentView.topAnchor.constraint(equalTo: topAnchor),
contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}
I have managed to get around the issue by using the UICollectionView.reloadSections(sections: IndexSet) method. This doesn't cause any crashes. I loop through all sections and add each section to an IndexSet variable then use that in the reload sections method like this;
var indexSet = IndexSet()
let rowCount = collectionView.numberOfSections
for row in 0..<rowCount {
indexSet = [row]
collectionView.reloadSections(indexSet)
}

difficulty dealing with the reusability of the collection view cell.

I am trying to create a seating plan using the collection view. In that each seat will have a specific number that means that each cell will have a label that displays the seat number. I am using custom collection view layout to create a seat map and the seat map is 2 way scrollable. Every thing goes well untill the seat map gets scrolled. once the seat map is scrolled the values of the seat numbers are being changed. I know that it is because of the reusability of the collection view cells but i can't find a way out. I tried to search for answer in stack but nothing is working for me. please help..
here is my view controller:
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
#IBOutlet weak var collectionView: UICollectionView!
var seatNo = 0
override func viewDidLoad() {
super.viewDidLoad()
collectionView.dataSource = self
collectionView.delegate = self
}
// func for number of section in collection view
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 10
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 15
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CollectionViewCell", for: indexPath) as! CollectionViewCell
if indexPath.row == 0 {
seatNo = 1
}
cell.assignSeat(seat: seatNo)
seatNo += 1
cell.seatNumber.text = "\(cell.seat ?? 0)"
return cell
}
}
and here is my collection view cell :
class CollectionViewCell: UICollectionViewCell {
#IBOutlet weak var seatNumber: UILabel!
var seat: Int?
override func prepareForReuse() {
seatNumber.text = ""
seat = 0
}
func assignSeat(seat: Int) {
self.seat = seat
}
}
and if needed here is the code for the custom Collection View Layout:
class CustomCollectionLayout: UICollectionViewLayout {
// var for cell height
var CELL_HEIGHT: Double!
//Variable for cell width
var CELL_WIDTH: Double!
// Variable for the status bar height
let STATUS_BAR = UIApplication.shared.statusBarFrame.height
// Array to store the cell attributes
var cache = [UICollectionViewLayoutAttributes]()
//variable to define the content size
var contentSize = CGSize.zero
// another variable to store the cell atributes
var cellAttrsDictionary = [UICollectionViewLayoutAttributes]()
// variable to store the cell padding
var cellPadding: Double!
// func that defines the collection view content size
override var collectionViewContentSize: CGSize{
return self.contentSize
}
// func to prepare the collection view
// how the cells are to be mapped is defined in this function
override func prepare() {
// assigning the values to the variables
CELL_HEIGHT = 44
CELL_WIDTH = 44
cellPadding = 2
// Cycle through each section of the data source.
if collectionView?.isDragging == false{
if collectionView!.numberOfSections > 0 {
for section in 0...collectionView!.numberOfSections-1 {
// Cycle through each item in the section.
if collectionView!.numberOfItems(inSection: section) > 0 {
for item in 0...collectionView!.numberOfItems(inSection: section)-1 {
// storing the index of the current cell
let cellIndex = NSIndexPath(item: item, section: section)
// defining the x and y coordinates for the other cells
let xPos = Double(item) * CELL_WIDTH
let yPos = Double(section) * CELL_HEIGHT
//creating the frame for the cell
let frame = CGRect(x: xPos, y: yPos, width: CELL_WIDTH, height: CELL_HEIGHT)
//providing the padding
let cellFinalAttribute = frame.insetBy(dx:CGFloat(cellPadding) ,dy:CGFloat(cellPadding))
//storing the cellattributes in the array
let cellAttributes = UICollectionViewLayoutAttributes(forCellWith: cellIndex as IndexPath)
cellAttributes.frame = cellFinalAttribute
cellAttrsDictionary.append(cellAttributes)
}
}
}
}
// Update content size.
let contentWidth = Double(collectionView!.numberOfItems(inSection: 0)) * CELL_WIDTH
let contentHeight = Double(collectionView!.numberOfSections) * CELL_HEIGHT
self.contentSize = CGSize(width: contentWidth, height: contentHeight)
}
}
// func that returns the cell attributes for the elements that are visible in the screen
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// Create an array to hold all elements found in our current view.
var attributesInRect = [UICollectionViewLayoutAttributes]()
// Check each element to see if it should be returned.
for cellAttributes in cellAttrsDictionary {
if rect.intersects(cellAttributes.frame) {
attributesInRect.append(cellAttributes)
}
}
// Return list of elements.
return attributesInRect
}
//func that returns the cell attributes for the indexpath
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cellAttrsDictionary[indexPath.row]
}
//this func call the prepare func if the user scrolls if returned true
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return false
}
}
Rather than using an array, why not say
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CollectionViewCell", for: indexPath) as! CollectionViewCell
let seatNo = (indexPath.row + 1) + (indexPath.section * 15)
cell.assignSeat(seat: seatNo)
cell.seatNumber.text = "\(cell.seat ?? 0)"
return cell
}
I haven't ran this code but the idea is to use
indexPath.row + 1
to get the seats position in its row.
then we can use
indexPath.section * 15 // (number of seats per row should be declared static)
to get the count of seats in the previous rows to this one. in the first row the section will be 0 so won't add anything but subsequent rows will be added correctly
I highly advise declaring the 15 and 10 at the top of your file to avoid magic numbers

iOS: How to create a collectionView with different size & clickable cells

I would like to create a planning app, what I have already done, but my problem is that now, I would like to create different cells as following:
I don't know if it is possible...
Today, I have this code:
func numberOfSections(in collectionView: UICollectionView) -> Int { //colonnes: models
if _event != nil && _event!.models.count > 0
{
return _event!.models.count + 1
}
return 0
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int //lignes: slots
{
if _event != nil && _event!.models.count > 0 && _event!.models[0].slots.count > 0
{
if (section == 0) // A vérifier
{
return _event!.models[0].slots.count + 1
}
else
{
return _event!.models[section - 1].slots.count + 1
}
}
return 0
}
With this result:
UICollectionViewLayout is definitely the way to go.
I've made a start on an example of this for you, It gets the number of sections and items and generates the layout you asked for, evenly distributing the items across the height of the UICollectionView.
I've cropped the screenshot above so I don't take up to much space, heres the code
class OrganiserLayout:UICollectionViewLayout {
let cellWidth:CGFloat = 100
var attrDict = Dictionary<IndexPath,UICollectionViewLayoutAttributes>()
var contentSize = CGSize.zero
override var collectionViewContentSize : CGSize {
return self.contentSize
}
override func prepare() {
// Generate the attributes for each cell based on the size of the collection view and our chosen cell width
if let cv = collectionView {
let collectionViewHeight = cv.frame.height
let numberOfSections = cv.numberOfSections
self.contentSize = cv.frame.size
self.contentSize.width = cellWidth*CGFloat(numberOfSections)
for section in 0...numberOfSections-1 {
let numberOfItemsInSection = cv.numberOfItems(inSection: section)
let itemHeight = collectionViewHeight/CGFloat(numberOfItemsInSection)
let itemXPos = cellWidth*CGFloat(section)
for item in 0...numberOfItemsInSection-1 {
let indexPath = IndexPath(item: item, section: section)
let itemYPos = itemHeight*CGFloat(item)
let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attr.frame = CGRect(x: itemXPos, y: itemYPos, width: cellWidth, height: itemHeight)
attrDict[indexPath] = attr
}
}
}
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// Here we return the layout attributes for cells in the current rectangle
var attributesInRect = [UICollectionViewLayoutAttributes]()
for cellAttributes in attrDict.values {
if rect.intersects(cellAttributes.frame) {
attributesInRect.append(cellAttributes)
}
}
return attributesInRect
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// Here we return one attribute object for the specified indexPath
return attrDict[indexPath]!
}
}
To test this I made a basic UIViewController and UICollectionViewCell, here is the view controller:
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
override func viewDidLoad() {
super.viewDidLoad()
// Use our new OrganiserLayout subclass
let layout = OrganiserLayout()
// Init the collection view with the layout
let collection = UICollectionView(frame: self.view.frame, collectionViewLayout: layout)
collection.delegate = self
collection.dataSource = self
collection.backgroundColor = UIColor.white
collection.register(OrganiserCollectionViewCell.self, forCellWithReuseIdentifier: "cell")
self.view.addSubview(collection)
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 5
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
switch section {
case 0:
return 8
case 1:
return 6
case 2:
return 4
case 3:
return 2
case 4:
return 4
default:
return 0
}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! OrganiserCollectionViewCell
cell.label.text = "\(indexPath.section)/\(indexPath.row)"
switch indexPath.section {
case 1:
cell.backgroundColor = UIColor.blue
case 2:
cell.backgroundColor = UIColor.red
case 3:
cell.backgroundColor = UIColor.green
default:
cell.backgroundColor = UIColor.cyan
}
return cell
}
}
And the UICollectionViewCell looks like this:
class OrganiserCollectionViewCell:UICollectionViewCell {
var label:UILabel!
var seperator:UIView!
override init(frame: CGRect) {
super.init(frame: frame)
label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(label)
seperator = UIView()
seperator.backgroundColor = UIColor.black
seperator.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(seperator)
let views:[String:UIView] = [
"label":label,
"sep":seperator
]
let cons = [
"V:|-20-[label]",
"V:[sep(1)]|",
"H:|[label]|",
"H:|[sep]|"
]
for con in cons {
self.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: con, options: [], metrics: nil, views: views))
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Hope some of this helps, It's a very simplified example and you will probably need to modify it in order to achieve the calendar looking result that you require.
I believe you have to resolve to implementing your own custom layout for the collectionView - you will need to implement custom UICollectionViewLayout. This is not a trivial thing, but there are several good tutorials for it, for example this tutorial.

How can I make a particular cell of an iOS collectionView fade out as the collectionView scrolls?

I want to make all the right side cells of my UICollectionView fade out as they scroll similar to Apple's messages app but not effect the color or transparency of the other cells in the collectionView. Is there a way to adjust the transparency of a UICollectionViewCell based on it's scroll position to achieve that effect?
You can do a lot of fun stuff to collection views. I like to subclass UICollectionViewFlowLayout. Here is an example that fades the top and the bottom of the collection view based on distance from center. I could modify it to fade only the very edges but you should figure it after you look through the code.
import UIKit
class FadingLayout: UICollectionViewFlowLayout,UICollectionViewDelegateFlowLayout {
//should be 0<fade<1
private let fadeFactor: CGFloat = 0.5
private let cellHeight : CGFloat = 60.0
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
init(scrollDirection:UICollectionViewScrollDirection) {
super.init()
self.scrollDirection = scrollDirection
}
override func prepare() {
setupLayout()
super.prepare()
}
func setupLayout() {
self.itemSize = CGSize(width: self.collectionView!.bounds.size.width,height:cellHeight)
self.minimumLineSpacing = 0
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
func scrollDirectionOver() -> UICollectionViewScrollDirection {
return UICollectionViewScrollDirection.vertical
}
//this will fade both top and bottom but can be adjusted
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributesSuper: [UICollectionViewLayoutAttributes] = super.layoutAttributesForElements(in: rect) as [UICollectionViewLayoutAttributes]!
if let attributes = NSArray(array: attributesSuper, copyItems: true) as? [UICollectionViewLayoutAttributes]{
var visibleRect = CGRect()
visibleRect.origin = collectionView!.contentOffset
visibleRect.size = collectionView!.bounds.size
for attrs in attributes {
if attrs.frame.intersects(rect) {
let distance = visibleRect.midY - attrs.center.y
let normalizedDistance = abs(distance) / (visibleRect.height * fadeFactor)
let fade = 1 - normalizedDistance
attrs.alpha = fade
}
}
return attributes
}else{
return nil
}
}
//appear and disappear at 0
override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let attributes = super.layoutAttributesForItem(at: itemIndexPath)! as UICollectionViewLayoutAttributes
attributes.alpha = 0
return attributes
}
override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
let attributes = super.layoutAttributesForItem(at: itemIndexPath)! as UICollectionViewLayoutAttributes
attributes.alpha = 0
return attributes
}
}
And in your setup in your controller with the collection view it would look like this.
let layout = FadingLayout(scrollDirection: .vertical)
collectionView.delegate = self
collectionView.dataSource = self
self.collectionView.setCollectionViewLayout(layout, animated: false)
I can tell you how to modify it if I knew the use case a bit better.
This is quite simple if you subclass UICollectionViewFlowLayout. First thing you'll need to do is make sure the visible attributes are recalculated when bounds change/scroll happens by returning true in
shouldInvalidateLayout(forBoundsChange newBounds: CGRect)
Then in layoutAttributesForElements(in rect: CGRect) delegate call, get the attributes calculated by the super class and modify the alpha value based on the offset of the item in the visible bounds, thats it.
Distinguishing between left/right side items can be handled in the controller with whatever logic you have and communicated to the layout class to avoid applying this effect on left side items. (I used ´CustomLayoutDelegate´ for that which is implemented in the controller that simply identifies items with odd indexPath.row as left side cells)
Here is a demo that applies this effect on items with with even indexPath.row skipping odd rows
import UIKit
class ViewController: UIViewController {
/// Custom flow layout
lazy var layout: CustomFlowLayout = {
let l: CustomFlowLayout = CustomFlowLayout()
l.itemSize = CGSize(width: self.view.bounds.width / 1.5, height: 100)
l.delegate = self
return l
}()
/// The collectionView if you're not using UICollectionViewController
lazy var collectionView: UICollectionView = {
let cv: UICollectionView = UICollectionView(frame: self.view.bounds, collectionViewLayout: self.layout)
cv.backgroundColor = UIColor.lightGray
cv.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
cv.dataSource = self
return cv
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(collectionView)
}
}
extension ViewController: UICollectionViewDataSource, CustomLayoutDelegate {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 30
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
cell.backgroundColor = UIColor.black
return cell
}
// MARK: CustomLayoutDelegate
func cellSide(forIndexPath indexPath: IndexPath) -> CellSide {
// TODO: Your implementation to distinguish left/right indexPath
// Even rows are .right and Odds .left
if indexPath.row % 2 == 0 {
return .right
} else {
return .left
}
}
}
public enum CellSide {
case right
case left
}
protocol CustomLayoutDelegate: class {
func cellSide(forIndexPath indexPath: IndexPath) -> CellSide
}
class CustomFlowLayout: UICollectionViewFlowLayout {
/// Delegates distinguishing between left and right items
weak var delegate: CustomLayoutDelegate!
/// Maximum alpha value
let kMaxAlpha: CGFloat = 1
/// Minimum alpha value. The alpha value you want the first visible item to have
let kMinAlpha: CGFloat = 0.3
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let cv = collectionView, let rectAtts = super.layoutAttributesForElements(in: rect) else { return nil }
for atts in rectAtts {
// Skip left sides
if delegate.cellSide(forIndexPath: atts.indexPath) == .left {
continue
}
// Offset Y on visible bounds. you can use
// ´cv.bounds.height - (atts.frame.origin.y - cv.contentOffset.y)´
// To reverse the effect
let offset_y = (atts.frame.origin.y - cv.contentOffset.y)
let alpha = offset_y * kMaxAlpha / cv.bounds.height
atts.alpha = alpha + kMinAlpha
}
return rectAtts
}
// Invalidate layout when scroll happens. Otherwise atts won't be recalculated
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
Sure! Note that UICollectionView is a subclass of UIScrollView, and that your UICollectionViewController is already the delegate of the collection view. This means that it also conforms to the UIScrollViewDelegate protocol, which includes a bunch of methods to inform you about scroll position changes.
Most notable to me is scrollViewDidScroll(_:), which will be called when the contentOffset in the collection view changes. You might implement that method to iterate over the collection view's visibleCells, either adjusting the cell's alpha yourself or sending some message to the cell to notify it to adjust its own alpha based on its frame and offset.
The simplest possible implementation I could come up with that does this – respecting your right-side-only requirement – is as follows. Note that this might exhibit some glitches near the top or the bottom of the view, since the cell's alpha is only adjusted on scroll, not on initial dequeue or reuse.
class FadingCollectionViewController: UICollectionViewController {
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 500
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
return cell
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let collectionView = collectionView else {
return
}
let offset = collectionView.contentOffset.y
let height = collectionView.frame.size.height
let width = collectionView.frame.size.width
for cell in collectionView.visibleCells {
let left = cell.frame.origin.x
if left >= width / 2 {
let top = cell.frame.origin.y
let alpha = (top - offset) / height
cell.alpha = alpha
} else {
cell.alpha = 1
}
}
}
}

Stuck understanding how to create a table with multiple columns in iOS Swift

I've spent the better half of the day so far researching and trying to understand how to make a table with multiple columns. Embarrassingly, I am still quite new to Swift and programming in general so a lot of the stuff I've read and found aren't helping me too much.
I have basically found exactly what I want to create with this gentleman's blo:
http://www.brightec.co.uk/blog/uicollectionview-using-horizontal-and-vertical-scrolling-sticky-rows-and-columns
However, even with his Github I'm still confused. It seems as if he did not use Storyboard at all (and for my project I've been using storyboard a lot). Am I correct in assuming this?
What I have so far is a UICollectionView embedded in a navigation controller. From here, I have created a new cocoa touch class file subclassed in the CollectionView. But from here is where I'm not entirely sure where to go.
If I can have some direction as to where to go from here or how to properly set it up that would be GREATLY appreciated.
Thanks so much in advance!
IOS 10, XCode 8, Swift 3.0
I found an awesome tutorial on this. thanks to Kyle Andrews
I created a vertical table which can be scrollable on both directions by subclassing UICollectionViewLayout. Below is the code.
class CustomLayout: UICollectionViewLayout {
let CELL_HEIGHT: CGFloat = 50
let CELL_WIDTH: CGFloat = 180
var cellAttributesDictionary = Dictionary<IndexPath, UICollectionViewLayoutAttributes>()
var contentSize = CGSize.zero
override var collectionViewContentSize: CGSize {
get {
return contentSize
}
}
var dataSourceDidUpdate = true
override func prepare() {
let STATUS_BAR_HEIGHT = UIApplication.shared.statusBarFrame.height
let NAV_BAR_HEIGHT = UINavigationController().navigationBar.frame.size.height
collectionView?.bounces = false
if !dataSourceDidUpdate {
let yOffSet = collectionView!.contentOffset.y
for section in 0 ..< collectionView!.numberOfSections {
if section == 0 {
for item in 0 ..< collectionView!.numberOfItems(inSection: section) {
let cellIndexPath = IndexPath(item: item, section: section)
if let attrs = cellAttributesDictionary[cellIndexPath] {
var frame = attrs.frame
frame.origin.y = yOffSet + STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT
attrs.frame = frame
}
}
}
}
return
}
dataSourceDidUpdate = false
for section in 0 ..< collectionView!.numberOfSections {
for item in 0 ..< collectionView!.numberOfItems(inSection: section) {
let cellIndexPath = IndexPath(item: item, section: section)
let xPos = CGFloat(item) * CELL_WIDTH
let yPos = CGFloat(section) * CELL_HEIGHT
let cellAttributes = UICollectionViewLayoutAttributes(forCellWith: cellIndexPath)
cellAttributes.frame = CGRect(x: xPos, y: yPos, width: CELL_WIDTH, height: CELL_HEIGHT)
// Determine zIndex based on cell type.
if section == 0 && item == 0 {
cellAttributes.zIndex = 4
} else if section == 0 {
cellAttributes.zIndex = 3
} else if item == 0 {
cellAttributes.zIndex = 2
} else {
cellAttributes.zIndex = 1
}
cellAttributesDictionary[cellIndexPath] = cellAttributes
}
}
let contentWidth = CGFloat(collectionView!.numberOfItems(inSection: 0)) * CELL_WIDTH
let contentHeight = CGFloat(collectionView!.numberOfSections) * CELL_HEIGHT
contentSize = CGSize(width: contentWidth, height: contentHeight)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var attributesInRect = [UICollectionViewLayoutAttributes]()
for cellAttrs in cellAttributesDictionary.values {
if rect.intersects(cellAttrs.frame) {
attributesInRect.append(cellAttrs)
}
}
return attributesInRect
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cellAttributesDictionary[indexPath]
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
Below is my CollectionViewController Code.
import UIKit
private let reuseIdentifier = "Cell"
class VerticalCVC: UICollectionViewController {
override func viewDidLoad() {
super.viewDidLoad()
collectionView?.isScrollEnabled = true
}
// MARK: UICollectionViewDataSource
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 20
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 10
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! CustomCell
if indexPath.section == 0 {
cell.backgroundColor = UIColor.darkGray
cell.titleLabel.textColor = UIColor.white
} else {
cell.backgroundColor = UIColor.white
cell.titleLabel.textColor = UIColor.black
}
cell.titleLabel.text = "section: \(indexPath.section) && row: \(indexPath.row)"
return cell
}
}
To force CollectionView to use Custom Layout instead of UICollectionViwFlowLayout check below image.
Result:
Portrait mode
landscape mode
One approach is to use a custom cell in a tableviewcontroller. Your story board consists of a table in which the cell is a custom cell with UILabels for columns laid out next to each other (with properly defined constraints).
Example code for the controllers looks like:
import UIKit
class TableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
// MARK: - Table view data source
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 3
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("reuseIdentifier", forIndexPath: indexPath) as TableViewCell
cell.column1.text = "1" // fill in your value for column 1 (e.g. from an array)
cell.column2.text = "2" // fill in your value for column 2
return cell
}
}
and:
import UIKit
class TableViewCell: UITableViewCell {
#IBOutlet weak var column1: UILabel!
#IBOutlet weak var column2: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
override func setSelected(selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
}
In IB I set up a tableview and added a stackview in the content view (can be done programmatically). The labels are setup programmatically since it allows me to set the width of each column as a fraction of the cell width. Also, I acknowledge that some of the calculations inside the table view cellForRow method should be moved out.
import UIKit
class tableViewController: UITableViewController {
var firstTime = true
var width = CGFloat(0.0)
var height = CGFloat(0.0)
var cellRect = CGRectMake(0.0,0.0,0.0,0.0)
let colors:[UIColor] = [
UIColor.greenColor(),
UIColor.yellowColor(),
UIColor.lightGrayColor(),
UIColor.blueColor(),
UIColor.cyanColor()
]
override func viewDidLoad() {
super.viewDidLoad()
// workaround to get the cell width
cellRect = CGRectMake(0, 0, self.tableView.frame.size.width ,44);
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
// MARK: - Table view data source
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 3
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1
}
var cellWidth = CGFloat(0.0)
var cellHeight = CGFloat(0.0)
let widths = [0.2,0.3,0.3,0.2]
let labels = ["0","1","2","3"]
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("cell", forIndexPath: indexPath)
let v = cell.contentView.subviews[0] // points to stack view
// Note: using w = v.frame.width picks up the width assigned by xCode.
cellWidth = cellRect.width-20.0 // work around to get a right width
cellHeight = cellRect.height
var x:CGFloat = 0.0
for i in 0 ..< labels.count {
let wl = cellWidth * CGFloat(widths[i])
let lFrame = CGRect(origin:CGPoint(x: x,y: 0),size: CGSize(width:wl,height: cellHeight))
let label = UILabel(frame: lFrame)
label.textAlignment = .Center
label.text = labels[i]
v.addSubview(label)
x = x + wl
print("i = ",i,v.subviews[i])
v.subviews[i].backgroundColor = colors[i]
}
return cell
}
}

Resources