简体   繁体   English

获取日历月的第一个星期一

[英]Get first Monday in Calendar month

I am trapped in a Calendar/TimeZone/DateComponents/Date hell and my mind is spinning.我被困在日历/时区/日期组件/日期地狱中,我的头脑在旋转。

I am creating a diary app and I want it to work worldwide (with a iso8601 calendar).我正在创建一个日记应用程序,我希望它在全球范围内工作(使用 iso8601 日历)。

I am trying to create a calendar view on iOS similar to the macOS calendar so that my user can navigate their diary entries (notice the 7x6 grid for each month):我正在尝试在 iOS 上创建一个类似于 macOS 日历的日历视图,以便我的用户可以浏览他们的日记条目(注意每个月的 7x6 网格):

/Users/adamwaite/Desktop/Scr​​eenshot 2018-10-04 at 08.22.24.png

To get the 1st of the month I can do something like the following:要获得本月的第一天,我可以执行以下操作:

func firstOfMonth(month: Int, year: Int) -> Date {

  let calendar = Calendar(identifier: .iso8601)

  var firstOfMonthComponents = DateComponents()
  // firstOfMonthComponents.timeZone = TimeZone(identifier: "UTC") // <- fixes daylight savings time
  firstOfMonthComponents.calendar = calendar
  firstOfMonthComponents.year = year
  firstOfMonthComponents.month = month
  firstOfMonthComponents.day = 01

  return firstOfMonthComponents.date!

}

(1...12).forEach {

  print(firstOfMonth(month: $0, year: 2018))

  /*

   Gives:

   2018-01-01 00:00:00 +0000
   2018-02-01 00:00:00 +0000
   2018-03-01 00:00:00 +0000
   2018-03-31 23:00:00 +0000
   2018-04-30 23:00:00 +0000
   2018-05-31 23:00:00 +0000
   2018-06-30 23:00:00 +0000
   2018-07-31 23:00:00 +0000
   2018-08-31 23:00:00 +0000
   2018-09-30 23:00:00 +0000
   2018-11-01 00:00:00 +0000
   2018-12-01 00:00:00 +0000
*/

}

There's an immediate issue here with daylight savings time.这里有一个关于夏令时的直接问题。 That issue can be "fixed" by uncommenting the commented line and forcing the date to be calculated in UTC.可以通过取消注释注释行并强制以 UTC 计算日期来“修复”该问题。 I feel as though by forcing it to UTC the dates become invalid when viewing the calendar view in different time zones.我觉得在不同时区查看日历视图时,通过强制将其设置为 UTC,日期会变得无效。

The real question is though: How do I get the first Monday in the week containing the 1st of the month?真正的问题是:我如何获得包含该月第一天的一周中的第一个星期一? For example, how do I get Monday 29th February, or Monday 26th April?例如,我如何获得 2 月 29 日星期一或 4 月 26 日星期一? (see the macOS screenshot). (请参阅 macOS 屏幕截图)。 To get the end of the month, do I just add on 42 days from the start?要到月底,我是否只需从开始的 42 天开始添加? Or is that naive?还是那太天真了?

Edit编辑

Thanks to the current answers, but we're still stuck.感谢当前的答案,但我们仍然被卡住了。

The following works, until you take daylight savings time into account:以下工作,直到您考虑夏令时:

几乎

OK, this is going to be a long and involved answer (hooray for you!).好的,这将是一个冗长而复杂的答案(万岁!)。

Overall you're on the right track but there are a couple of subtle errors going on.总体而言,您走在正确的轨道上,但仍存在一些细微的错误。

Conceptualizing Dates概念化日期

It's pretty well-established by now (I hope) that Date represents an instant in time.现在(我希望) Date代表时间的瞬间已经非常成熟。 What's not quite as well-established is the inverse.不太确定的是相反的情况。 A Date represents an instant, but what represents a calendar value? Date代表一个瞬间,但什么代表日历值?

