一个声明式且高性能的日历UI组件,支持从简单的日期选择器到功能齐全的日历应用程序的各种用例。
HorizonCalendar
是一个用于 iOS 的声明式且高性能的日历 UI 组件。它提供了许多自定义点,以支持各种各样的设计和用例,并且被用于实现 Airbnb iOS 应用程序中的每个日历和日期选择器。
特性
Foundation.Calendar
的所有日历(公历、日语、希伯来语等)搜索 | 住宿可用性日历 | 愿望清单 | 体验预订 | 体验房东日历管理 |
---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
提供了一个示例应用程序来展示并使您能够测试 HorizonCalendar
的一些功能。它可以在 ./Example/HorizonCalendarExample.xcworkspace
中找到。
注意:请务必使用 .xcworkspace
文件,而不是 .xcodeproj
文件,因为后者无法访问 HorizonCalendar.framework
。
该示例应用程序有几个演示视图控制器可以尝试,具有垂直和水平布局变体
垂直 | 水平 |
---|---|
![]() |
![]() |
垂直 | 水平 |
---|---|
![]() |
![]() |
垂直 | 水平 |
---|---|
![]() |
![]() |
垂直 | 水平 |
---|---|
![]() |
![]() |
要使用 Swift Package Manager 安装 HorizonCalendar
,请将 .package(name: "HorizonCalendar", url: "https://github.com/airbnb/HorizonCalendar.git", from: "1.0.0"),"
添加到您的 Package.swift,然后按照 此处的 集成教程进行操作。
要使用 Carthage 安装 HorizonCalendar
,请将 github "airbnb/HorizonCalendar"
添加到您的 Cartfile,然后按照 此处的 集成教程进行操作。
要使用 CocoaPods 安装 HorizonCalendar
,请将 pod 'HorizonCalendar'
添加到您的 Podfile,然后按照 此处的 集成教程进行操作。
将 HorizonCalendar
安装到您的项目后,只需几个步骤即可使基本日历正常工作。
在您要使用 HorizonCalendar
的文件顶部,导入 HorizonCalendar
import HorizonCalendar
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)
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 视图一样将您的日历添加到视图层次结构中。由于日历没有固有的内容大小,因此您需要使用 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)
}
将您的日历添加为子视图,然后使用自动布局或手动设置其 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),
])
此时,构建并运行您的应用程序应该产生如下所示的结果
HorizonCalendar
带有月份标题、星期几项目和日期项目的默认视图。您还可以为每种项目类型提供自定义视图,从而使您能够显示对您的应用程序有意义的任何自定义内容。
让我们首先自定义用于每一天的视图
由于 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 将无法将该状态识别为您视图的依赖项,除非它在您视图的初始主体评估期间被读取。这将导致状态更改时错过更新。
由于 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
}
类似的项提供程序函数可用于自定义用于月份标题、星期几项目等的视图。
如果您构建并运行您的应用程序,它现在应该看起来像这样
我们可以通过在各个日期和月份之间添加一些额外的间距来改善当前日历的布局
CalendarViewRepresentable(...)
.days { ... }
.interMonthSpacing(24)
.verticalDayMargin(8)
.horizontalDayMargin(8)
return CalendarViewContent(...)
.dayItemProvider { ... }
.interMonthSpacing(24)
.verticalDayMargin(8)
.horizontalDayMargin(8)
就像我们通过 day provider 配置自定义的日视图一样,布局指标的更改也是通过 CalendarViewContent
完成的。interMonthSpacing(_:)
、verticalDayMargin(_:)
和 horizontalDayMargin(_:)
都会返回一个经过修改的 CalendarViewContent
,其中相应的布局指标值已更新,使您可以将函数调用链接在一起以生成最终的内容实例。
构建并运行您的应用程序后,您应该会看到一个不那么拥挤的布局
日期范围指示器对于需要突出显示不仅是单个日期,而是日期范围的日历非常有用。为此,我们可以创建一个自定义视图来表示整个高亮区域,然后将该视图提供给日历,用于我们关心的日期范围。
首先,我们需要创建自定义的日期范围指示器视图。此视图负责绘制特定日期范围的整个高亮区域,该区域可能跨越多个星期、月份甚至年份。我们将使用 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
接下来,我们将在 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
中更新,以防止视图重用问题。
接下来,我们需要在我们的 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 闭包返回一个代表我们的 DayRangeIndicatorView
的 CalendarItemModel
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
HorizonCalendar
提供了一个 API,用于在每个月份后面添加一个装饰性背景。通过使用包含的 MonthGridBackgroundView
,我们可以轻松地向日历中的每个月份添加网格线
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
中更新,以防止视图重用问题。
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 闭包。此布局上下文包含有关月份中元素的大小和位置的信息。使用此信息,您可以绘制网格线、边框、背景等。
如果您正在构建日期选择器,您很可能需要响应用户在日历中点击日期。
在 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 中,通过设置 CalendarView
的 daySelectionHandler
来提供日期选择处理程序闭包
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)
}
构建并运行应用程序后,点击日期应该会使它们变成蓝色
如果您想了解 HorizonCalendar
是如何实现的,请查看 技术细节 文档。它提供了 HorizonCalendar
架构的概述,以及有关为什么它没有使用 UICollectionView
实现的信息。
HorizonCalendar
欢迎修复、改进和功能添加。如果您想贡献,请打开一个拉取请求,其中包含对您所做更改的详细描述。
作为经验法则,如果您要提出一个破坏 API 的更改或对现有功能的更改,请考虑通过打开一个 issue 而不是 pull request 来提出它;我们将使用该 issue 作为公共论坛来讨论该提案是否有意义。有关更多详细信息,请参见 CONTRIBUTING。
Bryan Keller
Bryan Keller
Bryn Bodayle
如果您或您的公司发现 HorizonCalendar
很有用,请告诉我们!
HorizonCalendar
根据 Apache License 2.0 发布。有关详细信息,请参见 LICENSE。