更新於 2022 年 09 月 11 日

iOS15+ 的日期格式化方法

iOS15 增加了新的格式化 API,方便我們描述如何在不同類型之間轉換。同時,也提供了許多內建的 FormatStyle,其中「Date.FormatStyle」,就是指從 Date 轉換成 String 。

  • 轉換具體時間、相對時間或是一段時間。
  • 預設的轉換結果會根據使用者的 Locale 設定而不同。
(以下示範都用西曆 + 台灣中文)
💡 如果你在Playground 測試,可以加上這個 extension 來調整預設的語系和日曆。
extension NSLocale {
    @objc
    static let currentLocale = NSLocale(localeIdentifier: "語系和日曆 ID")
}

ID 範例

台灣中文:"zh_TW"
中文和台灣日曆:"zh_TW@calendar=roc"
日文和日本日曆:"ja_JP@calendar=japanese"

具體時間

 預設風格

  • 不加參數,直接根據系統印出預設的日期格式。
    Date.now.formatted()
    // 2022/9/11 下午8:38
    
  • 用 date 和 time 參數表示想要呈現的「長度」。
    date.formatted(date: .complete, time: .complete)
    // 2022年9月11日 星期日 GMT+8 下午8:38:42
    
    date.formatted(date: .long, time: .standard)
    // 2022年9月11日 下午8:38:42
    
    date.formatted(date: .abbreviated, time: .shortened)
    // 2022年9月11日 下午8:38
    
    date.formatted(date: .numeric, time: .omitted)
    // 2022/9/11
    

 自訂風格

上面我們在 date 和 time 的參數中放的其實就是預設提供的幾種 Date.FormatStyle 選項,我們也可以透過以下兩種類型開始建立。

  • Date.FormatStyle:根據 Locale 轉換成不同的語言和日期。
  • Date.ISO8601FormatStyle:固定 ISO8601 格式。

上面兩種都可以直接在 formatted 裡面快速建立:

date.formatted(.dateTime)
// 2022年9月11日 下午8:38

date.formatted(.iso8601)
// 2022-09-11T12:38:42Z"

現在跟上面預設的結果一樣,我們可以接著用類型裡的方法描述要顯示「哪些日期和時間單位,以及顯示方式」,以下簡單示範幾種。

  • 順序不會產生影響,一律套用 Locale 慣用排序。
date.formatted(.iso8601.year().month().day())
// 2022-09-11

date.formatted(.dateTime.month().day().hour().minute().weekday(.wide))
// 9月11日 星期日 下午8:38

date.formatted(.dateTime.hour().minute().second().timeZone(.genericLocation))
// 台灣時間 下午8:38:42

date.formatted(.dateTime.year().quarter())
// 2022年3季

date.formatted(.dateTime.year().week())
// 2022年 (週: 38)
⚠️ 要注意的是省略「amPM」並不會自動轉成 24 小時制,12/24 小時制還是根據系統設定。

以下示範一個晚上 10 點的顯示結果。

let pmTime = Calendar.current.date(bySetting: .hour, value: 22, of: date)!
pmTime.formatted(.dateTime.hour().minute())
// 下午10:00
pmTime.formatted(.dateTime.hour(.defaultDigits(amPM: .omitted)).minute())
// 10:00

// 手動改成日本 Locale。
let japanLocale = Locale(identifier: "ja_JP")
pmTime.formatted(.dateTime.hour().minute().locale(japanLocale))
// 22:00 << 預設就已經是 24 小時制。
pmTime.formatted(.dateTime.hour(.defaultDigits(amPM: .omitted)).minute().locale(japanLocale))
// 22:00

但是這個改法還是可能受到一些其他設定影響,所以目前想要顯示 24 小時制的話還是用舊方法最安全。

let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
formatter.string(from: pmTime)
// 22:00

轉成方便的 Extension

這個 Format API 使用上很直覺,但是直接加在 date 後面很容易讓程式碼顯得很雜亂,建議把自己常用的 FormatStyle 加到 extension 中使用。
以下是我自己用的 Extension,因為我已經太習慣格式化代碼了,所以直接用代碼命名。

extension FormatStyle where Self == Date.FormatStyle {
    static var MMMd: Self { dateTime.MMMd }
    static var hhmm: Self { dateTime.hhmm }
    
    var MMMd: Self { self.month(.abbreviated).day() }
    var E: Self { self.weekday(.abbreviated) }
    var hhmm: Self { self.hour(.conversationalTwoDigits(amPM: .wide)).minute(.twoDigits) }
    
    var gmt: Self { self.timeZone(.localizedGMT(.long)) }
    var gmtLocalized: Self { self.timeZone(.specificName(.long))  }
}

