Home · iOS & Swift Tutorials

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.

4.8/5 9 Ratings


  • Swift 5, iOS 13, Xcode 11

Providing users with a way of selecting dates is a common functionality in mobile apps. Sometimes, using the built-in UIDatePicker is all you need, but what if you want something more customized?

Although UIDatePicker works well for basic tasks, it lacks important context for guiding users to the date they want to select. In daily life, you use a calendar to keep track of dates. A calendar provides more context than UIDatePicker, as it tells you what day of the week a date falls on.

In this tutorial, you’ll build a custom calendar UI control that adds that vital clarity and context when selecting dates. In the process, you’ll learn how to:

  • Generate and manipulate dates using Calendar APIs provided by the Foundation framework.
  • Display the data in a UICollectionView.
  • Make the control accessible to assistive technologies such as VoiceOver.

Are you ready? Then jump right in! :]

Note: This tutorial assumes you know the basics of UICollectionView. If you’re new to iOS development, check out our UICollectionView Tutorial: Getting Started first.

Getting Started

Download the project materials by clicking the Download Materials button at the top or bottom of this tutorial.

The sample project, Checkmate, outlines a Reminders-like checklist app that allows a user to create tasks and set their due dates.

Open the starter project. Then, build and run.

The task list inside of the Checkmate app. One task shown: Complete the Diffable Data Sources tutorial on raywenderlich.com

The app shows a list of tasks. Tap Complete the Diffable Data Sources tutorial on raywenderlich.com.

Task screen in the Checkmate app

A details screen opens, showing the task’s name and due date.

Tap Due Date. Right now nothing happens, but soon, tapping here will present your calendar control.

Breaking Down the UI

Here’s a screenshot of what the completed control will look like:

Sections of the calendar picker with the header view at the top, the month view in the middle and the footer view at the bottom.

Three components make up the calendar control:

  • Green, Header view: Shows the current month and year, allows the user to close the picker and displays the weekday labels.
  • Blue, Month view: Displays the days of the month, along with the currently-selected date.
  • Pink, Footer view: Allows the user to select different months.

You’ll start by tackling the month view.

Creating the Month View

Open CalendarPickerViewController.swift inside the CalendarPicker folder.

The file currently contains dimmedBackgroundView, a transparent black view used to elevate the calendar picker from the background, as well as other boilerplate code.

First, create a UICollectionView. Below the definition of dimmedBackgroundView, enter the following code:

private lazy var collectionView: UICollectionView = {
  let layout = UICollectionViewFlowLayout()
  layout.minimumLineSpacing = 0
  layout.minimumInteritemSpacing = 0
  let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
  collectionView.isScrollEnabled = false
  collectionView.translatesAutoresizingMaskIntoConstraints = false
  return collectionView

In the code above, you create a collection view with no spacing between its cells and disable its scrolling. You also disable automatic translation of its auto-resizing mask into constraints, since you’ll create your own constraints for it.

Great! Next, you’ll add the collection view to the view hierarchy and set up its Auto Layout constraints.

Setting up the Collection View

Inside viewDidLoad() and below super.viewDidLoad(), set the background color of the collection view:

collectionView.backgroundColor = .systemGroupedBackground

Next, below view.addSubview(dimmedBackgroundView), add the collection view to the view hierarchy:


Finally, you’ll give it four constraints. Add the following constraints just before the existing call to NSLayoutConstraint.activate(_:):

constraints.append(contentsOf: [
    equalTo: view.readableContentGuide.leadingAnchor),
    equalTo: view.readableContentGuide.trailingAnchor),
    equalTo: view.centerYAnchor,
    constant: 10),
    equalTo: view.heightAnchor,
    multiplier: 0.5)

This looks complex, but don’t panic! Using this code, you:

  1. Constrain the collection view’s leading (left) and trailing (right) edges to the view’s readable content guide’s leading and trailing edges.
  2. Vertically center the collection view within the view controller, shifted down by 10 points.
  3. Set the height of the collection view to be half of the view controller’s height.

Build and run. Open the calendar picker, and…

Square collection view with a white background overlaid on a dimmed item detail controller

Success! Well… kinda. There isn’t much to see right now, but you’ll solve that later, when you generate data for the calendar.

Breaking Down the Data

To display a month, you’ll need a list of days. Create a new file in the Models folder named Day.swift.

Inside this file, enter the following code:

struct Day {
  // 1 
  let date: Date 
  // 2
  let number: String 
  // 3
  let isSelected: Bool
  // 4
  let isWithinDisplayedMonth: Bool

So what are these properties for? Here’s what each does:

