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

11

我被日历/时区/日期组件/日期问题困扰,头脑混乱。

我正在创建一个日记应用程序,并希望它可以在全球范围内使用(采用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
*/

}

在这里,存在着有关夏令时的一个紧急问题。通过取消对已注释代码行的注释并强制计算日期为UTC,可以“修复”该问题。然而,我认为将其强制转换为UTC后,在不同时区查看日历视图时,日期会变得无效。

真正的问题是:如何获取包含该月1号的那一周中的第一个星期一?例如,如何获得2月29日星期一或4月26日星期一?(请参见macOS截图)。要获得月末,只需从开始日添加42天吗?还是说这样做太过幼稚?

编辑

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

以下方法有效,但没有考虑到夏令时:

almost


嗨。我正在寻找一个合理的日历小部件来用于我的应用程序,但很多都存在错误或已过时。最终我找到并使用了这个。代码被很好地分离成逻辑部分。这里是日期操作的一部分,它可能会对你有所帮助JTDateHelper - Modo Ltunzher
请查看苹果的Calendar文档中的“获取日历信息”和“计算时间间隔”部分。 - vikingosegundo
嗯...那个夏令时错误让我困惑了。你能否检查一下你当前是否处于夏令时并加/减一小时?虽然我不确定这会起作用...当然,你还必须检查你所在的国家以获取正确的夏令时...该死。 - user10176969
1
@user10176969 是的,这很困难。我担心如果我处理夏令时出了问题,那么每个单元格后面的日期在呈现用户当前日期时会不正确。 - Adam Waite
1
@MattJohnson,我想说的是:如果日历中的每个单元格都按日期间隔后退,例如 25/12/18 at 00:00 UTC -> 26/12/18 00:00 UTC,并且我想要在纽约用户在午夜之前查看日历时突出显示当前日期,那么将会突出显示错误的日期,因为他们仍然处于24/12/18,除非我进行某种转换。我认为在继续之前,我需要理解日历和时区的工作原理。 - Adam Waite
显示剩余2条评论
4个回答

46

好的,这将是一个冗长而复杂的答案(恭喜你!)。

总体上,你的想法是正确的,但有一些微妙的错误存在。

时间概念

现在已经相当明确(希望如此)Date表示时间的瞬间。但反过来就不那么明确了。一个Date表示一个时间点,那么什么表示日历值呢?

仔细思考,“天”、“小时”(甚至“月份”)都是一个范围。它们是代表从起始时刻到终止时刻之间所有可能瞬间的值。

因此,在处理日历值时,我发现将其视为范围最有帮助。换句话说,通过问“这一分钟/小时/天/周/月/年的范围是什么?”等问题来思考。

有了这个,你会发现理解正在发生的事情变得更容易了。你已经了解了Range<Int>,对吧?所以Range<Date>同样直观。

幸运的是,Calendar上有获取范围的API。不幸的是,由于Objective-C遗留API的存在,我们不能完全使用Range<Date>。但我们有NSDateInterval,它实际上是相同的东西。

你可以通过请求包含特定瞬间的小时/天/周/月/年的范围来请求范围,就像这样:

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

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

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

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)")
}

我写这篇文章的时候,它打印出如下内容:

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)

您可以看到各个单位的范围。这里有两点需要指出:

  1. 此方法返回一个可选项 (DateInterval?),因为可能会有奇怪的情况导致失败。我想不出任何问题,但日历是棘手的东西,所以请注意。

  2. 询问时代的范围通常是毫无意义的操作,即使时代对于正确构建日期至关重要。在某些日历之外(主要是日本日历),很难精确描述时代,因此那个值“公元纪年(CE或AD)始于公元1年1月3日”可能是错误的,但我们无法对此做任何事情。当然,我们也不知道当前时代何时结束,所以我们只能假设它永远存在。

打印Date

这带我来到下一个点,关于打印日期。我在你的代码中注意到了这一点,我认为这可能是你遇到问题的根源。我赞赏你努力解决夏令时带来的混乱,但我认为你被某些实际上并非错误的事情所困扰了,那就是:

日期始终以UTC打印。

总是总是总是。