// 顯示日期
date.formatted(.MMMd) // 9月11日
date.formatted(.MMMd.E) // 9月11日 週日
date.formatted(.MMMd.E.year()) // 2022年9月11日 週日

// 顯示時間
date.formatted(.hhmm) // 下午08:38
date.formatted(.hhmm.gmt) // GMT+08:00 下午08:38
date.formatted(.hhmm.gmtLocalized) // 台北標準時間 下午08:38

// 顯示日期 + 時間
date.formatted(.MMMd.hhmm) // 9月11日 下午08:38
date.formatted(.MMMd.E.hhmm) // 9月11日 週日 下午08:38

// 如果是在 SwiftUI,當你的 Text 只有要顯示時間資訊時,用 format 參數會讓你的程式碼更清晰。
Text(date, format: .hhmm)
Text(date, format: .MMMd.hhmm.second(.twoDigits))

設定時區和語系

通常用系統設定的時區和語系會比較好,但也可能有特別情況你想固定。
可以在啟動 Date.FormatStyle 時加上這些參數。

注意這個 FormatStyle 中的「屬性」timeZone 是使用的時區,而「方法」 timeZone 是描述如何顯示時區資訊。
let engFormatter = Date.FormatStyle.init(locale: .init(identifier: "en_GB"),
                                         timeZone: .init(abbreviation: "BST")!)
date.formatted(.MMMd.HHmm.gmtLocalized)
// 9月11日 台北標準時間 下午08:38
date.formatted(engFormatter.MMMd.HHmm.gmtLocalized)
// 11 Sep, 01:38 pm British Summer Time

轉回 Date

除了轉成 String,這兩種類型也有遵循 ParseStrategy,所以也可以從 String 轉回 Date,不過要建立 Date 一定要有完整的資訊,搭配 .datetime / .iso8601 提供預設值。

try! Date("2022-09-11T12:38:42Z", strategy: .iso8601)
// 2022年9月11日 下午8:38

try! Date("2022-09-11", strategy: .iso8601.year().month().day())
// 2022年9月11日 上午8:00(預設 UTC+0 0 點時間)

try! Date("9月11日", strategy: .dateTime.MMMd)
// 2022年9月11日 上午5:47 (沒有提供的年和時間會是你現在的時間)

相對時間

使用 Date.RelativeFormatStyle 時間轉換成和現在相對的時間,可以用 .relative 來快速啟動。

let afterTomorrow = Calendar.current.date(byAdding: .day, value: 2, to: Date())!
afterTomorrow.formatted(.relative(presentation: .named, unitsStyle: .wide))
// 後天

afterTomorrow.formatted(.relative(presentation: .numeric, unitsStyle: .wide))
// 2天後

afterTomorrow.formatted(.relative(presentation: .numeric, unitsStyle: .spellOut))
// 二天後

不過這個寫法固定就是和現在比較,如果要設定相對於哪個時間,一樣得用舊方法。

let relativeFormatter = RelativeDateTimeFormatter()
relativeFormatter.locale = .current
relativeFormatter.dateTimeStyle = .numeric
relativeFormatter.unitsStyle = .full

let xMas = try! Date("2022/12/25", strategy: .dateTime.year().month().day())
let timeToXMas = relativeFormatter.localizedString(for: xMas, relativeTo: .now)
print("還有\(timeToXMas)到聖誕節🎄")
// 還有3個月後到聖誕節🎄

let lastXMas = try! Date("2021/12/25", strategy: .dateTime.year().month().day())
let timeToLastXMas = relativeFormatter.localizedString(for: lastXMas, relativeTo: .now)
print("🤶上次聖誕節是 \(timeToLastXMas)")
// 上次聖誕節是 8個月前

一段時間

最後,也可以使用 Date.IntervalFormatStyle 搭配 Range<Date> 來顯示一段時間。同樣也有快速啟動的 .interval

(date..<afterTomorrow).formatted(.interval)
// 2022/9/11 下午8:38至2022/9/14 上午6:56

(date..<afterTomorrow).formatted(.interval.day())
// 11日至14日

(date..<afterTomorrow).formatted(.interval.weekday())
// 週日至週三

(date..<afterTomorrow).formatted(.interval.day().hour()) 
// 11日 下午8時至14日 上午6時

(date..<afterTomorrow).formatted(.interval.month().day())
// 9月11日至14日

(date..<afterTomorrow).formatted(.interval.month().day().year())
// 2022年9月11日至14日

以上介紹的這些方法,相信已經能滿足大部分的需求,不過如果你還有特別的需求,你也可以建立自己的 FormatStyle 類型來描述如何轉換。