在日历月的第一个星期一

Ada*_*ite 10 timezone calendar date ios swift

我被困在Calendar/TimeZone/DateComponents/Date地狱中,我的思绪在旋转.

我正在创建一个日记应用程序,我希望它在全球范围内工作(使用iso8601日历).

我试图在iOS上创建类似于macOS日历的日历视图,以便我的用户可以浏览他们的日记条目(注意每个月的7x6网格):

/ Users/adamwaite/Desktop/Screenshot 2018-10-04 at 08.22.24.png

要获得本月的第一天,我可以执行以下操作:

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
*/

}
Run Code Online (Sandbox Code Playgroud)

这里有一个直接的问题,夏令时.通过取消注释注释行并强制以UTC计算日期,可以"修复"该问题.我觉得通过强制它到UTC,在不同时区查看日历视图时日期变得无效.

真正的问题是:如何在包含本月1日的一周中获得第一个星期一?例如,我如何获得2月29日星期一或4月26日星期一?(参见macOS截图).为了到月底,我是否只是从一开始就添加了42天?还是天真的?

编辑

感谢目前的答案,但我们仍然陷入困境.

以下是有效的,直到您将夏令时考虑在内:

几乎

Dav*_*ong 34

好的,这将是一个漫长而复杂的答案(对你来说很好!).

总的来说,你是在正确的轨道上,但有一些微妙的错误正在发生.

概念化日期

现在(我希望)它已经非常成熟,Date代表了一个瞬间.什么不是很完善的反面.A Date代表一个瞬间,但什么代表日历值?

当你考虑它时,"日"或"小时"(或甚至"月")是一个范围.它们是代表开始瞬间和结束瞬间之间所有可能瞬间的值.

出于这个原因,我发现在处理日历值时考虑范围是最有帮助的.换句话说,询问"这分钟/小时/日/周/月/年的范围是多少?" 等等

有了这个,我想你会发现它更容易理解发生了什么.你Range<Int>已经理解了,对吧?所以a Range<Date>同样直观.

幸运的是,有API Calendar可以获得范围.不幸的是,Range<Date>由于Objective-C时代的延迟API,我们不太习惯使用.但我们得到了NSDateInterval,这实际上是一回事.

您通过询问Calendar包含特定时刻的小时/日/周/月/年的范围来询问范围,如下所示:

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

let today = calendar.dateInterval(of: .day, for: now)
Run Code Online (Sandbox Code Playgroud)

有了这个,您可以获得几乎任何东西的范围:

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)")
}
Run Code Online (Sandbox Code Playgroud)

在我写这篇文章的时候,它会打印:

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)
Run Code Online (Sandbox Code Playgroud)

您可以查看显示的各个单位的范围.这里有两点需要指出:

  1. 此方法返回一个optional(DateInterval?),因为可能会出现奇怪的情况.我无法想到任何问题,但是日历是变化无常的东西,所以请关注这一点.

  2. 要求一个时代的范围通常是一个荒谬的操作,即使时代对于正确构建日期至关重要.在某些日历(主要是日历)之外的时代很难有精确度,所以说"普通时代(CE或AD)在1年1月3日开始"的价值可能是错误的,但我们不能真的对此有所帮助.当然,我们也不知道当前时代何时结束,所以我们假设它永远存在.

印刷Date价值

这让我想到了关于打印日期的下一点.我在你的代码中注意到了这一点,我认为它可能是你的一些问题的根源.我赞赏你为解决夏令时的可憎行为所做的努力,但我认为你已经陷入了一些实际上并不是错误的事情,但是这是:

Dates 始终以UTC格式打印.

始终永远.

这是因为它们是时间的瞬间,无论你是在加利福尼亚州还是在吉布提或加德满都或其他地方,它们都是同一时刻.一个Date值也不能有一个时区.它实际上只是与另一个已知时间点的偏移.但是,560375786.836208作为Date's描述打印似乎对人类没有用,因此API的创建者将其打印为相对于UTC时区的格里高利日期.

