时间格式化

方便快捷地格式化时间。


DateFormat

通常我们对时间进行格式化,每次都会创建一个 DateFormatter,并且指定 dateFormat。代码如下。

1
2
3
4
5
6
let date = Date()

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm"

let string = dateFormatter.string(from: date)

但是每次创建和设置 DateFormatter,显得十分繁琐,对此方法进行封装。代码如下。

1
2
3
4
5
6
7
8
9
10
11
public extension Date {
func string(format: String) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = format
return dateFormatter.string(from: self)
}
}

let date = Date()

let string = date.string(format: "yyyy-MM-dd HH:mm")

大多数时候,dateFormat 是固定的,一直复制粘贴也容易出错,可以考虑提取成一个类型。代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public extension DateFormatter {
struct DateFormat: RawRepresentable, ExpressibleByStringLiteral {
public var rawValue: String

public init?(rawValue: String) {
self.rawValue = rawValue
}

public init(stringLiteral value: String) {
rawValue = value
}
}
}

public extension Date {
func string(dateFormat: DateFormatter.DateFormat) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = dateFormat.rawValue
return dateFormatter.string(from: self)
}
}

public extension DateFormatter.DateFormat {
static let MMddHHmm: DateFormatter.DateFormat = "MM-dd HH:mm"
static let yyyyMMddHHmm: DateFormatter.DateFormat = "yyyy-MM-dd HH:mm"
}

let date = Date()

let string1 = date.string(dateFormat: .MMddHHmm)
let string2 = date.string(dateFormat: .yyyyMMddHHmm)

DateFormat 有一个类型为字符串的原始值,并且支持字符串字面量进行初始化。这时可以预先定义一些经常用到的常量,然后使用点语法直接访问。

外部调用已经强类型了,内部还是每次创建,可以考虑使用缓存。代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private extension DateFormatter {
static let dateFormatters = NSCache<NSString, DateFormatter>()
}

private extension DateFormatter {
static func computeCacheKey(dateFormat: DateFormatter.DateFormat) -> String {
return [
"format",
dateFormat.rawValue,
]
.joined(separator: "|")
}
}

public extension DateFormatter {
static func formatter(dateFormat: DateFormatter.DateFormat) -> DateFormatter {
let cacheKey = computeCacheKey(dateFormat: dateFormat) as NSString

if let dateFormatter = dateFormatters.object(forKey: cacheKey) {
return dateFormatter
}

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = dateFormat.rawValue

DateFormatter.dateFormatters.setObject(dateFormatter,
forKey: cacheKey)

return dateFormatter
}
}

为了修改方便,cacheKey 使用 joined(separator:) 生成,可以直接使用字符串插值。


FormatStyle

时间格式化使用点语法调用具体的 DateFormat 已经足够,但不能明确表达此时格式化的业务需求。可以考虑定制一套 FormatStyle。

  • short:只显示时间,不显示日期。
  • long:显示月日和时间,非本年度显示年份。
  • normal:按需显示。一分钟内显示「刚刚」,一小时内显示「xx 分钟前」,当天显示「xx 小时前」,昨天显示「昨天+时间」,前天显示「前天+时间」,三天及更早显示月日和时间。

代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

public extension Date {
enum FormatStyle {
case short
case normal
case long
}

func string(for style: FormatStyle) -> String {
switch style {
case .short:
return string(dateFormat: .HHmm)
case .normal:
let now = Date()

guard self <= now else {
return string(dateFormat: .yyyyMMddHHmm)
}

let originYear = Calendar.current.component(.year, from: self)
let nowYear = Calendar.current.component(.year, from: now)

guard originYear == nowYear else {
return string(dateFormat: .yyyyMMddHHmm)
}

let originDays = Calendar.current.ordinality(of: .day,
in: .year,
for: self)
let nowDays = Calendar.current.ordinality(of: .day,
in: .year,
for: now)

switch nowDays! - originDays! {
case 3...:
return string(dateFormat: .MMddHHmm)
case 2:
return "前天" + string(dateFormat: .HHmm)
case 1:
return "昨天" + string(dateFormat: .HHmm)
default:
let difference = Calendar.current.dateComponents([.hour, .minute],
from: self,
to: now)

guard difference.hour! < 1 else {
return "\(difference.hour!)小时前"
}

guard difference.minute! < 1 else {
return "\(difference.minute!)分钟前"
}

return "刚刚"
}
case .long:
return Calendar.current.compare(self,
to: Date(),
toGranularity: .year)
== .orderedSame
? string(dateFormat: .MMddHHmm)
: string(dateFormat: .yyyyMMddHHmm)
}
}
}

在 normal 分支中,首先判断是否为将来,是则返回具体时间。接着判断是否为本年度,不是则返回具体时间。然后调用 ordinality(of:in:for:) 获取该时间在一年中是第几天,这样可以处理跨月的情况。最后调用 dateComponents(_:from:to:) 获取相差的小时和分钟,判断后返回对应的描述。

在 normal 和 long 分支中,比较年份使用了不同的方法,但结果一样。

在 long 分支中,没有对将来进行判断,需注意。


Locale

一开始设计 API 时,除了 dateFormat,还引入了 locale,提供默认值 current。但后来发现 DateFormatter 的 locale 默认值就是 current,而且考虑到几乎没有用户经常改动语言地区设置而不重启 App,所以去除了 locale,只保留 dateFormat。

如果真的要设置 Locale,应该使用 autoupdatingCurrent,这个常量会追踪用户的修改。


Localized

除了直接修改 dateFormat,还有 setLocalizedDateFormatFromTemplate(_:) 可以使用,但两者的行为有些不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let df1 = DateFormatter()
df1.locale = Locale(identifier: "zh_CN")
df1.dateFormat = "yyyy-MM-dd HH:mm"

let df2 = DateFormatter()
df2.locale = Locale(identifier: "zh_CN")
df2.setLocalizedDateFormatFromTemplate("yyyy-MM-dd HH:mm")

let df3 = DateFormatter()
df3.dateFormat = "yyyy-MM-dd HH:mm"

let df4 = DateFormatter()
df4.setLocalizedDateFormatFromTemplate("yyyy-MM-dd HH:mm")

let date = Date()

for df in [df1, df2, df3, df4] {
print(df.locale!, df.dateFormat!, df.string(from: date))
}
1
2
3
4
zh_CN (fixed) yyyy-MM-dd HH:mm 2019-07-14 22:40
zh_CN (fixed) yyyy/MM/dd HH:mm 2019/07/14 22:40
en_US (current) yyyy-MM-dd HH:mm 2019-07-14 22:40
en_US (current) MM/dd/yyyy, HH:mm 07/14/2019, 22:40

直接修改 dateFormat,格式化文本不会受 locale 影响,所谓「所见即所得」。通过调用 setLocalizedDateFormatFromTemplate(_:),格式化文本会受 locale 影响,只使用其中的 Calendar.Component,其余的字符和顺序不会生效。


源码:Swift-Utils/DateFormatter