When you think about it, a "day" or an "hour" (or even a "month") are a range .当您考虑它时,“天”或“小时”(甚至“月”)是一个范围 They're values that represent all possible instants between a start instant and an end instant.它们是表示开始时刻和结束时刻之间所有可能时刻的值。

For this reason, I find it most helpful to think about ranges when I'm dealing with calendar values.出于这个原因,我发现在处理日历值时考虑范围最有帮助。 In other words by asking "what is the range for this minute/hour/day/week/month/year?"换句话说,通过询问“这一分钟/小时/天/周/月/年的范围是多少?” etc.等等。

With this, I think you'll find it's easier to understand what's going on.有了这个,我想你会发现更容易理解发生了什么。 You understand a Range<Int> already, right?你已经了解Range<Int>了吧? So a Range<Date> is similarly intuitive.所以Range<Date>同样直观。

Fortunately, there's API on Calendar to get the range.幸运的是, Calendar上有 API 可以获取范围。 Unfortunately we don't quite get to just use Range<Date> because of holdover API from the Objective-C days.不幸的是,由于 Objective-C 时代的保留 API,我们不能完全使用Range<Date> But we get NSDateInterval , which is effectively the same thing.但是我们得到了NSDateInterval ,这实际上是一回事。

You ask for ranges by asking the Calendar for the range of the hour/day/week/month/year that contains a particular instant, like this:您可以通过向Calendar询问包含特定时刻的小时/日/周/月/年范围来请求范围,如下所示:

let now = Date()
let calendar = Calendar.current

let today = calendar.dateInterval(of: .day, for: now)

Armed with this, you can get the range of just about anything:有了这个,你可以得到几乎任何东西的范围:

let units = [Calendar.Component.second, .minute, .hour, .day, .weekOfMonth, .month, .quarter, .year, .era]

for unit in units {
    let range = calendar.dateInterval(of: unit, for: now)
    print("Range of \(unit): \(range)")
}

At the time I'm writing this, it prints:在我写这篇文章的时候,它打印:

Range of second: Optional(2018-10-04 19:50:10 +0000 to 2018-10-04 19:50:11 +0000)
Range of minute: Optional(2018-10-04 19:50:00 +0000 to 2018-10-04 19:51:00 +0000)
Range of hour: Optional(2018-10-04 19:00:00 +0000 to 2018-10-04 20:00:00 +0000)
Range of day: Optional(2018-10-04 06:00:00 +0000 to 2018-10-05 06:00:00 +0000)
Range of weekOfMonth: Optional(2018-09-30 06:00:00 +0000 to 2018-10-07 06:00:00 +0000)
Range of month: Optional(2018-10-01 06:00:00 +0000 to 2018-11-01 06:00:00 +0000)
Range of quarter: Optional(2018-10-01 06:00:00 +0000 to 2019-01-01 07:00:00 +0000)
Range of year: Optional(2018-01-01 07:00:00 +0000 to 2019-01-01 07:00:00 +0000)
Range of era: Optional(0001-01-03 00:00:00 +0000 to 139369-07-19 02:25:04 +0000)

You can see for the ranges of the respective units getting displayed.您可以看到显示的各个单位的范围。 There are two things to point out here:这里有两点需要指出:

  1. This method returns an optional ( DateInterval? ), because there might be weird situations where it fails.此方法返回一个可选的DateInterval? ),因为它可能会在奇怪的情况下失败。 I can't think of any off the top of my head, but calendars are fickle things, so heads up about this.我想不出任何事情,但日历是变化无常的东西,所以请注意这一点。

  2. Asking for an era's range is usually a nonsensical operation, even though eras are vital to properly constructing dates.要求一个时代的范围通常是一个荒谬的操作,即使时代对于正确构建日期至关重要。 It's difficult to have much precision with eras outside of certain calendars (primarily the Japanese calendar), so that value saying "The Common Era (CE or AD) started on January 3rd of the year 1" is probably wrong, but we can't really do anything about that.在某些日历(主要是日本日历)之外的时代很难有太多的精确度,所以说“共同时代(公元或公元)开始于第一年的 1 月 3 日”的价值可能是错误的,但我们不能真的对此有所作为。 Also, of course, we have no idea when the current era will end, so we just assume it goes on forever.此外,当然,我们不知道当前时代何时结束,所以我们只是假设它永远持续下去。

