UIKit框架(四十八) —— 自定义Calendar Control的简单示例(二)

版本记录

版本号 时间
V1.0 2020.08.26 星期三

前言

iOS中有关视图控件用户能看到的都在UIKit框架里面,用户交互也是通过UIKit进行的。感兴趣的参考上面几篇文章。
1. UIKit框架(一) —— UIKit动力学和移动效果(一)
2. UIKit框架(二) —— UIKit动力学和移动效果(二)
3. UIKit框架(三) —— UICollectionViewCell的扩张效果的实现(一)
4. UIKit框架(四) —— UICollectionViewCell的扩张效果的实现(二)
5. UIKit框架(五) —— 自定义控件:可重复使用的滑块(一)
6. UIKit框架(六) —— 自定义控件:可重复使用的滑块(二)
7. UIKit框架(七) —— 动态尺寸UITableViewCell的实现(一)
8. UIKit框架(八) —— 动态尺寸UITableViewCell的实现(二)
9. UIKit框架(九) —— UICollectionView的数据异步预加载(一)
10. UIKit框架(十) —— UICollectionView的数据异步预加载(二)
11. UIKit框架(十一) —— UICollectionView的重用、选择和重排序(一)
12. UIKit框架(十二) —— UICollectionView的重用、选择和重排序(二)
13. UIKit框架(十三) —— 如何创建自己的侧滑式面板导航(一)
14. UIKit框架(十四) —— 如何创建自己的侧滑式面板导航(二)
15. UIKit框架(十五) —— 基于自定义UICollectionViewLayout布局的简单示例(一)
16. UIKit框架(十六) —— 基于自定义UICollectionViewLayout布局的简单示例(二)
17. UIKit框架(十七) —— 基于自定义UICollectionViewLayout布局的简单示例(三)
18. UIKit框架(十八) —— 基于CALayer属性的一种3D边栏动画的实现(一)
19. UIKit框架(十九) —— 基于CALayer属性的一种3D边栏动画的实现(二)
20. UIKit框架(二十) —— 基于UILabel跑马灯类似效果的实现(一)
21. UIKit框架(二十一) —— UIStackView的使用(一)
22. UIKit框架(二十二) —— 基于UIPresentationController的自定义viewController的转场和展示(一)
23. UIKit框架(二十三) —— 基于UIPresentationController的自定义viewController的转场和展示(二)
24. UIKit框架(二十四) —— 基于UICollectionViews和Drag-Drop在两个APP间的使用示例 (一)
25. UIKit框架(二十五) —— 基于UICollectionViews和Drag-Drop在两个APP间的使用示例 (二)
26. UIKit框架(二十六) —— UICollectionView的自定义布局 (一)
27. UIKit框架(二十七) —— UICollectionView的自定义布局 (二)
28. UIKit框架(二十八) —— 一个UISplitViewController的简单实用示例 (一)
29. UIKit框架(二十九) —— 一个UISplitViewController的简单实用示例 (二)
30. UIKit框架(三十) —— 基于UICollectionViewCompositionalLayout API的UICollectionViews布局的简单示例(一)
31. UIKit框架(三十一) —— 基于UICollectionViewCompositionalLayout API的UICollectionViews布局的简单示例(二)
32. UIKit框架(三十二) —— 替换Peek and Pop交互的基于iOS13的Context Menus(一)
33. UIKit框架(三十三) —— 替换Peek and Pop交互的基于iOS13的Context Menus(二)
34. UIKit框架(三十四) —— Accessibility的使用(一)
35. UIKit框架(三十五) —— Accessibility的使用(二)
36. UIKit框架(三十六) —— UICollectionView UICollectionViewDiffableDataSource的使用(一)
37. UIKit框架(三十七) —— UICollectionView UICollectionViewDiffableDataSource的使用(二)
38. UIKit框架(三十八) —— 基于CollectionView转盘效果的实现(一)
39. UIKit框架(三十九) —— iOS 13中UISearchController 和 UISearchBar的新更改(一)
40. UIKit框架(四十) —— iOS 13中UISearchController 和 UISearchBar的新更改(二)
41. UIKit框架(四十一) —— 使用协议构建自定义Collection(一)
42. UIKit框架(四十二) —— 使用协议构建自定义Collection(二)
43. UIKit框架(四十三) —— CALayer的简单实用示例(一)
44. UIKit框架(四十四) —— CALayer的简单实用示例(二)
45. UIKit框架(四十五) —— 支持DarkMode的简单示例(一)
46. UIKit框架(四十六) —— 支持DarkMode的简单示例(二)
47. UIKit框架(四十七) —— 自定义Calendar Control的简单示例(一)

