Creating a Custom Calendar Control for iOS
In this calendar UI control tutorial, you’ll build an iOS control that gives users vital clarity and context when interacting with dates. By Jordan Osterberg.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Creating a Custom Calendar Control for iOS
25 mins
- Getting Started
- Breaking Down the UI
- Creating the Month View
- Setting up the Collection View
- Breaking Down the Data
- Using the Calendar API
- Generating a Month’s Metadata
- Looping Through the Month
- Handling the Last Week of the Month
- Creating the Collection View Cell
- Setting the Cell’s Constraints
- Configuring the Cell’s Appearance
- Preparing the Month View for Data
- Adding UICollectionViewDelegateFlowLayout Conformance
- Presenting the Calendar
- Adding the Header and Footer
- Where to Go From Here?
Configuring the Cell’s Appearance
Next, CalendarDateCollectionViewCell
needs a reference to a Day
to display.
Below reuseIdentifier
, create a day
property:
var day: Day? {
didSet {
guard let day = day else { return }
numberLabel.text = day.number
accessibilityLabel = accessibilityDateFormatter.string(from: day.date)
}
}
When day
is set, you update numberLabel
to reflect the new Day
. You also update the cell’s accessibilityLabel
to a formatted string of day
‘s date. This provides an accessible experience for all of users.
At the bottom of the file, add the following extension:
// MARK: - Appearance
private extension CalendarDateCollectionViewCell {
// 1
func updateSelectionStatus() {
guard let day = day else { return }
if day.isSelected {
applySelectedStyle()
} else {
applyDefaultStyle(isWithinDisplayedMonth: day.isWithinDisplayedMonth)
}
}
// 2
var isSmallScreenSize: Bool {
let isCompact = traitCollection.horizontalSizeClass == .compact
let smallWidth = UIScreen.main.bounds.width <= 350
let widthGreaterThanHeight =
UIScreen.main.bounds.width > UIScreen.main.bounds.height
return isCompact && (smallWidth || widthGreaterThanHeight)
}
// 3
func applySelectedStyle() {
accessibilityTraits.insert(.selected)
accessibilityHint = nil
numberLabel.textColor = isSmallScreenSize ? .systemRed : .white
selectionBackgroundView.isHidden = isSmallScreenSize
}
// 4
func applyDefaultStyle(isWithinDisplayedMonth: Bool) {
accessibilityTraits.remove(.selected)
accessibilityHint = "Tap to select"
numberLabel.textColor = isWithinDisplayedMonth ? .label : .secondaryLabel
selectionBackgroundView.isHidden = true
}
}
With the code above, you:
- Define
updateSelectionStatus()
, in which you apply a different style to the cell based on the selection status of the day. - Add a computed property that determines if the screen size has a limited amount of width.
- Add
applySelectedStyle()
, which applies when the user selects the cell, based on the screen size. - Define
applyDefaultStyle(isWithinDisplayedMonth:)
, which applies a default style to the cell.
To wrap up, add the following at the end of the didSet
closure on day
:
updateSelectionStatus()
CalendarDateCollectionViewCell
is now ready for prime time.
Preparing the Month View for Data
Open CalendarPickerViewController.swift. Below selectedDate
in the Calendar Data Values section, add the following code:
private var baseDate: Date {
didSet {
days = generateDaysInMonth(for: baseDate)
collectionView.reloadData()
}
}
private lazy var days = generateDaysInMonth(for: baseDate)
private var numberOfWeeksInBaseDate: Int {
calendar.range(of: .weekOfMonth, in: .month, for: baseDate)?.count ?? 0
}
This creates baseDate
, which holds the due date of the task. When this changes, you generate new month data and reload the collection view. days
holds the month’s data for the base date. The default value of days
executes when CalendarPickerViewController
is initialized. numberOfWeeksInBaseDate
represents the number of weeks in the currently-displayed month.
Next, inside the initializer, below self.selectedDate = baseDate
, assign a value to baseDate
:
self.baseDate = baseDate
Then, at the bottom of the file, add the following extension:
// MARK: - UICollectionViewDataSource
extension CalendarPickerViewController: UICollectionViewDataSource {
func collectionView(
_ collectionView: UICollectionView,
numberOfItemsInSection section: Int
) -> Int {
days.count
}
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let day = days[indexPath.row]
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: CalendarDateCollectionViewCell.reuseIdentifier,
for: indexPath) as! CalendarDateCollectionViewCell
cell.day = day
return cell
}
}
In the above code, you simply implement the collection view’s data source, returning the number of day cells from collectionView(_:numberOfItemsInSection:)
and the specific cell for each index path from days
. In collectionView(_:cellForItemAt:)
.
Adding UICollectionViewDelegateFlowLayout Conformance
Now that you have the basic data source delegate set, you also must implement the collection view’s Flow Layout delegate to define the exact size of each cell in the collection view layout.
Implement this delegate by adding the following extension at the bottom of the file:
// MARK: - UICollectionViewDelegateFlowLayout
extension CalendarPickerViewController: UICollectionViewDelegateFlowLayout {
func collectionView(
_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath
) {
let day = days[indexPath.row]
selectedDateChanged(day.date)
dismiss(animated: true, completion: nil)
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
let width = Int(collectionView.frame.width / 7)
let height = Int(collectionView.frame.height) / numberOfWeeksInBaseDate
return CGSize(width: width, height: height)
}
}
Since UICollectionViewDelegateFlowLayout
is actually a sub-protocol of UICollectionViewDelegate
, you also use the opportunity to implement collectionView(_:didSelectItemAt:)
to define what happens when the user selects a day cell.
As you did in collectionView(_:cellForItemAt:)
, the first thing you do in collectionView(_:didSelectItemAt:)
is access the Day
for the cell. Then, you call the selectedDateChanged
closure with the selected date. Finally, you dismiss the calendar picker.
In collectionView(_:layout:sizeForItemAt:)
, you calculate the size of each collection view cell. The width is the width of the collection view, divided by seven — the number of days in a week. The height is the height of the collection view divided by the number of weeks in the month.
If you’d like to learn more, check out: What Every Computer Scientist Should Know About Floating-Point Arithmetic.
width
and height
are Int
and not left as CGFloat
in collectionView(_:layout:sizeForItemAt:)
. This is because arithmetic precision with floating point types is never guaranteed. Therefore, values are subject to rounding errors, which can produce undefined results in your code. Int
rounds down a CGFloat
to the nearest whole number, which is safer in this case.
If you’d like to learn more, check out: What Every Computer Scientist Should Know About Floating-Point Arithmetic.
Presenting the Calendar
You’re almost ready to see your custom calendar! There are only two quick steps left.
In CalendarPickerViewController.swift, at the bottom of viewDidLoad()
, add this code:
collectionView.register(
CalendarDateCollectionViewCell.self,
forCellWithReuseIdentifier: CalendarDateCollectionViewCell.reuseIdentifier
)
collectionView.dataSource = self
collectionView.delegate = self
This registers the custom cell with the collection view and sets up the data source and delegate.
Finally, below viewDidLoad()
, add this method:
override func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator
) {
super.viewWillTransition(to: size, with: coordinator)
collectionView.reloadData()
}
This allows the collection view to recalculate its layout when the device rotates or enters Split View on an iPad.
It’s now time for a first peek at your calendar. Build and run!
Look at that: your first glimpse of your shiny new calendar control!
It looks so good, but it’s not quite finished. It’s time to add the header and footer.
Adding the Header and Footer
You might have noticed that CalendarPickerHeaderView.swift and CalendarPickerFooterView.swift are already inside the project. However, they aren’t integrated into CalendarPickerViewController
. You’ll do that now.
Inside CalendarPickerViewController.swift and below the collectionView
in the Views section, add the following view properties:
private lazy var headerView = CalendarPickerHeaderView { [weak self] in
guard let self = self else { return }
self.dismiss(animated: true)
}
private lazy var footerView = CalendarPickerFooterView(
didTapLastMonthCompletionHandler: { [weak self] in
guard let self = self else { return }
self.baseDate = self.calendar.date(
byAdding: .month,
value: -1,
to: self.baseDate
) ?? self.baseDate
},
didTapNextMonthCompletionHandler: { [weak self] in
guard let self = self else { return }
self.baseDate = self.calendar.date(
byAdding: .month,
value: 1,
to: self.baseDate
) ?? self.baseDate
})
These views each store closures to respond to UI events that occur in them. The header view calls its closure when the user taps the Exit button.
The footer view’s closures occur when the user taps either the Previous or Next buttons. As a result, the code in these closures increments or decrements baseDate
accordingly.
Next, add this line of code at both the end of baseDate
‘s didSet
block and the bottom of viewDidLoad()
:
headerView.baseDate = baseDate
This updates headerView
‘s baseDate
.
The final step is to head into viewDidLoad()
and add the header and footer views to the Hierarchy.
Below view.addSubview(collectionView)
, add this code:
view.addSubview(headerView)
view.addSubview(footerView)
Then, add the following constraints just before the existing call to NSLayoutConstraint.activate(_:)
:
constraints.append(contentsOf: [
headerView.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
headerView.bottomAnchor.constraint(equalTo: collectionView.topAnchor),
headerView.heightAnchor.constraint(equalToConstant: 85),
footerView.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor),
footerView.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
footerView.topAnchor.constraint(equalTo: collectionView.bottomAnchor),
footerView.heightAnchor.constraint(equalToConstant: 60)
])
The header view appears above the collection view with a fixed height of 85 points, while the footer view appears on the bottom with a height of 60 points.
It’s time for your hard earned moment of pride. Build and run!
Awesome! You now have a functional calendar picker. Your users will never struggle when selecting dates again. :]