Printing Date values打印Date

This brings me to the next point, about printing dates.这让我想到下一点,关于打印日期。 I noticed this in your code and I think it might be the source of some of your issues.我在您的代码中注意到了这一点,我认为这可能是您的某些问题的根源。 I applaud your efforts to account for the abomination that is Daylight Saving Time, but I think you're getting caught up on something that isn't actually a bug, but is this:我赞赏你为解释夏令时这一令人憎恶的事情所做的努力,但我认为你被一些实际上不是错误的东西所吸引,但这是:

Dates always print in UTC. Dates总是以 UTC 打印。

Always always always.永远永远永远。

This is because they are instants in time, and they are the same instant regardless of whether you're in California or Djibouti or Khatmandu or whatever.这是因为它们是时间上的瞬间,无论您是在加利福尼亚、吉布提、加德满都还是其他任何地方,它们都是相同的瞬间 A Date value does not have a timezone.一个Date没有一个时区。 It's really just an offset from another known point in time.它实际上只是与另一个已知时间点的偏移。 However, printing 560375786.836208 as a Date's description doesn't seem that useful to humans, so the creators of the API made it print as a Gregorian date relative to the UTC timezone.但是,将560375786.836208打印为Date's描述对人类来说似乎没什么用,因此 API 的创建者将其打印为相对于 UTC 时区的公历日期。

If you want to print a date, even for debugging , you're almost definitely going to need a DateFormatter .如果你想打印一个日期,即使是为了调试,你几乎肯定需要一个DateFormatter

DateComponents.date

This isn't a problem with your code per-say, but is more of a heads up.这不是你的代码的问题,而是更多的提醒。 The .date property on DateComponents does not guarantee to return the first instant that matches the components specified..date物业DateComponents并不能保证返回指定的组件匹配的第一个瞬间。 It does not guarantee to return the last instant that matches the components.它不保证返回与组件匹配的最后时刻。 All it does is guarantee that, if it can find an answer, it will give you a Date somewhere in the range of the specified units.它所做的只是保证,如果它可以找到答案,它将在指定单位范围内某处为您提供Date

I don't think you're technically relying on this in your code, but it's a good thing to be aware of, and I've seen this misunderstanding causing bugs in software.我不认为您在技术上依赖于您的代码中的这一点,但了解这一点是件好事,而且我已经看到这种误解导致了软件中的错误。


With all of this in mind, let's take a look at what you're wanting to do:考虑到所有这些,让我们来看看您想要做什么:

  1. You want to know the range of the year你想知道一年的范围
  2. You want to know all of the months in the year你想知道一年中的所有月份
  3. You want to know all of the days in a month你想知道一个月的所有日子
  4. You want to know the week that contains the first day of the month您想知道包含该月第一天的那一周

So, based on what we've learned about ranges, this should be pretty straight-forward now, and interestingly, we can do it all without needing a single DateComponents value.因此,根据我们对范围的了解,现在这应该非常简单,而且有趣的是,我们可以在不需要单个DateComponents值的情况下完成所有DateComponents In the code below, I'll be force-unwrapping values for brevity.在下面的代码中,为了简洁起见,我将强制展开值。 In your actual code you'll probably want to have better error handling.在您的实际代码中,您可能希望有更好的错误处理。

The range of the year年的范围

This is easy:这很简单:

let yearRange = calendar.dateInterval(of: .year, for: now)!

The months in the year一年中的几个月

This is a little bit more complicated, but not too bad.这有点复杂,但还不错。

var monthsOfYear = Array<DateInterval>()
var startOfCurrentMonth = yearRange.start
repeat {
    let monthRange = calendar.dateInterval(of: .month, for: startOfCurrentMonth)!
    monthsOfYear.append(monthRange)
    startOfCurrentMonth = monthRange.end.addingTimeInterval(1)
} while yearRange.contains(startOfCurrentMonth)