这是因为它们是时间的瞬间,无论您身在加利福尼亚、吉布提还是加德满都,它们都是相同的瞬间。一个日期没有时区。它实际上只是另一个已知时间点的偏移量。但是,将560375786.836208打印为日期的描述对人类来说似乎并不那么有用,因此API的创建者使其打印为格里高利日历相对于UTC时区的日期。

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

DateComponents.date

这不是您代码的问题,而是提醒。 DateComponents上的.date属性不能保证返回与指定组件匹配的第一个瞬间。它也不能保证返回与指定组件匹配的最后一个瞬间。它所做的只是保证,如果可以找到答案,它将为您提供一个位于指定单位范围内的日期

我不认为您在技术上依赖此功能,但了解这一点是很好的,我曾经看到这种误解导致软件中出现错误。


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

  1. 您想知道该年份的范围
  2. 您想知道该年份的所有月份
  3. 您想知道一个月的所有日期
  4. 您想知道包含该月第一天的星期

因此,根据我们对范围的了解,现在应该相当简单,有趣的是,我们可以在不需要单个DateComponents值的情况下完成所有工作。在下面的代码中,我将强制展开值以简

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

一年中的月份

这有点复杂,但不是太难。

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)

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

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

[
    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
]

需要再次指出的是,这些日期是以协调世界时(UTC)为准,尽管我位于山区时区。因此,在07:00和06:00之间小时会发生变化,因为山区时区与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)

当我运行这段代码时,我得到了如下结果:

[
    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
]

包含一个月初的星期

让我们回到之前创建的 monthsOfYear 数组。我们将使用它来确定每个月的星期:

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

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

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
你会发现这里的一件事是,这里将周的第一天设置为星期日。例如,在你的截图中,2月1日所在的那一周从29日开始。
这种行为由Calendar上的firstWeekday属性控制。默认情况下,该值可能为1(根据您的区域设置而定),表示星期从星期日开始。如果你想让你的日历进行计算,其中一周从星期一开始,那么你需要将该属性的值更改为2
var weeksStartOnMondayCalendar = calendar
weeksStartOnMondayCalendar.firstWeekday = 2

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

现在当我运行该代码时,我看到包含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

最后一点小建议

通过这篇文章,你已经拥有了构建类似Calendar.app界面所需的所有工具。

另外值得一提的是,我在这里发布的所有代码都可以 适用于任何日历、时区和语言环境。它可以用来构建日本日历、科普特日历、希伯来日历、ISO8601等各种类型的UI。而且它会适用于任何时区和语言环境。

此外,拥有所有这些 DateInterval 值很可能会使你的应用程序实现更加容易,因为当制作一个日历应用程序时,进行“范围包含”检查是你想要进行的检查之一。

唯一需要注意的是,在将这些值渲染成 String 时,请使用 DateFormatter。请勿分离单个日期组件。

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


1
这是一个了不起的答案!关于firstWeekday的快速注释:我不确定OP是否真的想要强制让星期一成为第一列,无论用户当前使用的日历是什么-如果是这种情况,您设置firstWeekday = 2的建议确实是正确的-还是OP只是不知道并非所有的日历都以星期一开始,有些日历以星期日开始...现在他们知道了,也许他们更愿意尊重日历设置并使用其firstWeekday来确定第一列,而不是硬编码为星期一;-) - AliSoftware
是的,感谢@AliSoftware的澄清。设置“firstWeekday”的目的是使底层的“DateInterval”与UI中显示的内容匹配。但您绝对正确,更改“firstWeekday”可能不是符合文化习惯的事情。 - Dave DeLong

1

那对我有用...

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))
}

1

查看我几年前编写的一些代码来做类似的事情,大致如下...

  • 获取月份的第一天的日期
  • 获取该日的 dayNumber (CalendarUnit.weekday)
  • 要显示的上一周的天数 = dayNumber - 1
  • 从第一天中减去这个数以获取日历开始日期

你为什么需要上一周的天数?如果dayNumber小于或等于4,只需减去dayNumber天即可,否则加上7-dayNumber天。这将使你接近最近的星期一。 - user10176969
几乎了。夏令时会影响这个。 - Adam Waite

0

我有第一个问题的答案:

第一个问题:如何获取包含该月1日的星期一?例如,如何获取2月29日星期一或4月26日星期一?(请参见macOS截图)。要获取月底,我只需从开始添加42天吗?还是这很幼稚?

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
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接