Skip to content

Commit c4f166c

Browse files
committed
Propose API improvements to Calendar
1 parent 4962477 commit c4f166c

File tree

1 file changed

+161
-0
lines changed

1 file changed

+161
-0
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Calendar Sequence Enumeration
2+
3+
* Proposal: [SF-NNNN](NNNN-calendar-improvements.md)
4+
* Authors: [Tony Parker](https://github.com/parkera)
5+
* Review Manager: TBD
6+
* Status: **Awaiting review**
7+
* Implementation: Coming soon
8+
* Review: [Pitch](https://forums.swift.org/t/pitch-calendar-sequence-enumeration/68521)
9+
10+
## Introduction
11+
12+
In macOS 14 / iOS 17, `Calendar` was rewritten entirely in Swift. One of the many benefits of this change is that we can now more easily create Swift-specific `Calendar` API that feels more natural than the existing `enumerate` methods. In addition, we are taking the opportunity to add a new field to the `DateComponents` type to handle one case that was only exposed via the somewhat esoteric CoreFoundation API `CFCalendarDecomposeAbsoluteTime`.
13+
14+
## Motivation
15+
16+
The existing `enumerateDates` method on `Calendar` is basically imported from an Objective-C implementation. We can provide much better integration with other Swift API by providing a `Sequence`-backed enumeration.
17+
18+
## Proposed solution
19+
20+
We propose a new field on `DateComponents` and associated options / units:
21+
22+
```swift
23+
extension Calendar {
24+
public enum Component : Sendable {
25+
// .. existing fields
26+
27+
@available(FoundationPreview 0.4, *)
28+
case dayOfYear
29+
}
30+
}
31+
```
32+
33+
```swift
34+
extension DateComponents {
35+
/// A day of the year.
36+
/// For example, in the Gregorian calendar, can go from 1 to 365 or 1 to 366 in leap years.
37+
/// - note: This value is interpreted in the context of the calendar in which it is used.
38+
@available(FoundationPreview 0.4, *)
39+
public var dayOfYear: Int?
40+
}
41+
```
42+
43+
We also propose a new API on `Calendar` to use enumeration in a `Sequence`-friendly way:
44+
45+
```swift
46+
extension Calendar {
47+
/// Computes the dates which match (or most closely match) a given set of components, returned as a `Sequence`.
48+
///
49+
/// If `direction` is set to `.backward`, this method finds the previous match before the given date. The intent is that the same matches as for a `.forward` search will be found. For example, if you are searching forwards or backwards for each hour with minute "27", the seconds in the date you will get in both a `.forward` and `.backward` search would be 00. Similarly, for DST backwards jumps which repeat times, you'll get the first match by default, where "first" is defined from the point of view of searching forwards. Therefore, when searching backwards looking for a particular hour, with no minute and second specified, you don't get a minute and second of 59:59 for the matching hour but instead 00:00.
50+
///
51+
/// If an exact match is not possible, and requested with the `strict` option, the sequence ends.
52+
///
53+
/// Result dates have an integer number of seconds (as if 0 was specified for the nanoseconds property of the `DateComponents` matching parameter), unless a value was set in the nanoseconds property, in which case the result date will have that number of nanoseconds, or as close as possible with floating point numbers.
54+
/// - parameter start: The `Date` at which to start the search.
55+
/// - parameter components: The `DateComponents` to use as input to the search algorithm.
56+
/// - parameter matchingPolicy: Determines the behavior of the search algorithm when the input produces an ambiguous result.
57+
/// - parameter repeatedTimePolicy: Determines the behavior of the search algorithm when the input produces a time that occurs twice on a particular day.
58+
/// - parameter direction: Which direction in time to search. The default value is `.forward`, which means later in time.
59+
@available(FoundationPreview 0.4, *)
60+
public func dates(startingAt start: Date,
61+
matching components: DateComponents,
62+
matchingPolicy: MatchingPolicy = .nextTime,
63+
repeatedTimePolicy: RepeatedTimePolicy = .first,
64+
direction: SearchDirection = .forward) -> DateSequence
65+
}
66+
67+
extension Calendar {
68+
/// A `Sequence` of `Date`s which match the specified search criteria.
69+
/// - note: This sequence will terminate after a built-in search limit to prevent infinite loops.
70+
@available(FoundationPreview 0.4, *)
71+
public struct DateSequence : Sendable, Sequence {
72+
public typealias Element = Date
73+
74+
public var calendar: Calendar
75+
public var start: Date
76+
public var matchingComponents: DateComponents
77+
public var matchingPolicy: Calendar.MatchingPolicy
78+
public var repeatedTimePolicy: Calendar.RepeatedTimePolicy
79+
public var direction: Calendar.SearchDirection
80+
81+
public init(calendar: Calendar, start: Date, matchingComponents: DateComponents, matchingPolicy: Calendar.MatchingPolicy = .nextTime, repeatedTimePolicy: Calendar.RepeatedTimePolicy = .first, direction: Calendar.SearchDirection = .forward)
82+
83+
public func makeIterator() -> Iterator
84+
85+
public struct Iterator: Sendable, IteratorProtocol {
86+
// No public initializer
87+
public mutating func next() -> Element?
88+
}
89+
}
90+
}
91+
```
92+
93+
94+
## Detailed design
95+
96+
The new `Sequence`-based API is a great fit for Swift because it composes with all the existing algorithms and functions that exist on `Sequence`. For example, the following code finds the next 3 minutes after _August 22, 2022 at 3:02:38 PM PDT_, then uses `zip` to combine them with some strings. The second array naturally has 3 elements. In contrast with the existing `enumerate` method, no additional counting of how many values we've seen and manully setting a `stop` argument to break out of a loop is required.
97+
98+
```swift
99+
let cal = Calendar(identifier: .gregorian)
100+
let date = Date(timeIntervalSinceReferenceDate: 682869758.712307) // August 22, 2022 at 7:02:38 AM PDT
101+
let dates = zip(
102+
cal.dates(startingAt: date, matching: DateComponents(minute: 0), matchingPolicy: .nextTime),
103+
["1st period", "2nd period", "3rd period"]
104+
)
105+
106+
let description = dates
107+
.map { "\($0.formatted(date: .omitted, time: .shortened)): \($1)" }
108+
.formatted()
109+
// 8:00 AM: 1st period, 9:00 AM: 2nd period, and 10:00 AM: 3rd period
110+
```
111+
112+
Another example is simply using the `prefix` function. Here, it is combined with use of the new `dayOfYear` field:
113+
114+
```swift
115+
var matchingComps = DateComponents()
116+
matchingComps.dayOfYear = 234
117+
// Including a leap year, find the next 5 "day 234"s
118+
let nextFive = cal.dates(startingAt: date, matching: matchingComps).prefix(5)
119+
/*
120+
Result:
121+
2022-08-22 00:00:00 +0000
122+
2023-08-22 00:00:00 +0000
123+
2024-08-21 00:00:00 +0000 // note: leap year, one additional day in Feb
124+
2025-08-22 00:00:00 +0000
125+
2026-08-22 00:00:00 +0000
126+
*/
127+
```
128+
129+
The new `dayOfYear` option composes with existing `Calendar` API, and can be useful for specialized calculations.
130+
131+
```swift
132+
let date = Date(timeIntervalSinceReferenceDate: 682898558.712307) // 2022-08-22 22:02:38 UTC, day 234
133+
let dayOfYear = cal.component(.dayOfYear, from: date) // 234
134+
135+
let range1 = cal.range(of: .dayOfYear, in: .year, for: date) // 1..<366
136+
let range2 = cal.range(of: .dayOfYear, in: .year, for: leapYearDate // 1..<367
137+
138+
// What day of the week is the 100th day of the year?
139+
let whatDay = cal.date(bySetting: .dayOfYear, value: 100, of: Date.now)!
140+
let dayOfWeek = cal.component(.weekday, from: whatDay) // 3 (Tuesday)
141+
```
142+
## Source compatibility
143+
144+
The proposed changes are additive and no significant impact on existing code is expected. Some `Calendar` API will begin to return `DateComponents` results with the additional field populated.
145+
146+
## Implications on adoption
147+
148+
The new API has an availability of FoundationPreview 0.4 or later.
149+
150+
## Alternatives considered
151+
152+
The `DateSequence` API is missing one parameter that `enumerateDates` has - a `Boolean` argument to indicate if the result date is an exact match or not. In research for this proposal, we surveyed many callers of the existing `enumerateDates` API and found only one that did not ignore this argument. Given the greater usability of having a simple `Date` as the element of the `Sequence`, we decided to omit the value from the `Sequence` API. The existing `enumerateDates` method will continue to exist in the rare case that the exact-match value is required.
153+
154+
We decided not to add the new fields to the `DateComponents` initializer. Swift might add a new "magic `with`" [operator](https://github.com/apple/swift-evolution/pull/2123) which will provide a better pattern for initializing immutable struct types with `var` fields. Even if that proposal does not end up accepted, adding a new initializer for each new field will quickly become unmanageable, and using default values makes the initializers ambiguous. Instead, the caller can simply set the desired value after initialization.
155+
156+
We originally considered adding a field for Julian days, but decided this would be better expressed as a conversion from `Date` instead of from a `DateComponents`. Julian days are similar to `Date` in that they represent a point on a fixed timeline. For Julian days, they also assume a fixed calendar and time zone. Combining this with the open API of a `DateComponents`, which allows setting both a `Calendar` and `TimeZone` property, provides an opportunity for confusion. In addition, ICU defines a Julian day slightly differently than other standards and our current implementation relies on ICU for the calculations. This discrepency could lead to errors if the developer was not careful to offset the result manually.
157+
158+
159+
## Acknowledgments
160+
161+
Thanks to [Tina Liu](https://github.com/itingliu) for early feedback on this proposal.

0 commit comments

Comments
 (0)