Basically, we're going to start with the first instant of the year and ask the calendar for the month that contains that instant.基本上,我们将从一年中的第一个时刻开始,并在日历中查询包含该时刻的月份。 Once we've got that, we'll get the final instant of that month and add one second to it to (ostensibly) shift it into the next month.一旦我们得到了那个,我们将获得那个月的最后时刻,并为其增加一秒以(表面上)将其转移到下个月。 Then we'll get the range of the month that contains that instant, and so on.然后我们将获得包含该时刻的月份范围,依此类推。 Repeat until we have a value that is no longer in the year, which means we've aggregated all of the month ranges in the initial year.重复直到我们有一个不再在年份中的值,这意味着我们已经聚合了初始年份的所有月份范围。

When I run this on my machine, I get this result:当我在我的机器上运行它时,我得到了这个结果:

[
    2018-01-01 07:00:00 +0000 to 2018-02-01 07:00:00 +0000, 
    2018-02-01 07:00:00 +0000 to 2018-03-01 07:00:00 +0000, 
    2018-03-01 07:00:00 +0000 to 2018-04-01 06:00:00 +0000, 
    2018-04-01 06:00:00 +0000 to 2018-05-01 06:00:00 +0000, 
    2018-05-01 06:00:00 +0000 to 2018-06-01 06:00:00 +0000, 
    2018-06-01 06:00:00 +0000 to 2018-07-01 06:00:00 +0000, 
    2018-07-01 06:00:00 +0000 to 2018-08-01 06:00:00 +0000, 
    2018-08-01 06:00:00 +0000 to 2018-09-01 06:00:00 +0000, 
    2018-09-01 06:00:00 +0000 to 2018-10-01 06:00:00 +0000, 
    2018-10-01 06:00:00 +0000 to 2018-11-01 06:00:00 +0000, 
    2018-11-01 06:00:00 +0000 to 2018-12-01 07:00:00 +0000, 
    2018-12-01 07:00:00 +0000 to 2019-01-01 07:00:00 +0000
]

Again, it's important to point out here that these dates are in UTC, even though I am in the Mountain Timezone.同样,重要的是要在这里指出这些日期是 UTC,即使我在山区时区。 Because of that, the hour shifts between 07:00 and 06:00, because the Mountain Timezone is either 7 or 6 hours behind UTC, depending on whether we've observing DST.因此,小时在 07:00 和 06:00 之间变化,因为山区时区比 UTC 晚 7 或 6 小时,这取决于我们是否观察到 DST。 So these values are accurate for my calendar's timezone.所以这些值对于我日历的时区是准确的。

The days in a month一个月的日子

This is just like the previous code:这就像之前的代码:

var daysInMonth = Array<DateInterval>()
var startOfCurrentDay = currentMonth.start
repeat {
    let dayRange = calendar.dateInterval(of: .day, for: startOfCurrentDay)!
    daysInMonth.append(dayRange)
    startOfCurrentDay = dayRange.end.addingTimeInterval(1)
} while currentMonth.contains(startOfCurrentDay)

And when I run this, I get this:当我运行它时,我得到了这个:

[
    2018-10-01 06:00:00 +0000 to 2018-10-02 06:00:00 +0000, 
    2018-10-02 06:00:00 +0000 to 2018-10-03 06:00:00 +0000, 
    2018-10-03 06:00:00 +0000 to 2018-10-04 06:00:00 +0000, 
    ... snipped for brevity ...
    2018-10-29 06:00:00 +0000 to 2018-10-30 06:00:00 +0000, 
    2018-10-30 06:00:00 +0000 to 2018-10-31 06:00:00 +0000, 
    2018-10-31 06:00:00 +0000 to 2018-11-01 06:00:00 +0000
]

The week containing a month's start包含一个月开始的一周