源码

1. Swift

首先看下工程组织结构

下面就是源码了

1. SceneDelegate.swift
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?

  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }

    #if targetEnvironment(macCatalyst)
    windowScene.titlebar?.titleVisibility = .hidden
    #endif
    window = UIWindow(windowScene: windowScene)
    window?.rootViewController = UINavigationController(rootViewController: ItemListViewController())
    window?.makeKeyAndVisible()
  }
}
2. ChecklistItemTableViewCell.swift
import UIKit

class ChecklistItemTableViewCell: UITableViewCell {
  lazy var titleLabel: UILabel = {
    let label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.font = UIFont.preferredFont(forTextStyle: .headline)
    label.textColor = .label
    label.numberOfLines = 0
    label.adjustsFontForContentSizeCategory = true
    label.accessibilityTraits = .button
    label.accessibilityHint = "Double tap to open"
    label.isAccessibilityElement = true
    return label
  }()

  lazy var subtitleLabel: UILabel = {
    let label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.font = UIFont.preferredFont(forTextStyle: .subheadline)
    label.textColor = .secondaryLabel
    label.numberOfLines = 1
    label.adjustsFontForContentSizeCategory = true
    label.isAccessibilityElement = false
    return label
  }()

  lazy var completionButton: UIButton = {
    let button = UIButton()
    button.translatesAutoresizingMaskIntoConstraints = false
    button.contentMode = .scaleAspectFit

    let configuration = UIImage.SymbolConfiguration(scale: .large)
    let image = UIImage(systemName: "square", withConfiguration: configuration)
    button.setImage(image, for: .normal)
    button.tintColor = UIColor.systemBlue
    button.isUserInteractionEnabled = true
    button.isAccessibilityElement = true
    button.accessibilityTraits = .button
    button.accessibilityLabel = "Mark as Complete"
    return button
  }()

