HorizonCalendar

一个声明式且高性能的日历UI组件,支持从简单的日期选择器到功能齐全的日历应用程序的各种用例。

Swift Package Manager compatible Carthage compatible Version License Platform Swift Swift Package Manager compatible

简介

HorizonCalendar 是一个用于 iOS 的声明式且高性能的日历 UI 组件。它提供了许多自定义点,以支持各种各样的设计和用例,并且被用于实现 Airbnb iOS 应用程序中的每个日历和日期选择器。

特性

搜索 住宿可用性日历 愿望清单 体验预订 体验房东日历管理
Search Stay Availability Calendar Wish List Experience Reservation Experience Host Calendar Management

目录

示例应用程序

提供了一个示例应用程序来展示并使您能够测试 HorizonCalendar 的一些功能。它可以在 ./Example/HorizonCalendarExample.xcworkspace 中找到。

注意:请务必使用 .xcworkspace 文件,而不是 .xcodeproj 文件,因为后者无法访问 HorizonCalendar.framework

演示

该示例应用程序有几个演示视图控制器可以尝试,具有垂直和水平布局变体

Demo Picker

单日选择

垂直 水平
Single Day Selection Vertical Single Day Selection Horizontal

日期范围选择

垂直 水平
Day Range Selection Vertical Day Range Selection Horizontal

所选日期工具提示

垂直 水平
Selected Day Tooltip Vertical Selected Day Tooltip Horizontal

滚动到带动画的日期

垂直 水平
Scroll to Day with Animation Vertical Scroll to Day with Animation Horizontal

集成教程

要求

安装

Swift Package Manager

要使用 Swift Package Manager 安装 HorizonCalendar,请将 .package(name: "HorizonCalendar", url: "https://github.com/airbnb/HorizonCalendar.git", from: "1.0.0")," 添加到您的 Package.swift,然后按照 此处的 集成教程进行操作。

Carthage

要使用 Carthage 安装 HorizonCalendar,请将 github "airbnb/HorizonCalendar" 添加到您的 Cartfile,然后按照 此处的 集成教程进行操作。

CocoaPods

要使用 CocoaPods 安装 HorizonCalendar,请将 pod 'HorizonCalendar' 添加到您的 Podfile,然后按照 此处的 集成教程进行操作。

创建一个日历

HorizonCalendar 安装到您的项目后,只需几个步骤即可使基本日历正常工作。

基本设置

导入 HorizonCalendar

在您要使用 HorizonCalendar 的文件顶部,导入 HorizonCalendar

import HorizonCalendar 

实例化视图

SwiftUI

CalendarViewRepresentable 是代表日历的 SwiftUI 视图类型。与其他 SwiftUI 视图一样,所有自定义都通过初始化程序参数和修饰符完成。要创建一个基本日历,您可以使用一些初始数据初始化一个 CalendarViewRepresentable

let calendar = Calendar.current

let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 01))!
let endDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 31))!

CalendarViewRepresentable(
  calendar: calendar,
  visibleDateRange: startDate...endDate,
  monthsLayout: .vertical(options: VerticalMonthsLayoutOptions()),
  dataDependency: nil)
UIKit

CalendarView 是渲染日历的 UIView 子类。 CalendarView 的所有视觉方面都通过单个类型 CalendarViewContent 控制。要创建一个基本的 CalendarView,您可以使用一个初始的 CalendarViewContent 来初始化一个

let calendarView = CalendarView(initialContent: makeContent())
private func makeContent() -> CalendarViewContent {
  let calendar = Calendar.current

  let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 01))!
  let endDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 31))!

  return CalendarViewContent(
    calendar: calendar,
    visibleDateRange: startDate...endDate,
    monthsLayout: .vertical(options: VerticalMonthsLayoutOptions()))
}

至少,CalendarViewContent 必须使用 Calendar、可见日期范围和月份布局(垂直或水平)进行初始化。可见日期范围将被解释为使用为 calendar 参数传入的 Calendar 实例的日期范围。

