HorizonCalendar

repository·master·Indexed 25 days ago

https://github.com/airbnb/horizoncalendar

A declarative, high-performance calendar UI component for iOS (SwiftUI and UIKit) developed by Airbnb. It supports vertical and horizontal month layouts, virtually-infinite date ranges, and all Foundation.Calendar types. Key features include customizable views for days and month headers, single and multi-day selection, and a memory-efficient architecture that scales from simple date pickers to complex calendar management apps.

Tokens
4.3K
Snippets
10
Records
17
Agent score
35%

What's inside HorizonCalendar

  1. Understand HorizonCalendar Architecture

    master

    HorizonCalendar uses a custom layout solution designed for high performance and constant memory usage, regardless of the date range being displayed. Unlike UICollectionView, which scales memory usage linearly with the number of items, HorizonCalendar's memory footprint is dependent only on the number of items currently visible in the viewport.

    Key architectural components include:

    • CalendarView: The UIView subclass that acts as the central coordinator.
    • VisibleItemsProvider: Determines which subset of calendar items is currently visible by checking adjacent items against the current viewport.
    • LayoutItemTypeEnumerator: Used by the provider to traverse adjacent layout items.
    • FrameProvider: Calculates item frames incrementally. It determines the position of an item (like a Month or a day) based on the frame of a known adjacent item, allowing for lazy layout at arbitrary date offsets.
    • ItemViewReuseManager: Manages the creation and reuse of views, similar to UICollectionView's reuse mechanism, by calculating the difference between the current and previous sets of visible items.
  2. HorizonCalendar Overview and Features

    master

    HorizonCalendar is a declarative and performant calendar UI component for iOS that supports both SwiftUI and UIKit. It is designed to handle use cases ranging from simple date pickers to complex, fully-featured calendar applications.

    Key Features

    • Layouts: Supports both vertical and horizontal month layouts, including paging for horizontal layouts.
    • Data Flow: Uses a declarative API that encourages unidirectional data flow.
    • Customization: Highly customizable views for days, month headers, days of the week, and month grid backgrounds. Supports custom views for highlighting date ranges and overlays (e.g., tooltips).
    • Selection: Includes handlers for single-day selection and multi-day selection via drag gestures.
    • Flexibility: Supports virtually-infinite date ranges, all Foundation.Calendar types (Gregorian, Japanese, Hebrew, etc.), and right-to-left (RTL) layouts.
    • Interaction: Supports scrolling to arbitrary dates/months with or without animation and pinning the days-of-the-week row.
  3. Configure HORIZON_CALENDAR_DISABLE_DISPLAY_LINK in Xcode

    master

    To disable DisplayLink for all tests within an Xcode test scheme:

    1. Edit your test scheme in Xcode.
    2. Go to TestArgumentsEnvironment Variables.
    3. Add HORIZON_CALENDAR_DISABLE_DISPLAY_LINK and set its value to true.
  4. Explore the HorizonCalendar Example App

    master

    An example project is provided to showcase features and demos.

    Important: When opening the project, use the .xcworkspace file located at ./Example/HorizonCalendarExample.xcworkspace. Do not use the .xcodeproj file, as it will not have access to the HorizonCalendar.framework.

    Available Demos

    The example app includes several demo view controllers demonstrating both vertical and horizontal layouts for:

    • Single Day Selection
    • Day Range Selection
    • Selected Day Tooltip
    • Scroll to Day with Animation
  5. Set up HorizonCalendar in UIKit

    master

    In UIKit, use the CalendarView class. Visual configuration is managed via a CalendarViewContent object.

    Important: CalendarView does not have an intrinsic content size. You must provide a valid frame using Auto Layout or manual frame setting. If using Auto Layout, ensure you set translatesAutoresizingMaskIntoConstraints = false and activate constraints.

    // 1. Create the content
    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()))
    }
    
    // 2. Initialize and add to view
    let calendarView = CalendarView(initialContent: makeContent())
    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),
    ])
  6. Set up HorizonCalendar in SwiftUI

    master

    To use HorizonCalendar in a SwiftUI project, import the module and use CalendarViewRepresentable.

    Because the calendar does not have an intrinsic content size, you must apply the .frame(maxWidth: .infinity, maxHeight: .infinity) modifier to ensure it occupies the desired space. You can also use .layoutMargins for internal padding and standard .padding for external padding.

    import HorizonCalendar 
    
    // Inside a SwiftUI View
    CalendarViewRepresentable(
      calendar: calendar,
      visibleDateRange: startDate...endDate,
      monthsLayout: .vertical(options: VerticalMonthsLayoutOptions()),
      dataDependency: nil)
      .layoutMargins(.init(top: 8, leading: 8, bottom: 8, trailing: 8))
      .padding(.horizontal, 16)
      .frame(maxWidth: .infinity, maxHeight: .infinity)
  7. How HorizonCalendar manages infinite scrolling

    master

    To support virtually infinite date ranges without calculating total content size upfront, HorizonCalendar manages an internal UIScrollView using these steps:

    1. Large Content Size: The UIScrollView content size is set to a very large value to provide ample scrolling room.
    2. Lazy Layout: Items are laid out just-in-time as they enter the viewport.
    3. Dynamic Boundaries: When the scroll position approaches the start or end of the actual date range, the calendar creates scroll boundaries by adjusting the UIScrollView's contentInset to align with the boundary month.
  8. Install HorizonCalendar

    master

    You can install HorizonCalendar using Swift Package Manager, Carthage, or CocoaPods.

    Swift Package Manager

    Add the following to your Package.swift:

    .package(name: "HorizonCalendar", url: "https://github.com/airbnb/HorizonCalendar.git", from: "1.0.0")

    Carthage

    Add the following to your Cartfile:

    github "airbnb/HorizonCalendar"

    CocoaPods

    Add the following to your Podfile:

    pod 'HorizonCalendar'
  9. Disable DisplayLink for accessibility or UI testing

    master

    When performing automated UI testing or using accessibility testing frameworks (like GTXiLib), CalendarView's CADisplayLink animations may cause crashes if callbacks fire before the view hierarchy is fully initialized.

    You can disable DisplayLink by setting the HORIZON_CALENDAR_DISABLE_DISPLAY_LINK environment variable to true.

    Note: When disabled, scroll animations will complete immediately without animation. This is expected behavior in test mode and does not affect the calendar's functionality or accessibility testing.

    // Programmatic setup in XCTest
    override func setUp() {
      super.setUp()
      setenv("HORIZON_CALENDAR_DISABLE_DISPLAY_LINK", "true", 1)
    }
    
    // Setting via XCUIApplication launch environment
    let app = XCUIApplication()
    app.launchEnvironment = ["HORIZON_CALENDAR_DISABLE_DISPLAY_LINK": "true"]
    app.launch()
  10. Customize day views in UIKit

    master

    In UIKit, use the dayItemProvider(_:) method on CalendarViewContent. This method returns a new CalendarViewContent instance configured with a provider closure that returns a CalendarItemModel for a given DayComponents.

    To use a custom view, implement the CalendarItemViewRepresentable protocol. This requires two static methods:

    1. makeView(withInvariantViewProperties:): Creates the view using properties that don't change (e.g., font, colors).
    2. setContent(_:on:): Updates data-dependent properties (e.g., the day number text) on the existing view instance.
    // 1. Define the view representable
    struct DayLabel: CalendarItemViewRepresentable {
      struct InvariantViewProperties: Hashable {
        let font: UIFont
        let textColor: UIColor
        let borderColor: UIColor
      }
    
      struct Content: Equatable {
        let day: DayComponents
      }
    
      static func makeView(withInvariantViewProperties invariantViewProperties: InvariantViewProperties) -> UILabel {
        let label = UILabel()
        label.font = invariantViewProperties.font
        label.textColor = invariantViewProperties.textColor
        label.layer.borderColor = invariantViewProperties.borderColor.cgColor
        label.layer.borderWidth = 1
        label.layer.cornerRadius = 12
        label.textAlignment = .center
        return label
      }
    
      static func setContent(_ content: Content, on view: UILabel) {
        view.text = "\(content.day.day)"
      }
    }
    
    // 2. Use it in content configuration
    return CalendarViewContent(...)
      .dayItemProvider { day in
        DayLabel.calendarItemModel(
          invariantViewProperties: .init(font: .systemFont(ofSize: 18), textColor: .label, borderColor: .systemBlue),
          content: .init(day: day))
      }
  11. Adjust layout metrics

    master

    You can adjust spacing and margins between months and days using the following methods. In SwiftUI, these are modifiers on CalendarViewRepresentable. In UIKit, these are methods on CalendarViewContent that return a mutated instance for chaining.

    • interMonthSpacing(_:): Spacing between months.
    • verticalDayMargin(_:): Vertical margin for days.
    • horizontalDayMargin(_:): Horizontal margin for days.
    // SwiftUI
    CalendarViewRepresentable(...)
      .interMonthSpacing(24)
      .verticalDayMargin(8)
      .horizontalDayMargin(8)
    
    // UIKit
    return CalendarViewContent(...)
      .interMonthSpacing(24)
      .verticalDayMargin(8)
      .horizontalDayMargin(8)
  12. Add grid lines as month backgrounds

    master

    You can add decorative backgrounds (like grid lines) behind each month using the month background provider. This provider is invoked for each month with a layout context containing the frames of the days in that month.

    SwiftUI: Use the .monthBackgrounds { ... } modifier.

    UIKit: Use the .monthBackgroundItemProvider { ... } method on CalendarViewContent.

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