├── .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 |
--------------------------------------------------------------------------------