Let's return to that monthsOfYear array we created above.让我们回到上面创建的monthsOfYear数组。 We'll use that to figure out the weeks for each month:我们将使用它来计算每个月的周数:

for month in monthsOfYear {
    let weekContainingStart = calendar.dateInterval(of: .weekOfMonth, for: month.start)!
    print(weekContainingStart)
}

And for my timezone, this prints:对于我的时区,这会打印:

2017-12-31 07:00:00 +0000 to 2018-01-07 07:00:00 +0000
2018-01-28 07:00:00 +0000 to 2018-02-04 07:00:00 +0000
2018-02-25 07:00:00 +0000 to 2018-03-04 07:00:00 +0000
2018-04-01 06:00:00 +0000 to 2018-04-08 06:00:00 +0000
2018-04-29 06:00:00 +0000 to 2018-05-06 06:00:00 +0000
2018-05-27 06:00:00 +0000 to 2018-06-03 06:00:00 +0000
2018-07-01 06:00:00 +0000 to 2018-07-08 06:00:00 +0000
2018-07-29 06:00:00 +0000 to 2018-08-05 06:00:00 +0000
2018-08-26 06:00:00 +0000 to 2018-09-02 06:00:00 +0000
2018-09-30 06:00:00 +0000 to 2018-10-07 06:00:00 +0000
2018-10-28 06:00:00 +0000 to 2018-11-04 06:00:00 +0000
2018-11-25 07:00:00 +0000 to 2018-12-02 07:00:00 +0000

One thing you'll notice here is that the this has Sunday as the first day of the week.您会在这里注意到的一件事是,这将星期日作为一周的第一天。 In your screenshot, for example, you have the week containing Feburary 1st starting on the 29th.例如,在您的屏幕截图中,您的一周包含 2 月 1 日,从 29 日开始。

That behavior is governed by the firstWeekday property on Calendar .该行为由Calendar上的firstWeekday属性控制。 By default, that value is probably 1 (depending on your locale), indicating that weeks start on Sunday.默认情况下,该值可能为1 (取决于您的语言环境),表示周从星期日开始。 If you want your calendar to do computations where a week starts on Monday , then you'll change the value of that property to 2 :如果您希望您的日历在一周从Monday开始的地方进行计算,那么您需要将该属性的值更改为2

var weeksStartOnMondayCalendar = calendar
weeksStartOnMondayCalendar.firstWeekday = 2

for month in monthsOfYear {
    let weekContainingStart = weeksStartOnMondayCalendar.dateInterval(of: .weekOfMonth, for: month.start)!
    print(weekContainingStart)
}

Now when I run that code, I see that the week containing Feburary 1st "starts" on January 29th:现在,当我运行该代码时,我看到包含 2 月 1 日的那一周从 1 月 29 日“开始”:

2018-01-01 07:00:00 +0000 to 2018-01-08 07:00:00 +0000
2018-01-29 07:00:00 +0000 to 2018-02-05 07:00:00 +0000
2018-02-26 07:00:00 +0000 to 2018-03-05 07:00:00 +0000
2018-03-26 06:00:00 +0000 to 2018-04-02 06:00:00 +0000
2018-04-30 06:00:00 +0000 to 2018-05-07 06:00:00 +0000
2018-05-28 06:00:00 +0000 to 2018-06-04 06:00:00 +0000
2018-06-25 06:00:00 +0000 to 2018-07-02 06:00:00 +0000
2018-07-30 06:00:00 +0000 to 2018-08-06 06:00:00 +0000
2018-08-27 06:00:00 +0000 to 2018-09-03 06:00:00 +0000
2018-10-01 06:00:00 +0000 to 2018-10-08 06:00:00 +0000
2018-10-29 06:00:00 +0000 to 2018-11-05 07:00:00 +0000
2018-11-26 07:00:00 +0000 to 2018-12-03 07:00:00 +0000

Final Thoughts最后的想法

With all of this, you have all the tools you'll need to build the same UI as Calendar.app shows.有了所有这些,您就拥有了构建与 Calendar.app 所示相同的 UI 所需的所有工具。

