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):
UICollectionViewFlowLayout
the space between cells is set to 0
, after every two cells we can notice a minimum spaceYou 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:
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:
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:
which looks a little more "balanced."
If we really want full "edge-to-edge" coverage:
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)
}
}
}
}