Получить первый понедельник в календарном месяце

Я застрял в календаре / часовом поясе/DateComponents / дата ад, и мой ум вращается.

Я создаю приложение дневника, и я хочу, чтобы он работал по всему миру (с календарем 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: , 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. Я чувствую, что, заставляя его мирового даты становятся недействительными при просмотре календаря в разных часовых поясах.

реальный вопрос: как я могу получить первый понедельник на неделе, содержащей 1-е число месяца? Например, как мне получить понедельник, 29 февраля, или Понедельник, 26 апреля? (см. скриншот macOS). Чтобы получить конец месяца, я просто добавляю 42 дня с самого начала? Или это наивно?

редактировать

благодаря текущим ответам, но мы все еще застряли.

следующие работы, пока вы не учитываете летнее время:

almost

3 ответов


хорошо, это будет длинный и сложный ответ (Ура вам!).

в целом вы на правильном пути, но есть несколько тонких ошибок происходит.

Концептуализации Времени

теперь это довольно хорошо установлено (Я надеюсь), что Date представляет момент времени. Что не так хорошо установлено обратное. А Date представляет мгновение, но что представляет собой значение календаря?

когда вы думаете о нем, " день "или" час "(или даже" месяц") - это ряд. Это значения, которые представляют все возможные моменты между моментом начала и моментом окончания.

по этой причине я считаю наиболее полезным думать о диапазоны когда я имею дело со значениями календаря. Другими словами, спрашивая: "каков диапазон для этой минуты/часа/дня/недели/месяца / года?" п.

с этим, я думаю, вам будет легче понять, что происходит на. Вы понимаете Range<Int> уже, да? Так Range<Date> аналогично и интуитивно понятный.

к счастью, есть API на Calendar чтобы получить диапазон. К сожалению, мы не совсем получаем, чтобы просто использовать Range<Date> из - за API удержания от дней Objective-C. Но мы получаем 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)

вы можете увидеть для диапазонов соответствующих единиц отображается. Здесь следует указать на две вещи:--42-->

  1. этот метод возвращает дополнительно (DateInterval?), потому что могут быть странные ситуации, когда он терпит неудачу. Я не могу придумать ничего лучшего. из моей головы, но календари непостоянны, поэтому предупреждаю об этом.

  2. запрос диапазона эры обычно является бессмысленной операцией, хотя эры жизненно важны для правильного построения дат. Трудно иметь большую точность с эпохами за пределами определенных календарей (в первую очередь японского календаря), так что значение "общая Эра (CE или AD) началась 3 января года 1", вероятно, неправильно, но мы ничего не можем с этим поделать. Также конечно, мы понятия не имеем, когда закончится нынешняя эра, поэтому мы просто предполагаем, что она продолжается вечно.

печати Date значения

это подводит меня к следующему пункту, о сроках печатания. Я заметил это в коде и я думаю, что это может быть источником некоторых проблем. Я приветствую ваши усилия объяснить мерзость, которая является летнее время, но я думаю, что вы попадаете на что-то, что на самом деле не ошибка, но это это:

Dates всегда печать в формате UTC.

всегда всегда всегда.

это потому, что они являются мгновениями во времени, и они являются тут же независимо от того, находитесь ли вы в Калифорнии или Джибути или Khatmandu или любой другой. А Date значение не есть пояс. На самом деле это просто смещение от другого известного момента времени. Однако печать 560375786.836208 как Date's описание не кажется это полезно для людей, поэтому создатели API заставили его печатать как григорианскую дату относительно часового пояса UTC.

если вы хотите напечатать дату, даже для отладки, вам почти определенно понадобится DateFormatter.

DateComponents.date

это не проблема с вашим кодом, скажем, но это больше похоже на хедз-ап. The .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, потому что Горный часовой пояс либо 7 или 6 часов позади UTC, в зависимости от того, наблюдаем ли мы DST. Таким образом, эти значения точны для часового пояса моего календаря.

дни в месяце

это так же, как и предыдущий код:

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

одна вещь, вы заметите, что это воскресенье как первый день недели. На скриншоте, например, у вас есть неделя, содержащая 1-й Feburary, начиная с 29-го.

это поведение управляется 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)
}

теперь, когда я запускаю этот код, я вижу, что неделя, содержащая Feburary 1st, "начинается" 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

Заключение

все из этого у вас есть все инструменты, необходимые для создания того же пользовательского интерфейса, что и календарь.приложение показывает.

в качестве дополнительного бонуса, весь код я разместил здесь работает независимо от вашего календаря, часовой пояс и язык. Он будет работать для создания UIs для японского календаря, коптских календарей, еврейского календаря, ISO8601 и т. д. Он будет работать в любом часовом поясе и в любом месте.

кроме того, все DateInterval такие значения, скорее всего, сделают реализация вашего приложения проще, потому что проверка "диапазон содержит" - это такая проверка, которую вы хочу делать при создании приложения календаря.

единственное, чтобы убедиться, что вы используете DateFormatter, когда визуализация эти значения в String. Пожалуйста,не вытаскивайте отдельные компоненты даты.

если у вас есть последующие вопросы, разместите их как новые вопросы и @mention мне в комментарии.


У меня есть ответ на первый вопрос:

первый вопрос: как мне теперь получить первый понедельник недели, содержащий 1-е число месяца? Например, как получить понедельник 29 февраля или понедельник 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
    }

просматривая какой-то код, который я написал несколько лет назад, чтобы сделать что-то подобное, он пошел что-то вроде...

  • получить дату первого дня месяца
  • скачать dayNumber день (CalendarUnit.weekday)
  • количество дней предыдущей недели показать = dayNumber - 1
  • вычесть, что с первого дня, чтобы получить дату начала календаря