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 3 of 4 of this article. Click here to view the first page.

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:

  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:

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.

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.

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!

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:

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!

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