Search code examples
swiftuicollectionviewuikit

Undefined behaviour of UICollectionView cells


I'm making a calendar in which you can select a time range. The design of the calendar is made in such a way that at the start and end date of the selected period a circle appears around the number, and the intermediate dates of the range are painted in the same color with an opacity of 0.5.

I created a data structure consisting of CelendarMonth (for the header with the name of the month) which contains a CalendarDate array.

I populate the data structure for a collection in generateDates() and update the collection after clicking on one of the cells in the updateSelection() method

This code has several problems (all of which can be seen in the attached screenshot):

  1. When you click on the cells of the collection, the numbers randomly disappear from the calendar
  2. Sometimes when you select a range, a circle may appear in an intermediate cell (which should only be for the start and end date of the range)
  3. Although in UICollectionViewFlowLayout the space between cells is set to 0, after every two cells we can notice a minimum space

You can run this code by pasting the listing I provided into the ViewController file of a new Xcode project

class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
    
    private var months = [CalendarMonth]()
    private var selectedRange: (start: Date?, end: Date?) = (nil, nil)
    
    private let collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.minimumInteritemSpacing = 0
        layout.minimumLineSpacing = 2
        let size = floor(UIScreen.main.bounds.width / 7)
        layout.itemSize = CGSize(width: size, height: size)
        layout.headerReferenceSize = CGSize(width: UIScreen.main.bounds.width, height: 40)
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.register(CalendarDateCell.self, forCellWithReuseIdentifier: CalendarDateCell.identifier)
        collectionView.register(CalendarHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CalendarHeaderView.identifier)
        return collectionView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(collectionView)
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.frame = view.bounds
        generateDates()
    }
    
    private func generateDates() {
        let calendar = Calendar.current
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "MMMM yyyy"
        
        let currentDate = Date()
        guard let startDate = calendar.date(byAdding: .month, value: 0, to: currentDate),
              let endDate = calendar.date(byAdding: .month, value: 12, to: currentDate) else {
            return
        }
        
        var date = startDate
        var currentMonth: CalendarMonth?
        
        while date <= endDate {
            let components = calendar.dateComponents([.year, .month, .day], from: date)
            
            if let firstOfMonth = calendar.date(from: DateComponents(year: components.year, month: components.month, day: 1)) {
                
                let monthTitle = dateFormatter.string(from: firstOfMonth)
                
                if currentMonth == nil || currentMonth!.title != monthTitle {
                    if var existingMonth = currentMonth {
                        months.append(existingMonth)
                    }
                    currentMonth = CalendarMonth(title: monthTitle, dates: [])
                }
            }
            
            currentMonth?.dates.append(CalendarDate(date: date, isSelected: false))
            date = calendar.date(byAdding: .day, value: 1, to: date)!
        }
        
        if var existingMonth = currentMonth {
            months.append(existingMonth)
        }
    }
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return months.count
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return months[section].dates.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CalendarDateCell.identifier, for: indexPath) as! CalendarDateCell
        let date = months[indexPath.section].dates[indexPath.item]
        cell.configure(with: date)
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CalendarHeaderView.identifier, for: indexPath) as! CalendarHeaderView
        header.configure(with: months[indexPath.section].title)
        return header
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let selectedDate = months[indexPath.section].dates[indexPath.item].date
        if selectedRange.start == nil || selectedRange.end != nil {
            selectedRange = (start: selectedDate, end: nil)
        } else if let start = selectedRange.start, selectedDate >= start {
            selectedRange.end = selectedDate
        }
        updateSelection()
    }
    
    private func updateSelection() {
        guard let start = selectedRange.start else { return }
        for monthIndex in 0..<months.count {
            for dateIndex in 0..<months[monthIndex].dates.count {
                if let end = selectedRange.end {
                    months[monthIndex].dates[dateIndex].isSelected =  months[monthIndex].dates[dateIndex].date >= start && months[monthIndex].dates[dateIndex].date <= end
                    months[monthIndex].dates[dateIndex].isStartRange = months[monthIndex].dates[dateIndex].date == start
                    months[monthIndex].dates[dateIndex].isEndRange = months[monthIndex].dates[dateIndex].date == end
                } else {
                    months[monthIndex].dates[dateIndex].isSelected = months[monthIndex].dates[dateIndex].date == start
                    months[monthIndex].dates[dateIndex].isStartRange = months[monthIndex].dates[dateIndex].date == start
                    months[monthIndex].dates[dateIndex].isEndRange = false
                }
            }
        }
        collectionView.reloadData()
    }
}