  private lazy var dateFormatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.dateStyle = .long
    return dateFormatter
  }()

  var item: ChecklistItem? {
    didSet {
      guard let item = item else {
        return
      }

      let subtitleText = dateFormatter.string(from: item.date)

      titleLabel.text = item.title
      subtitleLabel.text = subtitleText

      // The title acts as the cell to voice over because marking the cell as the accessibility element would prevent the checkmark box from being discovered by VoiceOver and other accessibility technologies
      titleLabel.accessibilityLabel = "\(item.title)\n\(subtitleText)"

      updateCompletionStatusAccessibilityInformation()
    }
  }

  static let reuseIdentifier = String(describing: ChecklistItemTableViewCell.self)

  override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    super.init(style: style, reuseIdentifier: reuseIdentifier)

    selectionStyle = .none

    addSubview(titleLabel)
    addSubview(subtitleLabel)
    addSubview(completionButton)

    completionButton.addTarget(self, action: #selector(userDidTapOnCheckmarkBox), for: .touchUpInside)

    layoutSubviews()
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func layoutSubviews() {
    super.layoutSubviews()

    if traitCollection.horizontalSizeClass == .compact {
      NSLayoutConstraint.activate([
        titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor, constant: 4),
        completionButton.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor, constant: -4)
      ])
    } else {
      NSLayoutConstraint.activate([
        titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
        completionButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16)
      ])
    }

    NSLayoutConstraint.activate([
      titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 10),
      titleLabel.trailingAnchor.constraint(equalTo: completionButton.leadingAnchor, constant: -5),

      subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor),
      subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
      subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
      subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10),

      completionButton.centerYAnchor.constraint(equalTo: centerYAnchor),
      completionButton.widthAnchor.constraint(equalToConstant: 30),
      completionButton.heightAnchor.constraint(equalToConstant: 30)
    ])
  }

  @objc func userDidTapOnCheckmarkBox() {
    guard let item = item else {
      return
    }

    item.completed.toggle()

    updateCompletionStatusAccessibilityInformation()

    UIView.transition(
      with: completionButton,
      duration: 0.2,
      options: .transitionCrossDissolve,
      animations: {
      let symbolName: String

      if item.completed {
        symbolName = "checkmark.square"
      } else {
        symbolName = "square"
      }

      let configuration = UIImage.SymbolConfiguration(scale: .large)
      let image = UIImage(systemName: symbolName, withConfiguration: configuration)
      self.completionButton.setImage(image, for: .normal)
      },
      completion: nil)
  }

  private func updateCompletionStatusAccessibilityInformation() {
    if item?.completed == true {
      completionButton.accessibilityLabel = "Mark as incomplete"
    } else {
      completionButton.accessibilityLabel = "Mark as complete"
    }
  }

  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)

    layoutSubviews()
  }
}
3. ItemListViewController.swift
import UIKit

class ItemListViewController: UITableViewController {
  // MARK: Diffable Data Source Setup

  enum Section {
    case main
  }

  typealias DataSource = UITableViewDiffableDataSource
  typealias Snapshot = NSDiffableDataSourceSnapshot

  // MARK: Properties

  private lazy var dataSource = makeDataSource()
  private lazy var items = ChecklistItem.exampleItems

  private var searchQuery: String? = nil {
    didSet {
      applySnapshot()
    }
  }

  // MARK: Controller Setup

  private lazy var searchController = makeSearchController()

  override func viewDidLoad() {
    super.viewDidLoad()

    view.backgroundColor = .systemBackground

    navigationItem.largeTitleDisplayMode = .automatic
    navigationController?.navigationBar.prefersLargeTitles = true
    title = "Checkmate"

    navigationItem.searchController = searchController
    definesPresentationContext = true

    navigationItem.rightBarButtonItem = UIBarButtonItem(
      image: UIImage(systemName: "plus.circle"),
      style: .done,
      target: self,
      action: #selector(didTapNewItemButton)
    )
    navigationItem.rightBarButtonItem?.accessibilityLabel = "New Item"

    tableView.register(
      ChecklistItemTableViewCell.self,
      forCellReuseIdentifier: ChecklistItemTableViewCell.reuseIdentifier)

    tableView.tableFooterView = UIView()
  }

  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    applySnapshot(animatingDifferences: false)
  }

  @objc func didTapNewItemButton() {
    let newItemAlert = UIAlertController(
      title: "New Item",
      message: "What would you like to do today?",
      preferredStyle: .alert)
    newItemAlert.addTextField { textField in
      textField.placeholder = "Item Text"
    }
    newItemAlert.addAction(UIAlertAction(title: "Create Item", style: .default) { [weak self] _ in
      guard let self = self else { return }

      guard
        let title = newItemAlert.textFields?[0].text,
        !title.isEmpty
        else {
          let errorAlert = UIAlertController(
            title: "Error",
            message: "You can't leave the title empty.",
            preferredStyle: .alert)
          errorAlert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
          self.present(errorAlert, animated: true, completion: nil)
          return
      }

      self.items.append(
        ChecklistItem(title: title, date: Date())
      )

      self.applySnapshot()
    })
    newItemAlert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
    present(newItemAlert, animated: true, completion: nil)
  }
}

