Получить первый понедельник в календарном месяце - PullRequest
0 голосов
/ 04 октября 2018

Я попал в ловушку ада 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-й измесяц?Например, как мне получить понедельник 29 февраля или понедельник 26 апреля?(см. скриншот macOS).Чтобы получить конец месяца, нужно ли просто добавить 42 дня с начала?Или это наивно?

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

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

Следующие работы, пока вы не возьметепереход на летнее время:

almost

Ответы [ 3 ]

0 голосов
/ 04 октября 2018

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

Первый вопрос: как мне теперь получить первый понедельник на неделе, содержащей 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
    }
0 голосов
/ 04 октября 2018

ОК, это будет длинный и сложный ответ (ура!).

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

Концептуализация дат

К настоящему времени (я надеюсь) уже достаточно хорошо известно, что 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)

Вы можете увидеть, как отображаются соответствующие единицы измерения.Здесь нужно указать на две вещи:

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

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

Печать 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)!

Месяцы в году

Это немного сложнее, но не так уж плохо.

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

Одна вещь, которую вы заметите, это то, чтоэто воскресенье как первый день недели.Например, на скриншоте у вас есть неделя с 1 февраля, начинающаяся 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)
}

Теперь, когда я запускаю этокод, я вижу, что неделя, содержащая 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 и т. Д. Он будет работать в любом часовом поясе и в любой локали.

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

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

Если у вас есть дополнительные вопросы, опубликуйте их как новые вопросы и @mention меня в комментарии.

0 голосов
/ 04 октября 2018

Просматривая код, который я написал несколько лет назад, чтобы сделать что-то похожее, он выглядел примерно так:

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