struct CalendarMonth {
    let title: String
    var dates: [CalendarDate]
}

class CalendarHeaderView: UICollectionReusableView {
    static let identifier = "CalendarHeaderView"
    
    private let titleLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        label.font = UIFont.boldSystemFont(ofSize: 16)
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(titleLabel)
        titleLabel.frame = bounds
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func configure(with title: String) {
        titleLabel.text = title
    }
}

struct CalendarDate {
    let date: Date
    var isSelected: Bool
    var isStartRange: Bool = false
    var isEndRange: Bool = false
}

class CalendarDateCell: UICollectionViewCell {
    static let identifier = "CalendarDateCell"
    
    private let dateLabel: UILabel = {
        let label = UILabel()
        label.textAlignment = .center
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(dateLabel)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        dateLabel.frame = contentView.bounds
    }
    
    func configure(with date: CalendarDate) {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "d"
        dateLabel.text = dateFormatter.string(from: date.date)
        contentView.backgroundColor = .clear
        
        if date.isSelected {
            if date.isStartRange || date.isEndRange {
                dateLabel.textColor = .white
                dateLabel.backgroundColor = .blue
                dateLabel.layer.cornerRadius = dateLabel.bounds.width / 2
                dateLabel.layer.masksToBounds = true
            } else {
                dateLabel.textColor = .black
                contentView.backgroundColor = UIColor.blue.withAlphaComponent(0.5)
            }
        } else {
            dateLabel.backgroundColor = .clear
            dateLabel.layer.cornerRadius = 0
            dateLabel.layer.masksToBounds = false
        }
    }
}

This is how the bugs look like:


Solution

  • As Larme pointed out, you need to account for cell reuse when setting its appearance.

    So, instead of:

    if date.isSelected {
        // set selected colors
    } else {
        // set not selected colors
    }
    

    It's easier to do this:

    // start with appearance for "not in selected range"
    contentView.backgroundColor = .clear
    dateLabel.backgroundColor = .clear
    dateLabel.layer.cornerRadius = 0
    dateLabel.layer.masksToBounds = false
    dateLabel.textColor = .black
    
    // now, if the date IS in the selected range, update the appearance
    if date.isSelected {
        if date.isStartRange || date.isEndRange {
            dateLabel.textColor = .white
            dateLabel.backgroundColor = .blue
            dateLabel.layer.cornerRadius = dateLabel.bounds.width / 2
            dateLabel.layer.masksToBounds = true
        } else {
            dateLabel.textColor = .black
            contentView.backgroundColor = UIColor.blue.withAlphaComponent(0.5)
        }
    }
    

    As for the after every two cells we can notice a minimum space...

    Note that the property:

    .minimumInteritemSpacing = 0
    

    is minimum, not absolute.

    Running your code on an iPhone 15 Pro, for example, the view width (we shouldn't be using UIScreen.main.bounds.width) is 393.0 ... you are then setting itemSize width to:

    let size = floor(UIScreen.main.bounds.width / 7)
    
    // size now == 56.0
    

    but... the collection view width is 393.0, and 56.0 * 7.0 == 392.0 -- which means UIKit will "add space" between some of the cells.

    To get around that, we can update the frame.size.width of the collection view to size * 7.0. Unfortunately, that leaves a 1-point space on the right-side:

    enter image description here

    So, we could center the collection view ... in this case, we'd set collectionView.frame.origin.x to 0.5 -- which is not a whole number... and UIKit will try to give us 1.5-pixel space on each side, but since it cannot draw "partial pixels" we actually end up with 1-pixel on the left and 2-pixels on the right:

    enter image description here

    which looks a little more "balanced."

    If we really want full "edge-to-edge" coverage:

    enter image description here

    we could calculate the cellSize and the number of cells that need to be 1-point wider:

    layout.estimatedItemSize = CGSize(width: cellSize, height: cellSize)
    let totalCellsWidth: CGFloat = cellSize * 7.0
    numberOfPlusOneCells = Int(collectionView.frame.width - totalCellsWidth)
    

    conform to flow layout delegate:

    class ViewController: UIViewController, 
                            UICollectionViewDelegate, UICollectionViewDataSource, 
                            UICollectionViewDelegateFlowLayout {
    

    and implement sizeForItemAt:

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        // account for non-even cell widths
        if indexPath.item % 7 < numberOfPlusOneCells {
            return .init(width: cellSize + 1.0, height: cellSize)
        }
        return .init(width: cellSize, height: cellSize)
    }
    

    Here is your complete code, with the above changes:

    class ViewController: UIViewController, 
                            UICollectionViewDelegate, UICollectionViewDataSource,
                            UICollectionViewDelegateFlowLayout {
        
        private var months = [CalendarMonth]()
        private var selectedRange: (start: Date?, end: Date?) = (nil, nil)
    
        // track the current view width, so we can update the collection view properties
        //  when layout gives us the correct size
        private var curViewWidth: CGFloat = 0.0
        
        // these will be set in viewDidLayoutSubviews()
        private var cellSize: CGFloat = 0.0
        private var numberOfPlusOneCells: Int = 0
    
        private let collectionView: UICollectionView = {
            let layout = UICollectionViewFlowLayout()
            layout.minimumInteritemSpacing = 0
            layout.minimumLineSpacing = 2
            // sizes will be updated in viewDidLayoutSubviews()
            let size = floor(UIScreen.main.bounds.width / 7)
            layout.estimatedItemSize = CGSize(width: size, height: size)
            layout.headerReferenceSize = CGSize(width: UIScreen.main.bounds.width, height: 40)
            let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
            collectionView.register(CalendarDateCell.self, forCellWithReuseIdentifier: CalendarDateCell.identifier)
            collectionView.register(CalendarHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CalendarHeaderView.identifier)
            return collectionView
        }()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            view.addSubview(collectionView)
            collectionView.delegate = self
            collectionView.dataSource = self
            collectionView.frame = view.bounds
            generateDates()
        }
        
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
            
            // this will be called on first layout - and possibly (probably) additional times
            // so only execute this code if the view width has changed...
            if curViewWidth != view.frame.width {
                curViewWidth = view.frame.width
    
                if let fl = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
    
                    // update collection view frame width
                    collectionView.frame.size.width = curViewWidth
    
                    // we can't "draw on partial pixels" so
                    //  get a whole number of width / 7 for item size
                    let size = floor(curViewWidth / 7.0)
                    cellSize = size
                    
                    // now, the actual collection view width needs to be
                    //  size * 7, NOT the full view width
                    //  so get the number of cells that need width to be cellSize+1
                    let totalCellsWidth: CGFloat = cellSize * 7.0
                    numberOfPlusOneCells = Int(curViewWidth - totalCellsWidth)
                    
                    // update flow layout's estimatedItemSize
                    fl.estimatedItemSize = CGSize(width: cellSize, height: cellSize)
                    
                    // update headerReferenceSize tp be the collection view width
                    fl.headerReferenceSize = CGSize(width: curViewWidth, height: 40)
                    
                }
    
            }
            
        }
        
        private func generateDates() {
            let calendar = Calendar.current
            let dateFormatter = DateFormatter()
            dateFormatter.dateFormat = "MMMM yyyy"
            
            let currentDate = Date()
            guard let startDate = calendar.date(byAdding: .month, value: 0, to: currentDate),
                  let endDate = calendar.date(byAdding: .month, value: 12, to: currentDate) else {
                return
            }
            
            var date = startDate
            var currentMonth: CalendarMonth?
            
            while date <= endDate {
                let components = calendar.dateComponents([.year, .month, .day], from: date)
                
                if let firstOfMonth = calendar.date(from: DateComponents(year: components.year, month: components.month, day: 1)) {
                    
                    let monthTitle = dateFormatter.string(from: firstOfMonth)
                    
                    if currentMonth == nil || currentMonth!.title != monthTitle {
                        if var existingMonth = currentMonth {
                            months.append(existingMonth)
                        }
                        currentMonth = CalendarMonth(title: monthTitle, dates: [])
                    }
                }
                
                currentMonth?.dates.append(CalendarDate(date: date, isSelected: false))
                date = calendar.date(byAdding: .day, value: 1, to: date)!
            }
            
            if var existingMonth = currentMonth {
                months.append(existingMonth)
            }
        }
        
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
            // account for non-even cell widths
            if indexPath.item % 7 < numberOfPlusOneCells {
                return .init(width: cellSize + 1.0, height: cellSize)
            }
            return .init(width: cellSize, height: cellSize)
        }
        func numberOfSections(in collectionView: UICollectionView) -> Int {
            return months.count
        }
        
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return months[section].dates.count
        }
        
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CalendarDateCell.identifier, for: indexPath) as! CalendarDateCell
            let date = months[indexPath.section].dates[indexPath.item]
            cell.configure(with: date)
            return cell
        }
        
        func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
            let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CalendarHeaderView.identifier, for: indexPath) as! CalendarHeaderView
            header.configure(with: months[indexPath.section].title)
            return header
        }
        
        func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
            let selectedDate = months[indexPath.section].dates[indexPath.item].date
            if selectedRange.start == nil || selectedRange.end != nil {
                selectedRange = (start: selectedDate, end: nil)
            } else if let start = selectedRange.start, selectedDate >= start {
                selectedRange.end = selectedDate
            }
            updateSelection()
        }
        
        private func updateSelection() {
            guard let start = selectedRange.start else { return }
            for monthIndex in 0..<months.count {
                for dateIndex in 0..<months[monthIndex].dates.count {
                    if let end = selectedRange.end {
                        months[monthIndex].dates[dateIndex].isSelected =  months[monthIndex].dates[dateIndex].date >= start && months[monthIndex].dates[dateIndex].date <= end
                        months[monthIndex].dates[dateIndex].isStartRange = months[monthIndex].dates[dateIndex].date == start
                        months[monthIndex].dates[dateIndex].isEndRange = months[monthIndex].dates[dateIndex].date == end
                    } else {
                        months[monthIndex].dates[dateIndex].isSelected = months[monthIndex].dates[dateIndex].date == start
                        months[monthIndex].dates[dateIndex].isStartRange = months[monthIndex].dates[dateIndex].date == start
                        months[monthIndex].dates[dateIndex].isEndRange = false
                    }
                }
            }
            collectionView.reloadData()
        }
    }
    
    struct CalendarMonth {
        let title: String
        var dates: [CalendarDate]
    }
    
    class CalendarHeaderView: UICollectionReusableView {
        static let identifier = "CalendarHeaderView"
        
        private let titleLabel: UILabel = {
            let label = UILabel()
            label.textAlignment = .center
            label.font = UIFont.boldSystemFont(ofSize: 16)
            return label
        }()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            addSubview(titleLabel)
            titleLabel.frame = bounds
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        func configure(with title: String) {
            titleLabel.text = title
        }
    }
    
    struct CalendarDate {
        let date: Date
        var isSelected: Bool
        var isStartRange: Bool = false
        var isEndRange: Bool = false
    }
    
    class CalendarDateCell: UICollectionViewCell {
        static let identifier = "CalendarDateCell"
        
        private let dateLabel: UILabel = {
            let label = UILabel()
            label.textAlignment = .center
            return label
        }()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            contentView.addSubview(dateLabel)
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func layoutSubviews() {
            super.layoutSubviews()
            dateLabel.frame = contentView.bounds
        }
        
        func configure(with date: CalendarDate) {
            let dateFormatter = DateFormatter()
            dateFormatter.dateFormat = "d"
            dateLabel.text = dateFormatter.string(from: date.date)
    
            // start with appearance for "not in selected range"
            contentView.backgroundColor = .clear
            dateLabel.backgroundColor = .clear
            dateLabel.layer.cornerRadius = 0
            dateLabel.layer.masksToBounds = false
            dateLabel.textColor = .black
            
            // now, if the date IS in the selected range, update the appearance
            if date.isSelected {
                if date.isStartRange || date.isEndRange {
                    dateLabel.textColor = .white
                    dateLabel.backgroundColor = .blue
                    dateLabel.layer.cornerRadius = dateLabel.bounds.width / 2
                    dateLabel.layer.masksToBounds = true
                } else {
                    dateLabel.textColor = .black
                    contentView.backgroundColor = UIColor.blue.withAlphaComponent(0.5)
                }
            }
    
        }
    }