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.

4.7 (26) · 3 Reviews

Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

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
  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:

  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:

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:

  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.