在此示例中,我们使用公历、2020-01-01 至 2021-12-31 的日期范围和垂直月份布局。

确保将 calendarView 添加为子视图,然后使用自动布局或手动设置其 frame 属性来为其提供有效的框架。如果您正在使用自动布局,请注意 CalendarView 没有固有的内容大小。

view.addSubview(calendarView)

calendarView.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
  calendarView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
  calendarView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
  calendarView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
  calendarView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor),
])

至少,您需要提供一个 Calendar、一个可见的日期范围和一个月份布局(垂直或水平)。可见日期范围将被解释为使用为 calendar 参数传入的 Calendar 实例的日期范围。

在此示例中,我们使用公历、2020-01-01 至 2021-12-31 的日期范围和垂直月份布局。

添加视图

接下来,我们将日历添加到视图层次结构中。

SwiftUI

像任何其他 SwiftUI 视图一样将您的日历添加到视图层次结构中。由于日历没有固有的内容大小,因此您需要使用 frame 修饰符来告诉 SwiftUI 它应该消耗所有垂直和水平空间。可以选择使用 layoutMargins 修饰符来应用内部填充,并使用普通的 SwiftUI padding 修饰符来应用一些来自父边缘的外部填充。

var body: some View {
  CalendarViewRepresentable(...)
    .layoutMargins(.init(top: 8, leading: 8, bottom: 8, trailing: 8))
    .padding(.horizontal, 16)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
}
UIKit

将您的日历添加为子视图,然后使用自动布局或手动设置其 frame 属性来为其提供有效的框架。如果您正在使用自动布局,请注意 CalendarView 没有固有的内容大小。

view.addSubview(calendarView)

calendarView.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
  calendarView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
  calendarView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
  calendarView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
  calendarView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor),
])

此时,构建并运行您的应用程序应该产生如下所示的结果

Basic Calendar Screenshot

自定义

为每一天提供一个自定义视图

HorizonCalendar 带有月份标题、星期几项目和日期项目的默认视图。您还可以为每种项目类型提供自定义视图,从而使您能够显示对您的应用程序有意义的任何自定义内容。

让我们首先自定义用于每一天的视图

SwiftUI

由于 CalendarViewRepresentable 的所有视觉方面都通过修饰符配置,因此我们将使用 days 修饰符为日历中的每一天提供带有圆形边框的自定义视图

CalendarViewRepresentable(...)

  .days { day in
    Text("\(day.day)")
      .font(.system(size: 18))
      .foregroundColor(Color(UIColor.label))
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .overlay {
        RoundedRectangle(cornerRadius: 12)
          .stroke(Color(UIColor.systemBlue), lineWidth: 1)
      }
  }

要使用 UIKit 视图,请使用 UIViewRepresentable 包装它,并从同一函数返回它。

注意

视图提供程序闭包在日历的各个部分进入视图时以延迟方式调用。如果您在任何视图提供程序闭包中读取任何视图状态,请确保使用捕获列表显式捕获它。如果您不这样做,SwiftUI 将无法将该状态识别为您视图的依赖项,除非它在您视图的初始主体评估期间被读取。这将导致状态更改时错过更新。

UIKit

由于 CalendarView 的所有视觉方面都通过 CalendarViewContent 配置,因此我们将扩展我们的 makeContent 函数。让我们首先为日历中的每一天提供一个自定义视图

private func makeContent() -> CalendarViewContent {
  return CalendarViewContent(...)
    .dayItemProvider { day in
      // Return a `CalendarItemModel` representing the view for each day
    }
}

CalendarViewContent 上的 dayItemProvider(_:) 函数返回一个新的 CalendarViewContent 实例,其中配置了自定义日期项目模型提供程序。此函数采用一个参数 - 一个提供程序闭包,该闭包为给定的 DayComponents 返回一个 CalendarItemModel