// MARK: Table View Methods

extension ItemListViewController {
  func applySnapshot(animatingDifferences: Bool = true) {
    var items: [ChecklistItem] = self.items

    if let searchQuery = searchQuery, !searchQuery.isEmpty {
      items = items.filter { item in
        return item.title.lowercased().contains(searchQuery.lowercased())
      }
    }

    items = items.sorted { one, two in
      return one.date < two.date
    }
    var snapshot = Snapshot()
    snapshot.appendSections([.main])
    snapshot.appendItems(items)
    dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
  }

  func makeDataSource() -> DataSource {
    DataSource(tableView: tableView) { tableView, indexPath, item in
      let cell = tableView.dequeueReusableCell(
        withIdentifier: ChecklistItemTableViewCell.reuseIdentifier,
        for: indexPath) as? ChecklistItemTableViewCell
      cell?.item = item
      return cell
    }
  }

  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    navigationController?.pushViewController(ItemDetailViewController(item: items[indexPath.row]), animated: true)
  }

  // MARK: Contexual Menus

  override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
    let configuration = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
      guard
        let self = self,
        let item = self.dataSource.itemIdentifier(for: indexPath)
        else {
          return nil
      }

      let deleteAction = UIAction(
        title: "Delete Item",
        image: UIImage(systemName: "trash"),
        attributes: .destructive) { _ in
          self.items.removeAll { existingItem in
            return existingItem == item
          }

          self.applySnapshot()
      }

      return UIMenu(title: item.title.truncatedPrefix(12), image: nil, children: [deleteAction])
    }

    return configuration
  }
}

// MARK: Search Controller Setup

extension ItemListViewController: UISearchResultsUpdating {
  func makeSearchController() -> UISearchController {
    let controller = UISearchController(searchResultsController: nil)
    controller.searchResultsUpdater = self
    controller.obscuresBackgroundDuringPresentation = false
    controller.searchBar.placeholder = "Search Items"
    return controller
  }

  func updateSearchResults(for searchController: UISearchController) {
    searchQuery = searchController.searchBar.text
  }
}
4. ItemDetailViewController.swift
import UIKit

class ItemDetailViewController: UITableViewController {
  lazy var dateFormatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.dateStyle = .long
    return dateFormatter
  }()

  let item: ChecklistItem

  init(item: ChecklistItem) {
    self.item = item

    super.init(style: .insetGrouped)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    view.backgroundColor = .systemGroupedBackground

    title = String(item.title.truncatedPrefix(16))

    tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
  }

  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    2
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = UITableViewCell(style: .value1, reuseIdentifier: "cell")

    if indexPath.row == 0 {
      cell.textLabel?.text = "Task Name"
      cell.detailTextLabel?.text = item.title
    } else {
      cell.textLabel?.text = "Due Date"
      cell.detailTextLabel?.text = dateFormatter.string(from: item.date)
    }

    cell.accessoryType = .disclosureIndicator

    return cell
  }

  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    tableView.deselectRow(at: indexPath, animated: true)

    if indexPath.row == 0 {
      let changeTaskNameAlert = UIAlertController(
        title: "Edit Name",
        message: "What should this task be called?",
        preferredStyle: .alert)
      changeTaskNameAlert.addTextField { [weak self] textField in
        guard let self = self else { return }

        textField.text = self.item.title
        textField.placeholder = "Task Name"
      }
      changeTaskNameAlert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self] _ in
        guard let self = self else { return }

        guard
          let newTitle = changeTaskNameAlert.textFields?[0].text,
          !newTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
          else {
            return
        }

        self.item.title = newTitle
        self.tableView.reloadRows(at: [IndexPath(row: 0, section: 0)], with: .fade)
      })

      changeTaskNameAlert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
      present(changeTaskNameAlert, animated: true, completion: nil)
    } else if indexPath.row == 1 {
      let pickerController = CalendarPickerViewController(
        baseDate: item.date,
        selectedDateChanged: { [weak self] date in
        guard let self = self else { return }

        self.item.date = date
        self.tableView.reloadRows(at: [IndexPath(row: 1, section: 0)], with: .fade)
        })

      present(pickerController, animated: true, completion: nil)
    }
  }
}
5. MonthMetadata.swift
import Foundation

