├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── CHANGELOG.md ├── Docs ├── CONTRIBUTING.md ├── Images │ ├── architecture_overview.png │ ├── collection_view_memory_usage.gif │ ├── day_range_selection_horizontal.png │ ├── day_range_selection_vertical.png │ ├── demo_picker.png │ ├── experience_host.png │ ├── experience_reservation.png │ ├── horizon_calendar_memory_usage.gif │ ├── scroll_to_day_with_animation_horizontal.gif │ ├── scroll_to_day_with_animation_vertical.gif │ ├── selected_day_tooltip_horizontal.png │ ├── selected_day_tooltip_vertical.png │ ├── single_day_selection_horizontal.png │ ├── single_day_selection_vertical.png │ ├── stay_availability.png │ ├── stay_search.png │ ├── tutorial_day.png │ ├── tutorial_day_range.png │ ├── tutorial_day_selection.png │ ├── tutorial_grid.png │ ├── tutorial_layout_metrics.png │ ├── tutorial_setup.png │ └── wish_list.png ├── PULL_REQUEST_TEMPLATE.md └── TECHNICAL_DETAILS.md ├── Example ├── HorizonCalendarExample.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── bryan.xcuserdatad │ │ ├── IDEFindNavigatorScopes.plist │ │ └── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist └── HorizonCalendarExample │ ├── HorizonCalendarExample.xcodeproj │ ├── project.pbxproj │ └── xcuserdata │ │ └── bryan.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist │ └── HorizonCalendarExample │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ └── LaunchScreen.storyboard │ ├── DayRangeIndicatorView.swift │ ├── DayRangeSelectionTracker.swift │ ├── Demo View Controllers │ ├── DayRangeSelectionDemoViewController.swift │ ├── DemoViewController.swift │ ├── LargeDayRangeDemoViewController.swift │ ├── MonthBackgroundDemoViewController.swift │ ├── PartialMonthVisibilityDemoViewController.swift │ ├── ScrollToDayWithAnimationDemoViewController.swift │ ├── SelectedDayTooltipDemoViewController.swift │ ├── SingleDaySelectionDemoViewController.swift │ ├── SwiftUIItemModelsDemoViewController.swift │ └── SwiftUIScreenDemoViewController.swift │ ├── DemoPickerViewController.swift │ ├── Info.plist │ ├── SwiftUIDayView.swift │ └── TooltipView.swift ├── HorizonCalendar.podspec ├── HorizonCalendar.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ └── xcschemes │ │ └── HorizonCalendar.xcscheme └── xcuserdata │ └── bryan.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── Info.plist ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Internal │ ├── CGFloat+MaxLayoutValue.swift │ ├── Calendar+Helpers.swift │ ├── CalendarScrollView.swift │ ├── ColorViewRepresentable.swift │ ├── Dictionary+MutatingValueForKey.swift │ ├── DoubleLayoutPassHelpers.swift │ ├── FrameProvider.swift │ ├── Hasher+CGRect.swift │ ├── ItemView.swift │ ├── ItemViewReuseManager.swift │ ├── LayoutItem.swift │ ├── LayoutItemTypeEnumerator.swift │ ├── PaginationHelpers.swift │ ├── ScreenPixelAlignment.swift │ ├── ScrollMetricsMutator.swift │ ├── ScrollToItemContext.swift │ ├── SubviewInsertionIndexTracker.swift │ ├── UIView+NoAnimation.swift │ ├── VisibleItem.swift │ └── VisibleItemsProvider.swift └── Public │ ├── AnyCalendarItemModel.swift │ ├── CalendarItemModel.swift │ ├── CalendarItemViewRepresentable.swift │ ├── CalendarView.swift │ ├── CalendarViewContent.swift │ ├── CalendarViewProxy.swift │ ├── CalendarViewRepresentable.swift │ ├── CalendarViewScrollPosition.swift │ ├── Day.swift │ ├── DayOfWeekPosition.swift │ ├── DayRange.swift │ ├── DayRangeLayoutContext.swift │ ├── DaysOfTheWeekRowSeparatorOptions.swift │ ├── ItemViews │ ├── DayOfWeekView.swift │ ├── DayView.swift │ ├── DrawingConfig.swift │ ├── MonthGridBackgroundView.swift │ ├── MonthHeaderView.swift │ ├── Shape.swift │ └── SwiftUIWrapperView.swift │ ├── Month.swift │ ├── MonthLayoutContext.swift │ ├── MonthRange.swift │ ├── MonthsLayout.swift │ ├── OverlaidItemLocation.swift │ └── OverlayLayoutContext.swift └── Tests ├── CalendarContentTests.swift ├── DayHelperTests.swift ├── DayOfWeekPositionTests.swift ├── FrameProviderTests.swift ├── HorizontalMonthsLayoutOptionsTests.swift ├── Info.plist ├── ItemViewReuseManagerTests.swift ├── LayoutItemTypeEnumeratorTests.swift ├── MonthHelperTests.swift ├── MonthRowTests.swift ├── MonthTests.swift ├── PaginationHelpersTests.swift ├── ScreenPixelAlignmentTests.swift ├── ScrollMetricsMutatorTests.swift ├── SubviewsManagerTests.swift └── VisibleItemsProviderTests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-13 12 | strategy: 13 | matrix: 14 | xcode: 15 | - '15.0' # Swift 5.9 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Build 19 | run: xcodebuild clean build -scheme HorizonCalendar -destination "generic/platform=iOS Simulator" 20 | - name: Run tests 21 | run: xcodebuild clean test -project HorizonCalendar.xcodeproj -scheme HorizonCalendar -destination "name=iPhone 14,OS=17.2" 22 | 23 | lint-swift: 24 | runs-on: macos-13 25 | strategy: 26 | matrix: 27 | xcode: 28 | - '15.0' # Swift 5.9 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Lint Swift 32 | run: swift package --allow-writing-to-package-directory format --lint 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | Gemfile.lock 9 | 10 | ## Various settings 11 | *.pbxuser 12 | !default.pbxuser 13 | *.mode1v3 14 | !default.mode1v3 15 | *.mode2v3 16 | !default.mode2v3 17 | *.perspectivev3 18 | !default.perspectivev3 19 | xcuserdata/ 20 | 21 | ## Other 22 | *.moved-aside 23 | *.xccheckout 24 | *.xcscmblueprint 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | *.ipa 29 | *.dSYM.zip 30 | *.dSYM 31 | 32 | ## Playgrounds 33 | timeline.xctimeline 34 | playground.xcworkspace 35 | 36 | # Swift Package Manager 37 | # 38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 39 | # Packages/ 40 | # Package.pins 41 | # Package.resolved 42 | .build/ 43 | 44 | # CocoaPods 45 | # 46 | # We recommend against adding the Pods directory to your .gitignore. However 47 | # you should judge for yourself, the pros and cons are mentioned at: 48 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 49 | # 50 | # Pods/ 51 | 52 | # Carthage 53 | # 54 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 55 | # Carthage/Checkouts 56 | 57 | Carthage/Build 58 | 59 | # fastlane 60 | # 61 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 62 | # screenshots whenever they are needed. 63 | # For more information about the recommended setup visit: 64 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 65 | 66 | fastlane/report.xml 67 | fastlane/Preview.html 68 | fastlane/screenshots/**/*.png 69 | fastlane/test_output 70 | *.xcuserstate 71 | xcshareddata 72 | UserInterfaceState.xcuserstate 73 | .DS_Store 74 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | `HorizonCalendar` welcomes both fixes, improvements, and feature additions. If you'd like to contribute, open an issue or a Pull Request with a detailed description of your proposal and/or changes. If you'd like some help getting started, take a look at the [Technical Details](Docs/TECHNICAL_DETAILS.md) document for an overview of `HorizonCalendar`'s architecture. 4 | 5 | ### One issue or bug per Pull Request 6 | 7 | Keep your Pull Requests small. Small PRs are easier to reason about which makes them significantly more likely to get merged. 8 | 9 | ### Issues before features 10 | 11 | If you want to add a feature, consider filing an [Issue](../../issues). An Issue can provide the opportunity to discuss the requirements and implications of a feature with you before you start writing code. This is not a hard requirement, however. Submitting a Pull Request to demonstrate an idea in code is also acceptable, it just carries more risk of change. 12 | 13 | ### Backwards compatibility 14 | 15 | Respect the minimum deployment target. If you are adding code that uses new APIs, make sure to prevent older clients from crashing or misbehaving. Our CI runs against our minimum deployment targets, so you will not get a green build unless your code is backwards compatible. 16 | 17 | ### Forwards compatibility 18 | 19 | Please do not write new code using deprecated APIs. 20 | 21 | ### Pull Request Process 22 | 23 | 1. Use the provided [Pull Request template](Docs/PULL_REQUEST_TEMPLATE.md). 24 | 2. Update the README.md with details of changes to the public interface, if necessary. 25 | 3. Increase the version number in the [Xcode project file](HorizonCalendar.xcodeproj) (edit in Xcode, not directly) and in the [Podspec file](HorizonCalendar.podspec) with the new version that this 26 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 27 | -------------------------------------------------------------------------------- /Docs/Images/architecture_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/architecture_overview.png -------------------------------------------------------------------------------- /Docs/Images/collection_view_memory_usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/collection_view_memory_usage.gif -------------------------------------------------------------------------------- /Docs/Images/day_range_selection_horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/day_range_selection_horizontal.png -------------------------------------------------------------------------------- /Docs/Images/day_range_selection_vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/day_range_selection_vertical.png -------------------------------------------------------------------------------- /Docs/Images/demo_picker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/demo_picker.png -------------------------------------------------------------------------------- /Docs/Images/experience_host.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/experience_host.png -------------------------------------------------------------------------------- /Docs/Images/experience_reservation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/experience_reservation.png -------------------------------------------------------------------------------- /Docs/Images/horizon_calendar_memory_usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/horizon_calendar_memory_usage.gif -------------------------------------------------------------------------------- /Docs/Images/scroll_to_day_with_animation_horizontal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/scroll_to_day_with_animation_horizontal.gif -------------------------------------------------------------------------------- /Docs/Images/scroll_to_day_with_animation_vertical.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/scroll_to_day_with_animation_vertical.gif -------------------------------------------------------------------------------- /Docs/Images/selected_day_tooltip_horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/selected_day_tooltip_horizontal.png -------------------------------------------------------------------------------- /Docs/Images/selected_day_tooltip_vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/selected_day_tooltip_vertical.png -------------------------------------------------------------------------------- /Docs/Images/single_day_selection_horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/single_day_selection_horizontal.png -------------------------------------------------------------------------------- /Docs/Images/single_day_selection_vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/single_day_selection_vertical.png -------------------------------------------------------------------------------- /Docs/Images/stay_availability.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/stay_availability.png -------------------------------------------------------------------------------- /Docs/Images/stay_search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/stay_search.png -------------------------------------------------------------------------------- /Docs/Images/tutorial_day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/tutorial_day.png -------------------------------------------------------------------------------- /Docs/Images/tutorial_day_range.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/tutorial_day_range.png -------------------------------------------------------------------------------- /Docs/Images/tutorial_day_selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/tutorial_day_selection.png -------------------------------------------------------------------------------- /Docs/Images/tutorial_grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/tutorial_grid.png -------------------------------------------------------------------------------- /Docs/Images/tutorial_layout_metrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/tutorial_layout_metrics.png -------------------------------------------------------------------------------- /Docs/Images/tutorial_setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/tutorial_setup.png -------------------------------------------------------------------------------- /Docs/Images/wish_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/airbnb/HorizonCalendar/697c6f22f7e2f6da69fd0ca5dca0d08c973ea3a8/Docs/Images/wish_list.png -------------------------------------------------------------------------------- /Docs/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Details 2 | 3 | 4 | 5 | ## Related Issue 6 | 7 | 8 | 9 | ## Motivation and Context 10 | 11 | 12 | 13 | ## How Has This Been Tested 14 | 15 | 16 | 17 | 18 | 19 | ## Types of changes 20 | 21 | 22 | 23 | - [ ] Docs change / refactoring / dependency upgrade 24 | - [ ] Bug fix (non-breaking change which fixes an issue) 25 | - [ ] New feature (non-breaking change which adds functionality) 26 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 27 | 28 | ## Checklist 29 | 30 | 31 | 32 | 33 | - [ ] My code follows the code style of this project. 34 | - [ ] My change requires a change to the documentation. 35 | - [ ] I have updated the documentation accordingly. 36 | - [ ] I have read the **CONTRIBUTING** document. 37 | - [ ] I have added tests to cover my changes. 38 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample.xcworkspace/xcuserdata/bryan.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample.xcworkspace/xcuserdata/bryan.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample.xcodeproj/xcuserdata/bryan.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | HorizonCalendarExample.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 1 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/31/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | // MARK: - AppDelegate 19 | 20 | @UIApplicationMain 21 | final class AppDelegate: UIResponder, UIApplicationDelegate { 22 | 23 | var window: UIWindow? 24 | 25 | func application( 26 | _: UIApplication, 27 | didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) 28 | -> Bool 29 | { 30 | window = UIWindow(frame: UIScreen.main.bounds) 31 | let demoPickerViewController = DemoPickerViewController() 32 | let navigationController = UINavigationController(rootViewController: demoPickerViewController) 33 | window?.rootViewController = navigationController 34 | window?.makeKeyAndVisible() 35 | return true 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/7/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | // MARK: - DayRangeIndicatorView 20 | 21 | final class DayRangeIndicatorView: UIView { 22 | 23 | // MARK: Lifecycle 24 | 25 | fileprivate init(indicatorColor: UIColor) { 26 | self.indicatorColor = indicatorColor 27 | 28 | super.init(frame: .zero) 29 | 30 | backgroundColor = .clear 31 | } 32 | 33 | required init?(coder _: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | 37 | // MARK: Internal 38 | 39 | override func draw(_: CGRect) { 40 | let context = UIGraphicsGetCurrentContext() 41 | context?.setFillColor(indicatorColor.cgColor) 42 | 43 | if traitCollection.layoutDirection == .rightToLeft { 44 | context?.translateBy(x: bounds.midX, y: bounds.midY) 45 | context?.scaleBy(x: -1, y: 1) 46 | context?.translateBy(x: -bounds.midX, y: -bounds.midY) 47 | } 48 | 49 | // Get frames of day rows in the range 50 | var dayRowFrames = [CGRect]() 51 | var currentDayRowMinY: CGFloat? 52 | for dayFrame in framesOfDaysToHighlight { 53 | if dayFrame.minY != currentDayRowMinY { 54 | currentDayRowMinY = dayFrame.minY 55 | dayRowFrames.append(dayFrame) 56 | } else { 57 | let lastIndex = dayRowFrames.count - 1 58 | dayRowFrames[lastIndex] = dayRowFrames[lastIndex].union(dayFrame) 59 | } 60 | } 61 | 62 | // Draw rounded rectangles for each day row 63 | for dayRowFrame in dayRowFrames { 64 | let cornerRadius = dayRowFrame.height / 2 65 | let roundedRectanglePath = UIBezierPath(roundedRect: dayRowFrame, cornerRadius: cornerRadius) 66 | context?.addPath(roundedRectanglePath.cgPath) 67 | context?.fillPath() 68 | } 69 | } 70 | 71 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 72 | super.traitCollectionDidChange(previousTraitCollection) 73 | setNeedsDisplay() 74 | } 75 | 76 | // MARK: Fileprivate 77 | 78 | fileprivate var framesOfDaysToHighlight = [CGRect]() { 79 | didSet { 80 | guard framesOfDaysToHighlight != oldValue else { return } 81 | setNeedsDisplay() 82 | } 83 | } 84 | 85 | // MARK: Private 86 | 87 | private let indicatorColor: UIColor 88 | 89 | } 90 | 91 | // MARK: CalendarItemViewRepresentable 92 | 93 | extension DayRangeIndicatorView: CalendarItemViewRepresentable { 94 | 95 | struct InvariantViewProperties: Hashable { 96 | var indicatorColor = UIColor(.accentColor.opacity(0.3)) 97 | } 98 | 99 | struct Content: Equatable { 100 | let framesOfDaysToHighlight: [CGRect] 101 | } 102 | 103 | static func makeView( 104 | withInvariantViewProperties invariantViewProperties: InvariantViewProperties) 105 | -> DayRangeIndicatorView 106 | { 107 | DayRangeIndicatorView(indicatorColor: invariantViewProperties.indicatorColor) 108 | } 109 | 110 | static func setContent(_ content: Content, on view: DayRangeIndicatorView) { 111 | view.framesOfDaysToHighlight = content.framesOfDaysToHighlight 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/DayRangeSelectionTracker.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 2/8/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | enum DayRangeSelectionHelper { 20 | 21 | static func updateDayRange( 22 | afterTapSelectionOf day: DayComponents, 23 | existingDayRange: inout DayComponentsRange?) 24 | { 25 | if 26 | let _existingDayRange = existingDayRange, 27 | _existingDayRange.lowerBound == _existingDayRange.upperBound, 28 | day > _existingDayRange.lowerBound 29 | { 30 | existingDayRange = _existingDayRange.lowerBound...day 31 | } else { 32 | existingDayRange = day...day 33 | } 34 | } 35 | 36 | static func updateDayRange( 37 | afterDragSelectionOf day: DayComponents, 38 | existingDayRange: inout DayComponentsRange?, 39 | initialDayRange: inout DayComponentsRange?, 40 | state: UIGestureRecognizer.State, 41 | calendar: Calendar) 42 | { 43 | switch state { 44 | case .began: 45 | if day != existingDayRange?.lowerBound, day != existingDayRange?.upperBound { 46 | existingDayRange = day...day 47 | } 48 | initialDayRange = existingDayRange 49 | 50 | case .changed, .ended: 51 | guard let initialDayRange else { 52 | fatalError("`initialDayRange` should not be `nil`") 53 | } 54 | 55 | let startingLowerDate = calendar.date(from: initialDayRange.lowerBound.components)! 56 | let startingUpperDate = calendar.date(from: initialDayRange.upperBound.components)! 57 | let selectedDate = calendar.date(from: day.components)! 58 | 59 | let numberOfDaysToLowerDate = calendar.dateComponents( 60 | [.day], 61 | from: selectedDate, 62 | to: startingLowerDate).day! 63 | let numberOfDaysToUpperDate = calendar.dateComponents( 64 | [.day], 65 | from: selectedDate, 66 | to: startingUpperDate).day! 67 | 68 | if 69 | abs(numberOfDaysToLowerDate) < abs(numberOfDaysToUpperDate) || 70 | day < initialDayRange.lowerBound 71 | { 72 | existingDayRange = day...initialDayRange.upperBound 73 | } else if 74 | abs(numberOfDaysToLowerDate) > abs(numberOfDaysToUpperDate) || 75 | day > initialDayRange.upperBound 76 | { 77 | existingDayRange = initialDayRange.lowerBound...day 78 | } 79 | 80 | default: 81 | existingDayRange = nil 82 | initialDayRange = nil 83 | } 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/DayRangeSelectionDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/18/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | final class DayRangeSelectionDemoViewController: BaseDemoViewController { 20 | 21 | // MARK: Internal 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | 26 | title = "Day Range Selection" 27 | 28 | calendarView.daySelectionHandler = { [weak self] day in 29 | guard let self else { return } 30 | 31 | DayRangeSelectionHelper.updateDayRange( 32 | afterTapSelectionOf: day, 33 | existingDayRange: &selectedDayRange) 34 | 35 | calendarView.setContent(makeContent()) 36 | } 37 | 38 | calendarView.multiDaySelectionDragHandler = { [weak self, calendar] day, state in 39 | guard let self else { return } 40 | 41 | DayRangeSelectionHelper.updateDayRange( 42 | afterDragSelectionOf: day, 43 | existingDayRange: &selectedDayRange, 44 | initialDayRange: &selectedDayRangeAtStartOfDrag, 45 | state: state, 46 | calendar: calendar) 47 | 48 | calendarView.setContent(makeContent()) 49 | } 50 | } 51 | 52 | override func makeContent() -> CalendarViewContent { 53 | let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 01))! 54 | let endDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 31))! 55 | 56 | let dateRanges: Set> 57 | let selectedDayRange = selectedDayRange 58 | if 59 | let selectedDayRange, 60 | let lowerBound = calendar.date(from: selectedDayRange.lowerBound.components), 61 | let upperBound = calendar.date(from: selectedDayRange.upperBound.components) 62 | { 63 | dateRanges = [lowerBound...upperBound] 64 | } else { 65 | dateRanges = [] 66 | } 67 | 68 | return CalendarViewContent( 69 | calendar: calendar, 70 | visibleDateRange: startDate...endDate, 71 | monthsLayout: monthsLayout) 72 | 73 | .interMonthSpacing(24) 74 | .verticalDayMargin(8) 75 | .horizontalDayMargin(8) 76 | 77 | .dayItemProvider { [calendar, dayDateFormatter] day in 78 | var invariantViewProperties = DayView.InvariantViewProperties.baseInteractive 79 | 80 | let isSelectedStyle: Bool 81 | if let selectedDayRange { 82 | isSelectedStyle = day == selectedDayRange.lowerBound || day == selectedDayRange.upperBound 83 | } else { 84 | isSelectedStyle = false 85 | } 86 | 87 | if isSelectedStyle { 88 | invariantViewProperties.backgroundShapeDrawingConfig.fillColor = .systemBackground 89 | invariantViewProperties.backgroundShapeDrawingConfig.borderColor = UIColor(.accentColor) 90 | } 91 | 92 | let date = calendar.date(from: day.components) 93 | 94 | return DayView.calendarItemModel( 95 | invariantViewProperties: invariantViewProperties, 96 | content: .init( 97 | dayText: "\(day.day)", 98 | accessibilityLabel: date.map { dayDateFormatter.string(from: $0) }, 99 | accessibilityHint: nil)) 100 | } 101 | 102 | .dayRangeItemProvider(for: dateRanges) { dayRangeLayoutContext in 103 | DayRangeIndicatorView.calendarItemModel( 104 | invariantViewProperties: .init(), 105 | content: .init( 106 | framesOfDaysToHighlight: dayRangeLayoutContext.daysAndFrames.map { $0.frame })) 107 | } 108 | } 109 | 110 | // MARK: Private 111 | 112 | private var selectedDayRange: DayComponentsRange? 113 | private var selectedDayRangeAtStartOfDrag: DayComponentsRange? 114 | 115 | } 116 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/DemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/18/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | import HorizonCalendar 5 | import UIKit 6 | 7 | // MARK: - DemoViewController 8 | 9 | protocol DemoViewController: UIViewController { 10 | 11 | init(monthsLayout: MonthsLayout) 12 | 13 | var calendar: Calendar { get } 14 | var monthsLayout: MonthsLayout { get } 15 | 16 | } 17 | 18 | // MARK: - BaseDemoViewController 19 | 20 | class BaseDemoViewController: UIViewController, DemoViewController { 21 | 22 | // MARK: Lifecycle 23 | 24 | required init(monthsLayout: MonthsLayout) { 25 | self.monthsLayout = monthsLayout 26 | 27 | super.init(nibName: nil, bundle: nil) 28 | } 29 | 30 | required init?(coder _: NSCoder) { 31 | fatalError("init(coder:) has not been implemented") 32 | } 33 | 34 | // MARK: Internal 35 | 36 | let monthsLayout: MonthsLayout 37 | 38 | lazy var calendarView = CalendarView(initialContent: makeContent()) 39 | lazy var calendar = Calendar.current 40 | lazy var dayDateFormatter: DateFormatter = { 41 | let dateFormatter = DateFormatter() 42 | dateFormatter.calendar = calendar 43 | dateFormatter.locale = calendar.locale 44 | dateFormatter.dateFormat = DateFormatter.dateFormat( 45 | fromTemplate: "EEEE, MMM d, yyyy", 46 | options: 0, 47 | locale: calendar.locale ?? Locale.current) 48 | return dateFormatter 49 | }() 50 | 51 | override func viewDidLoad() { 52 | super.viewDidLoad() 53 | 54 | view.backgroundColor = .systemBackground 55 | 56 | view.addSubview(calendarView) 57 | 58 | calendarView.translatesAutoresizingMaskIntoConstraints = false 59 | switch monthsLayout { 60 | case .vertical: 61 | NSLayoutConstraint.activate([ 62 | calendarView.topAnchor.constraint(equalTo: view.topAnchor), 63 | calendarView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 64 | calendarView.leadingAnchor.constraint( 65 | greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), 66 | calendarView.trailingAnchor.constraint( 67 | lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor), 68 | calendarView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 69 | calendarView.widthAnchor.constraint(lessThanOrEqualToConstant: 375), 70 | calendarView.widthAnchor.constraint(equalToConstant: 375).prioritize(at: .defaultLow), 71 | ]) 72 | case .horizontal: 73 | NSLayoutConstraint.activate([ 74 | calendarView.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.centerYAnchor), 75 | calendarView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor), 76 | calendarView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor), 77 | calendarView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 78 | calendarView.widthAnchor.constraint(lessThanOrEqualToConstant: 375), 79 | calendarView.widthAnchor.constraint(equalToConstant: 375).prioritize(at: .defaultLow), 80 | ]) 81 | } 82 | } 83 | 84 | func makeContent() -> CalendarViewContent { 85 | fatalError("Must be implemented by a subclass.") 86 | } 87 | 88 | } 89 | 90 | // MARK: NSLayoutConstraint + Priority Helper 91 | 92 | extension NSLayoutConstraint { 93 | 94 | fileprivate func prioritize(at priority: UILayoutPriority) -> NSLayoutConstraint { 95 | self.priority = priority 96 | return self 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/LargeDayRangeDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/18/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | final class LargeDayRangeDemoViewController: BaseDemoViewController { 20 | 21 | // MARK: Internal 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | 26 | title = "Large Day Range" 27 | } 28 | 29 | override func viewWillLayoutSubviews() { 30 | super.viewWillLayoutSubviews() 31 | 32 | guard !didScrollToInitialMonth else { return } 33 | 34 | let padding: CGFloat 35 | switch monthsLayout { 36 | case .vertical: padding = calendarView.layoutMargins.top 37 | case .horizontal: padding = calendarView.layoutMargins.left 38 | } 39 | 40 | let january1500CE = calendar.date(from: DateComponents(era: 1, year: 1500, month: 01, day: 01))! 41 | calendarView.scroll( 42 | toMonthContaining: january1500CE, 43 | scrollPosition: .firstFullyVisiblePosition(padding: padding), 44 | animated: false) 45 | 46 | didScrollToInitialMonth = true 47 | } 48 | 49 | override func makeContent() -> CalendarViewContent { 50 | let startDate = calendar.date(from: DateComponents(era: 0, year: 0100, month: 01, day: 01))! 51 | let endDate = calendar.date(from: DateComponents(era: 1, year: 2000, month: 12, day: 31))! 52 | 53 | return CalendarViewContent( 54 | calendar: calendar, 55 | visibleDateRange: startDate...endDate, 56 | monthsLayout: monthsLayout) 57 | .interMonthSpacing(24) 58 | } 59 | 60 | // MARK: Private 61 | 62 | private var didScrollToInitialMonth = false 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/MonthBackgroundDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 1/30/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | final class MonthBackgroundDemoViewController: BaseDemoViewController { 20 | 21 | // MARK: Internal 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | 26 | title = "Month Grid Background" 27 | } 28 | 29 | override func makeContent() -> CalendarViewContent { 30 | let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 01))! 31 | let endDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 31))! 32 | 33 | return CalendarViewContent( 34 | calendar: calendar, 35 | visibleDateRange: startDate...endDate, 36 | monthsLayout: monthsLayout) 37 | 38 | .interMonthSpacing(24) 39 | .verticalDayMargin(8) 40 | .horizontalDayMargin(8) 41 | 42 | .monthBackgroundItemProvider { monthLayoutContext in 43 | MonthGridBackgroundView.calendarItemModel( 44 | invariantViewProperties: .init(horizontalDayMargin: 8, verticalDayMargin: 8), 45 | content: .init(framesOfDays: monthLayoutContext.daysAndFrames.map { $0.frame })) 46 | } 47 | } 48 | 49 | // MARK: Private 50 | 51 | private var selectedDate: Date? 52 | 53 | } 54 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/PartialMonthVisibilityDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/23/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | import HorizonCalendar 5 | import UIKit 6 | 7 | final class PartialMonthVisibilityDemoViewController: BaseDemoViewController { 8 | 9 | // MARK: Internal 10 | 11 | override func viewDidLoad() { 12 | super.viewDidLoad() 13 | 14 | title = "Partial Month Visibility" 15 | 16 | calendarView.daySelectionHandler = { [weak self] day in 17 | guard let self else { return } 18 | 19 | selectedDate = calendar.date(from: day.components) 20 | calendarView.setContent(makeContent()) 21 | } 22 | } 23 | 24 | override func makeContent() -> CalendarViewContent { 25 | let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 16))! 26 | let endDate = calendar.date(from: DateComponents(year: 2020, month: 12, day: 05))! 27 | 28 | let selectedDate = selectedDate 29 | 30 | return CalendarViewContent( 31 | calendar: calendar, 32 | visibleDateRange: startDate...endDate, 33 | monthsLayout: monthsLayout) 34 | 35 | .interMonthSpacing(24) 36 | .verticalDayMargin(8) 37 | .horizontalDayMargin(8) 38 | 39 | .dayItemProvider { [calendar, dayDateFormatter] day in 40 | var invariantViewProperties = DayView.InvariantViewProperties.baseInteractive 41 | 42 | let date = calendar.date(from: day.components) 43 | if date == selectedDate { 44 | invariantViewProperties.backgroundShapeDrawingConfig.borderColor = .blue 45 | invariantViewProperties.backgroundShapeDrawingConfig.fillColor = .blue.withAlphaComponent(0.15) 46 | } 47 | 48 | return DayView.calendarItemModel( 49 | invariantViewProperties: invariantViewProperties, 50 | content: .init( 51 | dayText: "\(day.day)", 52 | accessibilityLabel: date.map { dayDateFormatter.string(from: $0) }, 53 | accessibilityHint: nil)) 54 | } 55 | } 56 | 57 | // MARK: Private 58 | 59 | private var selectedDate: Date? 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/ScrollToDayWithAnimationDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/18/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | final class ScrollToDayWithAnimationDemoViewController: BaseDemoViewController { 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | title = "Scroll to Day with Animation" 25 | } 26 | 27 | override func viewDidAppear(_ animated: Bool) { 28 | super.viewDidAppear(animated) 29 | 30 | let july2020 = calendar.date(from: DateComponents(year: 2020, month: 07, day: 11))! 31 | calendarView.scroll( 32 | toDayContaining: july2020, 33 | scrollPosition: .centered, 34 | animated: true) 35 | } 36 | 37 | override func makeContent() -> CalendarViewContent { 38 | let startDate = calendar.date(from: DateComponents(year: 2016, month: 07, day: 01))! 39 | let endDate = calendar.date(from: DateComponents(year: 2020, month: 12, day: 31))! 40 | 41 | return CalendarViewContent( 42 | calendar: calendar, 43 | visibleDateRange: startDate...endDate, 44 | monthsLayout: monthsLayout) 45 | .interMonthSpacing(24) 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SelectedDayTooltipDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/18/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | final class SelectedDayTooltipDemoViewController: BaseDemoViewController { 20 | 21 | // MARK: Internal 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | 26 | title = "Selected Day Tooltip" 27 | 28 | calendarView.daySelectionHandler = { [weak self] day in 29 | guard let self else { return } 30 | 31 | selectedDate = calendar.date(from: day.components) 32 | calendarView.setContent(makeContent()) 33 | } 34 | } 35 | 36 | override func makeContent() -> CalendarViewContent { 37 | let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 01))! 38 | let endDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 31))! 39 | 40 | let selectedDate = selectedDate 41 | 42 | let overlaidItemLocations: Set 43 | if let selectedDate { 44 | overlaidItemLocations = [.day(containingDate: selectedDate)] 45 | } else { 46 | overlaidItemLocations = [] 47 | } 48 | 49 | return CalendarViewContent( 50 | calendar: calendar, 51 | visibleDateRange: startDate...endDate, 52 | monthsLayout: monthsLayout) 53 | 54 | .interMonthSpacing(24) 55 | 56 | .dayItemProvider { [calendar, dayDateFormatter] day in 57 | var invariantViewProperties = DayView.InvariantViewProperties.baseInteractive 58 | 59 | let date = calendar.date(from: day.components) 60 | if date == selectedDate { 61 | invariantViewProperties.backgroundShapeDrawingConfig.borderColor = .blue 62 | invariantViewProperties.backgroundShapeDrawingConfig.fillColor = .blue.withAlphaComponent(0.15) 63 | } 64 | 65 | return DayView.calendarItemModel( 66 | invariantViewProperties: invariantViewProperties, 67 | content: .init( 68 | dayText: "\(day.day)", 69 | accessibilityLabel: date.map { dayDateFormatter.string(from: $0) }, 70 | accessibilityHint: nil)) 71 | } 72 | 73 | .overlayItemProvider(for: overlaidItemLocations) { overlayLayoutContext in 74 | TooltipView.calendarItemModel( 75 | invariantViewProperties: .init(), 76 | content: .init( 77 | frameOfTooltippedItem: overlayLayoutContext.overlaidItemFrame, 78 | text: "Selected Day")) 79 | } 80 | } 81 | 82 | // MARK: Private 83 | 84 | private var selectedDate: Date? 85 | 86 | } 87 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SingleDaySelectionDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/31/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | final class SingleDaySelectionDemoViewController: BaseDemoViewController { 20 | 21 | // MARK: Lifecycle 22 | 23 | required init(monthsLayout: MonthsLayout) { 24 | super.init(monthsLayout: monthsLayout) 25 | selectedDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 19))! 26 | } 27 | 28 | required init?(coder _: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | // MARK: Internal 33 | 34 | override func viewDidLoad() { 35 | super.viewDidLoad() 36 | 37 | title = "Single Day Selection" 38 | 39 | calendarView.daySelectionHandler = { [weak self] day in 40 | guard let self else { return } 41 | 42 | selectedDate = calendar.date(from: day.components) 43 | calendarView.setContent(makeContent()) 44 | } 45 | } 46 | 47 | override func makeContent() -> CalendarViewContent { 48 | let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 01))! 49 | let endDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 31))! 50 | 51 | let selectedDate = selectedDate 52 | 53 | return CalendarViewContent( 54 | calendar: calendar, 55 | visibleDateRange: startDate...endDate, 56 | monthsLayout: monthsLayout) 57 | 58 | .interMonthSpacing(24) 59 | .verticalDayMargin(8) 60 | .horizontalDayMargin(8) 61 | 62 | .dayItemProvider { [calendar, dayDateFormatter] day in 63 | var invariantViewProperties = DayView.InvariantViewProperties.baseInteractive 64 | 65 | let date = calendar.date(from: day.components) 66 | if date == selectedDate { 67 | invariantViewProperties.backgroundShapeDrawingConfig.borderColor = .blue 68 | invariantViewProperties.backgroundShapeDrawingConfig.fillColor = .blue.withAlphaComponent(0.15) 69 | } 70 | 71 | return DayView.calendarItemModel( 72 | invariantViewProperties: invariantViewProperties, 73 | content: .init( 74 | dayText: "\(day.day)", 75 | accessibilityLabel: date.map { dayDateFormatter.string(from: $0) }, 76 | accessibilityHint: nil)) 77 | } 78 | } 79 | 80 | // MARK: Private 81 | 82 | private var selectedDate: Date? 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Demo View Controllers/SwiftUIItemModelsDemoViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 8/23/22. 2 | // Copyright © 2022 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import SwiftUI 18 | import UIKit 19 | 20 | final class SwiftUIItemModelsDemoViewController: BaseDemoViewController { 21 | 22 | // MARK: Internal 23 | 24 | override func viewDidLoad() { 25 | super.viewDidLoad() 26 | 27 | title = "SwiftUI Day and Month Views" 28 | 29 | calendarView.daySelectionHandler = { [weak self] day in 30 | guard let self else { return } 31 | 32 | selectedDate = calendar.date(from: day.components) 33 | calendarView.setContent(makeContent()) 34 | } 35 | } 36 | 37 | override func makeContent() -> CalendarViewContent { 38 | let startDate = calendar.date(from: DateComponents(year: 2020, month: 01, day: 01))! 39 | let endDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 31))! 40 | 41 | let selectedDate = selectedDate 42 | 43 | return CalendarViewContent( 44 | calendar: calendar, 45 | visibleDateRange: startDate...endDate, 46 | monthsLayout: monthsLayout) 47 | 48 | .interMonthSpacing(24) 49 | .verticalDayMargin(8) 50 | .horizontalDayMargin(8) 51 | 52 | .monthHeaderItemProvider { [calendar, monthDateFormatter] month in 53 | guard let firstDateInMonth = calendar.date(from: month.components) else { 54 | preconditionFailure("Could not find a date corresponding to the month \(month).") 55 | } 56 | let monthText = monthDateFormatter.string(from: firstDateInMonth) 57 | return HStack { 58 | Text(monthText).font(.headline) 59 | Spacer() 60 | } 61 | .padding(.vertical) 62 | .accessibilityAddTraits(.isHeader) 63 | .calendarItemModel 64 | } 65 | 66 | .dayItemProvider { [calendar, selectedDate] day in 67 | let date = calendar.date(from: day.components) 68 | let isSelected = date == selectedDate 69 | return SwiftUIDayView(dayNumber: day.day, isSelected: isSelected).calendarItemModel 70 | } 71 | } 72 | 73 | // MARK: Private 74 | 75 | private lazy var monthDateFormatter: DateFormatter = { 76 | let dateFormatter = DateFormatter() 77 | dateFormatter.calendar = calendar 78 | dateFormatter.locale = calendar.locale 79 | dateFormatter.dateFormat = DateFormatter.dateFormat( 80 | fromTemplate: "MMMM yyyy", 81 | options: 0, 82 | locale: calendar.locale ?? Locale.current) 83 | return dateFormatter 84 | }() 85 | 86 | private var selectedDate: Date? 87 | 88 | } 89 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/DemoPickerViewController.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/18/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | // MARK: - DemoPickerViewController 20 | 21 | final class DemoPickerViewController: UIViewController { 22 | 23 | // MARK: Internal 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | 28 | title = "HorizonCalendar Example App" 29 | 30 | view.backgroundColor = .systemBackground 31 | 32 | view.addSubview(tableView) 33 | view.addSubview(monthsLayoutPicker) 34 | 35 | tableView.translatesAutoresizingMaskIntoConstraints = false 36 | monthsLayoutPicker.translatesAutoresizingMaskIntoConstraints = false 37 | NSLayoutConstraint.activate([ 38 | monthsLayoutPicker.centerXAnchor.constraint(equalTo: view.centerXAnchor), 39 | monthsLayoutPicker.topAnchor.constraint( 40 | equalTo: view.layoutMarginsGuide.topAnchor, 41 | constant: 8), 42 | 43 | tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 44 | tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 45 | tableView.topAnchor.constraint(equalTo: monthsLayoutPicker.bottomAnchor, constant: 8), 46 | tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 47 | ]) 48 | } 49 | 50 | override func viewWillAppear(_ animated: Bool) { 51 | super.viewWillAppear(true) 52 | 53 | if let selectedIndexPath = tableView.indexPathForSelectedRow { 54 | tableView.deselectRow(at: selectedIndexPath, animated: animated) 55 | } 56 | } 57 | 58 | // MARK: Private 59 | 60 | private let verticalDemoDestinations: [(name: String, destinationType: DemoViewController.Type)] = 61 | [ 62 | ("Single Day Selection", SingleDaySelectionDemoViewController.self), 63 | ("Day Range Selection", DayRangeSelectionDemoViewController.self), 64 | ("Selected Day Tooltip", SelectedDayTooltipDemoViewController.self), 65 | ("Large Day Range", LargeDayRangeDemoViewController.self), 66 | ("Scroll to Day With Animation", ScrollToDayWithAnimationDemoViewController.self), 67 | ("Partial Month Visibility", PartialMonthVisibilityDemoViewController.self), 68 | ("Month Grid Background", MonthBackgroundDemoViewController.self), 69 | ("SwiftUI Day and Month View", SwiftUIItemModelsDemoViewController.self), 70 | ("SwiftUI Screen", SwiftUIScreenDemoViewController.self), 71 | ] 72 | 73 | private let horizontalDemoDestinations: [(name: String, destinationType: DemoViewController.Type)] = 74 | [ 75 | ("Single Day Selection", SingleDaySelectionDemoViewController.self), 76 | ("Day Range Selection", DayRangeSelectionDemoViewController.self), 77 | ("Selected Day Tooltip", SelectedDayTooltipDemoViewController.self), 78 | ("Large Day Range", LargeDayRangeDemoViewController.self), 79 | ("Scroll to Day With Animation", ScrollToDayWithAnimationDemoViewController.self), 80 | ("Month Grid Background", MonthBackgroundDemoViewController.self), 81 | ("SwiftUI Day and Month View", SwiftUIItemModelsDemoViewController.self), 82 | ("SwiftUI Screen", SwiftUIScreenDemoViewController.self), 83 | ] 84 | 85 | private lazy var tableView: UITableView = { 86 | let tableView = UITableView(frame: .zero) 87 | tableView.dataSource = self 88 | tableView.delegate = self 89 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") 90 | return tableView 91 | }() 92 | 93 | private lazy var monthsLayoutPicker: UISegmentedControl = { 94 | let segmentedControl = UISegmentedControl(items: ["Vertical", "Horizontal"]) 95 | segmentedControl.selectedSegmentIndex = 0 96 | segmentedControl.addTarget( 97 | self, 98 | action: #selector(monthsLayoutPickerValueChanged), 99 | for: .valueChanged) 100 | return segmentedControl 101 | }() 102 | 103 | @objc 104 | private func monthsLayoutPickerValueChanged() { 105 | tableView.reloadData() 106 | } 107 | 108 | } 109 | 110 | // MARK: UITableViewDataSource 111 | 112 | extension DemoPickerViewController: UITableViewDataSource { 113 | 114 | func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { 115 | monthsLayoutPicker.selectedSegmentIndex == 0 116 | ? verticalDemoDestinations.count 117 | : horizontalDemoDestinations.count 118 | } 119 | 120 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 121 | let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) 122 | 123 | let demoDestination = monthsLayoutPicker.selectedSegmentIndex == 0 124 | ? verticalDemoDestinations[indexPath.item] 125 | : horizontalDemoDestinations[indexPath.item] 126 | cell.textLabel?.text = demoDestination.name 127 | 128 | return cell 129 | } 130 | 131 | } 132 | 133 | // MARK: UITableViewDelegate 134 | 135 | extension DemoPickerViewController: UITableViewDelegate { 136 | 137 | func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { 138 | let demoDestination = monthsLayoutPicker.selectedSegmentIndex == 0 139 | ? verticalDemoDestinations[indexPath.item] 140 | : horizontalDemoDestinations[indexPath.item] 141 | 142 | let demoViewController = demoDestination.destinationType.init( 143 | monthsLayout: monthsLayoutPicker.selectedSegmentIndex == 0 144 | ? .vertical( 145 | options: VerticalMonthsLayoutOptions( 146 | pinDaysOfWeekToTop: false, 147 | alwaysShowCompleteBoundaryMonths: false, 148 | scrollsToFirstMonthOnStatusBarTap: false)) 149 | : .horizontal( 150 | options: HorizontalMonthsLayoutOptions( 151 | maximumFullyVisibleMonths: 1.5, 152 | scrollingBehavior: .paginatedScrolling( 153 | .init( 154 | restingPosition: .atLeadingEdgeOfEachMonth, 155 | restingAffinity: .atPositionsClosestToTargetOffset))))) 156 | 157 | navigationController?.pushViewController(demoViewController, animated: true) 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | UIInterfaceOrientationPortraitUpsideDown 37 | 38 | UISupportedInterfaceOrientations~ipad 39 | 40 | UIInterfaceOrientationPortrait 41 | UIInterfaceOrientationPortraitUpsideDown 42 | UIInterfaceOrientationLandscapeLeft 43 | UIInterfaceOrientationLandscapeRight 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/SwiftUIDayView.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 8/23/22. 2 | // Copyright © 2022 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import SwiftUI 18 | 19 | // MARK: - SwiftUIDayView 20 | 21 | struct SwiftUIDayView: View { 22 | 23 | let dayNumber: Int 24 | let isSelected: Bool 25 | 26 | var body: some View { 27 | ZStack(alignment: .center) { 28 | Circle() 29 | .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) 30 | .background { 31 | Circle() 32 | .foregroundColor(isSelected ? Color(UIColor.systemBackground) : .clear) 33 | } 34 | .aspectRatio(1, contentMode: .fill) 35 | Text("\(dayNumber)").foregroundColor(Color(UIColor.label)) 36 | } 37 | .accessibilityAddTraits(.isButton) 38 | } 39 | 40 | } 41 | 42 | // MARK: - SwiftUIDayView_Previews 43 | 44 | struct SwiftUIDayView_Previews: PreviewProvider { 45 | 46 | // MARK: Internal 47 | 48 | static var previews: some View { 49 | Group { 50 | SwiftUIDayView(dayNumber: 1, isSelected: false) 51 | SwiftUIDayView(dayNumber: 19, isSelected: false) 52 | SwiftUIDayView(dayNumber: 27, isSelected: true) 53 | } 54 | .frame(width: 50, height: 50) 55 | } 56 | 57 | // MARK: Private 58 | 59 | private static let calendar = Calendar.current 60 | } 61 | -------------------------------------------------------------------------------- /Example/HorizonCalendarExample/HorizonCalendarExample/TooltipView.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/15/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import HorizonCalendar 17 | import UIKit 18 | 19 | // MARK: - TooltipView 20 | 21 | final class TooltipView: UIView { 22 | 23 | // MARK: Lifecycle 24 | 25 | fileprivate init(invariantViewProperties: InvariantViewProperties) { 26 | backgroundView = UIView() 27 | backgroundView.backgroundColor = invariantViewProperties.backgroundColor 28 | backgroundView.layer.borderColor = invariantViewProperties.borderColor.cgColor 29 | backgroundView.layer.borderWidth = 1 30 | backgroundView.layer.cornerRadius = 6 31 | 32 | label = UILabel() 33 | label.font = invariantViewProperties.font 34 | label.textAlignment = invariantViewProperties.textAlignment 35 | label.lineBreakMode = .byTruncatingTail 36 | label.textColor = invariantViewProperties.textColor 37 | 38 | super.init(frame: .zero) 39 | 40 | isUserInteractionEnabled = false 41 | addSubview(backgroundView) 42 | addSubview(label) 43 | } 44 | 45 | required init?(coder _: NSCoder) { 46 | fatalError("init(coder:) has not been implemented") 47 | } 48 | 49 | // MARK: Internal 50 | 51 | override func layoutSubviews() { 52 | super.layoutSubviews() 53 | 54 | guard let frameOfTooltippedItem else { return } 55 | 56 | label.sizeToFit() 57 | let labelSize = CGSize( 58 | width: min(label.bounds.size.width, bounds.width), 59 | height: label.bounds.size.height) 60 | 61 | let backgroundSize = CGSize(width: labelSize.width + 16, height: labelSize.height + 16) 62 | 63 | let proposedFrame = CGRect( 64 | x: frameOfTooltippedItem.midX - (backgroundSize.width / 2), 65 | y: frameOfTooltippedItem.minY - backgroundSize.height - 4, 66 | width: backgroundSize.width, 67 | height: backgroundSize.height) 68 | 69 | let frame: CGRect 70 | if proposedFrame.maxX > bounds.width { 71 | frame = proposedFrame.applying(.init(translationX: bounds.width - proposedFrame.maxX, y: 0)) 72 | } else if proposedFrame.minX < 0 { 73 | frame = proposedFrame.applying(.init(translationX: -proposedFrame.minX, y: 0)) 74 | } else { 75 | frame = proposedFrame 76 | } 77 | 78 | backgroundView.frame = frame 79 | label.center = backgroundView.center 80 | } 81 | 82 | // MARK: Fileprivate 83 | 84 | fileprivate var frameOfTooltippedItem: CGRect? { 85 | didSet { 86 | guard frameOfTooltippedItem != oldValue else { return } 87 | setNeedsLayout() 88 | } 89 | } 90 | 91 | fileprivate var text: String { 92 | get { label.text ?? "" } 93 | set { label.text = newValue } 94 | } 95 | 96 | // MARK: Private 97 | 98 | private let backgroundView: UIView 99 | private let label: UILabel 100 | 101 | } 102 | 103 | // MARK: CalendarItemViewRepresentable 104 | 105 | extension TooltipView: CalendarItemViewRepresentable { 106 | 107 | struct InvariantViewProperties: Hashable { 108 | var backgroundColor = UIColor.white 109 | var borderColor = UIColor.black 110 | var font = UIFont.systemFont(ofSize: 16) 111 | var textAlignment = NSTextAlignment.center 112 | var textColor = UIColor.black 113 | } 114 | 115 | struct Content: Equatable { 116 | let frameOfTooltippedItem: CGRect? 117 | let text: String 118 | } 119 | 120 | static func makeView( 121 | withInvariantViewProperties invariantViewProperties: InvariantViewProperties) 122 | -> TooltipView 123 | { 124 | TooltipView(invariantViewProperties: invariantViewProperties) 125 | } 126 | 127 | static func setContent(_ content: Content, on view: TooltipView) { 128 | view.frameOfTooltippedItem = content.frameOfTooltippedItem 129 | view.text = content.text 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /HorizonCalendar.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = "HorizonCalendar" 3 | spec.version = "2.0.0" 4 | spec.license = "Apache License, Version 2.0" 5 | spec.summary = "A declarative, performant, calendar UI component that supports use cases ranging from simple date pickers to fully-featured calendar apps." 6 | 7 | spec.description = <<-DESC 8 | `HorizonCalendar` is an interactive calendar component for iOS (compatible with UIKit and SwiftUI). Its declarative API makes updating the calendar straightforward, while also providing many customization points to support a diverse set of designs and use cases. 9 | 10 | Features: 11 | 12 | - Supports all calendars from `Foundation.Calendar` (Gregorian, Japanese, Hebrew, etc.) 13 | - Display months in a vertically-scrolling or horizontally-scrolling layout 14 | - Declarative API that encourages unidirectional data flow for updating the content of the calendar 15 | - A custom layout system that enables virtually infinite date ranges without increasing memory usage 16 | - Animated content updates 17 | - Pagination for horizontally-scrolling calendars 18 | - Self-sizing month headers 19 | - Specify custom views (`UIView` or SwiftUI `View`) for individual days, month headers, and days of the week 20 | - Specify custom views (`UIView` or SwiftUI `View`) to highlight date ranges 21 | - Specify custom views (`UIView` or SwiftUI `View`) to overlay parts of the calendar, enabling features like tooltips 22 | - Specify custom views (`UIView` or SwiftUI `View`) for month background decorations (colors, grids, etc.) 23 | - Specify custom views (`UIView` or SwiftUI `View`) for day background decorations (colors, patterns, etc.) 24 | - A day selection handler to monitor when a day is tapped 25 | - A multi-day selection handler to monitor when multiple days are selected via a drag gesture 26 | - Customizable layout metrics 27 | - Pin the days-of-the-week row to the top 28 | - Show partial boundary months (exactly 2020-03-14 to 2020-04-20, for example) 29 | - Scroll to arbitrary dates and months, with or without animation 30 | - Robust accessibility support 31 | - Inset the content without affecting the scrollable region using layout margins 32 | - Separator below the days-of-the-week row 33 | - Right-to-left layout support 34 | DESC 35 | spec.source = { :git => "https://github.com/airbnb/HorizonCalendar.git", :tag => "v#{spec.version}" } 36 | spec.homepage = "https://github.com/airbnb/HorizonCalendar" 37 | spec.authors = { "Bryan Keller" => "kellerbryan19@gmail.com" } 38 | spec.social_media_url = "https://twitter.com/BKYourWay19" 39 | spec.swift_version = "5.9" 40 | spec.ios.deployment_target = '12.0' 41 | spec.source_files = "Sources/**/*.{swift,h}" 42 | end 43 | -------------------------------------------------------------------------------- /HorizonCalendar.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /HorizonCalendar.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /HorizonCalendar.xcodeproj/xcshareddata/xcschemes/HorizonCalendar.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /HorizonCalendar.xcodeproj/xcuserdata/bryan.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /HorizonCalendar.xcodeproj/xcuserdata/bryan.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | HorizonCalendar.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 9396F3C12483261B008AD306 16 | 17 | primary 18 | 19 | 20 | 9396F3CA2483261B008AD306 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/airbnb/swift", 7 | "state" : { 8 | "revision" : "b408d36b4f5e73ea75441fb9791b849b0a40f58b", 9 | "version" : "1.0.5" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-argument-parser", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-argument-parser", 16 | "state" : { 17 | "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", 18 | "version" : "1.2.3" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8.1 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "HorizonCalendar", 6 | platforms: [ 7 | .iOS(.v12), 8 | ], 9 | products: [ 10 | .library(name: "HorizonCalendar", targets: ["HorizonCalendar"]), 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/airbnb/swift", .upToNextMajor(from: "1.0.1")), 14 | ], 15 | targets: [ 16 | .target( 17 | name: "HorizonCalendar", 18 | path: "Sources"), 19 | .testTarget( 20 | name: "HorizonCalendarTests", 21 | dependencies: ["HorizonCalendar"], 22 | path: "Tests"), 23 | ], 24 | swiftLanguageVersions: [.v5]) 25 | -------------------------------------------------------------------------------- /Sources/Internal/CGFloat+MaxLayoutValue.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 7/26/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | extension CGFloat { 19 | 20 | // Used to work around "... returned inf for an intrinsicContentSize dimension. Using 2.5e+07 21 | // instead." 22 | static let maxLayoutValue: CGFloat = 2.5E07 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Internal/Calendar+Helpers.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/31/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import Foundation 17 | 18 | // MARK: Month Helpers 19 | 20 | extension Calendar { 21 | 22 | func month(containing date: Date) -> Month { 23 | Month( 24 | era: component(.era, from: date), 25 | year: component(.year, from: date), 26 | month: component(.month, from: date), 27 | isInGregorianCalendar: identifier == .gregorian) 28 | } 29 | 30 | func firstDate(of month: Month) -> Date { 31 | guard let firstDate = date(from: month.components) else { 32 | preconditionFailure("Failed to create a `Date` representing the first day of \(month).") 33 | } 34 | 35 | return firstDate 36 | } 37 | 38 | func lastDate(of month: Month) -> Date { 39 | let firstDate = firstDate(of: month) 40 | guard let numberOfDaysInMonth = range(of: .day, in: .month, for: firstDate)?.count else { 41 | preconditionFailure("Could not get number of days in month from \(firstDate).") 42 | } 43 | 44 | let lastDateComponents = DateComponents( 45 | era: month.era, 46 | year: month.year, 47 | month: month.month, 48 | day: numberOfDaysInMonth) 49 | guard let lastDate = date(from: lastDateComponents) else { 50 | preconditionFailure("Failed to create a `Date` representing the last day of \(month).") 51 | } 52 | 53 | return lastDate 54 | } 55 | 56 | func month(byAddingMonths numberOfMonths: Int, to month: Month) -> Month { 57 | guard 58 | let firstDateOfNextMonth = date( 59 | byAdding: .month, 60 | value: numberOfMonths, 61 | to: firstDate(of: month)) 62 | else { 63 | preconditionFailure("Failed to advance \(month) by \(numberOfMonths) months.") 64 | } 65 | 66 | return self.month(containing: firstDateOfNextMonth) 67 | } 68 | 69 | } 70 | 71 | // MARK: Day Helpers 72 | 73 | extension Calendar { 74 | 75 | func day(containing date: Date) -> Day { 76 | let month = Month( 77 | era: component(.era, from: date), 78 | year: component(.year, from: date), 79 | month: component(.month, from: date), 80 | isInGregorianCalendar: identifier == .gregorian) 81 | return Day(month: month, day: component(.day, from: date)) 82 | } 83 | 84 | func startDate(of day: Day) -> Date { 85 | guard let date = date(from: day.components) else { 86 | preconditionFailure("Failed to create a `Date` representing the start of \(day).") 87 | } 88 | 89 | return date 90 | } 91 | 92 | func day(byAddingDays numberOfDays: Int, to day: Day) -> Day { 93 | guard 94 | let firstDateOfNextDay = date(byAdding: .day, value: numberOfDays, to: startDate(of: day)) 95 | else { 96 | preconditionFailure("Failed to advance \(day) by \(numberOfDays) days.") 97 | } 98 | 99 | return self.day(containing: firstDateOfNextDay) 100 | } 101 | 102 | } 103 | 104 | // MARK: Day of Week Helpers 105 | 106 | extension Calendar { 107 | 108 | func dayOfWeekPosition(for date: Date) -> DayOfWeekPosition { 109 | let weekdayComponent = component(.weekday, from: date) 110 | let distanceFromFirstWeekday = firstWeekday - weekdayComponent 111 | 112 | let numberOfPositions = DayOfWeekPosition.numberOfPositions 113 | let weekdayIndex = (numberOfPositions - distanceFromFirstWeekday) % numberOfPositions 114 | 115 | guard let dayOfWeekPosition = DayOfWeekPosition(rawValue: weekdayIndex + 1) else { 116 | preconditionFailure(""" 117 | Could not find a day of the week position for date \(date) in calendar \(self). 118 | """) 119 | } 120 | 121 | return dayOfWeekPosition 122 | } 123 | 124 | func weekdayIndex(for dayOfWeekPosition: DayOfWeekPosition) -> Int { 125 | let indexOfFirstWeekday = firstWeekday - 1 126 | let numberOfWeekdays = DayOfWeekPosition.numberOfPositions 127 | let weekdayIndex = (indexOfFirstWeekday + (dayOfWeekPosition.rawValue - 1)) % numberOfWeekdays 128 | return weekdayIndex 129 | } 130 | 131 | } 132 | 133 | // MARK: Month Row Helpers 134 | 135 | extension Calendar { 136 | 137 | // Some locales have a minimum number of days requirement for a date to be considered in the first 138 | // week. To abstract away this complexity and simplify the layout of "weeks," we do all layout 139 | // calculations based on a date's "row" in a month (which may or not map to a week, depending on 140 | // the minimum days requirement for the first week in some locales). 141 | func rowInMonth(for date: Date) -> Int { 142 | let firstDateOfMonth = firstDate( 143 | of: Month( 144 | era: component(.era, from: date), 145 | year: component(.year, from: date), 146 | month: component(.month, from: date), 147 | isInGregorianCalendar: identifier == .gregorian)) 148 | 149 | let numberOfPositions = DayOfWeekPosition.numberOfPositions 150 | let dayOfWeekPosition = dayOfWeekPosition(for: firstDateOfMonth) 151 | let daysFromEndOfWeek = numberOfPositions - (dayOfWeekPosition.rawValue - 1) 152 | let isFirstDayInFirstWeek = daysFromEndOfWeek >= minimumDaysInFirstWeek 153 | 154 | if isFirstDayInFirstWeek { 155 | return component(.weekOfMonth, from: date) - 1 156 | } else { 157 | return component(.weekOfMonth, from: date) 158 | } 159 | } 160 | 161 | } 162 | -------------------------------------------------------------------------------- /Sources/Internal/CalendarScrollView.swift: -------------------------------------------------------------------------------- 1 | // Created by bryan_keller on 11/27/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | /// A scroll view with altered behavior to better fit the needs of `CalendarView`. 19 | /// 20 | /// - Forces `contentInsetAdjustmentBehavior == .never`. 21 | /// - The main thing this prevents is the situation where the view hierarchy is traversed to find a scroll view, and attempts are made to 22 | /// change that scroll view's `contentInsetAdjustmentBehavior`. 23 | /// - Customizes the accessibility elements of the scroll view 24 | final class CalendarScrollView: UIScrollView { 25 | 26 | // MARK: Lifecycle 27 | 28 | init() { 29 | super.init(frame: .zero) 30 | contentInsetAdjustmentBehavior = .never 31 | } 32 | 33 | required init?(coder _: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | 37 | // MARK: Internal 38 | 39 | var cachedAccessibilityElements: [Any]? 40 | 41 | override var contentInsetAdjustmentBehavior: ContentInsetAdjustmentBehavior { 42 | didSet { 43 | super.contentInsetAdjustmentBehavior = .never 44 | } 45 | } 46 | 47 | override var isAccessibilityElement: Bool { 48 | get { false } 49 | set { } 50 | } 51 | 52 | override var accessibilityElements: [Any]? { 53 | get { 54 | guard let itemViews = subviews as? [ItemView] else { 55 | fatalError("Only `ItemView`s can be used as subviews of the scroll view.") 56 | } 57 | cachedAccessibilityElements = cachedAccessibilityElements ?? itemViews 58 | .filter { 59 | switch $0.itemType { 60 | case .layoutItemType: 61 | return true 62 | default: 63 | return false 64 | } 65 | } 66 | .sorted { 67 | guard 68 | case .layoutItemType(let lhsItemType) = $0.itemType, 69 | case .layoutItemType(let rhsItemType) = $1.itemType 70 | else { 71 | fatalError("Cannot sort views for Voice Over that aren't layout items.") 72 | } 73 | return lhsItemType < rhsItemType 74 | } 75 | 76 | return cachedAccessibilityElements 77 | } 78 | set { } 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Sources/Internal/ColorViewRepresentable.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 1/27/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | enum ColorViewRepresentable: CalendarItemViewRepresentable { 19 | 20 | static func makeView( 21 | withInvariantViewProperties invariantViewProperties: UIColor) 22 | -> UIView 23 | { 24 | let view = UIView() 25 | view.backgroundColor = invariantViewProperties 26 | return view 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Internal/Dictionary+MutatingValueForKey.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/25/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | extension Dictionary { 17 | 18 | // If a value exists for the specified key, it will be returned without invoking 19 | // `missingValueProvider`. If a value does not exist for the specified key, then it will be 20 | // created and stored in the dictionary by invoking `missingValueProvider`. 21 | // 22 | // Useful when a dictionary is used as a cache. 23 | mutating func value(for key: Key, missingValueProvider: () -> Value) -> Value { 24 | if let value = self[key] { 25 | return value 26 | } else { 27 | let value = missingValueProvider() 28 | self[key] = value 29 | return value 30 | } 31 | } 32 | 33 | // If a value exists for the specified key, it will be returned without invoking 34 | // `missingValueProvider`. If a value does not exist for the specified key, then it will be 35 | // created and stored in the dictionary by invoking `missingValueProvider`. 36 | // 37 | // Useful when a dictionary is used as a cache. 38 | mutating func optionalValue(for key: Key, missingValueProvider: () -> Value?) -> Value? { 39 | if let value = self[key] { 40 | return value 41 | } else { 42 | let value = missingValueProvider() 43 | self[key] = value 44 | return value 45 | } 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Internal/DoubleLayoutPassHelpers.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 11/5/21. 2 | // Copyright © 2021 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | // MARK: CalendarView + Double Layout Pass Helpers 19 | 20 | /// `CalendarView`'s `intrinsicContentSize.height` should be equal to the maximum vertical space that a month can 21 | /// occupy. This month height calculation is dependent on the available width. For a horizontally-scrolling calendar, the available width for 22 | /// a month is dependent on the `bounds.width`, the `interMonthSpacing`, and the `maximumFullyVisibleMonths`. For 23 | /// a vertically-scrolling calendar, the available width for a month is dependent on the `bounds.width` and any horizontal layout 24 | /// margins. 25 | /// 26 | /// UIKit does not provide custom views with a final width before calling `intrinsicContentSize`, making it difficult to do 27 | /// width-dependent height calculations in time for the Auto Layout engine's layout computations. When embedding such a view in a 28 | /// `UICollectionViewCell` or `UITableViewCell`, self-sizing will not work correctly due to the view not having enough 29 | /// information to return an accurate `intrinsicContentSize`. None of this is terribly surprising, since the documentation for 30 | /// `intrinsicContentSize` specifically says "...intrinsic size must be independent of the content frame, 31 | /// because there’s no way to dynamically communicate a changed width to the layout system based on a changed height, for 32 | /// example." 33 | /// 34 | /// There is one exception to this rule: `UILabel`. When `-[UILabel setNumberOfLines:]` is called with `0` as the number of 35 | /// lines, the private method `-[UIView _needsDoubleUpdateConstraintPass]` will return `true`. UIKit will then invoke 36 | /// `-[UILabel intrinsicContentSize]` multiple times with the most up-to-date `preferredMaxLayoutWidth`, enabling 37 | /// the label to size correctly. 38 | /// 39 | /// `CalendarView` can leverage `UILabel`'s double layout pass behavior by calling into this extension. Under the hood, a sizing 40 | /// `UILabel` is used to get a second layout pass, giving `CalendarView` a chance to size itself knowing its final width. 41 | 42 | extension CalendarView { 43 | 44 | // MARK: Public 45 | 46 | public override func invalidateIntrinsicContentSize() { 47 | doubleLayoutPassSizingLabel.invalidateIntrinsicContentSize() 48 | } 49 | 50 | public override func setContentHuggingPriority( 51 | _ priority: UILayoutPriority, 52 | for axis: NSLayoutConstraint.Axis) 53 | { 54 | doubleLayoutPassSizingLabel.setContentHuggingPriority(priority, for: axis) 55 | } 56 | 57 | public override func setContentCompressionResistancePriority( 58 | _ priority: UILayoutPriority, 59 | for axis: NSLayoutConstraint.Axis) 60 | { 61 | doubleLayoutPassSizingLabel.setContentCompressionResistancePriority(priority, for: axis) 62 | } 63 | 64 | // MARK: Internal 65 | 66 | func installDoubleLayoutPassSizingLabel() { 67 | doubleLayoutPassSizingLabel.removeFromSuperview() 68 | addSubview(doubleLayoutPassSizingLabel) 69 | subviews.first.map(doubleLayoutPassSizingLabel.sendSubviewToBack(_:)) 70 | 71 | doubleLayoutPassSizingLabel.translatesAutoresizingMaskIntoConstraints = false 72 | NSLayoutConstraint.activate([ 73 | doubleLayoutPassSizingLabel.leadingAnchor.constraint( 74 | equalTo: layoutMarginsGuide.leadingAnchor), 75 | doubleLayoutPassSizingLabel.trailingAnchor.constraint( 76 | equalTo: layoutMarginsGuide.trailingAnchor), 77 | doubleLayoutPassSizingLabel.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 78 | doubleLayoutPassSizingLabel.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), 79 | ]) 80 | } 81 | 82 | } 83 | 84 | // MARK: - WidthDependentIntrinsicContentHeightProviding 85 | 86 | protocol WidthDependentIntrinsicContentHeightProviding: CalendarView { 87 | 88 | func intrinsicContentSize(forHorizontallyInsetWidth width: CGFloat) -> CGSize 89 | 90 | } 91 | 92 | // MARK: - DoubleLayoutPassSizingLabel 93 | 94 | final class DoubleLayoutPassSizingLabel: UILabel { 95 | 96 | // MARK: Lifecycle 97 | 98 | init(provider: WidthDependentIntrinsicContentHeightProviding) { 99 | self.provider = provider 100 | 101 | super.init(frame: .zero) 102 | 103 | numberOfLines = 0 104 | isUserInteractionEnabled = false 105 | isAccessibilityElement = false 106 | isHidden = true 107 | } 108 | 109 | @available(*, unavailable) 110 | required init?(coder _: NSCoder) { 111 | fatalError("init(coder:) has not been implemented") 112 | } 113 | 114 | // MARK: Internal 115 | 116 | override var intrinsicContentSize: CGSize { 117 | guard let provider else { 118 | preconditionFailure( 119 | "The sizing label's `provider` should not be `nil` for the duration of the its life") 120 | } 121 | if preferredMaxLayoutWidth == 0 { 122 | return super.intrinsicContentSize 123 | } else { 124 | return provider.intrinsicContentSize(forHorizontallyInsetWidth: preferredMaxLayoutWidth) 125 | } 126 | } 127 | 128 | // MARK: Private 129 | 130 | private weak var provider: WidthDependentIntrinsicContentHeightProviding? 131 | 132 | } 133 | -------------------------------------------------------------------------------- /Sources/Internal/Hasher+CGRect.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 2/2/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import Foundation 17 | 18 | extension Hasher { 19 | 20 | mutating func combine(_ rect: CGRect) { 21 | combine(rect.origin.x) 22 | combine(rect.origin.y) 23 | combine(rect.size.width) 24 | combine(rect.size.height) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Internal/ItemView.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 1/30/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | // MARK: - ItemView 19 | 20 | /// The container view for every visual item that can be displayed in the calendar. 21 | final class ItemView: UIView { 22 | 23 | // MARK: Lifecycle 24 | 25 | init(initialCalendarItemModel: AnyCalendarItemModel) { 26 | calendarItemModel = initialCalendarItemModel 27 | contentView = calendarItemModel._makeView() 28 | 29 | super.init(frame: .zero) 30 | 31 | contentView.insetsLayoutMarginsFromSafeArea = false 32 | addSubview(contentView) 33 | 34 | updateContent() 35 | } 36 | 37 | required init?(coder _: NSCoder) { 38 | fatalError("init(coder:) has not been implemented") 39 | } 40 | 41 | // MARK: Internal 42 | 43 | override class var layerClass: AnyClass { 44 | CATransformLayer.self 45 | } 46 | 47 | let contentView: UIView 48 | 49 | var selectionHandler: (() -> Void)? 50 | 51 | var itemType: VisibleItem.ItemType? 52 | 53 | override var isAccessibilityElement: Bool { 54 | get { false } 55 | set { } 56 | } 57 | 58 | override var isHidden: Bool { 59 | get { contentView.isHidden } 60 | set { contentView.isHidden = newValue } 61 | } 62 | 63 | var calendarItemModel: AnyCalendarItemModel { 64 | didSet { 65 | guard calendarItemModel._itemViewDifferentiator == oldValue._itemViewDifferentiator else { 66 | preconditionFailure(""" 67 | Cannot configure a reused `ItemView` with a calendar item model that was created with a 68 | different instance of invariant view properties. 69 | """) 70 | } 71 | 72 | // Only update the content if it's different from the old one. 73 | guard !calendarItemModel._isContentEqual(toContentOf: oldValue) else { return } 74 | 75 | updateContent() 76 | } 77 | } 78 | 79 | override func layoutSubviews() { 80 | super.layoutSubviews() 81 | 82 | contentView.frame = bounds 83 | } 84 | 85 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 86 | super.touchesEnded(touches, with: event) 87 | 88 | func isTouchInView(_ touch: UITouch) -> Bool { 89 | contentView.bounds.contains(touch.location(in: contentView)) 90 | } 91 | 92 | if touches.first.map(isTouchInView(_:)) ?? false { 93 | selectionHandler?() 94 | } 95 | } 96 | 97 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 98 | let view = super.hitTest(point, with: event) 99 | 100 | // If the returned view is `self`, that means that our `contentView` isn't interactive for this 101 | // `point`. This can happen if the `contentView` also overrides `hitTest` or `pointInside` to 102 | // customize the interaction. It can also happen if our `contentView` has 103 | // `isUserInteractionEnabled` set to `false`. 104 | if view === self { 105 | return nil 106 | } 107 | 108 | return view 109 | } 110 | 111 | // MARK: Private 112 | 113 | private func updateContent() { 114 | calendarItemModel._setContent(onViewOfSameType: contentView) 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /Sources/Internal/ItemViewReuseManager.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 1/29/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | // MARK: - ItemViewReuseManager 19 | 20 | /// Facilitates the reuse of `ItemView`s to prevent new views being allocated when an existing view of the same type 21 | /// already exists and is off screen / available to be used in a different location. 22 | final class ItemViewReuseManager { 23 | 24 | // MARK: Internal 25 | 26 | func reusedViewContexts( 27 | visibleItems: Set, 28 | reuseUnusedViews: Bool) 29 | -> [ReusedViewContext] 30 | { 31 | var contexts = [ReusedViewContext]() 32 | 33 | var previousViewsForVisibleItems = viewsForVisibleItems 34 | viewsForVisibleItems.removeAll(keepingCapacity: true) 35 | 36 | for visibleItem in visibleItems { 37 | let viewDifferentiator = visibleItem.calendarItemModel._itemViewDifferentiator 38 | 39 | let context: ReusedViewContext = 40 | if let view = previousViewsForVisibleItems.removeValue(forKey: visibleItem) 41 | { 42 | ReusedViewContext( 43 | view: view, 44 | visibleItem: visibleItem, 45 | isViewReused: true, 46 | isReusedViewSameAsPreviousView: true) 47 | } else if !(unusedViewsForViewDifferentiators[viewDifferentiator]?.isEmpty ?? true) { 48 | ReusedViewContext( 49 | view: unusedViewsForViewDifferentiators[viewDifferentiator]!.remove(at: 0), 50 | visibleItem: visibleItem, 51 | isViewReused: true, 52 | isReusedViewSameAsPreviousView: false) 53 | } else { 54 | ReusedViewContext( 55 | view: ItemView(initialCalendarItemModel: visibleItem.calendarItemModel), 56 | visibleItem: visibleItem, 57 | isViewReused: false, 58 | isReusedViewSameAsPreviousView: false) 59 | } 60 | 61 | contexts.append(context) 62 | 63 | viewsForVisibleItems[visibleItem] = context.view 64 | } 65 | 66 | if reuseUnusedViews { 67 | for (visibleItem, unusedView) in previousViewsForVisibleItems { 68 | let viewDifferentiator = visibleItem.calendarItemModel._itemViewDifferentiator 69 | unusedViewsForViewDifferentiators[viewDifferentiator, default: .init()].append(unusedView) 70 | } 71 | } 72 | 73 | return contexts 74 | } 75 | 76 | // MARK: Private 77 | 78 | private var viewsForVisibleItems = [VisibleItem: ItemView]() 79 | private var unusedViewsForViewDifferentiators = [_CalendarItemViewDifferentiator: [ItemView]]() 80 | 81 | } 82 | 83 | // MARK: - ReusedViewContext 84 | 85 | struct ReusedViewContext { 86 | let view: ItemView 87 | let visibleItem: VisibleItem 88 | let isViewReused: Bool 89 | let isReusedViewSameAsPreviousView: Bool 90 | } 91 | -------------------------------------------------------------------------------- /Sources/Internal/LayoutItem.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 3/29/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | // MARK: - LayoutItem 19 | 20 | /// Represents an item that's used to build the base layout of the calendar. 21 | struct LayoutItem { 22 | let itemType: ItemType 23 | let frame: CGRect 24 | } 25 | 26 | // MARK: LayoutItem.ItemType 27 | 28 | extension LayoutItem { 29 | 30 | enum ItemType: Equatable, Hashable { 31 | 32 | case monthHeader(Month) 33 | case dayOfWeekInMonth(position: DayOfWeekPosition, month: Month) 34 | case day(Day) 35 | 36 | var month: Month { 37 | switch self { 38 | case .monthHeader(let month): return month 39 | case .dayOfWeekInMonth(_, let month): return month 40 | case .day(let day): return day.month 41 | } 42 | } 43 | 44 | } 45 | 46 | } 47 | 48 | // MARK: - LayoutItem.ItemType + Comparable 49 | 50 | extension LayoutItem.ItemType: Comparable { 51 | 52 | static func < (lhs: LayoutItem.ItemType, rhs: LayoutItem.ItemType) -> Bool { 53 | switch (lhs, rhs) { 54 | case (.monthHeader(let lhsMonth), .monthHeader(let rhsMonth)): 55 | return lhsMonth < rhsMonth 56 | case (.monthHeader(let lhsMonth), .dayOfWeekInMonth(_, let rhsMonth)): 57 | return lhsMonth <= rhsMonth 58 | case (.monthHeader(let lhsMonth), .day(let rhsDay)): 59 | return lhsMonth <= rhsDay.month 60 | 61 | case ( 62 | .dayOfWeekInMonth(let lhsPosition, let lhsMonth), 63 | .dayOfWeekInMonth(let rhsPosition, let rhsMonth)): 64 | return lhsMonth < rhsMonth || (lhsMonth == rhsMonth && lhsPosition < rhsPosition) 65 | case (.dayOfWeekInMonth(_, let lhsMonth), .monthHeader(let rhsMonth)): 66 | return lhsMonth < rhsMonth 67 | case (.dayOfWeekInMonth(_, let lhsMonth), .day(let rhsDay)): 68 | return lhsMonth <= rhsDay.month 69 | 70 | case (.day(let lhsDay), .day(let rhsDay)): 71 | return lhsDay < rhsDay 72 | case (.day(let lhsDay), .monthHeader(let rhsMonth)): 73 | return lhsDay.month < rhsMonth 74 | case (.day(let lhsDay), .dayOfWeekInMonth(_, let rhsMonth)): 75 | return lhsDay.month < rhsMonth 76 | } 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Internal/LayoutItemTypeEnumerator.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 2/12/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | // MARK: - LayoutItemTypeEnumerator 19 | 20 | /// Facilitates the enumeration of layout item types adjacent to a starting layout item type. For example, month header -> weekday 21 | /// header (x7) -> day (x31) -> month header (cont...). The core structure of the calendar (the ordering of it's core elements) is defined by 22 | /// this class. 23 | final class LayoutItemTypeEnumerator { 24 | 25 | // MARK: Lifecycle 26 | 27 | init(calendar: Calendar, monthsLayout: MonthsLayout, monthRange: MonthRange, dayRange: DayRange) { 28 | self.calendar = calendar 29 | self.monthsLayout = monthsLayout 30 | self.monthRange = monthRange 31 | self.dayRange = dayRange 32 | } 33 | 34 | // MARK: Internal 35 | 36 | func enumerateItemTypes( 37 | startingAt startingItemType: LayoutItem.ItemType, 38 | itemTypeHandlerLookingBackwards: (LayoutItem.ItemType, _ shouldStop: inout Bool) -> Void, 39 | itemTypeHandlerLookingForwards: (LayoutItem.ItemType, _ shouldStop: inout Bool) -> Void) 40 | { 41 | var currentItemType = previousItemType(from: startingItemType) 42 | 43 | var shouldStopLookingBackwards = false 44 | while !shouldStopLookingBackwards { 45 | guard isItemTypeInRange(currentItemType) else { break } 46 | itemTypeHandlerLookingBackwards(currentItemType, &shouldStopLookingBackwards) 47 | currentItemType = previousItemType(from: currentItemType) 48 | } 49 | 50 | currentItemType = startingItemType 51 | 52 | var shouldStopLookingForwards = false 53 | while !shouldStopLookingForwards { 54 | guard isItemTypeInRange(currentItemType) else { break } 55 | itemTypeHandlerLookingForwards(currentItemType, &shouldStopLookingForwards) 56 | currentItemType = nextItemType(from: currentItemType) 57 | } 58 | } 59 | 60 | // MARK: Private 61 | 62 | private let calendar: Calendar 63 | private let monthsLayout: MonthsLayout 64 | private let monthRange: MonthRange 65 | private let dayRange: DayRange 66 | 67 | private func isItemTypeInRange(_ itemType: LayoutItem.ItemType) -> Bool { 68 | switch itemType { 69 | case .monthHeader(let month): 70 | return monthRange.contains(month) 71 | case .dayOfWeekInMonth(_, let month): 72 | return monthRange.contains(month) 73 | case .day(let day): 74 | return dayRange.contains(day) 75 | } 76 | } 77 | 78 | private func previousItemType(from itemType: LayoutItem.ItemType) -> LayoutItem.ItemType { 79 | switch itemType { 80 | case .monthHeader(let month): 81 | let previousMonth = calendar.month(byAddingMonths: -1, to: month) 82 | let lastDateOfPreviousMonth = calendar.lastDate(of: previousMonth) 83 | return .day(calendar.day(containing: lastDateOfPreviousMonth)) 84 | 85 | case .dayOfWeekInMonth(let position, let month): 86 | if position == .first { 87 | return .monthHeader(month) 88 | } else { 89 | guard let previousPosition = DayOfWeekPosition(rawValue: position.rawValue - 1) else { 90 | preconditionFailure("Could not get the day-of-week position preceding \(position).") 91 | } 92 | return .dayOfWeekInMonth(position: previousPosition, month: month) 93 | } 94 | 95 | case .day(let day): 96 | if day.day == 1 || day == dayRange.lowerBound { 97 | if case .vertical(let options) = monthsLayout, options.pinDaysOfWeekToTop { 98 | return .monthHeader(day.month) 99 | } else { 100 | return .dayOfWeekInMonth(position: .last, month: day.month) 101 | } 102 | } else { 103 | return .day(calendar.day(byAddingDays: -1, to: day)) 104 | } 105 | } 106 | } 107 | 108 | private func nextItemType(from itemType: LayoutItem.ItemType) -> LayoutItem.ItemType { 109 | switch itemType { 110 | case .monthHeader(let month): 111 | if case .vertical(let options) = monthsLayout, options.pinDaysOfWeekToTop { 112 | return .day(firstDayInRange(in: month)) 113 | } else { 114 | return .dayOfWeekInMonth(position: .first, month: month) 115 | } 116 | 117 | case .dayOfWeekInMonth(let position, let month): 118 | if position == .last { 119 | return .day(firstDayInRange(in: month)) 120 | } else { 121 | guard let nextPosition = DayOfWeekPosition(rawValue: position.rawValue + 1) else { 122 | preconditionFailure("Could not get the day-of-week position succeeding \(position).") 123 | } 124 | return .dayOfWeekInMonth(position: nextPosition, month: month) 125 | } 126 | 127 | case .day(let day): 128 | let nextDay = calendar.day(byAddingDays: 1, to: day) 129 | if day.month != nextDay.month { 130 | return .monthHeader(nextDay.month) 131 | } else if day == dayRange.upperBound { 132 | let nextMonth = calendar.month(byAddingMonths: 1, to: nextDay.month) 133 | return .monthHeader(nextMonth) 134 | } else { 135 | return .day(nextDay) 136 | } 137 | } 138 | } 139 | 140 | private func firstDayInRange(in month: Month) -> Day { 141 | let firstDate = calendar.firstDate(of: month) 142 | let firstDay = calendar.day(containing: firstDate) 143 | 144 | if month == dayRange.lowerBound.month { 145 | return max(firstDay, dayRange.lowerBound) 146 | } else { 147 | return firstDay 148 | } 149 | } 150 | 151 | } 152 | -------------------------------------------------------------------------------- /Sources/Internal/PaginationHelpers.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 1/26/21. 2 | // Copyright © 2021 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | enum PaginationHelpers { 19 | 20 | static func closestPageIndex(forOffset offset: CGFloat, pageSize: CGFloat) -> Int { 21 | Int((offset / pageSize).rounded()) 22 | } 23 | 24 | /// Returns the closest valid page offset to the target offset. This function is used when the horizontal pagination resting affinity is 25 | /// set to `.atPositionsClosestToTargetOffset`. 26 | static func closestPageOffset( 27 | toTargetOffset targetOffset: CGFloat, 28 | touchUpOffset: CGFloat, 29 | velocity: CGFloat, 30 | pageSize: CGFloat) 31 | -> CGFloat 32 | { 33 | let closestTargetPageIndex = closestPageIndex(forOffset: targetOffset, pageSize: pageSize) 34 | let proposedFinalOffset = CGFloat(closestTargetPageIndex) * pageSize 35 | 36 | if velocity > 0, proposedFinalOffset < touchUpOffset { 37 | return proposedFinalOffset + pageSize 38 | } else if velocity < 0, proposedFinalOffset > touchUpOffset { 39 | return proposedFinalOffset - pageSize 40 | } else { 41 | return proposedFinalOffset 42 | } 43 | } 44 | 45 | /// Returns the closest valid page offset to the current page. This function is used when the horizontal pagination resting affinity is 46 | /// set to `.atPositionsAdjacentToPrevious`. 47 | static func adjacentPageOffset( 48 | toPreviousPageIndex previousPageIndex: Int, 49 | targetOffset: CGFloat, 50 | velocity: CGFloat, 51 | pageSize: CGFloat) 52 | -> CGFloat 53 | { 54 | let closestTargetPageIndex = closestPageIndex(forOffset: targetOffset, pageSize: pageSize) 55 | 56 | let pageIndex: Int 57 | if velocity > 0 || closestTargetPageIndex > previousPageIndex { 58 | pageIndex = previousPageIndex + 1 59 | } else if velocity < 0 || closestTargetPageIndex < previousPageIndex { 60 | pageIndex = previousPageIndex - 1 61 | } else { 62 | pageIndex = previousPageIndex 63 | } 64 | 65 | return CGFloat(pageIndex) * pageSize 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Internal/ScreenPixelAlignment.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 10/3/19. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | extension CGFloat { 19 | 20 | /// Rounds `self` so that it's aligned on a pixel boundary for a screen with the provided scale. 21 | func alignedToPixel(forScreenWithScale scale: CGFloat) -> CGFloat { 22 | (self * scale).rounded() / scale 23 | } 24 | 25 | /// Tests `self` for approximate equality, first rounding the operands to be pixel-aligned for a screen with the given 26 | /// `screenScale`. For example, 1.48 equals 1.52 if the `screenScale` is `2`. 27 | func isEqual(to rhs: CGFloat, screenScale: CGFloat) -> Bool { 28 | let lhs = alignedToPixel(forScreenWithScale: screenScale) 29 | let rhs = rhs.alignedToPixel(forScreenWithScale: screenScale) 30 | return lhs == rhs 31 | } 32 | 33 | } 34 | 35 | extension CGRect { 36 | 37 | /// Rounds a `CGRect`'s `origin` and `size` values so that they're aligned on pixel boundaries for a screen with the provided 38 | /// scale. 39 | func alignedToPixels(forScreenWithScale scale: CGFloat) -> CGRect { 40 | CGRect( 41 | x: minX.alignedToPixel(forScreenWithScale: scale), 42 | y: minY.alignedToPixel(forScreenWithScale: scale), 43 | width: width.alignedToPixel(forScreenWithScale: scale), 44 | height: height.alignedToPixel(forScreenWithScale: scale)) 45 | } 46 | 47 | } 48 | 49 | extension CGPoint { 50 | 51 | /// Rounds a `CGPoints`'s `x` and `y` values so that they're aligned on pixel boundaries for a screen with the provided scale. 52 | func alignedToPixels(forScreenWithScale scale: CGFloat) -> CGPoint { 53 | CGPoint( 54 | x: x.alignedToPixel(forScreenWithScale: scale), 55 | y: y.alignedToPixel(forScreenWithScale: scale)) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Internal/ScrollToItemContext.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 4/22/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | // MARK: - ScrollToItemContext 19 | 20 | struct ScrollToItemContext { 21 | let targetItem: TargetItem 22 | let scrollPosition: CalendarViewScrollPosition 23 | let animated: Bool 24 | } 25 | 26 | // MARK: ScrollToItemContext.TargetItem 27 | 28 | extension ScrollToItemContext { 29 | 30 | enum TargetItem { 31 | case month(Month) 32 | case day(Day) 33 | } 34 | 35 | } 36 | 37 | // MARK: ScrollToItemContext.PositionRelativeToVisibleBounds 38 | 39 | extension ScrollToItemContext { 40 | 41 | enum PositionRelativeToVisibleBounds { 42 | case before 43 | case after 44 | case partiallyOrFullyVisible(frame: CGRect) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Internal/SubviewInsertionIndexTracker.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 12/3/22. 2 | // Copyright © 2022 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | /// Tracks the correct insertion index when adding subviews during layout, ensuring that item views are inserted in the correct position in 17 | /// the `subviews` array so that they're ordered correctly along the z-axis. 18 | final class SubviewInsertionIndexTracker { 19 | 20 | // MARK: Internal 21 | 22 | func insertionIndex( 23 | forSubviewWithCorrespondingItemType itemType: VisibleItem.ItemType) 24 | -> Int 25 | { 26 | let index: Int 27 | switch itemType { 28 | case .monthBackground: 29 | index = monthBackgroundItemsEndIndex 30 | case .dayBackground: 31 | index = dayBackgroundItemsEndIndex 32 | case .dayRange: 33 | index = dayRangeItemsEndIndex 34 | case .layoutItemType: 35 | index = mainItemsEndIndex 36 | case .daysOfWeekRowSeparator: 37 | index = daysOfWeekRowSeparatorItemsEndIndex 38 | case .overlayItem: 39 | index = overlayItemsEndIndex 40 | case .pinnedDaysOfWeekRowBackground: 41 | index = pinnedDaysOfWeekRowBackgroundEndIndex 42 | case .pinnedDayOfWeek: 43 | index = pinnedDayOfWeekItemsEndIndex 44 | case .pinnedDaysOfWeekRowSeparator: 45 | index = pinnedDaysOfWeekRowSeparatorEndIndex 46 | } 47 | 48 | addValue(1, toItemTypesAffectedBy: itemType) 49 | 50 | return index 51 | } 52 | 53 | func removedSubview(withCorrespondingItemType itemType: VisibleItem.ItemType) { 54 | addValue(-1, toItemTypesAffectedBy: itemType) 55 | } 56 | 57 | // MARK: Private 58 | 59 | private var monthBackgroundItemsEndIndex = 0 60 | private var dayBackgroundItemsEndIndex = 0 61 | private var dayRangeItemsEndIndex = 0 62 | private var mainItemsEndIndex = 0 63 | private var daysOfWeekRowSeparatorItemsEndIndex = 0 64 | private var overlayItemsEndIndex = 0 65 | private var pinnedDaysOfWeekRowBackgroundEndIndex = 0 66 | private var pinnedDayOfWeekItemsEndIndex = 0 67 | private var pinnedDaysOfWeekRowSeparatorEndIndex = 0 68 | 69 | private func addValue(_ value: Int, toItemTypesAffectedBy itemType: VisibleItem.ItemType) { 70 | switch itemType { 71 | case .monthBackground: 72 | monthBackgroundItemsEndIndex += value 73 | dayRangeItemsEndIndex += value 74 | mainItemsEndIndex += value 75 | daysOfWeekRowSeparatorItemsEndIndex += value 76 | overlayItemsEndIndex += value 77 | pinnedDaysOfWeekRowBackgroundEndIndex += value 78 | pinnedDayOfWeekItemsEndIndex += value 79 | pinnedDaysOfWeekRowSeparatorEndIndex += value 80 | 81 | case .dayBackground: 82 | dayBackgroundItemsEndIndex += value 83 | dayRangeItemsEndIndex += value 84 | mainItemsEndIndex += value 85 | daysOfWeekRowSeparatorItemsEndIndex += value 86 | overlayItemsEndIndex += value 87 | pinnedDaysOfWeekRowBackgroundEndIndex += value 88 | pinnedDayOfWeekItemsEndIndex += value 89 | pinnedDaysOfWeekRowSeparatorEndIndex += value 90 | 91 | case .dayRange: 92 | dayRangeItemsEndIndex += value 93 | mainItemsEndIndex += value 94 | daysOfWeekRowSeparatorItemsEndIndex += value 95 | overlayItemsEndIndex += value 96 | pinnedDaysOfWeekRowBackgroundEndIndex += value 97 | pinnedDayOfWeekItemsEndIndex += value 98 | pinnedDaysOfWeekRowSeparatorEndIndex += value 99 | 100 | case .layoutItemType: 101 | mainItemsEndIndex += value 102 | daysOfWeekRowSeparatorItemsEndIndex += value 103 | overlayItemsEndIndex += value 104 | pinnedDaysOfWeekRowBackgroundEndIndex += value 105 | pinnedDayOfWeekItemsEndIndex += value 106 | pinnedDaysOfWeekRowSeparatorEndIndex += value 107 | 108 | case .daysOfWeekRowSeparator: 109 | daysOfWeekRowSeparatorItemsEndIndex += value 110 | overlayItemsEndIndex += value 111 | pinnedDaysOfWeekRowBackgroundEndIndex += value 112 | pinnedDayOfWeekItemsEndIndex += value 113 | pinnedDaysOfWeekRowSeparatorEndIndex += value 114 | 115 | case .overlayItem: 116 | overlayItemsEndIndex += value 117 | pinnedDaysOfWeekRowBackgroundEndIndex += value 118 | pinnedDayOfWeekItemsEndIndex += value 119 | pinnedDaysOfWeekRowSeparatorEndIndex += value 120 | 121 | case .pinnedDaysOfWeekRowBackground: 122 | pinnedDaysOfWeekRowBackgroundEndIndex += value 123 | pinnedDayOfWeekItemsEndIndex += value 124 | pinnedDaysOfWeekRowSeparatorEndIndex += value 125 | 126 | case .pinnedDayOfWeek: 127 | pinnedDayOfWeekItemsEndIndex += value 128 | pinnedDaysOfWeekRowSeparatorEndIndex += value 129 | 130 | case .pinnedDaysOfWeekRowSeparator: 131 | pinnedDaysOfWeekRowSeparatorEndIndex += value 132 | } 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /Sources/Internal/UIView+NoAnimation.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 10/4/22. 2 | // Copyright © 2022 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | extension UIView { 19 | 20 | static func conditionallyPerformWithoutAnimation( 21 | when condition: Bool, 22 | _ actions: () -> Void) 23 | { 24 | if condition { 25 | UIView.performWithoutAnimation(actions) 26 | } else { 27 | actions() 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Internal/VisibleItem.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 1/29/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | // MARK: - VisibleItem 19 | 20 | /// Represents any visible item in the calendar, ranging from core layout items like month headers and days, to secondary items like 21 | /// day range items and overlay items. 22 | /// 23 | /// - Note: This is a reference type because it's heavily used in `Set`s, especially in the reuse manager. By making it a reference 24 | /// type, we avoid `VisibleItem` `initializeWithCopy` when mutating the `Set`s. This type also caches its hash 25 | /// value, which otherwise would be recomputed for every `Set` operation performed by the reuse manager. On an iPhone 6s, this 26 | /// reduces CPU usage by nearly 10% when programmatically scrolling down at a rate of 500 points / frame. 27 | final class VisibleItem { 28 | 29 | // MARK: Lifecycle 30 | 31 | init(calendarItemModel: AnyCalendarItemModel, itemType: ItemType, frame: CGRect) { 32 | self.calendarItemModel = calendarItemModel 33 | self.itemType = itemType 34 | self.frame = frame 35 | 36 | var hasher = Hasher() 37 | hasher.combine(calendarItemModel._itemViewDifferentiator) 38 | hasher.combine(itemType) 39 | cachedHashValue = hasher.finalize() 40 | } 41 | 42 | // MARK: Internal 43 | 44 | let calendarItemModel: AnyCalendarItemModel 45 | let itemType: ItemType 46 | let frame: CGRect 47 | 48 | // MARK: Private 49 | 50 | // Performance optimization - storing this separately speeds up the `Hashable` implementation, 51 | // which is frequently invoked by the `ItemViewReuseManager`'s `Set` operations. 52 | private let cachedHashValue: Int 53 | 54 | } 55 | 56 | // MARK: Equatable 57 | 58 | extension VisibleItem: Equatable { 59 | 60 | static func == (lhs: VisibleItem, rhs: VisibleItem) -> Bool { 61 | lhs.calendarItemModel._itemViewDifferentiator == rhs.calendarItemModel._itemViewDifferentiator && 62 | lhs.itemType == rhs.itemType 63 | } 64 | 65 | } 66 | 67 | // MARK: Hashable 68 | 69 | extension VisibleItem: Hashable { 70 | 71 | func hash(into hasher: inout Hasher) { 72 | hasher.combine(cachedHashValue) 73 | } 74 | 75 | } 76 | 77 | // MARK: VisibleItem.ItemType 78 | 79 | extension VisibleItem { 80 | 81 | enum ItemType: Equatable, Hashable { 82 | case layoutItemType(LayoutItem.ItemType) 83 | case dayBackground(Day) 84 | case monthBackground(Month) 85 | case pinnedDayOfWeek(DayOfWeekPosition) 86 | case pinnedDaysOfWeekRowBackground 87 | case pinnedDaysOfWeekRowSeparator 88 | case daysOfWeekRowSeparator(Month) 89 | case dayRange(DayRange) 90 | case overlayItem(OverlaidItemLocation) 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /Sources/Public/AnyCalendarItemModel.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 7/15/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | // MARK: - AnyCalendarItemModel 19 | 20 | /// A type-erased calendar item model. 21 | /// 22 | /// Useful for working with types conforming to `CalendarItemModel` without knowing the underlying concrete type. 23 | public protocol AnyCalendarItemModel { 24 | 25 | /// A type that helps `ItemViewReuseManager` determine which views are compatible with one another and can therefore be 26 | /// recycled / reused. 27 | /// 28 | /// - Note: There is no reason to access this property from your feature code; it should only be accessed internally. 29 | var _itemViewDifferentiator: _CalendarItemViewDifferentiator { get } 30 | 31 | /// Builds an instance of `ViewType` by invoking its initializer with `invariantViewProperties`. 32 | /// 33 | /// - Note: There is no reason to invoke this function from your feature code; it should only be invoked internally. 34 | func _makeView() -> UIView 35 | 36 | /// Updates the content on an instance of `ViewType` by invoking `setContent`. 37 | /// 38 | /// - Note: There is no reason to invoke this function from your feature code; it should only be invoked internally. 39 | func _setContent(onViewOfSameType view: UIView) 40 | 41 | /// Compares the contents of two `CalendarItemModel`s for equality. 42 | /// 43 | /// - Note: There is no reason to invoke this function from your feature code; it should only be invoked internally. 44 | func _isContentEqual(toContentOf other: AnyCalendarItemModel) -> Bool 45 | 46 | // TODO: Remove this in the next major release. 47 | mutating func _setSwiftUIWrapperViewContentIDIfNeeded(_ id: AnyHashable) 48 | 49 | } 50 | 51 | // MARK: - _CalendarItemViewDifferentiator 52 | 53 | /// A type that helps `ItemViewReuseManager` determine which views are compatible with one another and can therefore be 54 | /// recycled / reused. 55 | /// 56 | /// - Note: There is no reason to create an instance of this enum from your feature code; it should only be invoked internally. 57 | public struct _CalendarItemViewDifferentiator: Hashable { 58 | let viewType: ObjectIdentifier 59 | let invariantViewProperties: AnyHashable 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Public/CalendarItemViewRepresentable.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 7/15/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | // MARK: - CalendarItemViewRepresentable 19 | 20 | /// A protocol to which types that can create and update views displayed in `CalendarView` must conform. By conforming to this 21 | /// protocol, `CalendarView` is able to treat each view it displays as a function of its `invariantViewProperties` and its 22 | /// `content`, simplifying the initialization and state updating of views. 23 | public protocol CalendarItemViewRepresentable { 24 | 25 | /// The type of view that this `CalendarItemViewRepresentable` can create and update. 26 | associatedtype ViewType: UIView 27 | 28 | /// A type containing all of the immutable / initial setup values necessary to initialize the view. Use this to configure appearance 29 | /// options that do not change based on the data in the `content`. 30 | associatedtype InvariantViewProperties: Hashable 31 | 32 | /// A type containing all of the variable data necessary to update the view. Use this to update the dynamic, data-driven parts of the 33 | /// view. 34 | /// 35 | /// If your view does not depend on any variable data, then `Content` can be `Never` and `setContent(_:on)` does not 36 | /// need to be implemented. This is not common, since most views used in the calendar change what they display based on the 37 | /// parameters passed into the `*itemProvider*` closures (e.g. the current day, month, or day range layout context information). 38 | associatedtype Content: Equatable = Never 39 | 40 | /// Creates a view using a set of invariant view properties that contain all of the immutable / initial setup values necessary to 41 | /// configure the view. All immutable / view-model-independent properties should be configured here. For example, you might set up 42 | /// a `UILabel`'s `textAlignment`, `textColor`, and `font`, assuming none of those properties change in response to 43 | /// `content` updates. 44 | /// 45 | /// - Parameters: 46 | /// - invariantViewProperties: An instance containing all of the immutable / initial setup values necessary to initialize the 47 | /// view. Use this to configure appearance options that do not change based on the data in the `content`. 48 | static func makeView( 49 | withInvariantViewProperties invariantViewProperties: InvariantViewProperties) 50 | -> ViewType 51 | 52 | /// Sets the content on your view. `CalendarView` invokes this whenever a view's data is stale and needs to be updated to 53 | /// reflect the data in a new content instance. 54 | /// 55 | /// - Parameters: 56 | /// - content: An instance containing all of the variable data necessary to update the view. 57 | static func setContent(_ content: Content, on view: ViewType) 58 | 59 | } 60 | 61 | extension CalendarItemViewRepresentable where Content == Never { 62 | public static func setContent(_: Content, on _: ViewType) { } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Public/CalendarViewProxy.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/24/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import Foundation 17 | 18 | /// A proxy type that enables a `CalendarViewRepresentable` to be programmatically scrolled. Initialize this type yourself from 19 | /// your SwiftUI view as a `@StateObject`, and use it when initializing a `CalendarViewRepresentable`. 20 | @available(iOS 13.0, *) 21 | public final class CalendarViewProxy: ObservableObject { 22 | 23 | // MARK: Lifecycle 24 | 25 | public init() { } 26 | 27 | // MARK: Public 28 | 29 | /// The range of months that are partially or fully visible. 30 | public var visibleMonthRange: MonthComponentsRange? { 31 | calendarView.visibleMonthRange 32 | } 33 | 34 | /// The range of months that are partially or fully visible. 35 | public var visibleDayRange: DayComponentsRange? { 36 | calendarView.visibleDayRange 37 | } 38 | 39 | /// Scrolls the calendar to the specified month with the specified position. 40 | /// 41 | /// If the calendar has a non-zero frame, this function will scroll to the specified month immediately. Otherwise the scroll-to-month 42 | /// action will be queued and executed once the calendar has a non-zero frame. If this function is invoked multiple times before the 43 | /// calendar has a non-zero frame, only the most recent scroll-to-month action will be executed. 44 | /// 45 | /// - Parameters: 46 | /// - dateInTargetMonth: A date in the target month to which to scroll into view. 47 | /// - scrollPosition: The final position of the `CalendarView`'s scrollable region after the scroll completes. 48 | /// - animated: Whether the scroll should be animated (from the current position), or whether the scroll should update the 49 | /// visible region immediately with no animation. 50 | public func scrollToMonth( 51 | containing dateInTargetMonth: Date, 52 | scrollPosition: CalendarViewScrollPosition, 53 | animated: Bool) 54 | { 55 | calendarView.scroll( 56 | toMonthContaining: dateInTargetMonth, 57 | scrollPosition: scrollPosition, 58 | animated: animated) 59 | } 60 | 61 | /// Scrolls the calendar to the specified day with the specified position. 62 | /// 63 | /// If the calendar has a non-zero frame, this function will scroll to the specified day immediately. Otherwise the scroll-to-day action 64 | /// will be queued and executed once the calendar has a non-zero frame. If this function is invoked multiple times before the calendar 65 | /// has a non-zero frame, only the most recent scroll-to-day action will be executed. 66 | /// 67 | /// - Parameters: 68 | /// - dateInTargetDay: A date in the target day to which to scroll into view. 69 | /// - scrollPosition: The final position of the `CalendarView`'s scrollable region after the scroll completes. 70 | /// - animated: Whether the scroll should be animated (from the current position), or whether the scroll should update the 71 | /// visible region immediately with no animation. 72 | public func scrollToDay( 73 | containing dateInTargetDay: Date, 74 | scrollPosition: CalendarViewScrollPosition, 75 | animated: Bool) 76 | { 77 | calendarView.scroll( 78 | toDayContaining: dateInTargetDay, 79 | scrollPosition: scrollPosition, 80 | animated: animated) 81 | } 82 | 83 | // MARK: Internal 84 | 85 | var calendarView: CalendarView { 86 | guard let _calendarView else { 87 | fatalError("Attempted to use `CalendarViewProxy` before passing it to the `CalendarViewRepresentable` initializer.") 88 | } 89 | return _calendarView 90 | } 91 | 92 | weak var _calendarView: CalendarView? { 93 | didSet { 94 | if oldValue != nil, _calendarView != oldValue { 95 | fatalError("Attempted to use an existing `CalendarViewProxy` instance with a new `CalendarViewRepresentable`.") 96 | } 97 | } 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /Sources/Public/CalendarViewScrollPosition.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 10/8/19. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | // MARK: - CalendarViewScrollPosition 19 | 20 | /// The scroll position for programmatically scrolling to a day or month. 21 | public enum CalendarViewScrollPosition { 22 | 23 | /// The position that centers the day or month in the visible bounds of the calendar along its scrollable axis. 24 | case centered 25 | 26 | /// The first position along the scrollable axis that makes the day or month fully visible in the visible bounds of the calendar, with 27 | /// additional padding provided via `padding`. 28 | /// 29 | /// If the calendar scrolls its months vertically, then the "first position" is the top edge. 30 | /// If the calendar scrolls its months horizontally, then the "first position" is the left edge. 31 | case firstFullyVisiblePosition(padding: CGFloat) 32 | 33 | /// The last position along the scrollable axis that makes the day or month fully visible in the visible bounds of the calendar, with 34 | /// additional padding provided via `padding`. 35 | /// 36 | /// If the calendar scrolls its months vertically, then the "last position" is the bottom edge. 37 | /// If the calendar scrolls its months horizontally, then the "last position" is the right edge. 38 | case lastFullyVisiblePosition(padding: CGFloat) 39 | 40 | } 41 | 42 | // MARK: CalendarViewScrollPosition Constants 43 | 44 | extension CalendarViewScrollPosition { 45 | 46 | /// The first position along the scrollable axis that makes the day or month fully visible in the visible bounds of the calendar. 47 | /// 48 | /// If the calendar scrolls its months vertically, then the "first position" is the top edge. 49 | /// If the calendar scrolls its months horizontally, then the "first position" is the left edge. 50 | public static let firstFullyVisiblePosition = Self.firstFullyVisiblePosition(padding: 0) 51 | 52 | /// The last position along the scrollable axis that makes the day or month fully visible in the visible bounds of the calendar. 53 | /// 54 | /// If the calendar scrolls its months vertically, then the "last position" is the bottom edge. 55 | /// If the calendar scrolls its months horizontally, then the "last position" is the right edge. 56 | public static let lastFullyVisiblePosition = Self.lastFullyVisiblePosition(padding: 0) 57 | 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Public/Day.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/31/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import Foundation 17 | 18 | // MARK: - Day 19 | 20 | typealias Day = DayComponents 21 | 22 | // MARK: - DayComponents 23 | 24 | /// Represents the components of a day. This type is created internally, then vended to you via the public API. All `DayComponents` 25 | /// instances that are vended to you are created using the `Calendar` instance that you provide when initializing your 26 | /// `CalendarView`. 27 | public struct DayComponents: Hashable { 28 | 29 | // MARK: Lifecycle 30 | 31 | init(month: MonthComponents, day: Int) { 32 | self.month = month 33 | self.day = day 34 | } 35 | 36 | // MARK: Public 37 | 38 | public let month: MonthComponents 39 | public let day: Int 40 | 41 | public var components: DateComponents { 42 | DateComponents(era: month.era, year: month.year, month: month.month, day: day) 43 | } 44 | 45 | } 46 | 47 | // MARK: CustomStringConvertible 48 | 49 | extension DayComponents: CustomStringConvertible { 50 | 51 | public var description: String { 52 | let yearDescription = String(format: "%04d", month.year) 53 | let monthDescription = String(format: "%02d", month.month) 54 | let dayDescription = String(format: "%02d", day) 55 | return "\(yearDescription)-\(monthDescription)-\(dayDescription)" 56 | } 57 | 58 | } 59 | 60 | // MARK: Comparable 61 | 62 | extension DayComponents: Comparable { 63 | 64 | public static func < (lhs: DayComponents, rhs: DayComponents) -> Bool { 65 | guard lhs.month == rhs.month else { return lhs.month < rhs.month } 66 | return lhs.day < rhs.day 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Sources/Public/DayOfWeekPosition.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/31/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import Foundation 17 | 18 | // MARK: - DayOfWeekPosition 19 | 20 | /// Represents a day of the week. In the Gregorian calendar, the first position is Sunday and the last position is Saturday. All calendars 21 | /// in `Foundation.Calendar` have 7 day of the week positions. 22 | public enum DayOfWeekPosition: Int, CaseIterable, Hashable { 23 | 24 | // MARK: Public 25 | 26 | case first = 1 27 | case second 28 | case third 29 | case fourth 30 | case fifth 31 | case sixth 32 | case last 33 | 34 | // MARK: Internal 35 | 36 | static let numberOfPositions = 7 37 | 38 | } 39 | 40 | // MARK: Comparable 41 | 42 | extension DayOfWeekPosition: Comparable { 43 | 44 | public static func < (lhs: DayOfWeekPosition, rhs: DayOfWeekPosition) -> Bool { 45 | lhs.rawValue < rhs.rawValue 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Public/DayRange.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/31/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import Foundation 17 | 18 | // MARK: DayRange 19 | 20 | typealias DayRange = DayComponentsRange 21 | 22 | // MARK: - DayComponentsRange 23 | 24 | public typealias DayComponentsRange = ClosedRange 25 | 26 | extension DayComponentsRange { 27 | 28 | /// Instantiates a `DayRange` that encapsulates the `dateRange` in the `calendar` as closely as possible. For example, 29 | /// a date range of [2020-05-20T23:59:59, 2021-01-01T00:00:00] will result in a day range of [2020-05-20, 2021-01-01]. 30 | init(containing dateRange: ClosedRange, in calendar: Calendar) { 31 | self.init( 32 | uncheckedBounds: ( 33 | lower: calendar.day(containing: dateRange.lowerBound), 34 | upper: calendar.day(containing: dateRange.upperBound))) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Public/DayRangeLayoutContext.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 2/2/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | /// The layout context for a day range, containing information about the frames of days in the day range and the bounding rect (union) 19 | /// of those frames. This can be used in a custom day range view to draw the day range in the correct location. 20 | public struct DayRangeLayoutContext: Hashable { 21 | /// The day range that this layout context describes. 22 | public let dayRange: DayComponentsRange 23 | 24 | /// An ordered list of tuples containing day and day frame pairs. 25 | /// 26 | /// Each frame represents the frame of an individual day in the day range in the coordinate system of 27 | /// `boundingUnionRectOfDayFrames`. If a day range extends beyond the `visibleDateRange`, this array will only 28 | /// contain the day-frame pairs for the visible portion of the day range. 29 | public let daysAndFrames: [(day: DayComponents, frame: CGRect)] 30 | 31 | /// A rectangle that perfectly contains all day frames in `daysAndFrames`. In other words, it is the union of all day frames in 32 | /// `daysAndFrames`. 33 | public let boundingUnionRectOfDayFrames: CGRect 34 | 35 | public static func == (lhs: DayRangeLayoutContext, rhs: DayRangeLayoutContext) -> Bool { 36 | lhs.dayRange == rhs.dayRange && 37 | lhs.daysAndFrames.elementsEqual( 38 | rhs.daysAndFrames, 39 | by: { $0.day == $1.day && $0.frame == $0.frame }) && 40 | lhs.boundingUnionRectOfDayFrames == rhs.boundingUnionRectOfDayFrames 41 | } 42 | 43 | public func hash(into hasher: inout Hasher) { 44 | hasher.combine(dayRange) 45 | for (day, frame) in daysAndFrames { 46 | hasher.combine(day) 47 | hasher.combine(frame) 48 | } 49 | hasher.combine(boundingUnionRectOfDayFrames) 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Public/DaysOfTheWeekRowSeparatorOptions.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 2/2/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | /// Used to configure the days-of-the-week row's separator. 19 | public struct DaysOfTheWeekRowSeparatorOptions: Hashable { 20 | 21 | // MARK: Lifecycle 22 | 23 | /// Initialized a new `DaysOfTheWeekRowSeparatorOptions`. 24 | /// 25 | /// - Parameters: 26 | /// - height: The height of the separator in points. 27 | /// - color: The color of the separator. 28 | public init(height: CGFloat = 1, color: UIColor = .lightGray) { 29 | self.height = height 30 | self.color = color 31 | } 32 | 33 | // MARK: Public 34 | 35 | @available(iOS 13.0, *) 36 | public static var systemStyleSeparator = DaysOfTheWeekRowSeparatorOptions( 37 | height: 1, 38 | color: .separator) 39 | 40 | /// The height of the separator in points. 41 | public var height: CGFloat 42 | 43 | /// The color of the separator. 44 | public var color: UIColor 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Public/ItemViews/DrawingConfig.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 9/12/21. 2 | // Copyright © 2021 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | /// A configuration used when creating a background or highlight layer; used by `DayView` and `DayOfWeekView`. 19 | public struct DrawingConfig: Hashable { 20 | 21 | // MARK: Lifecycle 22 | 23 | public init( 24 | fillColor: UIColor = .clear, 25 | borderColor: UIColor = .clear, 26 | borderWidth: CGFloat = 1) 27 | { 28 | self.fillColor = fillColor 29 | self.borderColor = borderColor 30 | self.borderWidth = borderWidth 31 | } 32 | 33 | // MARK: Public 34 | 35 | public static let transparent = DrawingConfig() 36 | 37 | public var fillColor = UIColor.clear 38 | public var borderColor = UIColor.clear 39 | public var borderWidth: CGFloat = 1 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Public/ItemViews/MonthGridBackgroundView.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 1/30/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | // MARK: - MonthGridBackgroundView 19 | 20 | /// A background grid view that draws separator lines between all days in a month. 21 | public final class MonthGridBackgroundView: UIView { 22 | 23 | // MARK: Lifecycle 24 | 25 | fileprivate init(invariantViewProperties: InvariantViewProperties) { 26 | self.invariantViewProperties = invariantViewProperties 27 | super.init(frame: .zero) 28 | backgroundColor = .clear 29 | } 30 | 31 | required init?(coder _: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | // MARK: Public 36 | 37 | public override func draw(_: CGRect) { 38 | let context = UIGraphicsGetCurrentContext() 39 | context?.setLineWidth(invariantViewProperties.lineWidth) 40 | context?.setStrokeColor(invariantViewProperties.color.cgColor) 41 | 42 | if traitCollection.layoutDirection == .rightToLeft { 43 | context?.translateBy(x: bounds.midX, y: bounds.midY) 44 | context?.scaleBy(x: -1, y: 1) 45 | context?.translateBy(x: -bounds.midX, y: -bounds.midY) 46 | } 47 | 48 | for dayFrame in framesOfDays { 49 | let gridRect = CGRect( 50 | x: dayFrame.minX - (invariantViewProperties.horizontalDayMargin / 2), 51 | y: dayFrame.minY - (invariantViewProperties.verticalDayMargin / 2), 52 | width: dayFrame.width + invariantViewProperties.horizontalDayMargin, 53 | height: dayFrame.height + invariantViewProperties.verticalDayMargin) 54 | context?.stroke(gridRect) 55 | } 56 | } 57 | 58 | public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 59 | super.traitCollectionDidChange(previousTraitCollection) 60 | setNeedsDisplay() 61 | } 62 | 63 | // MARK: Fileprivate 64 | 65 | fileprivate var framesOfDays = [CGRect]() { 66 | didSet { 67 | guard framesOfDays != oldValue else { return } 68 | setNeedsDisplay() 69 | } 70 | } 71 | 72 | // MARK: Private 73 | 74 | private let invariantViewProperties: InvariantViewProperties 75 | 76 | } 77 | 78 | // MARK: CalendarItemViewRepresentable 79 | 80 | extension MonthGridBackgroundView: CalendarItemViewRepresentable { 81 | 82 | public struct InvariantViewProperties: Hashable { 83 | 84 | // MARK: Lifecycle 85 | 86 | public init( 87 | lineWidth: CGFloat = 1, 88 | color: UIColor = .lightGray, 89 | horizontalDayMargin: CGFloat, 90 | verticalDayMargin: CGFloat) 91 | { 92 | self.lineWidth = lineWidth 93 | self.color = color 94 | self.horizontalDayMargin = horizontalDayMargin 95 | self.verticalDayMargin = verticalDayMargin 96 | } 97 | 98 | // MARK: Internal 99 | 100 | var lineWidth: CGFloat 101 | var color: UIColor 102 | var horizontalDayMargin: CGFloat 103 | var verticalDayMargin: CGFloat 104 | } 105 | 106 | public struct Content: Equatable { 107 | 108 | // MARK: Lifecycle 109 | 110 | public init(framesOfDays: [CGRect]) { 111 | self.framesOfDays = framesOfDays 112 | } 113 | 114 | // MARK: Internal 115 | 116 | let framesOfDays: [CGRect] 117 | } 118 | 119 | public static func makeView( 120 | withInvariantViewProperties invariantViewProperties: InvariantViewProperties) 121 | -> MonthGridBackgroundView 122 | { 123 | MonthGridBackgroundView(invariantViewProperties: invariantViewProperties) 124 | } 125 | 126 | public static func setContent(_ content: Content, on view: MonthGridBackgroundView) { 127 | view.framesOfDays = content.framesOfDays 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /Sources/Public/ItemViews/MonthHeaderView.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 9/12/21. 2 | // Copyright © 2021 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import UIKit 17 | 18 | // MARK: - MonthHeaderView 19 | 20 | /// A view that represents a day-of-the-week header in a calendar month. For example, Sun, Mon, Tue, etc. 21 | public final class MonthHeaderView: UIView { 22 | 23 | // MARK: Lifecycle 24 | 25 | fileprivate init(invariantViewProperties: InvariantViewProperties) { 26 | self.invariantViewProperties = invariantViewProperties 27 | 28 | label = UILabel() 29 | label.font = invariantViewProperties.font 30 | label.textAlignment = invariantViewProperties.textAlignment 31 | label.textColor = invariantViewProperties.textColor 32 | 33 | super.init(frame: .zero) 34 | 35 | isUserInteractionEnabled = false 36 | 37 | backgroundColor = invariantViewProperties.backgroundColor 38 | 39 | label.translatesAutoresizingMaskIntoConstraints = false 40 | addSubview(label) 41 | 42 | let edgeInsets = invariantViewProperties.edgeInsets 43 | NSLayoutConstraint.activate([ 44 | label.topAnchor.constraint(equalTo: topAnchor, constant: edgeInsets.top), 45 | label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: edgeInsets.bottom), 46 | label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: edgeInsets.leading), 47 | label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: edgeInsets.trailing), 48 | ]) 49 | } 50 | 51 | @available(*, unavailable) 52 | required init?(coder _: NSCoder) { 53 | fatalError("init(coder:) has not been implemented") 54 | } 55 | 56 | // MARK: Fileprivate 57 | 58 | fileprivate func setContent(_ content: Content) { 59 | label.text = content.monthText 60 | accessibilityLabel = content.accessibilityLabel 61 | } 62 | 63 | // MARK: Private 64 | 65 | private let invariantViewProperties: InvariantViewProperties 66 | private let label: UILabel 67 | 68 | } 69 | 70 | // MARK: Accessibility 71 | 72 | extension MonthHeaderView { 73 | 74 | public override var isAccessibilityElement: Bool { 75 | get { true } 76 | set { } 77 | } 78 | 79 | public override var accessibilityTraits: UIAccessibilityTraits { 80 | get { invariantViewProperties.accessibilityTraits } 81 | set { } 82 | } 83 | 84 | } 85 | 86 | // MARK: MonthHeaderView.Content 87 | 88 | extension MonthHeaderView { 89 | 90 | /// Encapsulates the data used to populate a `MonthHeaderView`'s text label. Use a `DateFormatter` to create the 91 | /// `monthText` and `accessibilityLabel` strings. 92 | /// 93 | /// - Note: To avoid performance issues, reuse the same `DateFormatter` for each month, rather than creating 94 | /// a new `DateFormatter` for each month. 95 | public struct Content: Equatable { 96 | 97 | // MARK: Lifecycle 98 | 99 | public init(monthText: String, accessibilityLabel: String?) { 100 | self.monthText = monthText 101 | self.accessibilityLabel = accessibilityLabel 102 | } 103 | 104 | // MARK: Public 105 | 106 | public let monthText: String 107 | public let accessibilityLabel: String? 108 | } 109 | 110 | } 111 | 112 | // MARK: MonthHeaderView.InvariantViewProperties 113 | 114 | extension MonthHeaderView { 115 | 116 | /// Encapsulates configurable properties that change the appearance and behavior of `MonthHeaderView`. These cannot be 117 | /// changed after a `MonthHeaderView` is initialized. 118 | public struct InvariantViewProperties: Hashable { 119 | 120 | // MARK: Lifecycle 121 | 122 | private init() { } 123 | 124 | // MARK: Public 125 | 126 | public static let base = InvariantViewProperties() 127 | 128 | /// The background color of the entire view, unaffected by `edgeInsets`. 129 | public var backgroundColor = UIColor.clear 130 | 131 | /// Edge insets that change the position of the month's label. 132 | public var edgeInsets = NSDirectionalEdgeInsets.zero 133 | 134 | /// The font of the month's label. 135 | public var font = UIFont.systemFont(ofSize: 22) 136 | 137 | /// The text alignment of the month's label. 138 | public var textAlignment = NSTextAlignment.natural 139 | 140 | /// The text color of the month's label. 141 | public var textColor: UIColor = { 142 | if #available(iOS 13.0, *) { 143 | return .label 144 | } else { 145 | return .black 146 | } 147 | }() 148 | 149 | /// The accessibility traits of the `MonthHeaderView`. 150 | public var accessibilityTraits = UIAccessibilityTraits.header 151 | 152 | public func hash(into hasher: inout Hasher) { 153 | hasher.combine(backgroundColor) 154 | hasher.combine(edgeInsets.leading) 155 | hasher.combine(edgeInsets.trailing) 156 | hasher.combine(edgeInsets.top) 157 | hasher.combine(edgeInsets.bottom) 158 | hasher.combine(font) 159 | hasher.combine(textAlignment) 160 | hasher.combine(textColor) 161 | hasher.combine(accessibilityTraits) 162 | } 163 | 164 | } 165 | 166 | } 167 | 168 | // MARK: CalendarItemViewRepresentable 169 | 170 | extension MonthHeaderView: CalendarItemViewRepresentable { 171 | 172 | public static func makeView( 173 | withInvariantViewProperties invariantViewProperties: InvariantViewProperties) 174 | -> MonthHeaderView 175 | { 176 | MonthHeaderView(invariantViewProperties: invariantViewProperties) 177 | } 178 | 179 | public static func setContent(_ content: Content, on view: MonthHeaderView) { 180 | view.setContent(content) 181 | } 182 | 183 | } 184 | -------------------------------------------------------------------------------- /Sources/Public/ItemViews/Shape.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 9/12/21. 2 | // Copyright © 2021 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | /// Represents a background or highlight layer shape; used by `DayView` and `DayOfWeekView`. 19 | public enum Shape: Hashable { 20 | case circle 21 | case rectangle(cornerRadius: CGFloat = 0) 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Public/ItemViews/SwiftUIWrapperView.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 10/12/22. 2 | // Copyright © 2022 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import SwiftUI 17 | 18 | // MARK: - SwiftUIWrapperView 19 | 20 | /// A wrapper view that enables SwiftUI `View`s to be used with `CalendarItemModel`s. 21 | /// 22 | /// Consider using the `calendarItemModel` property, defined as an extension on SwiftUI's`View`, to avoid needing to work with 23 | /// this wrapper view directly. 24 | /// e.g. `Text("\(dayNumber)").calendarItemModel` 25 | @available(iOS 13.0, *) 26 | public final class SwiftUIWrapperView: UIView { 27 | 28 | // MARK: Lifecycle 29 | 30 | public init(contentAndID: ContentAndID) { 31 | self.contentAndID = contentAndID 32 | hostingController = UIHostingController(rootView: contentAndID.content) 33 | hostingController._disableSafeArea = true 34 | 35 | super.init(frame: .zero) 36 | 37 | insetsLayoutMarginsFromSafeArea = false 38 | layoutMargins = .zero 39 | 40 | hostingControllerView.backgroundColor = .clear 41 | addSubview(hostingControllerView) 42 | } 43 | 44 | required init?(coder _: NSCoder) { 45 | fatalError("init(coder:) has not been implemented") 46 | } 47 | 48 | // MARK: Public 49 | 50 | public override class var layerClass: AnyClass { 51 | CATransformLayer.self 52 | } 53 | 54 | public override var isAccessibilityElement: Bool { 55 | get { false } 56 | set { } 57 | } 58 | 59 | public override var isHidden: Bool { 60 | didSet { 61 | if isHidden { 62 | hostingControllerView.removeFromSuperview() 63 | } else { 64 | addSubview(hostingControllerView) 65 | } 66 | } 67 | } 68 | 69 | public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { 70 | // `_UIHostingView`'s `isUserInteractionEnabled` is not affected by the `allowsHitTesting` 71 | // modifier. Its first subview's `isUserInteractionEnabled` _does_ appear to be affected by the 72 | // `allowsHitTesting` modifier, enabling us to properly ignore touch handling. 73 | if 74 | let firstSubview = hostingControllerView.subviews.first, 75 | !firstSubview.isUserInteractionEnabled 76 | { 77 | return false 78 | } else { 79 | return super.point(inside: point, with: event) 80 | } 81 | } 82 | 83 | public override func layoutSubviews() { 84 | super.layoutSubviews() 85 | hostingControllerView.frame = bounds 86 | } 87 | 88 | public override func systemLayoutSizeFitting( 89 | _ targetSize: CGSize, 90 | withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, 91 | verticalFittingPriority: UILayoutPriority) 92 | -> CGSize 93 | { 94 | hostingControllerView.systemLayoutSizeFitting( 95 | targetSize, 96 | withHorizontalFittingPriority: horizontalFittingPriority, 97 | verticalFittingPriority: verticalFittingPriority) 98 | } 99 | 100 | // MARK: Fileprivate 101 | 102 | fileprivate var contentAndID: ContentAndID { 103 | didSet { 104 | hostingController.rootView = contentAndID.content 105 | configureGestureRecognizers() 106 | } 107 | } 108 | 109 | // MARK: Private 110 | 111 | private let hostingController: UIHostingController 112 | 113 | private var hostingControllerView: UIView { 114 | hostingController.view 115 | } 116 | 117 | // This allows touches to be passed to `ItemView` even if the SwiftUI `View` has a gesture 118 | // recognizer. 119 | private func configureGestureRecognizers() { 120 | for gestureRecognizer in hostingControllerView.gestureRecognizers ?? [] { 121 | gestureRecognizer.cancelsTouchesInView = false 122 | } 123 | } 124 | 125 | } 126 | 127 | // MARK: CalendarItemViewRepresentable 128 | 129 | @available(iOS 13.0, *) 130 | extension SwiftUIWrapperView: CalendarItemViewRepresentable { 131 | 132 | public struct InvariantViewProperties: Hashable { 133 | 134 | // MARK: Lifecycle 135 | 136 | init(initialContentAndID: ContentAndID) { 137 | self.initialContentAndID = initialContentAndID 138 | } 139 | 140 | // MARK: Public 141 | 142 | public static func == (_: InvariantViewProperties, _: InvariantViewProperties) -> Bool { 143 | // Always true since two `SwiftUIWrapperView`'s with the same `Content` view are considered to 144 | // have the same "invariant view properties." 145 | true 146 | } 147 | 148 | public func hash(into _: inout Hasher) { } 149 | 150 | // MARK: Fileprivate 151 | 152 | fileprivate let initialContentAndID: ContentAndID 153 | 154 | } 155 | 156 | public struct ContentAndID: Equatable { 157 | 158 | // MARK: Lifecycle 159 | 160 | // TODO: Remove `id` and rename this type in the next major release. 161 | public init(content: Content, id _: AnyHashable) { 162 | self.content = content 163 | } 164 | 165 | // MARK: Public 166 | 167 | public static func == (_: ContentAndID, _: ContentAndID) -> Bool { 168 | false 169 | } 170 | 171 | // MARK: Fileprivate 172 | 173 | fileprivate let content: Content 174 | 175 | } 176 | 177 | public static func makeView( 178 | withInvariantViewProperties invariantViewProperties: InvariantViewProperties) 179 | -> SwiftUIWrapperView 180 | { 181 | SwiftUIWrapperView(contentAndID: invariantViewProperties.initialContentAndID) 182 | } 183 | 184 | public static func setContent( 185 | _ contentAndID: ContentAndID, 186 | on view: SwiftUIWrapperView) 187 | { 188 | view.contentAndID = contentAndID 189 | } 190 | 191 | } 192 | -------------------------------------------------------------------------------- /Sources/Public/Month.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/31/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import Foundation 17 | 18 | // MARK: - Month 19 | 20 | typealias Month = MonthComponents 21 | 22 | // MARK: - MonthComponents 23 | 24 | /// Represents the components of a month. This type is created internally, then vended to you via the public API. All 25 | /// `MonthComponents` instances that are vended to you are created using the `Calendar` instance that you provide when 26 | /// initializing your `CalendarView`. 27 | public struct MonthComponents: Hashable { 28 | 29 | // MARK: Lifecycle 30 | 31 | init(era: Int, year: Int, month: Int, isInGregorianCalendar: Bool) { 32 | self.era = era 33 | self.year = year 34 | self.month = month 35 | self.isInGregorianCalendar = isInGregorianCalendar 36 | } 37 | 38 | // MARK: Public 39 | 40 | public let era: Int 41 | public let year: Int 42 | public let month: Int 43 | 44 | public var components: DateComponents { 45 | DateComponents(era: era, year: year, month: month) 46 | } 47 | 48 | // MARK: Internal 49 | 50 | // In the Gregorian calendar, BCE years (era 0) get larger in descending order (10 BCE < 5 BCE). 51 | // This property exists to facilitate an accurate `Comparable` implementation. 52 | let isInGregorianCalendar: Bool 53 | 54 | } 55 | 56 | // MARK: CustomStringConvertible 57 | 58 | extension MonthComponents: CustomStringConvertible { 59 | 60 | public var description: String { 61 | "\(String(format: "%04d", year))-\(String(format: "%02d", month))" 62 | } 63 | 64 | } 65 | 66 | // MARK: Comparable 67 | 68 | extension MonthComponents: Comparable { 69 | 70 | public static func < (lhs: MonthComponents, rhs: MonthComponents) -> Bool { 71 | guard lhs.era == rhs.era else { return lhs.era < rhs.era } 72 | 73 | let lhsCorrectedYear = lhs.isInGregorianCalendar && lhs.era == 0 ? -lhs.year : lhs.year 74 | let rhsCorrectedYear = rhs.isInGregorianCalendar && rhs.era == 0 ? -rhs.year : rhs.year 75 | guard lhsCorrectedYear == rhsCorrectedYear else { return lhsCorrectedYear < rhsCorrectedYear } 76 | 77 | return lhs.month < rhs.month 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Public/MonthLayoutContext.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 2/2/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | /// The layout context for all of the views contained in a month, including frames for days, the month header, and days-of-the-week 19 | /// headers. Also included is the bounding rect (union) of those frames. This can be used in a custom month background view to draw 20 | /// the background around the month's foreground views. 21 | public struct MonthLayoutContext: Hashable { 22 | 23 | /// The month that this layout context describes. 24 | public let month: MonthComponents 25 | 26 | /// The frame of the month header in the coordinate system of `bounds`. 27 | public let monthHeaderFrame: CGRect 28 | 29 | /// An ordered list of tuples containing day-of-the-week positions and frames. 30 | /// 31 | /// Each frame corresponds to an individual day-of-the-week item (Sunday, Monday, etc.) in the month, in the coordinate system of 32 | /// `bounds`. If `monthsLayout` is `.vertical`, and `pinDaysOfWeekToTop` is `true`, then this array will be empty 33 | /// since day-of-the-week items appear outside of individual months. 34 | public let dayOfWeekPositionsAndFrames: [(dayOfWeekPosition: DayOfWeekPosition, frame: CGRect)] 35 | 36 | /// An ordered list of tuples containing day and day frame pairs. 37 | /// 38 | /// Each frame represents the frame of an individual day in the month in the coordinate system of `bounds`. 39 | public let daysAndFrames: [(day: DayComponents, frame: CGRect)] 40 | 41 | /// The bounds into which a background can be drawn without getting clipped. Additionally, all other frames in this type are in the 42 | /// coordinate system of this. 43 | public let bounds: CGRect 44 | 45 | public static func == (lhs: MonthLayoutContext, rhs: MonthLayoutContext) -> Bool { 46 | lhs.month == rhs.month && 47 | lhs.monthHeaderFrame == rhs.monthHeaderFrame && 48 | lhs.dayOfWeekPositionsAndFrames.elementsEqual( 49 | rhs.dayOfWeekPositionsAndFrames, 50 | by: { $0.dayOfWeekPosition == $1.dayOfWeekPosition && $0.frame == $1.frame }) && 51 | lhs.daysAndFrames.elementsEqual( 52 | rhs.daysAndFrames, 53 | by: { $0.day == $1.day && $0.frame == $0.frame }) && 54 | lhs.bounds == rhs.bounds 55 | } 56 | 57 | public func hash(into hasher: inout Hasher) { 58 | hasher.combine(month) 59 | hasher.combine(monthHeaderFrame) 60 | for (dayOfWeekPosition, frame) in dayOfWeekPositionsAndFrames { 61 | hasher.combine(dayOfWeekPosition) 62 | hasher.combine(frame) 63 | } 64 | for (day, frame) in daysAndFrames { 65 | hasher.combine(day) 66 | hasher.combine(frame) 67 | } 68 | hasher.combine(bounds) 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Public/MonthRange.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 5/30/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import Foundation 17 | 18 | // MARK: - MonthRange 19 | 20 | typealias MonthRange = MonthComponentsRange 21 | 22 | // MARK: - MonthComponentsRange 23 | 24 | public typealias MonthComponentsRange = ClosedRange 25 | 26 | extension MonthRange { 27 | 28 | /// Instantiates a `MonthRange` that encapsulates the `dateRange` in the `calendar` as closely as possible. For example, 29 | /// a date range of [2020-01-19, 2021-02-01] will result in a month range of [2020-01, 2021-02]. 30 | init(containing dateRange: ClosedRange, in calendar: Calendar) { 31 | self.init( 32 | uncheckedBounds: ( 33 | lower: calendar.month(containing: dateRange.lowerBound), 34 | upper: calendar.month(containing: dateRange.upperBound))) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Public/OverlaidItemLocation.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 2/2/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import Foundation 17 | 18 | /// Represents the location of an item that can be overlaid. 19 | public enum OverlaidItemLocation: Hashable { 20 | 21 | /// A month header location that can be overlaid. 22 | /// 23 | /// The particular month to be overlaid is specified with a `Date` instance, which will be used to determine the associated month 24 | /// using the `calendar` instance with which `CalendarViewContent` was instantiated. 25 | case monthHeader(monthContainingDate: Date) 26 | 27 | /// A day location that can be overlaid. 28 | /// 29 | /// The particular day to be overlaid is specified with a `Date` instance, which will be used to determine the associated day using 30 | /// the `calendar` instance with which `CalendarViewContent` was instantiated. 31 | case day(containingDate: Date) 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Public/OverlayLayoutContext.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 2/2/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import CoreGraphics 17 | 18 | /// The layout context for an overlaid item, containing information about the location and frame of the item being overlaid, as well as the 19 | /// bounds available to the overlay item for drawing and layout. 20 | public struct OverlayLayoutContext: Hashable { 21 | 22 | /// The location of the item to be overlaid. 23 | public let overlaidItemLocation: OverlaidItemLocation 24 | 25 | /// The frame of the overlaid item in the coordinate system of `availableBounds`. 26 | /// 27 | /// Use this property, in conjunction with `availableBounds`, to prevent your overlay item from laying out outside of the 28 | /// available bounds. 29 | public let overlaidItemFrame: CGRect 30 | 31 | /// A rectangle that defines the available region into which the overlay item can be laid out. 32 | /// 33 | /// Use this property, in conjunction with `overlaidItemFrame`, to prevent your overlay item from laying out outside of the 34 | /// available bounds. 35 | public let availableBounds: CGRect 36 | 37 | public func hash(into hasher: inout Hasher) { 38 | hasher.combine(overlaidItemLocation) 39 | hasher.combine(overlaidItemFrame) 40 | hasher.combine(availableBounds) 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Tests/CalendarContentTests.swift: -------------------------------------------------------------------------------- 1 | // Created by Cal Stephens on 9/18/23. 2 | // Copyright © 2023 Airbnb Inc. All rights reserved. 3 | 4 | import XCTest 5 | @testable import HorizonCalendar 6 | 7 | // MARK: - CalendarContentTests 8 | 9 | final class CalendarContentTests: XCTestCase { 10 | 11 | func testCanReturnNilFromCalendarContentClosures() { 12 | _ = CalendarViewContent( 13 | visibleDateRange: Date.distantPast...Date.distantFuture, 14 | monthsLayout: .vertical) 15 | .monthHeaderItemProvider { _ in 16 | nil 17 | } 18 | .dayOfWeekItemProvider { _, _ in 19 | nil 20 | } 21 | .dayItemProvider { _ in 22 | nil 23 | } 24 | .dayBackgroundItemProvider { _ in 25 | nil 26 | } 27 | } 28 | 29 | func testNilDayItemUsesDefaultValue() { 30 | let content = CalendarViewContent( 31 | visibleDateRange: Date.distantPast...Date.distantFuture, 32 | monthsLayout: .vertical) 33 | 34 | let day = Day(month: Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true), day: 1) 35 | let defaultDayItem = content.dayItemProvider(day) 36 | 37 | let contentWithNilDayItem = content.dayItemProvider { _ in nil } 38 | let updatedDayItem = contentWithNilDayItem.dayItemProvider(day) 39 | 40 | XCTAssert(defaultDayItem._isContentEqual(toContentOf: updatedDayItem)) 41 | } 42 | 43 | func testNilDayOfWeekItemUsesDefaultValue() { 44 | let content = CalendarViewContent( 45 | visibleDateRange: Date.distantPast...Date.distantFuture, 46 | monthsLayout: .vertical) 47 | 48 | let month = Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true) 49 | let defaultDayOfWeekItem = content.dayOfWeekItemProvider(month, 1) 50 | 51 | let contentWithNilDayOfWeekItem = content.dayOfWeekItemProvider { _, _ in nil } 52 | let updatedDayOfWeekItem = contentWithNilDayOfWeekItem.dayOfWeekItemProvider(month, 1) 53 | 54 | XCTAssert(defaultDayOfWeekItem._isContentEqual(toContentOf: updatedDayOfWeekItem)) 55 | } 56 | 57 | func testNilMonthHeaderItemUsesDefaultValue() { 58 | let content = CalendarViewContent( 59 | visibleDateRange: Date.distantPast...Date.distantFuture, 60 | monthsLayout: .vertical) 61 | 62 | let month = Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true) 63 | let defaultMonthHeaderItem = content.monthHeaderItemProvider(month) 64 | 65 | let contentWithNilMonthHeaderItem = content.monthHeaderItemProvider { _ in nil } 66 | let updatedMonthHeaderItem = contentWithNilMonthHeaderItem.monthHeaderItemProvider(month) 67 | 68 | XCTAssert(defaultMonthHeaderItem._isContentEqual(toContentOf: updatedMonthHeaderItem)) 69 | } 70 | 71 | } 72 | 73 | // MARK: - CalendarContentConfigurable 74 | 75 | /// Test case demonstrating that `CalendarViewContent` and `CalendarViewRepresentable` both have the same APIs 76 | /// and can be abstracted behind a single protocol 77 | protocol CalendarContentConfigurable { 78 | func monthHeaderItemProvider( 79 | _ monthHeaderItemProvider: @escaping (_ month: Month) -> AnyCalendarItemModel?) 80 | -> Self 81 | 82 | func dayOfWeekItemProvider( 83 | _ dayOfWeekItemProvider: @escaping ( 84 | _ month: Month?, 85 | _ weekdayIndex: Int) 86 | -> AnyCalendarItemModel?) 87 | -> Self 88 | 89 | func dayItemProvider(_ dayItemProvider: @escaping (_ day: Day) -> AnyCalendarItemModel?) -> Self 90 | } 91 | 92 | // MARK: - CalendarViewContent + CalendarContentConfigurable 93 | 94 | extension CalendarViewContent: CalendarContentConfigurable { } 95 | 96 | // MARK: - CalendarViewRepresentable + CalendarContentConfigurable 97 | 98 | @available(iOS 13.0, *) 99 | extension CalendarViewRepresentable: CalendarContentConfigurable { } 100 | -------------------------------------------------------------------------------- /Tests/DayHelperTests.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/7/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import XCTest 17 | @testable import HorizonCalendar 18 | 19 | // MARK: - DayHelperTests 20 | 21 | final class DayHelperTests: XCTestCase { 22 | 23 | // MARK: Internal 24 | 25 | func testDayComparable() { 26 | let january2020Day = Day( 27 | month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), 28 | day: 19) 29 | let december2020Day = Day( 30 | month: Month(era: 1, year: 2020, month: 12, isInGregorianCalendar: true), 31 | day: 05) 32 | XCTAssert(january2020Day < december2020Day, "Expected January 19, 2020 < December 5, 2020.") 33 | 34 | let june0006Day = Day( 35 | month: Month(era: 0, year: 0006, month: 06, isInGregorianCalendar: true), 36 | day: 10) 37 | let january0005Day = Day( 38 | month: Month(era: 1, year: 0005, month: 01, isInGregorianCalendar: true), 39 | day: 09) 40 | XCTAssert(june0006Day < january0005Day, "Expected June 10, 0006 BCE < January 9, 0005 CE.") 41 | 42 | let june30Day = Day( 43 | month: Month(era: 235, year: 30, month: 06, isInGregorianCalendar: false), 44 | day: 25) 45 | let august01Day = Day( 46 | month: Month(era: 236, year: 01, month: 08, isInGregorianCalendar: false), 47 | day: 30) 48 | XCTAssert(june30Day < august01Day, "Expected June 30, 30 era 235 < August 30, 02 era 236.") 49 | } 50 | 51 | func testDayContainingDate() { 52 | let january2020Date = gregorianCalendar.date( 53 | from: DateComponents(year: 2020, month: 01, day: 19))! 54 | let january2020Day = Day( 55 | month: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), 56 | day: 19) 57 | XCTAssert( 58 | gregorianCalendar.day(containing: january2020Date) == january2020Day, 59 | "Expected the day to be January 19, 2020.") 60 | 61 | let december0005Date = gregorianCalendar.date( 62 | from: DateComponents(era: 0, year: 0005, month: 12, day: 08))! 63 | let december0005Day = Day( 64 | month: Month(era: 0, year: 0005, month: 12, isInGregorianCalendar: true), 65 | day: 08) 66 | XCTAssert( 67 | gregorianCalendar.day(containing: december0005Date) == december0005Day, 68 | "Expected the day to be December 8, 0005.") 69 | 70 | let september02Date = japaneseCalendar.date( 71 | from: DateComponents(era: 236, year: 02, month: 09, day: 21))! 72 | let september02Day = Day( 73 | month: Month(era: 236, year: 02, month: 09, isInGregorianCalendar: false), 74 | day: 21) 75 | XCTAssert( 76 | japaneseCalendar.day(containing: september02Date) == september02Day, 77 | "Expected the day to be September 21, 02.") 78 | } 79 | 80 | func testStartDateOfDay() { 81 | let november2020Day = Day( 82 | month: Month(era: 1, year: 2020, month: 11, isInGregorianCalendar: true), 83 | day: 17) 84 | let november2020Date = gregorianCalendar.date( 85 | from: DateComponents(year: 2020, month: 11, day: 17))! 86 | XCTAssert( 87 | gregorianCalendar.startDate(of: november2020Day) == november2020Date, 88 | "Expected the date to be the earliest possible time for November 17, 2020.") 89 | 90 | let january0100Day = Day( 91 | month: Month(era: 0, year: 0100, month: 01, isInGregorianCalendar: true), 92 | day: 14) 93 | let january0100Date = gregorianCalendar.date( 94 | from: DateComponents(era: 0, year: 0100, month: 01, day: 14))! 95 | XCTAssert( 96 | gregorianCalendar.startDate(of: january0100Day) == january0100Date, 97 | "Expected the date to be the earliest possible time for January 14, 0100 BCE.") 98 | 99 | let june02Day = Day( 100 | month: Month(era: 236, year: 02, month: 06, isInGregorianCalendar: false), 101 | day: 11) 102 | let june02Date = japaneseCalendar.date( 103 | from: DateComponents(era: 236, year: 02, month: 06, day: 11))! 104 | XCTAssert( 105 | japaneseCalendar.startDate(of: june02Day) == june02Date, 106 | "Expected the date to be the earliest possible time for June 11, 02.") 107 | } 108 | 109 | func testDayByAddingDays() { 110 | let january2021Day = Day( 111 | month: Month(era: 1, year: 2021, month: 01, isInGregorianCalendar: true), 112 | day: 19) 113 | let june2020Day = Day( 114 | month: Month(era: 1, year: 2020, month: 11, isInGregorianCalendar: true), 115 | day: 16) 116 | XCTAssert( 117 | gregorianCalendar.day(byAddingDays: -64, to: january2021Day) == june2020Day, 118 | "Expected January 19, 2021 - 100 = June 16, 2020.") 119 | 120 | let april0069Day = Day( 121 | month: Month(era: 0, year: 0069, month: 04, isInGregorianCalendar: true), 122 | day: 20) 123 | let may0069Day = Day( 124 | month: Month(era: 0, year: 0069, month: 05, isInGregorianCalendar: true), 125 | day: 01) 126 | XCTAssert( 127 | gregorianCalendar.day(byAddingDays: 11, to: april0069Day) == may0069Day, 128 | "Expected April 20, 0069 BCE + 12 = May 01, 0069 BCE.") 129 | 130 | let january02Day = Day( 131 | month: Month(era: 236, year: 02, month: 01, isInGregorianCalendar: false), 132 | day: 01) 133 | let december01Day = Day( 134 | month: Month(era: 236, year: 01, month: 12, isInGregorianCalendar: false), 135 | day: 31) 136 | XCTAssert( 137 | japaneseCalendar.day(byAddingDays: -1, to: january02Day) == december01Day, 138 | "Expected January 1, 02 - 1 = December 31, 01.") 139 | } 140 | 141 | // MARK: Private 142 | 143 | private lazy var gregorianCalendar = Calendar(identifier: .gregorian) 144 | private lazy var japaneseCalendar = Calendar(identifier: .japanese) 145 | 146 | } 147 | -------------------------------------------------------------------------------- /Tests/DayOfWeekPositionTests.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/7/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import XCTest 17 | @testable import HorizonCalendar 18 | 19 | // MARK: - DayOfWeekPositionTests 20 | 21 | final class DayOfWeekPositionTests: XCTestCase { 22 | 23 | // MARK: Internal 24 | 25 | func testWeekdayIndex() throws { 26 | let allPositions = DayOfWeekPosition.allCases 27 | for (position, expectedWeekdayIndex) in zip(allPositions, 0...6) { 28 | XCTAssert( 29 | gregorianCalendar.weekdayIndex(for: position) == expectedWeekdayIndex, 30 | "Expected \(position) of the week to have weekday index = \(expectedWeekdayIndex).") 31 | } 32 | 33 | for (position, expectedWeekdayIndex) in zip(allPositions, [1, 2, 3, 4, 5, 6, 0]) { 34 | XCTAssert( 35 | gregorianUKCalendar.weekdayIndex(for: position) == expectedWeekdayIndex, 36 | "Expected \(position) of the week to have weekday index = \(expectedWeekdayIndex).") 37 | } 38 | } 39 | 40 | func testDayOfWeekComparable() { 41 | for dayOfWeekPositionRawValue in DayOfWeekPosition.allCases.map({ $0.rawValue }) { 42 | if dayOfWeekPositionRawValue > 1 { 43 | let dayOfWeekPosition = DayOfWeekPosition(rawValue: dayOfWeekPositionRawValue)! 44 | let previousDayOfWeekPosition = DayOfWeekPosition(rawValue: dayOfWeekPositionRawValue - 1)! 45 | XCTAssert( 46 | previousDayOfWeekPosition < dayOfWeekPosition, 47 | "Expected \(previousDayOfWeekPosition) < \(dayOfWeekPosition).") 48 | } 49 | } 50 | } 51 | 52 | func testDayOfWeekPositionForDate() { 53 | let date0 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 01, day: 19))! 54 | XCTAssert( 55 | gregorianCalendar.dayOfWeekPosition(for: date0) == .first, 56 | "Expected January 19, 2020 to fall on the first day of the week.") 57 | 58 | let date1 = gregorianCalendar.date(from: DateComponents(year: 2015, month: 05, day: 01))! 59 | XCTAssert( 60 | gregorianCalendar.dayOfWeekPosition(for: date1) == .sixth, 61 | "Expected May 1, 2015 to fall on the sixth day of the week.") 62 | 63 | let date2 = japaneseCalendar.date(from: DateComponents(era: 236, year: 01, month: 12, day: 25))! 64 | XCTAssert( 65 | japaneseCalendar.dayOfWeekPosition(for: date2) == .fourth, 66 | "Expected December 25, 01 era 236 to fall on the fourth day of the week.") 67 | 68 | let date3 = japaneseCalendar.date(from: DateComponents(era: 235, year: 30, month: 07, day: 10))! 69 | XCTAssert( 70 | japaneseCalendar.dayOfWeekPosition(for: date3) == .third, 71 | "Expected July 10, 30 era 235 to fall on the third day of the week.") 72 | 73 | let date4 = gregorianCalendar.date(from: DateComponents(year: 2100, month: 04, day: 22))! 74 | XCTAssert( 75 | gregorianCalendar.dayOfWeekPosition(for: date4) == .fifth, 76 | "Expected April 22, 2100 to fall on the fifth day of the week.") 77 | 78 | let date5 = gregorianCalendar.date( 79 | from: DateComponents(era: 0, year: 0018, month: 01, day: 03))! 80 | XCTAssert( 81 | gregorianCalendar.dayOfWeekPosition(for: date5) == .last, 82 | "Expected March 1, 0016 BCE to fall on the last day of the week.") 83 | 84 | let date6 = gregorianCalendar.date( 85 | from: DateComponents(era: 0, year: 1492, month: 09, day: 22))! 86 | XCTAssert( 87 | gregorianCalendar.dayOfWeekPosition(for: date6) == .second, 88 | "Expected September 19, 1492 to fall on the second day of the week.") 89 | } 90 | 91 | // MARK: Private 92 | 93 | private lazy var gregorianCalendar = Calendar(identifier: .gregorian) 94 | private lazy var japaneseCalendar = Calendar(identifier: .japanese) 95 | private lazy var gregorianUKCalendar: Calendar = { 96 | var calendar = Calendar(identifier: .gregorian) 97 | calendar.locale = Locale(identifier: "en_GB") 98 | return calendar 99 | }() 100 | 101 | } 102 | -------------------------------------------------------------------------------- /Tests/HorizontalMonthsLayoutOptionsTests.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 11/8/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import XCTest 17 | @testable import HorizonCalendar 18 | 19 | final class HorizontalMonthsLayoutOptionsTests: XCTestCase { 20 | 21 | func testMonthWidthOneVisibleMonth() { 22 | let options = HorizontalMonthsLayoutOptions(maximumFullyVisibleMonths: 1) 23 | 24 | XCTAssert( 25 | options.monthWidth(calendarWidth: 100, interMonthSpacing: 0) == 100, 26 | "Incorrect month width") 27 | 28 | XCTAssert( 29 | options.monthWidth(calendarWidth: 100, interMonthSpacing: 10) == 90, 30 | "Incorrect month width") 31 | } 32 | 33 | func testMonthWidthOneAndAHalfVisibleMonths() { 34 | let options = HorizontalMonthsLayoutOptions(maximumFullyVisibleMonths: 1.5) 35 | 36 | XCTAssert( 37 | options.monthWidth(calendarWidth: 120, interMonthSpacing: 0) == 80, 38 | "Incorrect month width") 39 | 40 | XCTAssert( 41 | options.monthWidth(calendarWidth: 120, interMonthSpacing: 10) == 70, 42 | "Incorrect month width") 43 | } 44 | 45 | func testMonthWidthFourVisibleMonths() { 46 | let options = HorizontalMonthsLayoutOptions(maximumFullyVisibleMonths: 4) 47 | 48 | XCTAssert( 49 | options.monthWidth(calendarWidth: 100, interMonthSpacing: 0) == 25, 50 | "Incorrect month width") 51 | 52 | XCTAssert( 53 | options.monthWidth(calendarWidth: 100, interMonthSpacing: 10) == 15, 54 | "Incorrect month width") 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/MonthRowTests.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 6/7/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import XCTest 17 | @testable import HorizonCalendar 18 | 19 | // MARK: - MonthRowTests 20 | 21 | final class MonthRowTests: XCTestCase { 22 | 23 | // MARK: Internal 24 | 25 | func testRowInMonthForDate() { 26 | let date0 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 06, day: 02))! 27 | XCTAssert( 28 | gregorianCalendar.rowInMonth(for: date0) == 0, 29 | "Expected June 2, 2020 to be in the first row of the month.") 30 | 31 | let date1 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 02, day: 05))! 32 | XCTAssert( 33 | gregorianCalendar.rowInMonth(for: date1) == 1, 34 | "Expected February 5, 2020 to be in the second row of the month.") 35 | 36 | let date2 = gregorianCalendar.date(from: DateComponents(year: 1500, month: 12, day: 15))! 37 | XCTAssert( 38 | gregorianCalendar.rowInMonth(for: date2) == 2, 39 | "Expected December 15, 1500 to be in the third row of the month.") 40 | 41 | let date3 = gregorianCalendar.date(from: DateComponents(year: 0001, month: 02, day: 21))! 42 | XCTAssert( 43 | gregorianCalendar.rowInMonth(for: date3) == 3, 44 | "Expected February 21, 0001 CE to be in the fourth row of the month.") 45 | 46 | let date4 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 06, day: 30))! 47 | XCTAssert( 48 | gregorianCalendar.rowInMonth(for: date4) == 4, 49 | "Expected June 30, 2020 to be in the fifth row of the month.") 50 | 51 | let date5 = gregorianCalendar.date(from: DateComponents(year: 2020, month: 05, day: 31))! 52 | XCTAssert( 53 | gregorianCalendar.rowInMonth(for: date5) == 5, 54 | "Expected May 31, 2020 to be in the sixth row of the month.") 55 | 56 | let date6 = japaneseCalendar.date(from: DateComponents(era: 236, year: 01, month: 03, day: 01))! 57 | XCTAssert( 58 | japaneseCalendar.rowInMonth(for: date6) == 0, 59 | "Expected March 1, 01 era 236 to be in the first row of the month.") 60 | 61 | let date7 = japaneseCalendar.date(from: DateComponents(era: 236, year: 01, month: 03, day: 31))! 62 | XCTAssert( 63 | japaneseCalendar.rowInMonth(for: date7) == 5, 64 | "Expected March 31, 01 era 236 to be in the sixth row of the month.") 65 | } 66 | 67 | // MARK: Private 68 | 69 | private lazy var gregorianCalendar = Calendar(identifier: .gregorian) 70 | private lazy var japaneseCalendar = Calendar(identifier: .japanese) 71 | 72 | } 73 | -------------------------------------------------------------------------------- /Tests/MonthTests.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 4/20/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import XCTest 17 | @testable import HorizonCalendar 18 | 19 | // MARK: - MonthTests 20 | 21 | final class MonthTests: XCTestCase { 22 | 23 | // MARK: Internal 24 | 25 | // MARK: - Advancing Months Tests 26 | 27 | func testAdvancingByNothing() { 28 | let month = calendar.month( 29 | byAddingMonths: 0, 30 | to: Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true)) 31 | XCTAssert(month == Month(era: 1, year: 2020, month: 01, isInGregorianCalendar: true), "Expected 2020-01.") 32 | } 33 | 34 | func testAdvancingByLessThanOneYear() { 35 | let month1 = calendar.month( 36 | byAddingMonths: 4, 37 | to: Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true)) 38 | XCTAssert(month1 == Month(era: 1, year: 2020, month: 10, isInGregorianCalendar: true), "Expected 2020-10.") 39 | 40 | let month2 = calendar.month( 41 | byAddingMonths: -4, 42 | to: Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true)) 43 | XCTAssert(month2 == Month(era: 1, year: 2020, month: 02, isInGregorianCalendar: true), "Expected 2020-02.") 44 | } 45 | 46 | func testAdvancingByMoreThanOneYear() { 47 | let month1 = calendar.month( 48 | byAddingMonths: 16, 49 | to: Month(era: 1, year: 2020, month: 08, isInGregorianCalendar: true)) 50 | XCTAssert(month1 == Month(era: 1, year: 2021, month: 12, isInGregorianCalendar: true), "Expected 2021-12.") 51 | 52 | let month2 = calendar.month( 53 | byAddingMonths: -25, 54 | to: Month(era: 1, year: 2020, month: 06, isInGregorianCalendar: true)) 55 | XCTAssert(month2 == Month(era: 1, year: 2018, month: 05, isInGregorianCalendar: true), "Expected 2018-05.") 56 | } 57 | 58 | // MARK: Private 59 | 60 | private let calendar = Calendar(identifier: .gregorian) 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Tests/ScreenPixelAlignmentTests.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 3/31/20. 2 | // Copyright © 2020 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import XCTest 17 | @testable import HorizonCalendar 18 | 19 | // MARK: - ScreenPixelAlignmentTests 20 | 21 | final class ScreenPixelAlignmentTests: XCTestCase { 22 | 23 | // MARK: Value alignment tests 24 | 25 | func test1xScaleValueAlignment() { 26 | XCTAssert( 27 | CGFloat(1).alignedToPixel(forScreenWithScale: 1) == CGFloat(1), 28 | "Incorrect screen pixel alignment") 29 | XCTAssert( 30 | CGFloat(1.5).alignedToPixel(forScreenWithScale: 1) == CGFloat(2), 31 | "Incorrect screen pixel alignment") 32 | XCTAssert( 33 | CGFloat(500.8232134315).alignedToPixel(forScreenWithScale: 1) == CGFloat(501), 34 | "Incorrect screen pixel alignment") 35 | } 36 | 37 | func test2xScaleValueAlignment() { 38 | XCTAssert( 39 | CGFloat(1).alignedToPixel(forScreenWithScale: 2) == CGFloat(1), 40 | "Incorrect screen pixel alignment") 41 | XCTAssert( 42 | CGFloat(1.5).alignedToPixel(forScreenWithScale: 2) == CGFloat(1.5), 43 | "Incorrect screen pixel alignment") 44 | XCTAssert( 45 | CGFloat(500.8232134315).alignedToPixel(forScreenWithScale: 2) == CGFloat(501), 46 | "Incorrect screen pixel alignment") 47 | } 48 | 49 | func test3xScaleValueAlignment() { 50 | XCTAssert( 51 | CGFloat(1).alignedToPixel(forScreenWithScale: 3) == CGFloat(1), 52 | "Incorrect screen pixel alignment") 53 | XCTAssert( 54 | CGFloat(1.5).alignedToPixel(forScreenWithScale: 3) == CGFloat(1.6666666666666667), 55 | "Incorrect screen pixel alignment") 56 | XCTAssert( 57 | CGFloat(500.8232134315).alignedToPixel(forScreenWithScale: 3) == CGFloat(500.6666666666667), 58 | "Incorrect screen pixel alignment") 59 | } 60 | 61 | // MARK: Point alignment tests 62 | 63 | func test1xScalePointAlignment() { 64 | let point1 = CGPoint(x: 1, y: 2.3) 65 | XCTAssert( 66 | point1.alignedToPixels(forScreenWithScale: 1) == CGPoint(x: 1, y: 2), 67 | "Incorrect screen pixel alignment") 68 | 69 | let point2 = CGPoint(x: 100.05, y: -50.51) 70 | XCTAssert( 71 | point2.alignedToPixels(forScreenWithScale: 1) == CGPoint(x: 100, y: -51), 72 | "Incorrect screen pixel alignment") 73 | } 74 | 75 | func test2xScalePointAlignment() { 76 | let point1 = CGPoint(x: -0.6, y: 199) 77 | XCTAssert( 78 | point1.alignedToPixels(forScreenWithScale: 2) == CGPoint(x: -0.5, y: 199), 79 | "Incorrect screen pixel alignment") 80 | 81 | let point2 = CGPoint(x: 52.33333333, y: 52.249999) 82 | XCTAssert( 83 | point2.alignedToPixels(forScreenWithScale: 2) == CGPoint(x: 52.5, y: 52), 84 | "Incorrect screen pixel alignment") 85 | } 86 | 87 | func test3xScalePointAlignment() { 88 | let point1 = CGPoint(x: -5.6, y: 0.85) 89 | XCTAssert( 90 | point1.alignedToPixels(forScreenWithScale: 3) == CGPoint(x: -5.666666666666667, y: 1), 91 | "Incorrect screen pixel alignment") 92 | 93 | let point2 = CGPoint(x: 99.91, y: 13.25) 94 | XCTAssert( 95 | point2.alignedToPixels(forScreenWithScale: 3) == CGPoint(x: 100, y: 13.333333333333334), 96 | "Incorrect screen pixel alignment") 97 | } 98 | 99 | // MARK: Rectangle alignment tests 100 | 101 | func test1xScaleRectAlignment() { 102 | let rect = CGRect(x: 0, y: 1.24, width: 10.25, height: 11.76) 103 | let expectedRect = CGRect(x: 0, y: 1, width: 10, height: 12) 104 | XCTAssert( 105 | rect.alignedToPixels(forScreenWithScale: 1) == expectedRect, 106 | "Incorrect screen pixel alignment") 107 | } 108 | 109 | func test2xScaleRectAlignment() { 110 | let rect = CGRect(x: 5.299999, y: -19.1994, width: 20.25, height: 0.76) 111 | let expectedRect = CGRect(x: 5.5, y: -19, width: 20.5, height: 1) 112 | XCTAssert( 113 | rect.alignedToPixels(forScreenWithScale: 2) == expectedRect, 114 | "Incorrect screen pixel alignment") 115 | } 116 | 117 | func test3xScaleRectAlignment() { 118 | let rect = CGRect(x: 71.13, y: 71.19, width: 20.25, height: 2) 119 | let expectedRect = CGRect(x: 71, y: 71.33333333333333, width: 20.333333333333332, height: 2) 120 | XCTAssert( 121 | rect.alignedToPixels(forScreenWithScale: 3) == expectedRect, 122 | "Incorrect screen pixel alignment") 123 | } 124 | 125 | // MARK: CGFloat Approximate Comparison Tests 126 | 127 | func testApproximateEquality() { 128 | XCTAssert(CGFloat(1.48).isEqual(to: 1.52, screenScale: 2)) 129 | XCTAssert(!CGFloat(1).isEqual(to: 10, screenScale: 9)) 130 | XCTAssert(!CGFloat(1).isEqual(to: 10, screenScale: 9)) 131 | XCTAssert(!CGFloat(1).isEqual(to: 9, screenScale: 9)) 132 | XCTAssert(!CGFloat(1.333).isEqual(to: 1.666, screenScale: 3)) 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /Tests/SubviewsManagerTests.swift: -------------------------------------------------------------------------------- 1 | // Created by Bryan Keller on 12/15/22. 2 | // Copyright © 2022 Airbnb Inc. All rights reserved. 3 | 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import XCTest 17 | @testable import HorizonCalendar 18 | 19 | // MARK: - SubviewInsertionIndexTrackerTests 20 | 21 | final class SubviewInsertionIndexTrackerTests: XCTestCase { 22 | 23 | // MARK: Internal 24 | 25 | func testCorrectSubviewsOrderFewItems() throws { 26 | let itemTypesToInsert: [VisibleItem.ItemType] = [ 27 | .pinnedDayOfWeek(.first), 28 | .pinnedDaysOfWeekRowSeparator, 29 | .pinnedDaysOfWeekRowBackground, 30 | .overlayItem(.monthHeader(monthContainingDate: Date())), 31 | .daysOfWeekRowSeparator( 32 | Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true)), 33 | .layoutItemType( 34 | .monthHeader(Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true))), 35 | .dayRange(.init(containing: Date()...Date(), in: .current)), 36 | ] 37 | 38 | var itemTypes = [VisibleItem.ItemType]() 39 | for itemTypeToInsert in itemTypesToInsert { 40 | let insertionIndex = subviewInsertionIndexTracker.insertionIndex( 41 | forSubviewWithCorrespondingItemType: itemTypeToInsert) 42 | itemTypes.insert(itemTypeToInsert, at: insertionIndex) 43 | } 44 | 45 | XCTAssert(itemTypes == itemTypesToInsert.sorted(), "Incorrect subviews order.") 46 | } 47 | 48 | func testCorrectSubviewsOrderManyItems() throws { 49 | let itemTypesToInsert: [VisibleItem.ItemType] = [ 50 | .layoutItemType( 51 | .monthHeader(Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true))), 52 | .dayRange(.init(containing: Date()...Date(), in: .current)), 53 | .dayRange(.init(containing: Date()...Date(), in: .current)), 54 | .dayRange(.init(containing: Date()...Date(), in: .current)), 55 | .layoutItemType( 56 | .monthHeader(Month(era: 1, year: 2022, month: 2, isInGregorianCalendar: true))), 57 | .layoutItemType( 58 | .monthHeader(Month(era: 1, year: 2022, month: 3, isInGregorianCalendar: true))), 59 | .layoutItemType( 60 | .monthHeader(Month(era: 1, year: 2022, month: 4, isInGregorianCalendar: true))), 61 | .layoutItemType( 62 | .monthHeader(Month(era: 1, year: 2022, month: 5, isInGregorianCalendar: true))), 63 | .layoutItemType( 64 | .monthHeader(Month(era: 1, year: 2022, month: 6, isInGregorianCalendar: true))), 65 | .layoutItemType( 66 | .monthHeader(Month(era: 1, year: 2022, month: 7, isInGregorianCalendar: true))), 67 | .layoutItemType( 68 | .monthHeader(Month(era: 1, year: 2022, month: 8, isInGregorianCalendar: true))), 69 | .overlayItem(.monthHeader(monthContainingDate: Date())), 70 | .pinnedDaysOfWeekRowBackground, 71 | .dayRange(.init(containing: Date()...Date(), in: .current)), 72 | .layoutItemType( 73 | .monthHeader(Month(era: 1, year: 2022, month: 9, isInGregorianCalendar: true))), 74 | .pinnedDayOfWeek(.first), 75 | .pinnedDayOfWeek(.second), 76 | .daysOfWeekRowSeparator( 77 | Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true)), 78 | .pinnedDayOfWeek(.third), 79 | .pinnedDaysOfWeekRowSeparator, 80 | .pinnedDayOfWeek(.fourth), 81 | .pinnedDayOfWeek(.fifth), 82 | .layoutItemType( 83 | .monthHeader(Month(era: 1, year: 2022, month: 10, isInGregorianCalendar: true))), 84 | .pinnedDayOfWeek(.sixth), 85 | .layoutItemType( 86 | .monthHeader(Month(era: 1, year: 2022, month: 11, isInGregorianCalendar: true))), 87 | .layoutItemType( 88 | .monthHeader(Month(era: 1, year: 2022, month: 12, isInGregorianCalendar: true))), 89 | .layoutItemType( 90 | .monthHeader(Month(era: 1, year: 2022, month: 1, isInGregorianCalendar: true))), 91 | .dayRange(.init(containing: Date()...Date(), in: .current)), 92 | .layoutItemType( 93 | .monthHeader(Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true))), 94 | .layoutItemType( 95 | .monthHeader(Month(era: 1, year: 2023, month: 2, isInGregorianCalendar: true))), 96 | .daysOfWeekRowSeparator( 97 | Month(era: 1, year: 2023, month: 1, isInGregorianCalendar: true)), 98 | .layoutItemType( 99 | .monthHeader(Month(era: 1, year: 2023, month: 3, isInGregorianCalendar: true))), 100 | .layoutItemType( 101 | .monthHeader(Month(era: 1, year: 2023, month: 4, isInGregorianCalendar: true))), 102 | .pinnedDayOfWeek(.last), 103 | ] 104 | 105 | var itemTypes = [VisibleItem.ItemType]() 106 | for itemTypeToInsert in itemTypesToInsert { 107 | let insertionIndex = subviewInsertionIndexTracker.insertionIndex( 108 | forSubviewWithCorrespondingItemType: itemTypeToInsert) 109 | itemTypes.insert(itemTypeToInsert, at: insertionIndex) 110 | } 111 | 112 | XCTAssert(itemTypes == itemTypesToInsert.sorted(), "Incorrect subviews order.") 113 | } 114 | 115 | // MARK: Private 116 | 117 | private let subviewInsertionIndexTracker = SubviewInsertionIndexTracker() 118 | 119 | } 120 | 121 | // MARK: - VisibleItem.ItemType + Comparable 122 | 123 | extension VisibleItem.ItemType: Comparable { 124 | 125 | // MARK: Public 126 | 127 | public static func < ( 128 | lhs: HorizonCalendar.VisibleItem.ItemType, 129 | rhs: HorizonCalendar.VisibleItem.ItemType) 130 | -> Bool 131 | { 132 | lhs.relativeDistanceFromBack < rhs.relativeDistanceFromBack 133 | } 134 | 135 | // MARK: Private 136 | 137 | private var relativeDistanceFromBack: Int { 138 | switch self { 139 | case .monthBackground: return 0 140 | case .dayBackground: return 1 141 | case .dayRange: return 2 142 | case .layoutItemType: return 3 143 | case .daysOfWeekRowSeparator: return 4 144 | case .overlayItem: return 5 145 | case .pinnedDaysOfWeekRowBackground: return 6 146 | case .pinnedDayOfWeek: return 7 147 | case .pinnedDaysOfWeekRowSeparator: return 8 148 | } 149 | } 150 | 151 | } 152 | --------------------------------------------------------------------------------