如果你想打印一个日期,即使是调试,你几乎肯定需要一个DateFormatter.

DateComponents.date

这不是你的代码所说的问题,而是更多的问题.在.date财产上DateComponents并不能保证返回指定的组件匹配的第一个瞬间.它不保证返回与组件匹配的最后一个瞬间.它所做的只是保证,如果它能找到答案,它会给你一个指定单位Date 范围内某个地方.

我不认为你在技术上依赖于你的代码,但是要注意这是一件好事,我已经看到这种误解导致了软件中的错误.


考虑到所有这些,让我们来看看你想要做什么:

  1. 你想知道一年的范围
  2. 你想知道一年中的所有月份
  3. 你想知道一个月内的所有日子
  4. 您想知道包含该月第一天的周

因此,根据我们对范围的了解,这应该是非常直接的,有趣的是,我们可以在不需要单个DateComponents值的情况下完成所有操作.在下面的代码中,为简洁起见,我将强制展开值.在您的实际代码中,您可能希望有更好的错误处理.

一年的范围

这很简单:

let yearRange = calendar.dateInterval(of: .year, for: now)!
Run Code Online (Sandbox Code Playgroud)

一年中的几个月

这有点复杂,但也不算太糟糕.

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)
Run Code Online (Sandbox Code Playgroud)

基本上,我们将从今年的第一个瞬间开始,并询问包含该瞬间的月份的日历.一旦我们得到了它,我们将获得该月的最后一刻,并加上一秒钟(表面上)将其转移到下个月.然后我们将获得包含该瞬间的月份范围,依此类推.重复,直到我们有一个不再是年份的值,这意味着我们已经汇总了第一年的所有月份范围.

当我在我的机器上运行它时,我得到了这个结果:

[
    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
]
Run Code Online (Sandbox Code Playgroud)

同样,重要的是要指出这些日期是UTC,即使我在山区时区.因此,小时在07:00到06:00之间移动,因为Mountain Timezone比UTC晚7或6小时,这取决于我们是否观察到夏令时.因此,这些值对于我的日历时区是准确的.

一个月的日子

这就像之前的代码一样:

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)
Run Code Online (Sandbox Code Playgroud)

当我运行这个时,我得到了这个:

[
    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
]
Run Code Online (Sandbox Code Playgroud)

这一周包含一个月的开始

让我们回到monthsOfYear上面创建的那个数组.我们将用它来计算每个月的周数:

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

而对于我的时区,这打印:

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
Run Code Online (Sandbox Code Playgroud)

你会注意到的一件事是星期日作为一周的第一天.例如,在截图中,您将在29日开始包含2月1日的一周.

该行为受firstWeekday财产管辖Calendar.默认情况下,该值可能1(取决于您的语言环境),表示周从星期日开始.如果您希望日历在星期一开始一周的计算,那么您将该属性的值更改为2:

var weeksStartOnMondayCalendar = calendar
weeksStartOnMondayCalendar.firstWeekday = 2

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

现在,当我运行该代码时,我看到包含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
Run Code Online (Sandbox Code Playgroud)

最后的想法

有了这一切,您就拥有了构建与Calendar.app显示相同的UI所需的所有工具.

作为额外的奖励,我发布的所有代码都适用于您的日历,时区和区域设置.它可以为日历,科普特日历,希伯来日历,ISO8601等构建UI.它可以在任何时区工作,并且可以在任何语言环境中使用.

此外,拥有这样的所有DateInterval值可能会使您的应用程序更容易实现,因为执行"范围包含"检查是您在制作日历应用程序时进行的检查.

唯一的另一件事是确保DateFormatter这些值渲染成a时使用String.请不要拉出各个日期组件.

如果您有后续问题,请将其作为新问题发布,并@mention在评论中发布.