iOS 15 中 Foundation 引入了一个新的协议:FormatStyle,它定义了一个转换方法用来将给定的数据转换成另外一种表现形式,并提供了一些本地化的支持,而与时间格式化相关的 TimeFormatStyle 在 iOS 16 中才姗姗来迟(注意这里并非日期格式化)。
在此之前我们将一段时间(比如:秒)格式化为字符串时一般的做法是:分别算出给定时间对应的小时,分钟和秒,然后再将这三个数据进行格式化输出。
extension Int {
func timeFormated() -> String {
let hour = self / 3_600
let minute = (self - hour * 3_600) / 60
let second = self % 60
return String(format: "%02d:%02d:%02d", hour, minute, second)
}
}
print(1234.timeFormated()) // 00:20:34
但这种方案都的缺点很明显:
- 无法知晓带格式化的数据为秒还是毫秒,或者其他单位导致数据错误;
- 无法指定格式化的样式,如:时分秒还是分秒;
- 数据进位不够灵活,如:
1:03:37.568,格式化成分秒形式后,究竟是63:37还是63:38; - API 可扩展性不够友好,未来添加新的格式化类型可能会导致不必要的重复代码出现,并降低可读性。
FormatStyle 和 TimeFormatStyle 为我们提供了新的思路去解决这些问题,但为了兼容 iOS 15 以下的系统版本,我们可以仿写一个 FormatStyle 用于格式化一个时间段(TimeInterval),将其命名为 FormatStyleBacked,之后我们所有的 FormatStyle 都基于该协议:
/// A type that can convert a given data type into a representation.
public protocol FormatStyleBacked : Decodable, Encodable, Hashable {
/// The type of data to format.
associatedtype FormatInput
/// The type of the formatted data.
associatedtype FormatOutput
/// Creates a `FormatOutput` instance from `value`.
func format(_ value: Self.FormatInput) -> Self.FormatOutput
/// If the format allows selecting a locale, returns a copy of this format with the new locale set. Default implementation returns an unmodified self.
func locale(_ locale: Locale) -> Self
}
extension FormatStyleBacked {
public func locale(_ locale: Locale) -> Self { self }
}
同样地,数据类型 DurationBacked 用来表示不同的时间段类型。
DurationBacked的种类并不太重要,后文中会对其进行扩展以支持任意时间段间隔,比如几分钟,几个小时或者几天。我们需要的只是绝对的时间时长:秒和纳秒。其中秒用来简化格式化计算的步骤,纳秒用来做对时间做增减计算。
public enum DurationBacked: Equatable, Sendable {
case seconds(Int)
case milliseconds(Int)
case microseconds(Int)
case nanoseconds(Int)
public static func == (lhs: DurationBacked, rhs: DurationBacked) -> Bool {
lhs.nanoseconds == rhs.nanoseconds
}
fileprivate var nanoseconds: Int {
switch self {
case .nanoseconds(let value): return value
case .microseconds(let value): return value * 1_000
case .milliseconds(let value): return value * 1_000_000
case .seconds(let value): return value * 1_000_000_000
}
}
fileprivate var seconds: TimeInterval {
switch self {
case .nanoseconds(let value): return Double(value) / 1_000_000_000
case .microseconds(let value): return Double(value) / 1_000_000
case .milliseconds(let value): return Double(value) / 1_000
case .seconds(let value): return Double(value)
}
}
}
随后我们开始进入正题,定义 TimeFormatStyle 结构体用于 DurationBacked 数据的转换。
extension DurationBacked {
public struct TimeFormatStyle: FormatStyleBacked, Sendable {
/// The units to display a Duration with and configurations for the units.
public struct Pattern : Hashable, Codable, Sendable {
fileprivate enum Style: Hashable, Codable, Sendable {
case hourMinute, hourMinuteSecond, minuteSecond
}
fileprivate let style: Style
fileprivate var padHourToLength: Int = 0
fileprivate var padMinuteToLength: Int = 0
fileprivate var fractionalSecondsLength: Int = 0
fileprivate var roundSeconds: FloatingPointRoundingRule = .toNearestOrEven
fileprivate var roundFractionalSeconds: FloatingPointRoundingRule = .toNearestOrEven
/// Displays a duration in hours and minutes.
public static var hourMinute: TimeFormatStyle.Pattern {
hourMinute(padHourToLength: 1)
}
/// Displays a duration in terms of hours and minutes with the specified configurations.
public static func hourMinute(padHourToLength: Int, roundSeconds: FloatingPointRoundingRule = .toNearestOrEven) -> TimeFormatStyle.Pattern {
Pattern(style: .hourMinute, padHourToLength: padHourToLength, roundSeconds: roundSeconds)
}
/// Displays a duration in hours, minutes, and seconds.
public static var hourMinuteSecond: TimeFormatStyle.Pattern {
hourMinuteSecond(padHourToLength: 1)
}
/// Displays a duration in terms of hours, minutes, and seconds with the specified configurations.
public static func hourMinuteSecond(padHourToLength: Int, fractionalSecondsLength: Int = 0, roundFractionalSeconds: FloatingPointRoundingRule = .toNearestOrEven) -> TimeFormatStyle.Pattern {
Pattern(style: .hourMinuteSecond,
padHourToLength: padHourToLength,
fractionalSecondsLength: fractionalSecondsLength,
roundFractionalSeconds: roundFractionalSeconds)
}
/// Displays a duration in minutes and seconds. For example, one hour is formatted as "60:00" in en_US locale.
public static var minuteSecond: TimeFormatStyle.Pattern {
minuteSecond(padMinuteToLength: 1)
}
/// Displays a duration in minutes and seconds with the specified configurations.
public static func minuteSecond(padMinuteToLength: Int, fractionalSecondsLength: Int = 0, roundFractionalSeconds: FloatingPointRoundingRule = .toNearestOrEven) -> TimeFormatStyle.Pattern {
Pattern(style: .minuteSecond,
padMinuteToLength: padMinuteToLength,
fractionalSecondsLength: fractionalSecondsLength,
roundFractionalSeconds: roundFractionalSeconds)
}
}
/// The locale to use when formatting the duration.
public var locale: Locale
/// The pattern to display a Duration with.
public var pattern: TimeFormatStyle.Pattern
/// The type of data to format.
public typealias FormatInput = DurationBacked
/// The type of the formatted data.
public typealias FormatOutput = String
/// Creates an instance using the provided pattern and locale.
public init(pattern: Pattern, locale: Locale = .autoupdatingCurrent) {
self.pattern = pattern
self.locale = locale
}
/// Creates a locale-aware string representation from a duration value.
public func format(_ value: DurationBacked) -> String {
// format code here.
}
/// Modifies the format style to use the specified locale.
public func locale(_ locale: Locale) -> TimeFormatStyle {
TimeFormatStyle(pattern: pattern, locale: locale)
}
}
}
为了尽可能的和系统框架保持一致的 API 风格,TimeFormatStyle 和 Pattern 的设计都借鉴了 Foundation 框架中的 Duration 相关的 API。Pattern 提供了 hourMinute, hourMinuteSecond和 minuteSecond 3 种格式化方案。每一种都可配置相关的格式化参数,padHourToLength 和 padMinuteToLength 用于在小时和分钟前补充占位的字符‘0’, fractionalSecondsLength 指定了秒后面小数点的精度范围,roundSeconds 和 roundFractionalSeconds 则用于决定使用何种规则对分钟和秒执行进位操作。具体可以查阅 Duration.TimeFormatStyle.Pattern
接下来,只需要实现 format(_:) 方法即可。在格式化之前需要先将 DurationBacked 类型的参数转换成带小数点的秒,然后根据 fractionalSecondsLength 和 roundFractionalSeconds对转换而来的秒进行预处理操作。
public func format(_ value: DurationBacked) -> String {
var seconds = value.seconds
guard seconds < .infinity else {
return "inf"
}
seconds *= pow(10, Double(pattern.fractionalSecondsLength))
seconds = seconds.rounded(pattern.roundFractionalSeconds)
seconds /= pow(10, Double(pattern.fractionalSecondsLength))
// format code here.
}
完成数据的预处理后,就可以针对不同的格式化方案执行分别格式化操作了。操作方式和上文中提到的方案一样,分别计算对应的小时,分钟和秒再格式化输出。这里有两点需要注意:
- 当 pattern 为
hourMinute时,需要将总秒再次换算成总分钟,并根据roundSeconds进行进位操作。 - 注意小时或者分钟以及秒数字符串的补零规则,详见代码。
public func format(_ value: DurationBacked) -> String {
var seconds = value.seconds
guard seconds < .infinity else {
return "inf"
}
seconds *= pow(10, Double(pattern.fractionalSecondsLength))
seconds = seconds.rounded(pattern.roundFractionalSeconds)
seconds /= pow(10, Double(pattern.fractionalSecondsLength))
switch pattern.style {
case .hourMinute:
let minutes = (seconds / 60).rounded(pattern.roundSeconds)
let hour = Int(minutes) / 60
let minute = minutes - Double(hour * 60)
return String(format: "%0\(pattern.padHourToLength)d:%02.f", hour, minute)
case .hourMinuteSecond:
let hour = Int(seconds / 3_600)
let minute = Int(seconds - Double(hour * 3_600)) / 60
let second = seconds - Double(hour * 3_600 + minute * 60)
let l1 = pattern.padHourToLength
let l2 = pattern.fractionalSecondsLength
let format = """
%0\(l1)d:%02d:%0\(l2 <= 0 ? 2 : l2 + 3).\(l2)f
"""
return String(format: format, hour, minute, second)
case .minuteSecond:
let minute = Int(seconds / 60)
let second = seconds - Double(minute * 60)
let l1 = pattern.padMinuteToLength
let l2 = pattern.fractionalSecondsLength
let format = """
%0\(l1)d:%0\(l2 <= 0 ? 2 : l2 + 3).\(l2)f
"""
return String(format: format, minute, second)
}
}
至此就可以使用 TimeFormatStyle 来格式化指定的时间段,比如使用时分秒的方式格式化 12345 秒:
let duration = DurationBacked.seconds(3 * 3_600 + 25 * 60 + 45)
let style = DurationBacked.TimeFormatStyle(pattern: .hourMinuteSecond(padHourToLength: 2))
let string = style.format(duration)
print(string) // 03:25:45
你会发现代码还不够简洁,我们可以为 Int 和 DurationBacked 添加扩展,让它更为友好的被我们使用。
extension Int {
public var nanoseconds: DurationBacked { .nanoseconds(self) }
public var microseconds: DurationBacked { .microseconds(self) }
public var milliseconds: DurationBacked { .milliseconds(self) }
public var seconds: DurationBacked { .seconds(self) }
public var minutes: DurationBacked { .seconds(self * 60) }
public var hours: DurationBacked { .seconds(self * 3_600) }
}
extension FormatStyleBacked where Self == DurationBacked.TimeFormatStyle {
/// A factory variable to create a time format style to format a duration.
public static func time(pattern: DurationBacked.TimeFormatStyle.Pattern) -> Self {
DurationBacked.TimeFormatStyle(pattern: pattern)
}
}
extension DurationBacked {
public func formated<S: FormatStyleBacked>(_ format: S) -> S.FormatOutput where Self == S.FormatInput {
format.format(self)
}
/// Formats `self` using the hour-minute-second time pattern
public func formated() -> String {
formated(.time(pattern: .hourMinuteSecond))
}
}
extension DurationBacked : AdditiveArithmetic {
public static func + (lhs: DurationBacked, rhs: DurationBacked) -> DurationBacked {
.nanoseconds(lhs.nanoseconds + rhs.nanoseconds)
}
public static func - (lhs: DurationBacked, rhs: DurationBacked) -> DurationBacked {
.nanoseconds(lhs.nanoseconds - rhs.nanoseconds)
}
public static var zero: DurationBacked { .nanoseconds(0) }
}
使用新的 API,上面的例子就可以变得非常简洁且可读性更好:
let duration = 3.hours + 25.minutes + 45.seconds
let string = duration.formated(.time(pattern: .hourMinuteSecond(padHourToLength: 2)))
print(string) // 03:25:45
结语
为了方便演示,同时也鉴于本地化的复杂性,在format(_:) 方法里并没有关于 local 的任何处理,各位感兴趣的读者可以自行实现,如果自定义的 format style 中不需要本地化相关的功能就不用处理 local。
对 TimeFormatStyle 的扩展也相对比较容易:定义一个新的 Pattern 或者任何其他新的方式。那么你可以为时间格式化添加自定义分隔符的功能吗?