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?
Looping Through the Month
Now, it’s time to move on to generating those all-important days. Add the following two methods below monthMetadata(for:)
:
// 1
func generateDaysInMonth(for baseDate: Date) -> [Day] {
// 2
guard let metadata = try? monthMetadata(for: baseDate) else {
fatalError("An error occurred when generating the metadata for \(baseDate)")
}
let numberOfDaysInMonth = metadata.numberOfDays
let offsetInInitialRow = metadata.firstDayWeekday
let firstDayOfMonth = metadata.firstDay
// 3
let days: [Day] = (1..<(numberOfDaysInMonth + offsetInInitialRow))
.map { day in
// 4
let isWithinDisplayedMonth = day >= offsetInInitialRow
// 5
let dayOffset =
isWithinDisplayedMonth ?
day - offsetInInitialRow :
-(offsetInInitialRow - day)
// 6
return generateDay(
offsetBy: dayOffset,
for: firstDayOfMonth,
isWithinDisplayedMonth: isWithinDisplayedMonth)
}
return days
}
// 7
func generateDay(
offsetBy dayOffset: Int,
for baseDate: Date,
isWithinDisplayedMonth: Bool
) -> Day {
let date = calendar.date(
byAdding: .day,
value: dayOffset,
to: baseDate)
?? baseDate
return Day(
date: date,
number: dateFormatter.string(from: date),
isSelected: calendar.isDate(date, inSameDayAs: selectedDate),
isWithinDisplayedMonth: isWithinDisplayedMonth
)
}
Here’s what you just did:
- Define a method named
generateDaysInMonth(for:)
, which takes in aDate
and returns an array ofDay
s. - Retrieve the metadata you need about the month, using
monthMetadata(for:)
. If something goes wrong here, the app can’t function. As a result, it terminates with afatalError
. - If a month starts on a day other than Sunday, you add the last few days from the previous month at the beginning. This avoids gaps in a month’s first row. Here, you create a
Range<Int>
that handles this scenario. For example, if a month starts on Friday,offsetInInitialRow
would add five extra days to even up the row. You then transform this range into[Day]
, usingmap(_:)
. - Check if the current day in the loop is within the current month or part of the previous month.
- Calculate the offset that
day
is from the first day of the month. Ifday
is in the previous month, this value will be negative. - Call
generateDay(offsetBy:for:isWithinDisplayedMonth:)
, which adds or subtracts an offset from aDate
to produce a new one, and return its result.
At first, it can be tricky to get your head around what’s going on in step four. Below is a diagram that makes it easier to understand:
Keep this concept in mind; you’ll be using it again in the next section.
Handling the Last Week of the Month
Much like the previous section, if the last day of the month doesn’t fall on a Saturday, you must add extra days to the calendar.
After the methods you’ve just added, add the following one:
// 1
func generateStartOfNextMonth(
using firstDayOfDisplayedMonth: Date
) -> [Day] {
// 2
guard
let lastDayInMonth = calendar.date(
byAdding: DateComponents(month: 1, day: -1),
to: firstDayOfDisplayedMonth)
else {
return []
}
// 3
let additionalDays = 7 - calendar.component(.weekday, from: lastDayInMonth)
guard additionalDays > 0 else {
return []
}
// 4
let days: [Day] = (1...additionalDays)
.map {
generateDay(
offsetBy: $0,
for: lastDayInMonth,
isWithinDisplayedMonth: false)
}
return days
}
Here’s a breakdown of what you just did:
- Define a method named
generateStartOfNextMonth(using:)
, which takes the first day of the displayed month and returns an array ofDay
objects. - Retrieve the last day of the displayed month. If this fails, you return an empty array.
- Calculate the number of extra days you need to fill the last row of the calendar. For instance, if the last day of the month is a Saturday, the result is zero and you return an empty array.
- Create a
Range<Int>
from one to the value ofadditionalDays
, as in the previous section. Then, it transforms this into an array ofDay
s. This time,generateDay(offsetBy:for:isWithinDisplayedMonth:)
adds the current day in the loop tolastDayInMonth
to generate the days at the beginning of the next month.
Finally, you’ll need to combine the results of this method with the days you generated in the previous section. Navigate to generateDaysInMonth(for:)
and change days
from a let
to a var
. Then, before the return statement, add the following line of code:
days += generateStartOfNextMonth(using: firstDayOfMonth)
You now have all your calendar data prepared, but what’s all that work for if you can’t see it? It’s time to create the UI to display it.
Creating the Collection View Cell
Open CalendarDateCollectionViewCell.swift in the CalendarPicker folder. This file contains boilerplate code, which you’ll expand upon.
At the top of the class, add these three properties:
private lazy var selectionBackgroundView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.clipsToBounds = true
view.backgroundColor = .systemRed
return view
}()
private lazy var numberLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: 18, weight: .medium)
label.textColor = .label
return label
}()
private lazy var accessibilityDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.calendar = Calendar(identifier: .gregorian)
dateFormatter.setLocalizedDateFormatFromTemplate("EEEE, MMMM d")
return dateFormatter
}()
selectionBackgroundView
is a red circle that appears when the user selects this cell — when there’s room to display it.
numberLabel
displays the day of the month for this cell.
accessibilityDateFormatter
is a DateFormatter
, which converts the cell’s date to a more accessible format.
Next, inside the initializer below accessibilityTraits = .button
, add selectionBackgroundView
and numberLabel
to the cell:
contentView.addSubview(selectionBackgroundView)
contentView.addSubview(numberLabel)
Next, you’ll set up the constraints for these views.
Setting the Cell’s Constraints
Inside layoutSubviews()
, add this code:
// This allows for rotations and trait collection
// changes (e.g. entering split view on iPad) to update constraints correctly.
// Removing old constraints allows for new ones to be created
// regardless of the values of the old ones
NSLayoutConstraint.deactivate(selectionBackgroundView.constraints)
// 1
let size = traitCollection.horizontalSizeClass == .compact ?
min(min(frame.width, frame.height) - 10, 60) : 45
// 2
NSLayoutConstraint.activate([
numberLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
numberLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
selectionBackgroundView.centerYAnchor
.constraint(equalTo: numberLabel.centerYAnchor),
selectionBackgroundView.centerXAnchor
.constraint(equalTo: numberLabel.centerXAnchor),
selectionBackgroundView.widthAnchor.constraint(equalToConstant: size),
selectionBackgroundView.heightAnchor
.constraint(equalTo: selectionBackgroundView.widthAnchor)
])
selectionBackgroundView.layer.cornerRadius = size / 2
Breaking this down, you:
- Calculate the width and height based on the device’s horizontal size class. If the device is horizontally compact, you use the full size of the cell while subtracting 10 (up to a limit of 60) to ensure the circle doesn’t stretch to the edge of the cell bounds. For non-compact devices, you use a static 45 x 45 size.
- Set up all of the needed constraints for the number label and the selection background view, as well as set the corner radius of the selection background view to half its size.