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

时间:2018-10-04 08:09:19

标签: ios swift date calendar timezone

我被困在Calendar / TimeZone / DateComponents / Date地狱中,我的头脑在旋转。

我正在创建一个日记应用程序,并且希望它在全球范围内都能使用(带有iso8601日历)。

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

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

要获得本月的1号,我可以执行以下操作:

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

4 个答案:

答案 0 :(得分:31)

好的,这将是一个冗长而复杂的答案(为您万岁!)。

总的来说,您的做法是正确的,但是仍然存在一些细微的错误。

概念化日期

到目前为止(我希望)Date代表时间的瞬间已经很成熟了。倒数还不够完善。 Date代表瞬间,但代表日历值呢?

考虑时,“天”或“小时”(甚至是“月”)是一个范围。它们是代表开始时刻和结束时刻之间所有可能时刻的值。

基于这个原因,当我处理日历值时,考虑到范围最为有用。换句话说,询问“此分钟/小时/天/周/月/月/年的范围是多少?”等

有了这个,我想您会发现更容易理解正在发生的事情。您已经了解Range<Int>了吧?因此Range<Date>同样直观。

幸运的是,Calendar上有API可以获取范围。不幸的是,由于从Objective-C时代开始就使用了保留API,因此我们不能完全使用Range<Date>。但是我们得到NSDateInterval,实际上是同一件事。

您可以通过向Calendar询问包含特定时刻的小时/天/周/月/年的范围来请求范围,例如:

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月3日”的值可能是错误的,但是我们不能真的为此做任何事情。另外,当然,我们不知道当前时代何时结束,所以我们假设它会永远持续下去。

打印Date

这将我带到关于打印日期的下一点。我在您的代码中注意到了这一点,我认为这可能是某些问题的根源。我为您为解释夏令时这一可憎之处而付出的努力表示赞赏,但我认为您已经发现了实际上并非bug的东西,但这是

Dates 始终以UTC打印。

总是总是。

这是因为它们是及时的时刻,它们是相同的时刻,无论您是在加利福尼亚州,吉布提州还是加德满都市还是任何地方。 Date not 没有时区。这实际上只是与另一个已知时间点的偏移。但是,将560375786.836208打印为Date's描述对人类似乎没什么用,因此API的创建者将其打印为相对于UTC时区的公历日期。

如果要打印日期,甚至用于调试,则几乎肯定需要DateFormatter

DateComponents.date

这不是每次说代码的问题,但要多加注意。 .date的{​​{1}}属性不能保证返回与指定组件匹配的第一个瞬间。它不能保证返回与组件匹配的最后一个瞬间。它所做的一切都是保证,如果能够找到答案,它将为您提供DateComponents 在指定单位范围内的某个地方

我不认为您在技术上会依赖此代码,但是要意识到这是一件好事,而且我已经看到这种误解会导致软件中的错误。


牢记所有这些,让我们看看您想要做什么:

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

因此,根据我们对范围的了解,现在这应该很简单,有趣的是,我们可以完成所有操作而无需单个Date值。在下面的代码中,为简洁起见,我将强制展开值。在您的实际代码中,您可能需要更好的错误处理。

年份范围

这很简单:

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)

再次,重要的是在这里指出这些日期都是UTC,即使我在山区时区也是如此。因此,时间在07:00和06:00之间转换,因为Mountain Timezone比UTC晚7或6个小时,具体取决于我们是否遵守DST。因此这些值在我日历的时区中是正确的。

一个月中的日子

这与之前的代码类似:

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

当我运行它时,我得到了:

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

您会在这里注意到的一件事是,该日期以星期日作为一周的第一天。例如,在屏幕截图中,您将在2月1日这一周从29日开始。

该行为由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 上的firstWeekday属性控制。默认情况下,该值可能是Calendar(取决于您的语言环境),表示几周从星期日开始。如果您希望日历从一周的 Monday 开始进行计算,则可以将该属性的值更改为1

2

现在,当我运行该代码时,我看到包含2月1日的一周从1月29日“开始”:

var weeksStartOnMondayCalendar = calendar
weeksStartOnMondayCalendar.firstWeekday = 2

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

最终思想

有了这些,您将拥有构建Calendar.app显示的相同UI所需的所有工具。

作为一项额外的奖励,无论您的日历,时区和语言环境如何,我在此处发布的所有代码均有效。它可以为日语日历,科普特日历,希伯来日历,ISO8601等构建UI。它可以在任何时区工作,并且可以在任何语言环境下工作。

此外,拥有所有这样的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 值将可能使应用程序的实现更加容易,因为执行“范围包含”检查是您想要进行的检查制作日历应用。

唯一的另一件事是确保在渲染这些值到DateInterval中时使用DateFormatter。请不要拉出各个日期组件。

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

答案 1 :(得分:1)

查看几年前写的代码来做类似的事情,结果就像…

  • 获取每月第一天的日期
  • 获取当天(dayNumber的{​​{1}}
  • 要显示的上一周的天数= CalendarUnit.weekday
  • 减去第一天的时间以获取日历的开始日期

答案 2 :(得分: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))
}

答案 3 :(得分: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
    }