CalendarItemModel 是一种抽象在日历中显示的视图的创建和配置的类型。它对于 ViewRepresentable 类型是通用的,它可以是任何符合 CalendarItemViewRepresentable 的类型。您可以将 CalendarItemViewRepresentable 视为创建和更新要在日历中显示的特定类型视图的实例的蓝图。例如,如果我们想使用 UILabel 作为具有圆形边框的自定义日期视图,我们将需要创建一个知道如何创建和更新该标签的类型。这是一个简单的例子

import HorizonCalendar

struct DayLabel: CalendarItemViewRepresentable {

  /// Properties that are set once when we initialize the view.
  struct InvariantViewProperties: Hashable {
    let font: UIFont
    let textColor: UIColor
    let borderColor: UIColor
  }

  /// Properties that will vary depending on the particular date being displayed.
  struct Content: Equatable {
    let day: DayComponents
  }

  static func makeView(
    withInvariantViewProperties invariantViewProperties: InvariantViewProperties)
    -> UILabel
  {
    let label = UILabel()
    
    label.isUserInteractionEnabled = true
    label.layer.borderWidth = 1
    label.layer.borderColor = invariantViewProperties.borderColor.cgColor
    label.font = invariantViewProperties.font
    label.textColor = invariantViewProperties.textColor

    label.textAlignment = .center
    label.clipsToBounds = true
    label.layer.cornerRadius = 12
    
    return label
  }

  static func setContent(_ content: Content, on view: UILabel) {
    view.text = "\(content.day.day)"
  }

}

CalendarItemViewRepresentable 要求我们实现一个 static makeView 函数,该函数应根据一组不变的视图属性创建并返回一个视图。我们希望我们的标签具有可配置的字体和文本颜色,因此我们通过 InvariantViewProperties 类型使这些可配置。在我们的 makeView 函数中,我们使用这些不变的视图属性来创建和配置标签的实例。

CalendarItemViewRepresentable 还要求我们实现一个 static setContent 函数,该函数应更新提供的视图上的所有数据相关属性(如日期文本)。

现在我们有了一个符合 CalendarItemViewRepresentable 的类型,我们可以使用它来创建一个 CalendarItemModel 以从日期项目模型提供程序返回

return CalendarViewContent(...)

  .dayItemProvider { day in
    DayLabel.calendarItemModel(
      invariantViewProperties: .init(
        font: .systemFont(ofSize: 18), 
        textColor: .label,
        borderColor: .systemBlue),
      content: .init(day: day))
  }

使用 SwiftUI 视图甚至更容易 - 只需初始化您的 SwiftUI 视图并对其调用 .calendarItemModel。无需创建像我们上面在 UIKit 示例中所做的那样符合 CalendarItemViewRepresentable 的自定义类型,或者为不变和变体(内容)视图属性提供单独的概念。

return CalendarViewContent(...)

  .dayItemProvider { day in
    Text("\(day.day)")
      .font(.system(size: 18))
      .foregroundColor(Color(UIColor.label))
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .overlay {
        RoundedRectangle(cornerRadius: 12)
          .stroke(Color(UIColor.systemBlue), lineWidth: 1)
      }
      .calendarItemModel
  }

类似的项提供程序函数可用于自定义用于月份标题、星期几项目等的视图。

如果您构建并运行您的应用程序,它现在应该看起来像这样

Custom Day Views Screenshot

调整布局指标

我们可以通过在各个日期和月份之间添加一些额外的间距来改善当前日历的布局

SwiftUI
CalendarViewRepresentable(...)
  .days { ... }

  .interMonthSpacing(24)
  .verticalDayMargin(8)
  .horizontalDayMargin(8)
UIKit
return CalendarViewContent(...)
  .dayItemProvider { ... }

  .interMonthSpacing(24)
  .verticalDayMargin(8)
  .horizontalDayMargin(8)

就像我们通过 day provider 配置自定义的日视图一样,布局指标的更改也是通过 CalendarViewContent 完成的。interMonthSpacing(_:)verticalDayMargin(_:)horizontalDayMargin(_:) 都会返回一个经过修改的 CalendarViewContent,其中相应的布局指标值已更新,使您可以将函数调用链接在一起以生成最终的内容实例。

