HorizonCalendar
一个声明式、高性能的日历 UI 组件,支持从简单的日期选择器到功能齐全的日历应用程序的各种用例。
简介
HorizonCalendar
是一个适用于 iOS 的交互式日历组件(与 UIKit 和 SwiftUI 兼容)。其声明式 API 使得更新日历变得简单,同时提供了许多自定义点以支持各种设计和用例。
特性
- 支持从
Foundation.Calendar
(如公历、日本、希伯来等)的所有日历 - 以垂直滚动或水平滚动布局显示月份
- 声明式 API 允许单向数据流更新日历内容
- 一个定制的布局系统,可以在不增加内存使用的情况下几乎无限地注册日期范围
- 水平滚动日历的分页
- 为单个日期、月标题和周几指定自定义视图(
UIView
或 SwiftUIView
) - 为突出日期范围指定自定义视图(
UIView
或 SwiftUIView
) - 为日历的覆盖部分指定自定义视图(
UIView
或 SwiftUIView
),例如工具提示功能 - 为月背景装饰指定自定义视图(
UIView
或 SwiftUIView
)(颜色、网格等) - 为日背景装饰指定自定义视图(
UIView
或 SwiftUIView
)(颜色、图案等) - 一个天选择器来监视何时点击了某一天
- 可定制的布局度量
- 使用布局边距将周几行固定在顶部
- 显示部分边界月份(例如,2020-03-14 到 2020-04-20)
- 滚动到任意日期和月份,带或不带动画
- 强大的无障碍支持
- 使用布局边距凹进内容,而不影响可滚动区域
- 在周几行下方设置分隔线
- 支持从右到左的布局
HorizonCalendar
是 Airbnb 最热门流中使用的日期选择器和日历的基础。
搜索 | 住宿可用性日历 | 愿望列表 | 体验预订 | 体验房东日历管理 |
---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
目录
示例应用
有可供展示和测试HorizonCalendar
一些功能的示例应用。它位于./Example/HorizonCalendarExample.xcworkspace
中。
注意:请务必使用.xcworkspace
文件,而不是.xcodeproj
文件,因为后者无法访问HorizonCalendar.framework
。
演示
示例应用有几个演示视图控制器,包括垂直和水平布局变体以供尝试。
单日选择
垂直 | 水平 |
---|---|
![]() |
![]() |
日期范围选择
垂直 | 水平 |
---|---|
![]() |
![]() |
选择日期提示信息
垂直 | 水平 |
---|---|
![]() |
![]() |
动画滚动到指定日期
垂直 | 水平 |
---|---|
![]() |
![]() |
集成教程
需求
- 部署目标 iOS 11.0及以上
- Swift 5及以上
- Xcode 10.2及以上
安装
Swift包管理器
要使用Swift包管理器安装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中,然后按照以下教程进行集成。
CalendarView
构建将HorizonCalendar
添加到项目中后,获得一个基本的日历功能只需几个步骤。
基本设置
HorizonCalendar
导入在您想使用HorizonCalendar
的地方(很可能是UIView
或UIViewController
的子类)顶部,导入HorizonCalendar
import HorizonCalendar
CalendarViewContent
初始化一个CalendarView
使用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),
])
在这个阶段,构建并运行您的应用程序应该会产生如下所示的内容
CalendarView
自定义 为每一天提供自定义视图
HorizonCalendar
包含默认的月份标题、星期项和日项视图。您还可以为这些项目类型中的每个提供自定义视图,使您能够显示任何适合您应用程序的自定义内容。
由于所有关于 CalendarView
的视觉方面都是通过 CalendarViewContent
配置的,我们将完善我们的 makeContent
函数。让我们首先提供日历中每一天的自定义视图。
private func makeContent() -> CalendarViewContent {
return CalendarViewContent(
calendar: calendar,
visibleDateRange: today...endDate,
monthsLayout: .vertical(VerticalMonthsLayoutOptions()))
.dayItemProvider { day in
// Return a `CalendarItemModel` representing the view for each day
}
}
在 CalendarViewContent
上的 dayItemProvider(_:)
函数返回一个新的 CalendarViewContent
实例,该实例已配置了自定义日项模型提供程序。此函数接受一个参数 - 一个提供闭包,它为给定的 Day
返回一个 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 backgroundColor: UIColor
}
/// Properties that will vary depending on the particular date being displayed.
struct ViewModel: Equatable {
let day: Day
}
static func makeView(
withInvariantViewProperties invariantViewProperties: InvariantViewProperties)
-> UILabel
{
let label = UILabel()
label.backgroundColor = invariantViewProperties.backgroundColor
label.font = invariantViewProperties.font
label.textColor = invariantViewProperties.textColor
label.textAlignment = .center
label.clipsToBounds = true
label.layer.cornerRadius = 12
return label
}
static func setViewModel(_ viewModel: ViewModel, on view: UILabel) {
view.text = "\(viewModel.day.day)"
}
}
CalendarItemViewRepresentable
要求我们实现一个 static
的 makeView
函数,该函数应创建并返回一个给定一组不变视图属性的视图。我们希望我们的标签具有可配置的字体和文本颜色,所以我们将这些属性通过 InvariantViewProperties
类型来配置。在 makeView
函数中,我们使用这些不变视图属性来创建和配置我们的标签实例。
CalendarItemViewRepresentable
还需要我们实现一个 static
setViewModel
函数,它应该更新提供视图的所有数据依赖属性(例如,日期文本)。
现在我们有了一个符合 CalendarItemViewRepresentable
类型的类型,我们可以用它来创建一个 CalendarItemModel
从日项模型提供者返回。
return CalendarViewContent(...)
.dayItemProvider { day in
DayLabel.calendarItemModel(
invariantViewProperties: .init(
font: UIFont.systemFont(ofSize: 18),
textColor: .darkGray,
backgroundColor: .clear),
viewModel: .init(day: day))
}
使用 SwiftUI 视图甚至更简单 - 只需初始化你的 SwiftUI 视图,然后在它上调用 .calendarItemModel
。我们不需要创建一个符合 CalendarItemViewRepresentable
的自定义类型,就像我们在上面的 UIKit 示例中必须做的那样。
return CalendarViewContent(...)
.dayItemProvider { day in
Text("\(day.day)")
.font(.system(size: 18))
.foregroundColor(Color(UIColor.darkGray))
.calendarItemModel
}
还有类似的项模型提供者函数可用来自定义月份标题、一周中的某一天项等视图。
如果你构建并运行了你的应用程序,它现在应该看起来像这样
调整布局参数
我们还可以使用 CalendarViewContent
来调整布局参数。通过在个别天数和月份之间添加一些额外的间距,我们可以改进当前 CalendarView
的布局。
return CalendarViewContent(...)
.dayItemProvider { ... }
.interMonthSpacing(24)
.verticalDayMargin(8)
.horizontalDayMargin(8)
就像我们通过天项提供者配置自定义天视图时一样,布局参数的更改也是通过 CalendarViewContent
来完成的。interMonthSpacing(_:)
、verticalDayMargin(_:)
和 horizontalDayMargin(_:)
每个都返回一个具有相应布局参数值更新的已修改的 CalendarViewContent
实例,允许您将函数调用链式组合在一起以生成最终内容实例。
构建并运行你的应用程序后,你应该会看到一个不那么拥挤的布局
添加日期范围指示器
日期范围指示器对于需要突出显示单个日期以及日期范围的日期选择器很有用。HorizonCalendar
提供了一个通过 CalendarViewContent
函数 dayRangeItemProvider(for:_:)
来实现的 API。《这样做正是我们所做的,为了我们的自定义天项模型提供者,对于日期范围,我们需要为要突出显示的每个日期范围提供一个 CalendarItemModel
。
首先,我们需要创建一个表示我们想要提供 CalendarItemModel
的日期范围的 ClosedRange<Date>
。我们范围中的 Date
将使用我们初始化 CalendarViewContent
时使用的 Calendar
实例解释为 Day
。
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
然后,我们需要在 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>>
中派生出的每个日期范围,我们的日期范围项模型提供者闭包将使用一个包含所需渲染视图所需全部信息的上下文实例来调用。以下是一个此类视图的实现示例
import UIKit
final class DayRangeIndicatorView: UIView {
private let indicatorColor: UIColor
init(indicatorColor: UIColor) {
self.indicatorColor = indicatorColor
super.init(frame: frame)
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()
}
}
}
接下来,我们需要一个符合 CalendarItemViewRepresentable
类型且知道如何创建和更新 DayRangeIndicatorView
实例的类型。为了简化,我们可以让我们的视图符合此协议
import HorizonCalendar
extension DayRangeIndicatorView: CalendarItemViewRepresentable {
struct InvariantViewProperties: Hashable {
let indicatorColor = UIColor.blue.withAlphaComponent(0.15)
}
struct ViewModel: Equatable {
let framesOfDaysToHighlight: [CGRect]
}
static func makeView(
withInvariantViewProperties invariantViewProperties: InvariantViewProperties)
-> DayRangeIndicatorView
{
DayRangeIndicatorView(indicatorColor: invariantViewProperties.indicatorColor)
}
static func setViewModel(_ viewModel: ViewModel, on view: DayRangeIndicatorView) {
view.framesOfDaysToHighlight = viewModel.framesOfDaysToHighlight
}
}
最后,我们需要从天范围项模型提供者闭包中返回一个代表我们的DayRangeIndicatorView
的CalendarItemModel
。
return CalendarViewContent(...)
...
.dayRangeItemProvider(for: [dateRangeToHighlight]) { dayRangeLayoutContext in
DayRangeIndicatorView.calendarItemModel(
invariantViewProperties: .init(indicatorColor: UIColor.blue.withAlphaComponent(0.15)),
viewModel: .init(framesOfDaysToHighlight: dayRangeLayoutContext.daysAndFrames.map { $0.frame }))
}
如果你构建并运行了该应用程序,你应该看到一个突出显示从2020-01-20到2020-02-07的天范围指示器视图。
添加工具提示
HorizonCalendar
提供了一个API,可以在日历的某些部分上重叠自定义视图。它支持的功能之一是在某些天添加工具提示 - 这是Airbnb应用程序中使用的功能,用于通知用户他们的退房日期必须在他们入住日期后的特定天数。
首先,我们需要决定我们希望用我们自己的自定义视图叠加的项的位置。我们可以在CalendarViewContent.OverlaidItemLocation
中叠加day
或monthHeader
这两个情况中的任何一个。让我们在2020-01-15叠加这一天。
let dateToOverlay = calendar.date(from: DateComponents(year: 2020, month: 01, day: 15))!
let overlaidItemLocation: CalendarViewContent.OverlaidItemLocation = .day(containingDate: dateToOverlay)
和其他所有自定义一样,我们将通过在我们的CalendarViewContent
实例上调用一个函数来添加叠加,该函数配置叠加项模型提供者闭包。
return CalendarViewContent(...)
...
.overlayItemProvider(for: [overlaidItemLocation]) { overlayLayoutContext in
// Return a `CalendarItemModel` representing the view to use as an overlay for the overlaid item location
}
对于传递给此函数的Set<CalendarViewContent.OverlaidItemLocation>
中的每个叠加项位置,我们的叠加项模型提供者闭包将与一个包含用于在特定叠加项位置渲染视图所需的所有信息的上下文实例一起调用。以下是一个工具提示叠加视图的示例实现
import UIKit
final class TooltipView: UIView {
init(backgroundColor: UIColor, borderColor: UIColor, font: UIFont, textColor: UIColor) {
super.init(frame: .zero)
backgroundView.backgroundColor = backgroundColor
backgroundView.layer.borderColor = borderColor
addSubview(backgroundView)
label.font = font
label.textColor = textColor
addSubview(label)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
var text: String {
get { label.text ?? "" }
set { label.text = newValue }
}
var frameOfTooltippedItem: CGRect? {
didSet {
guard frameOfTooltippedItem != oldValue else { return }
setNeedsLayout()
}
}
override func layoutSubviews() {
super.layoutSubviews()
guard let frameOfTooltippedItem = frameOfTooltippedItem else { return }
label.sizeToFit()
let labelSize = CGSize(
width: min(label.bounds.size.width, bounds.width),
height: label.bounds.size.height)
let backgroundSize = CGSize(width: labelSize.width + 16, height: labelSize.height + 16)
let proposedFrame = CGRect(
x: frameOfTooltippedItem.midX - (backgroundSize.width / 2),
y: frameOfTooltippedItem.minY - backgroundSize.height - 4,
width: backgroundSize.width,
height: backgroundSize.height)
let frame: CGRect
if proposedFrame.maxX > bounds.width {
frame = proposedFrame.applying(.init(translationX: bounds.width - proposedFrame.maxX, y: 0))
} else if proposedFrame.minX < 0 {
frame = proposedFrame.applying(.init(translationX: -proposedFrame.minX, y: 0))
} else {
frame = proposedFrame
}
backgroundView.frame = frame
label.center = backgroundView.center
}
// MARK: Private
private lazy var backgroundView: UIView = {
let view = UIView()
view.layer.borderWidth = 1
view.layer.cornerRadius = 6
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.8
view.layer.shadowOffset = .zero
view.layer.shadowRadius = 8
return view
}()
private lazy var label: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.lineBreakMode = .byTruncatingTail
return label
}()
}
接下来,我们需要一个符合CalendarItemViewRepresentable
的类型,该类型知道如何创建和更新TooltipView
实例。为了简化,我们可以使我们的视图符合这个协议
import HorizonCalendar
extension TooltipView: CalendarItemViewRepresentable {
struct InvariantViewProperties: Hashable {
let backgroundColor: UIColor
let borderColor: UIColor
let font: UIFont
let textColor: UIColor
}
struct ViewModel: Equatable {
let frameOfTooltippedItem: CGRect?
let text: String
}
static func makeView(
withInvariantViewProperties invariantViewProperties: InvariantViewProperties)
-> TooltipView
{
TooltipView(
borderColor: invariantViewProperties.borderColor,
font: invariantViewProperties.font,
textColor: invariantViewProperties.textColor)
}
static func setViewModel(_ viewModel: ViewModel, on view: TooltipView) {
view.frameOfTooltippedItem = viewModel.frameOfTooltippedItem
view.text = viewModel.text
}
}
最后,我们需要从叠加项模型提供者闭包中返回一个表示我们的TooltipView
的CalendarItemModel
return CalendarViewContent(...)
...
.overlayItemProvider(for: [overlaidItemLocation]) { overlayLayoutContext in
TooltipView.calendarItemModel(
invariantViewProperties: .init(
backgroundColor: .white,
borderColor: .black,
font: UIFont.systemFont(ofSize: 16),
textColor: .black),
viewModel: .init(
frameOfTooltippedItem: overlayLayoutContext.overlaidItemFrame,
text: "Dr. Martin Luther King Jr.'s Birthday"))
}
如果你构建并运行了该应用程序,你应该看到一个悬浮在2020-01-15上方的工具提示视图
添加网格线
HorizonCalendar
提供了一个API,可以在每个月份后面添加一个装饰性背景。通过使用内建的MonthGridBackgroundView
和monthBackgroundItemProvider
,我们可以轻松地为日历中的每个月添加网格线。
return CalendarViewContent(...)
...
.horizontalDayMargin(8)
.verticalDayMargin(8)
.monthBackgroundItemProvider { monthLayoutContext in
MonthGridBackgroundView.calendarItemModel(
invariantViewProperties: .init(horizontalDayMargin: 8, verticalDayMargin: 8),
viewModel: .init(framesOfDays: monthLayoutContext.daysAndFrames.map { $0.frame }))
}
月份背景项提供程序与叠加项提供程序和天范围项提供程序类似;对于日历中的每个月,项提供程序闭包将带着布局上下文被调用。这个布局上下文包含有关元素大小和位置的信息。使用这些信息,你可以绘制网格线、边框、背景等。
响应日期选择
如果您正在构建日期选择器,您很可能需要响应用户在日历上点击日期。为实现此目的,请通过CalendarView
的daySelectionHandler
提供日期选择器处理闭包
calendarView.daySelectionHandler = { [weak self] day in
self?.selectedDay = day
}
private var selectedDay: Day?
每当在日历中选中一个日期时,都会调用日期选择器处理闭包。您会提供一个表示选中日期的Day
实例。如果我们想在一旦被点击后突出显示选中日期,我们需要创建一个新的CalendarViewContent
,其中包含一个看起来与选中日期不同的日历项目模型
let selectedDay = self.selectedDay
return CalendarViewContent(...)
.dayItemProvider { day in
var invariantViewProperties = DayLabel.InvariantViewProperties(
font: UIFont.systemFont(ofSize: 18),
textColor: .darkGray,
backgroundColor: .clear)
if day == selectedDay {
invariantViewProperties.textColor = .white
invariantViewProperties.backgroundColor = .blue
}
return DayLabel.calendarItemModel(
invariantViewProperties: invariantViewProperties,
viewModel: .init(day: day))
}
最后,我们将更改日期选择器,使其不仅存储所选日期,还在calendarView
上设置更新的内容实例
calendarView.daySelectionHandler = { [weak self] day in
guard let self = self else { return }
self.selectedDay = day
let newContent = self.makeContent()
self.calendarView.setContent(newContent)
}
构建并运行应用程序后,点击日期应使它们变为蓝色
技术细节
如果您想了解HorizonCalendar
的实现方式,请查看技术细节文档。它提供了HorizonCalendar
架构的概述,以及为何未使用UICollectionView
实现的信息
贡献
HorizonCalendar
欢迎修复、改进和功能添加。如果您想做出贡献,请提交一个带有详细更改描述的拉取请求。
作为一般规则,如果您建议一个破坏API或更改现有功能,考虑通过打开问题提出建议,而不是通过拉取请求;我们将使用问题作为公开论坛来讨论该提议是否有意义。有关更多信息,请参阅CONTRIBUTING。
作者
Bryan Keller
维护者
Bryan Keller
布莱恩·博戴尔
如果您或您的公司认为 HorizonCalendar
有用,请告诉我们!
许可证
HorizonCalendar
在 Apache License 2.0 下发布。详细信息请参阅 LICENSE。