  1. Date represents a given day in a month.
  2. The number to display on the collection view cell.
  3. Keeps track of whether this date is selected.
  4. Tracks if this date is within the currently-viewed month.

Great, you can now use your data model!

Using the Calendar API

The next step is to get a reference to a Calendar. The Calendar API, which is part of Foundation, allows you to change and access information on Dates.

Open CalendarPickerViewController.swift. Below selectedDateChanged, create a new Calendar:

private let calendar = Calendar(identifier: .gregorian)

Setting the calendar identifier as .gregorian means the Calendar API should use the Gregorian calendar. The Gregorian is the calendar used most in the world, including by Apple’s Calendar app.

Note: The only countries that do not use the Gregorian calendar are Ethiopia, Nepal, Iran and Afghanistan. Therefore, if your app targets users in one of these countries, you might need to adapt this tutorial for their calendar system.

Now that you have a calendar object, it’s time to use it to generate some data.

Generating a Month’s Metadata

At the bottom of CalendarPickerViewController.swift, add this private extension:

// MARK: - Day Generation
private extension CalendarPickerViewController {
  // 1
  func monthMetadata(for baseDate: Date) throws -> MonthMetadata {
    // 2
      let numberOfDaysInMonth = calendar.range(
        of: .day,
        in: .month,
        for: baseDate)?.count,
      let firstDayOfMonth = calendar.date(
        from: calendar.dateComponents([.year, .month], from: baseDate))
      else {
        // 3
        throw CalendarDataError.metadataGeneration

    // 4
    let firstDayWeekday = calendar.component(.weekday, from: firstDayOfMonth)

    // 5
    return MonthMetadata(
      numberOfDays: numberOfDaysInMonth,
      firstDay: firstDayOfMonth,
      firstDayWeekday: firstDayWeekday)

  enum CalendarDataError: Error {
    case metadataGeneration

Now, to break this code down:

  1. First, you define a method named monthMetadata(for:), which accepts a Date and returns MonthMetadata. MonthMetadata already exists in the project, so there’s no need to create it.
  2. You ask the calendar for the number of days in baseDate‘s month, then you get the first day of that month.
  3. Both of the previous calls return optional values. If either returns nil, the code throws an error and returns.
  4. You get the weekday value, a number between one and seven that represents which day of the week the first day of the month falls on.
  5. Finally, you use these values to create an instance of MonthMetadata and return it.

Great! This metadata gives you all the information you need to generate the days of the month, plus a bit extra.

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:

  1. Define a method named generateDaysInMonth(for:), which takes in a Date and returns an array of Days.
  2. 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 a fatalError.
  3. 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], using map(_:).
  4. Check if the current day in the loop is within the current month or part of the previous month.
  5. Calculate the offset that day is from the first day of the month. If day is in the previous month, this value will be negative.
  6. Call generateDay(offsetBy:for:isWithinDisplayedMonth:), which adds or subtracts an offset from a Date 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:

The first week of May 2020, from April 26 to May 2

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
    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 {
      offsetBy: $0,
      for: lastDayInMonth,
      isWithinDisplayedMonth: false)

  return days

Here’s a breakdown of what you just did:

  1. Define a method named generateStartOfNextMonth(using:), which takes the first day of the displayed month and returns an array of Day objects.
  2. Retrieve the last day of the displayed month. If this fails, you return an empty array.
  3. 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.
  4. Create a Range<Int> from one to the value of additionalDays, as in the previous section. Then, it transforms this into an array of Days. This time, generateDay(offsetBy:for:isWithinDisplayedMonth:) adds the current day in the loop to lastDayInMonth 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:


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

// 1
let size = traitCollection.horizontalSizeClass == .compact ?
  min(min(frame.width, frame.height) - 10, 60) : 45

// 2
  numberLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  numberLabel.centerXAnchor.constraint(equalTo: centerXAnchor),

    .constraint(equalTo: numberLabel.centerYAnchor),
    .constraint(equalTo: numberLabel.centerXAnchor),
  selectionBackgroundView.widthAnchor.constraint(equalToConstant: size),
    .constraint(equalTo: selectionBackgroundView.widthAnchor)

selectionBackgroundView.layer.cornerRadius = size / 2

Breaking this down, you:

  1. 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.
  2. 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.

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 {
    } 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() {
    accessibilityHint = nil

    numberLabel.textColor = isSmallScreenSize ? .systemRed : .white
    selectionBackgroundView.isHidden = isSmallScreenSize

  // 4
  func applyDefaultStyle(isWithinDisplayedMonth: Bool) {
    accessibilityHint = "Tap to select"

    numberLabel.textColor = isWithinDisplayedMonth ? .label : .secondaryLabel
    selectionBackgroundView.isHidden = true

With the code above, you:

  1. Define updateSelectionStatus(), in which you apply a different style to the cell based on the selection status of the day.
  2. Add a computed property that determines if the screen size has a limited amount of width.
  3. Add applySelectedStyle(), which applies when the user selects the cell, based on the screen size.
  4. 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:


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)

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 {

  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]
    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.

Note: You might wonder why 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:

  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)

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!

The calendar for May 2020 with May 25 selected

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:


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!

The calendar picker with the header and footer displayed

Awesome! You now have a functional calendar picker. Your users will never struggle when selecting dates again. :]

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

If you’d like to learn more about the Calendar API in Foundation, you can look at Apple’s Calendar documentation page.

Formatting dates to display to users can be tricky. Check out nsdateformatter.com, which will help you remember all the different formatting strings.

If you have any questions or comments, join the forum discussion below!

Average Rating


Add a rating for this content

9 ratings

More like this