构建并运行您的应用程序后,您应该会看到一个不那么拥挤的布局

Custom Layout Metrics Screenshot

添加日期范围指示器

日期范围指示器对于需要突出显示不仅是单个日期,而是日期范围的日历非常有用。为此,我们可以创建一个自定义视图来表示整个高亮区域,然后将该视图提供给日历,用于我们关心的日期范围。

首先,我们需要创建自定义的日期范围指示器视图。此视图负责绘制特定日期范围的整个高亮区域,该区域可能跨越多个星期、月份甚至年份。我们将使用 UIKit 和 Core Graphics 来实现这一点,但也可以很容易地在 SwiftUI 中完成

import UIKit

final class DayRangeIndicatorView: UIView {

  private let indicatorColor: UIColor

  init(indicatorColor: UIColor) {
    self.indicatorColor = indicatorColor
    super.init(frame: .zero)
    backgroundColor = .clear
  }

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

  var framesOfDaysToHighlight = [CGRect]() {
    didSet {
      guard framesOfDaysToHighlight != oldValue else { return }
      setNeedsDisplay()
    }
  }

  override func draw(_ rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()
    context?.setFillColor(indicatorColor.cgColor)

    // Get frames of day rows in the range
    var dayRowFrames = [CGRect]()
    var currentDayRowMinY: CGFloat?
    for dayFrame in framesOfDaysToHighlight {
      if dayFrame.minY != currentDayRowMinY {
        currentDayRowMinY = dayFrame.minY
        dayRowFrames.append(dayFrame)
      } else {
        let lastIndex = dayRowFrames.count - 1
        dayRowFrames[lastIndex] = dayRowFrames[lastIndex].union(dayFrame)
      }
    }

    // Draw rounded rectangles for each day row
    for dayRowFrame in dayRowFrames {
      let roundedRectanglePath = UIBezierPath(roundedRect: dayRowFrame, cornerRadius: 12)
      context?.addPath(roundedRectanglePath.cgPath)
      context?.fillPath()
    }
  }

}

接下来,我们需要创建一个 ClosedRange<Date>,它表示我们想要显示日期范围指示器视图的日期范围。我们范围内的 Date 将使用我们最初设置日历时使用的 Calendar 实例解释为 DayComponents

let lowerDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 20))!
let upperDate = calendar.date(from: DateComponents(year: 2020, month: 02, day: 07))!
let dateRangeToHighlight = lowerDate...upperDate
SwiftUI

接下来,我们将在 CalendarViewRepresentable 上使用 dayRanges 修饰符

CalendarViewRepresentable(...)
  ...
  
  .dayRanges(for: [dateRangeToHighlight]) { dayRangeLayoutContext in 
    DayRangeIndicatorViewRepresentable(
      framesOfDaysToHighlight: dayRangeLayoutContext.daysAndFrames.map { $0.frame })
  }

对于从传递到此修饰符的 Set<ClosedRange<Date>> 派生的每个日期范围,我们的日期范围 provider 闭包将被调用,其中包含我们需要创建用于突出显示特定日期范围的视图的所有信息。由于 DayRangeIndicatorView 是一个 UIView,我们需要使用 UIViewRepresentable 将其桥接到 SwiftUI。

struct DayRangeIndicatorViewRepresentable: UIViewRepresentable {

  let framesOfDaysToHighlight: [CGRect]

  func makeUIView(context: Context) -> DayRangeIndicatorView {
    DayRangeIndicatorView(indicatorColor: UIColor.systemBlue.withAlphaComponent(0.15))
  }

  func updateUIView(_ uiView: DayRangeIndicatorView, context: Context) {
    uiView.framesOfDaysToHighlight = framesOfDaysToHighlight
  }

}

注意

UIViewRepresentable 中包装 UIKit 视图时,没有不变视图属性的等效概念;所有可自定义的属性都必须在 updateUIView 中更新,以防止视图重用问题。

UIKit

接下来,我们需要在我们的 CalendarViewContent 上调用 dayRangeItemProvider(for:_:)

  return CalendarViewContent(...)
    ...
    
    .dayRangeItemProvider(for: [dateRangeToHighlight]) { dayRangeLayoutContext in 
      // Return a `CalendarItemModel` representing the view that highlights the entire day range
    }

对于从传递给此函数的 Set<ClosedRange<Date>> 派生的每个日期范围,我们的日期范围项目模型 provider 闭包将被调用,其中包含我们需要渲染用于突出显示特定日期范围的视图的所有信息。这是一个此类视图的示例实现

接下来,我们需要一个符合 CalendarItemViewRepresentable 的类型,该类型知道如何创建和更新 DayRangeIndicatorView 的实例。为了方便起见,我们可以直接让我们的视图符合此协议

import HorizonCalendar

extension DayRangeIndicatorView: CalendarItemViewRepresentable {

  struct InvariantViewProperties: Hashable {
    let indicatorColor: UIColor
  }

  struct Content: Equatable {
    let framesOfDaysToHighlight: [CGRect]
  }

  static func makeView(
    withInvariantViewProperties invariantViewProperties: InvariantViewProperties)
    -> DayRangeIndicatorView
  {
    DayRangeIndicatorView(indicatorColor: invariantViewProperties.indicatorColor)
  }

  static func setContent(_ content: Content, on view: DayRangeIndicatorView) {
    view.framesOfDaysToHighlight = content.framesOfDaysToHighlight
  }

}

最后,我们需要从日期范围项目模型 provider 闭包返回一个代表我们的 DayRangeIndicatorViewCalendarItemModel

  return CalendarViewContent(...)
    ...
    
    .dayRangeItemProvider(for: [dateRangeToHighlight]) { dayRangeLayoutContext in
      DayRangeIndicatorView.calendarItemModel(
        invariantViewProperties: .init(indicatorColor: UIColor.blue.withAlphaComponent(0.15)),
        content: .init(framesOfDaysToHighlight: dayRangeLayoutContext.daysAndFrames.map { $0.frame }))
    }

如果构建并运行该应用程序,您应该会看到一个日期范围指示器视图,突出显示 2020-01-20 到 2020-02-07

Day Range Indicator Screenshot

添加网格线

HorizonCalendar 提供了一个 API,用于在每个月份后面添加一个装饰性背景。通过使用包含的 MonthGridBackgroundView,我们可以轻松地向日历中的每个月份添加网格线

SwiftUI
CalendarViewRepresentable(...)

  .monthBackgrounds { monthLayoutContext in
    MonthGridBackgroundViewRepresentable(
      framesOfDays: monthLayoutContext.daysAndFrames.map { $0.frame })
  }

由于 MonthGridBackgroundView 是一个 UIView,我们需要使用 UIViewRepresentable 将其桥接到 SwiftUI。

struct MonthGridBackgroundViewRepresentable: UIViewRepresentable {

  let framesOfDays: [CGRect]

  func makeUIView(context: Context) -> MonthGridBackgroundView {
    MonthGridBackgroundView(
      invariantViewProperties: .init(horizontalDayMargin: 8, verticalDayMargin: 8))
  }

  func updateUIView(_ uiView: MonthGridBackgroundView, context: Context) {
    uiView.framesOfDays = framesOfDays
  }

}

注意

UIViewRepresentable 中包装 UIKit 视图时,没有不变视图属性的等效概念;所有可自定义的属性都必须在 updateUIView 中更新,以防止视图重用问题。

UIKit
return CalendarViewContent(...)
  
  .monthBackgroundItemProvider { monthLayoutContext in
    MonthGridBackgroundView.calendarItemModel(
      invariantViewProperties: .init(horizontalDayMargin: 8, verticalDayMargin: 8),
      content: .init(framesOfDays: monthLayoutContext.daysAndFrames.map { $0.frame }))
  }

月份背景 provider 的工作方式与 overlay provider 和日期范围 provider 类似;对于日历中的每个月份,将使用布局上下文调用 provider 闭包。此布局上下文包含有关月份中元素的大小和位置的信息。使用此信息,您可以绘制网格线、边框、背景等。

Grid Background Screenshot

响应日期选择

如果您正在构建日期选择器,您很可能需要响应用户在日历中点击日期。

SwiftUI

在 SwiftUI 中,响应日期选择很容易。

首先,为当前选定的日期定义一个状态属性

@State var selectedDate: Date?

然后,使用 onDaySelection 修饰符更新选定的日期

CalendarViewRepresentable(...)
  ...
  
  .onDaySelection { day in
    selectedDate = calendar.date(from: day.components)
  }

最后,在您的日期 provider 闭包中返回一个不同的视图

CalendarViewRepresentable(...)
  ...
  
  .days { [selectedDate] day in
    let date = calendar.date(from: day.components)
    let borderColor: UIColor = date == selectedDate ? .systemRed : .systemBlue
    
    Text("\(day.day)")
      .font(.system(size: 18))
      .foregroundColor(Color(UIColor.label))
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .overlay {
        RoundedRectangle(cornerRadius: 12)
          .stroke(Color(borderColor), lineWidth: 1)
      }
  }

注意

视图提供程序闭包在日历的各个部分进入视图时以延迟方式调用。如果您在任何视图提供程序闭包中读取任何视图状态,请确保使用捕获列表显式捕获它。如果您不这样做,SwiftUI 将无法将该状态识别为您视图的依赖项,除非它在您视图的初始主体评估期间被读取。这将导致状态更改时错过更新。

UIKit

在 UIKit 中,通过设置 CalendarViewdaySelectionHandler 来提供日期选择处理程序闭包

calendarView.daySelectionHandler = { [weak self] day in
  self?.selectedDate = calendar.date(from: day.components)
}
private var selectedDate: Date?

每当选择日历中的某一天时,都会调用日期选择处理程序闭包。您将获得所选日期的 DayComponents 实例。如果我们想要在点击后突出显示选定的日期,我们需要创建一个新的 CalendarViewContent,其中包含对于选定日期看起来不同的日历项目模型

let selectedDay = self.selectedDay

return CalendarViewContent(...)
  ...

  .dayItemProvider { [selectedDate] day in
    let date = calendar.date(from: day.components)
    let borderColor: UIColor = date == selectedDate ? .systemRed : .systemBlue

    return DayLabel.calendarItemModel(
      invariantViewProperties: .init(
        font: .systemFont(ofSize: 18),
        textColor: .label,
        borderColor: borderColor),
      content: .init(day: day))
  }

最后,我们将更改我们的日期选择处理程序,使其不仅存储选定的日期,还在 calendarView 上设置更新的内容实例

calendarView.daySelectionHandler = { [weak self] day in
  guard let self else { return }

  selectedDate = calendar.date(from: day.components)

  let newContent = makeContent()
  calendarView.setContent(newContent)
}

构建并运行应用程序后,点击日期应该会使它们变成蓝色

Day Selection Screenshot

技术细节

如果您想了解 HorizonCalendar 是如何实现的,请查看 技术细节 文档。它提供了 HorizonCalendar 架构的概述,以及有关为什么它没有使用 UICollectionView 实现的信息。

贡献

HorizonCalendar 欢迎修复、改进和功能添加。如果您想贡献,请打开一个拉取请求,其中包含对您所做更改的详细描述。

作为经验法则,如果您要提出一个破坏 API 的更改或对现有功能的更改,请考虑通过打开一个 issue 而不是 pull request 来提出它;我们将使用该 issue 作为公共论坛来讨论该提案是否有意义。有关更多详细信息,请参见 CONTRIBUTING

作者

Bryan Keller

维护者

Bryan Keller

Bryn Bodayle

如果您或您的公司发现 HorizonCalendar 很有用,请告诉我们!

许可证

HorizonCalendar 根据 Apache License 2.0 发布。有关详细信息,请参见 LICENSE