struct MonthMetadata {
  let numberOfDays: Int
  let firstDay: Date
  let firstDayWeekday: Int
}
6. ChecklistItem.swift
import Foundation

class ChecklistItem: Hashable {
  var id: UUID
  var title: String
  var date: Date
  var completed: Bool

  init(title: String, date: Date, completed: Bool = false) {
    self.id = UUID()
    self.title = title
    self.date = date
    self.completed = completed
  }

  func hash(into hasher: inout Hasher) {
    hasher.combine(id)
  }

  static func == (lhs: ChecklistItem, rhs: ChecklistItem) -> Bool {
    return lhs.id == rhs.id
  }
}

extension ChecklistItem {
  static var exampleItems: [ChecklistItem] = [
    ChecklistItem(title: "Complete the Diffable Data Sources tutorial on raywenderlich.com", date: Date())
  ]
}
7. Day.swift
import Foundation

struct Day {
  // 1
  let date: Date
  // 2
  let number: String
  // 3
  let isSelected: Bool
  // 4
  let isWithinDisplayedMonth: Bool
}
8. CalendarPickerViewController.swift
import UIKit

class CalendarPickerViewController: UIViewController {
  // MARK: Views
  private lazy var dimmedBackgroundView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    view.backgroundColor = UIColor.black.withAlphaComponent(0.3)
    return view
  }()

  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
  }()

  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
    })

  // MARK: Calendar Data Values

  private let selectedDate: Date
  private var baseDate: Date {
    didSet {
      days = generateDaysInMonth(for: baseDate)
      collectionView.reloadData()
      headerView.baseDate = baseDate
    }
  }

  private lazy var days = generateDaysInMonth(for: baseDate)

  private var numberOfWeeksInBaseDate: Int {
    calendar.range(of: .weekOfMonth, in: .month, for: baseDate)?.count ?? 0
  }

  private let selectedDateChanged: ((Date) -> Void)
  private let calendar = Calendar(identifier: .gregorian)

  private lazy var dateFormatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "d"
    return dateFormatter
  }()

  // MARK: Initializers

  init(baseDate: Date, selectedDateChanged: @escaping ((Date) -> Void)) {
    self.selectedDate = baseDate
    self.baseDate = baseDate
    self.selectedDateChanged = selectedDateChanged

    super.init(nibName: nil, bundle: nil)

    modalPresentationStyle = .overCurrentContext
    modalTransitionStyle = .crossDissolve
    definesPresentationContext = true
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  // MARK: View Lifecycle

  override func viewDidLoad() {
    super.viewDidLoad()
    collectionView.backgroundColor = .systemGroupedBackground

    view.addSubview(dimmedBackgroundView)
    view.addSubview(collectionView)
    view.addSubview(headerView)
    view.addSubview(footerView)

    var constraints = [
      dimmedBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
      dimmedBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
      dimmedBackgroundView.topAnchor.constraint(equalTo: view.topAnchor),
      dimmedBackgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
    ]

    constraints.append(contentsOf: [
      //1
      collectionView.leadingAnchor.constraint(
        equalTo: view.readableContentGuide.leadingAnchor),
      collectionView.trailingAnchor.constraint(
        equalTo: view.readableContentGuide.trailingAnchor),
      //2
      collectionView.centerYAnchor.constraint(
        equalTo: view.centerYAnchor,
        constant: 10),
      //3
      collectionView.heightAnchor.constraint(
        equalTo: view.heightAnchor,
        multiplier: 0.5)
    ])

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

    NSLayoutConstraint.activate(constraints)

    collectionView.register(
      CalendarDateCollectionViewCell.self,
      forCellWithReuseIdentifier: CalendarDateCollectionViewCell.reuseIdentifier
    )

    collectionView.dataSource = self
    collectionView.delegate = self
    headerView.baseDate = baseDate
  }

  override func viewWillTransition(
    to size: CGSize,
    with coordinator: UIViewControllerTransitionCoordinator
  ) {
    super.viewWillTransition(to: size, with: coordinator)
    collectionView.reloadData()
  }
}

// MARK: - Day Generation
private extension CalendarPickerViewController {
  // 1
  func monthMetadata(for baseDate: Date) throws -> MonthMetadata {
    // 2
    guard
      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)
  }

  // 1
  func generateDaysInMonth(for baseDate: Date) -> [Day] {
    // 2
    guard let metadata = try? monthMetadata(for: baseDate) else {
      preconditionFailure("An error occurred when generating the metadata for \(baseDate)")
    }

    let numberOfDaysInMonth = metadata.numberOfDays
    let offsetInInitialRow = metadata.firstDayWeekday
    let firstDayOfMonth = metadata.firstDay

    // 3
    var 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)
      }

    days += generateStartOfNextMonth(using: firstDayOfMonth)

    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
    )
  }

  // 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
  }

  enum CalendarDataError: Error {
    case metadataGeneration
  }
}

// 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
    // swiftlint:disable:previous force_cast

    cell.day = day
    return cell
  }
}

// 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)
  }
}
9. CalendarPickerFooterView.swift
import UIKit

class CalendarPickerFooterView: UIView {
  lazy var separatorView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    view.backgroundColor = UIColor.label.withAlphaComponent(0.2)
    return view
  }()

  lazy var previousMonthButton: UIButton = {
    let button = UIButton()
    button.translatesAutoresizingMaskIntoConstraints = false
    button.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
    button.titleLabel?.textAlignment = .left

    if let chevronImage = UIImage(systemName: "chevron.left.circle.fill") {
      let imageAttachment = NSTextAttachment(image: chevronImage)
      let attributedString = NSMutableAttributedString()

      attributedString.append(
        NSAttributedString(attachment: imageAttachment)
      )

      attributedString.append(
        NSAttributedString(string: " Previous")
      )

      button.setAttributedTitle(attributedString, for: .normal)
    } else {
      button.setTitle("Previous", for: .normal)
    }

    button.titleLabel?.textColor = .label

    button.addTarget(self, action: #selector(didTapPreviousMonthButton), for: .touchUpInside)
    return button
  }()

  lazy var nextMonthButton: UIButton = {
    let button = UIButton()
    button.translatesAutoresizingMaskIntoConstraints = false
    button.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
    button.titleLabel?.textAlignment = .right

    if let chevronImage = UIImage(systemName: "chevron.right.circle.fill") {
      let imageAttachment = NSTextAttachment(image: chevronImage)
      let attributedString = NSMutableAttributedString(string: "Next ")

      attributedString.append(
        NSAttributedString(attachment: imageAttachment)
      )

      button.setAttributedTitle(attributedString, for: .normal)
    } else {
      button.setTitle("Next", for: .normal)
    }

    button.titleLabel?.textColor = .label

    button.addTarget(self, action: #selector(didTapNextMonthButton), for: .touchUpInside)
    return button
  }()

  let didTapLastMonthCompletionHandler: (() -> Void)
  let didTapNextMonthCompletionHandler: (() -> Void)

  init(
    didTapLastMonthCompletionHandler: @escaping (() -> Void),
    didTapNextMonthCompletionHandler: @escaping (() -> Void)
  ) {
    self.didTapLastMonthCompletionHandler = didTapLastMonthCompletionHandler
    self.didTapNextMonthCompletionHandler = didTapNextMonthCompletionHandler

    super.init(frame: CGRect.zero)

    translatesAutoresizingMaskIntoConstraints = false
    backgroundColor = .systemGroupedBackground

    layer.maskedCorners = [
      .layerMinXMaxYCorner,
      .layerMaxXMaxYCorner
    ]
    layer.cornerCurve = .continuous
    layer.cornerRadius = 15

    addSubview(separatorView)
    addSubview(previousMonthButton)
    addSubview(nextMonthButton)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  private var previousOrientation: UIDeviceOrientation = UIDevice.current.orientation

  override func layoutSubviews() {
    super.layoutSubviews()

    let smallDevice = UIScreen.main.bounds.width <= 350

    let fontPointSize: CGFloat = smallDevice ? 14 : 17

    previousMonthButton.titleLabel?.font = .systemFont(ofSize: fontPointSize, weight: .medium)
    nextMonthButton.titleLabel?.font = .systemFont(ofSize: fontPointSize, weight: .medium)

    NSLayoutConstraint.activate([
      separatorView.leadingAnchor.constraint(equalTo: leadingAnchor),
      separatorView.trailingAnchor.constraint(equalTo: trailingAnchor),
      separatorView.topAnchor.constraint(equalTo: topAnchor),
      separatorView.heightAnchor.constraint(equalToConstant: 1),

      previousMonthButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
      previousMonthButton.centerYAnchor.constraint(equalTo: centerYAnchor),

      nextMonthButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
      nextMonthButton.centerYAnchor.constraint(equalTo: centerYAnchor)
    ])
  }

  @objc func didTapPreviousMonthButton() {
    didTapLastMonthCompletionHandler()
  }

  @objc func didTapNextMonthButton() {
    didTapNextMonthCompletionHandler()
  }
}
10. CalendarPickerHeaderView.swift
import UIKit

class CalendarPickerHeaderView: UIView {
  lazy var monthLabel: UILabel = {
    let label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.font = .systemFont(ofSize: 26, weight: .bold)
    label.text = "Month"
    label.accessibilityTraits = .header
    label.isAccessibilityElement = true
    return label
  }()

  lazy var closeButton: UIButton = {
    let button = UIButton()
    button.translatesAutoresizingMaskIntoConstraints = false

    let configuration = UIImage.SymbolConfiguration(scale: .large)
    let image = UIImage(systemName: "xmark.circle.fill", withConfiguration: configuration)
    button.setImage(image, for: .normal)

    button.tintColor = .secondaryLabel
    button.contentMode = .scaleAspectFill
    button.isUserInteractionEnabled = true
    button.isAccessibilityElement = true
    button.accessibilityLabel = "Close Picker"
    return button
  }()

  lazy var dayOfWeekStackView: UIStackView = {
    let stackView = UIStackView()
    stackView.translatesAutoresizingMaskIntoConstraints = false
    stackView.distribution = .fillEqually
    return stackView
  }()

  lazy var separatorView: UIView = {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    view.backgroundColor = UIColor.label.withAlphaComponent(0.2)
    return view
  }()

  private lazy var dateFormatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.calendar = Calendar(identifier: .gregorian)
    dateFormatter.locale = Locale.autoupdatingCurrent
    dateFormatter.setLocalizedDateFormatFromTemplate("MMMM y")
    return dateFormatter
  }()

  var baseDate = Date() {
    didSet {
      monthLabel.text = dateFormatter.string(from: baseDate)
    }
  }

  var exitButtonTappedCompletionHandler: (() -> Void)

  init(exitButtonTappedCompletionHandler: @escaping (() -> Void)) {
    self.exitButtonTappedCompletionHandler = exitButtonTappedCompletionHandler

    super.init(frame: CGRect.zero)

    translatesAutoresizingMaskIntoConstraints = false

    backgroundColor = .systemGroupedBackground

    layer.maskedCorners = [
      .layerMinXMinYCorner,
      .layerMaxXMinYCorner
    ]
    layer.cornerCurve = .continuous
    layer.cornerRadius = 15

    addSubview(monthLabel)
    addSubview(closeButton)
    addSubview(dayOfWeekStackView)
    addSubview(separatorView)

    for dayNumber in 1...7 {
      let dayLabel = UILabel()
      dayLabel.font = .systemFont(ofSize: 12, weight: .bold)
      dayLabel.textColor = .secondaryLabel
      dayLabel.textAlignment = .center
      dayLabel.text = dayOfWeekLetter(for: dayNumber)

      // VoiceOver users don't need to hear these days of the week read to them, nor do SwitchControl or Voice Control users need to select them
      // If fact, they get in the way!
      // When a VoiceOver user highlights a day of the month, the day of the week is read to them.
      // That method provides the same amount of context as this stack view does to visual users
      dayLabel.isAccessibilityElement = false
      dayOfWeekStackView.addArrangedSubview(dayLabel)
    }

    closeButton.addTarget(self, action: #selector(didTapExitButton), for: .touchUpInside)
  }

  @objc func didTapExitButton() {
    exitButtonTappedCompletionHandler()
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  private func dayOfWeekLetter(for dayNumber: Int) -> String {
    switch dayNumber {
    case 1:
      return "S"
    case 2:
      return "M"
    case 3:
      return "T"
    case 4:
      return "W"
    case 5:
      return "T"
    case 6:
      return "F"
    case 7:
      return "S"
    default:
      return ""
    }
  }

  override func layoutSubviews() {
    super.layoutSubviews()

    NSLayoutConstraint.activate([
      monthLabel.topAnchor.constraint(equalTo: topAnchor, constant: 15),
      monthLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 15),
      monthLabel.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: 5),

      closeButton.centerYAnchor.constraint(equalTo: monthLabel.centerYAnchor),
      closeButton.heightAnchor.constraint(equalToConstant: 28),
      closeButton.widthAnchor.constraint(equalToConstant: 28),
      closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -15),

      dayOfWeekStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
      dayOfWeekStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
      dayOfWeekStackView.bottomAnchor.constraint(equalTo: separatorView.bottomAnchor, constant: -5),

      separatorView.leadingAnchor.constraint(equalTo: leadingAnchor),
      separatorView.trailingAnchor.constraint(equalTo: trailingAnchor),
      separatorView.bottomAnchor.constraint(equalTo: bottomAnchor),
      separatorView.heightAnchor.constraint(equalToConstant: 1)
    ])
  }
}
11. CalendarDateCollectionViewCell.swift
import UIKit

class CalendarDateCollectionViewCell: UICollectionViewCell {
  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
  }()

  static let reuseIdentifier = String(describing: CalendarDateCollectionViewCell.self)

  var day: Day? {
    didSet {
      guard let day = day else { return }

      numberLabel.text = day.number
      accessibilityLabel = accessibilityDateFormatter.string(from: day.date)
      updateSelectionStatus()
    }
  }

  override init(frame: CGRect) {
    super.init(frame: frame)

    isAccessibilityElement = true
    accessibilityTraits = .button

    contentView.addSubview(selectionBackgroundView)
    contentView.addSubview(numberLabel)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func layoutSubviews() {
    super.layoutSubviews()

    // 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
  }

  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)

    layoutSubviews()
  }
}

// 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
  }
}
12. String+Truncate.swift
import Foundation

extension String {
  func truncatedPrefix(_ maxLength: Int, using truncator: String = "...") -> String {
    "\(prefix(maxLength))\(truncator)"
  }
}

后记

本篇主要讲述了自定义Calendar Control的简单示例的简单示例,感兴趣的给个赞或者关注~~~

你可能感兴趣的:(UIKit框架(四十八) —— 自定义Calendar Control的简单示例(二))