As an added bonus, all of the code I've posted here works regardless of your calendar, timezone, and locale .作为额外的奖励,无论您的日历、时区和语言环境如何,我在此处发布的所有代码都可以运行。 It'll work to build UIs for the Japanese calendar, the Coptic calendars, the Hebrew calendar, ISO8601, etc. It'll work in any timezone, and it'll work in any locale.它可以为日本日历、科普特日历、希伯来日历、ISO8601 等构建 UI。它可以在任何时区工作,也可以在任何语言环境中工作。

Additionally, having all the DateInterval values like this will likely make implementing your app easier, because doing a "range contains" check is the sort of check you want to be doing when making a calendar app.此外,拥有像这样的所有DateInterval值可能会使实现您的应用程序更容易,因为进行“范围包含”检查是您在制作日历应用程序时想要进行的那种检查。

The only other thing is to make sure you use DateFormatter when rendering these values into a String .唯一的另一件事是确保在这些值呈现String时使用DateFormatter Please don't pull out the individual date components.请不要拉出单独的日期组件。

If you have follow-up questions, post them as new questions and @mention me in a comment.如果您有后续问题,请将它们作为新问题发布,并在评论中@mention我。

Looking through some code I wrote a few years ago to do something similar, it went something like…查看我几年前写的一些代码来做类似的事情,它就像......

  • Get the date of first day of the month获取当月第一天的日期
  • Get the dayNumber of that day ( CalendarUnit.weekday )获取当天的dayNumber ( CalendarUnit.weekday )
  • Number of days of previous week to show = dayNumber - 1要显示的前一周的天数 = dayNumber - 1
  • Subtract that from the first day to get the calendar start date从第一天减去它以获得日历开始日期

That worked for me...那对我有用...

let calendar = Calendar.autoupdatingCurrent
let date = Date().description(with: Locale.current)

func firstDayOfMonth(month:Int, year:Int) -> Date {

    var firstOfMonthComponents = DateComponents()
    firstOfMonthComponents.calendar = calendar
    firstOfMonthComponents.year = year
    firstOfMonthComponents.month = month
    firstOfMonthComponents.day = 01
    
    return firstOfMonthComponents.date!
}

func firstMonday(month:Int, year:Int) -> Date {
    
    let first = firstDayOfMonth(month: month, year: year)

    let dayNumber = calendar.component(.weekday, from: first)
    let daysToAdd = ((calendar.firstWeekday + 7) - dayNumber) % 7
    return calendar.date(byAdding: .day, value: daysToAdd, to: first)!

}

(1...12).forEach {
    print(firstMonday(month: $0, year: 2021).description(with: Locale.current))
}

I have the answer to the first question:我有第一个问题的答案:

First question: how do I now get the first Monday in the week containing the 1st of the month?第一个问题:我现在如何获得包含该月第一天的一周中的第一个星期一? For example, how do I get Monday 29th February, or Monday 26th April?例如,我如何获得 2 月 29 日星期一或 4 月 26 日星期一? (see the macOS screenshot). (请参阅 macOS 屏幕截图)。 To get the end of the month, do I just add on 42 days from the start?要到月底,我是否只需从开始的 42 天开始添加? Or is that naive?还是那太天真了?

let calendar = Calendar(identifier: .iso8601)
    func firstOfMonth(month: Int, year: Int) -> Date {



        var firstOfMonthComponents = DateComponents()
        firstOfMonthComponents.calendar = calendar
        firstOfMonthComponents.year = year
        firstOfMonthComponents.month = month
        firstOfMonthComponents.day = 01

        return firstOfMonthComponents.date!
    }

    var aug01 = firstOfMonth(month: 08, year: 2018)
    let dayOfWeek = calendar.component(.weekday, from: aug01)
    if dayOfWeek <= 4 { //thursday or earlier
         //take away dayOfWeek days from aug01
    else {
         //add 7-dayOfWeek days to aug01
    }

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM