HorizonCalendar 2.0.0

HorizonCalendar 2.0.0

Bryan Keller 维护。



HorizonCalendar

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

Swift Package Manager compatible Carthage compatible Version License Platform Swift

简介

HorizonCalendar 是一个适用于 iOS 的交互式日历组件(与 UIKit 和 SwiftUI 兼容)。其声明式 API 使得更新日历变得简单,同时提供了许多自定义点以支持各种设计和用例。

特性

  • 支持从 Foundation.Calendar(如公历、日本、希伯来等)的所有日历
  • 以垂直滚动或水平滚动布局显示月份
  • 声明式 API 允许单向数据流更新日历内容
  • 一个定制的布局系统,可以在不增加内存使用的情况下几乎无限地注册日期范围
  • 水平滚动日历的分页
  • 为单个日期、月标题和周几指定自定义视图(UIView 或 SwiftUI View
  • 为突出日期范围指定自定义视图(UIView 或 SwiftUI View
  • 为日历的覆盖部分指定自定义视图(UIView 或 SwiftUI View),例如工具提示功能
  • 为月背景装饰指定自定义视图(UIView 或 SwiftUI View)(颜色、网格等)
  • 为日背景装饰指定自定义视图(UIView 或 SwiftUI View)(颜色、图案等)
  • 一个天选择器来监视何时点击了某一天
  • 可定制的布局度量
  • 使用布局边距将周几行固定在顶部
  • 显示部分边界月份(例如,2020-03-14 到 2020-04-20)
  • 滚动到任意日期和月份,带或不带动画
  • 强大的无障碍支持
  • 使用布局边距凹进内容,而不影响可滚动区域
  • 在周几行下方设置分隔线
  • 支持从右到左的布局

HorizonCalendar 是 Airbnb 最热门流中使用的日期选择器和日历的基础。

搜索 住宿可用性日历 愿望列表 体验预订 体验房东日历管理
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

集成教程

需求

  • 部署目标 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的地方(很可能是UIViewUIViewController的子类)顶部,导入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),
])

在这个阶段,构建并运行您的应用程序应该会产生如下所示的内容

Basic Calendar

自定义 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 要求我们实现一个 staticmakeView 函数,该函数应创建并返回一个给定一组不变视图属性的视图。我们希望我们的标签具有可配置的字体和文本颜色,所以我们将这些属性通过 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
    }

还有类似的项模型提供者函数可用来自定义月份标题、一周中的某一天项等视图。

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

Custom Day Views

调整布局参数

我们还可以使用 CalendarViewContent 来调整布局参数。通过在个别天数和月份之间添加一些额外的间距,我们可以改进当前 CalendarView 的布局。

  return CalendarViewContent(...)
    .dayItemProvider { ... }

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

就像我们通过天项提供者配置自定义天视图时一样,布局参数的更改也是通过 CalendarViewContent 来完成的。interMonthSpacing(_:)verticalDayMargin(_:)horizontalDayMargin(_:) 每个都返回一个具有相应布局参数值更新的已修改的 CalendarViewContent 实例,允许您将函数调用链式组合在一起以生成最终内容实例。

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

Custom Layout Metrics

添加日期范围指示器

日期范围指示器对于需要突出显示单个日期以及日期范围的日期选择器很有用。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
  }

}

最后,我们需要从天范围项模型提供者闭包中返回一个代表我们的DayRangeIndicatorViewCalendarItemModel

  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的天范围指示器视图。

Day Range Indicator

添加工具提示

HorizonCalendar提供了一个API,可以在日历的某些部分上重叠自定义视图。它支持的功能之一是在某些天添加工具提示 - 这是Airbnb应用程序中使用的功能,用于通知用户他们的退房日期必须在他们入住日期后的特定天数。

首先,我们需要决定我们希望用我们自己的自定义视图叠加的项的位置。我们可以在CalendarViewContent.OverlaidItemLocation中叠加daymonthHeader这两个情况中的任何一个。让我们在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
  }

}

最后,我们需要从叠加项模型提供者闭包中返回一个表示我们的TooltipViewCalendarItemModel

  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上方的工具提示视图

Tooltip View

添加网格线

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

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

月份背景项提供程序与叠加项提供程序和天范围项提供程序类似;对于日历中的每个月,项提供程序闭包将带着布局上下文被调用。这个布局上下文包含有关元素大小和位置的信息。使用这些信息,你可以绘制网格线、边框、背景等。

Tooltip View

响应日期选择

如果您正在构建日期选择器,您很可能需要响应用户在日历上点击日期。为实现此目的,请通过CalendarViewdaySelectionHandler提供日期选择器处理闭包

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

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

Day Selection

技术细节

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

贡献

HorizonCalendar欢迎修复、改进和功能添加。如果您想做出贡献,请提交一个带有详细更改描述的拉取请求。

作为一般规则,如果您建议一个破坏API或更改现有功能,考虑通过打开问题提出建议,而不是通过拉取请求;我们将使用问题作为公开论坛来讨论该提议是否有意义。有关更多信息,请参阅CONTRIBUTING

作者

Bryan Keller

维护者

Bryan Keller

布莱恩·博戴尔

如果您或您的公司认为 HorizonCalendar 有用,请告诉我们!

许可证

HorizonCalendar 在 Apache License 2.0 下发布。详细信息请参阅 LICENSE