In the What’s in Foundation session at WWDC 2021, Apple prominently introduced the new Formatter API for Swift. There are already many articles online explaining how to use the new API. This article aims to provide a different perspective by showing how to create a Formatter that conforms to the new API, thereby understanding the design mechanism of the new Formatter API; it also compares the new and old APIs.
The demo code for this article can be downloaded from Github
Transition Between Old and New Styles
What Can the New Formatter API Do
The new Formatter offers a convenient interface, allowing Swift programmers to present localized format strings in applications in a more familiar way.
Is the New API Better Than the Old One
Good and bad are relative. For programmers primarily working with Swift or only knowing Swift (like myself), the new Formatter is not only easier to learn and use, but also more suitable for the increasingly popular declarative programming style. However, in terms of overall functionality and efficiency, the new Formatter does not have an advantage.
Comparison Between New and Old APIs
Ease of Use
The biggest advantage of the new API over the old one is that it is more intuitive and convenient to use.
Old API:
let number = 3.147
let numberFormat = NumberFormatter()
numberFormat.numberStyle = .decimal
numberFormat.maximumFractionDigits = 2
numberFormat.roundingMode = .halfUp
let numString = numberFormat.string(from: NSNumber(3.147))!
// 3.15
New API:
let number = 3.147
let numString = number.formatted(.number.precision(.fractionLength(2)).rounded(rule: .up))
// 3.15
Old API:
let numberlist = [3.345,534.3412,4546.4254]
let numberFormat = NumberFormatter()
numberFormat.numberStyle = .decimal
numberFormat.maximumFractionDigits = 2
numberFormat.roundingMode = .halfUp
let listFormat = ListFormatter()
let listString = listFormat
.string(from:
numberlist
.compactMap{numberFormat.string(from:NSNumber(value: $0))}
) ?? ""
// 3.35, 534.35, and 4,546.43
New API:
let numString1 = numberlist.formatted(
.list(
memberStyle: .number.precision(.fractionLength(2)).rounded(rule: .up),
type: .and
)
)
// 3.35, 534.35, and 4,546.43
Even if you’re not very familiar with the new API, just relying on code autocompletion, you can quickly combine the desired formatting result.
Performance Efficiency
In the WWDC video, Apple mentioned several times the performance improvements of the new API. However, Apple did not tell the whole truth.
From my personal testing data, compared to using a single instance of Formatter only once, the efficiency of the new API is quite apparent (30% — 300%). However, compared to reusable Formatter instances, there is still a significant gap.
Old API, creating a new instance each time:
func testDateFormatterLong() throws {
measure {
for _ in 0..<count {
let date = Date()
let formatter = DateFormatter()
formatter.dateStyle = .full
formatter.timeStyle = .full
_ = formatter.string(from: date)
}
}
}
// 0.121
Old API, creating only one instance:
func testDateFormatterLongCreateOnce() throws {
let formatter = DateFormatter()
measure {
for _ in 0..<count {
let date = Date()
formatter.dateStyle = .full
formatter.timeStyle = .full
_ = formatter.string(from: date)
}
}
}
// 0.005
New API:
func testDateFormatStyleLong() throws {
measure {
for _ in 0..<count {
_ = Date().formatted(.dateTime.year().month(.wide).day().weekday(.wide).hour(.conversationalTwoDigits(amPM: .wide)).minute(.defaultDigits).second(.twoDigits).timeZone(.genericName(.long)))
}
}
}
// 0.085
Using the new API, the more configurations you have, the longer the execution time. However, except for scenarios with very high performance requirements, the execution efficiency of the new API is still
satisfactory.
In the Demo attached to this article, some Unit Test code is included, which you can test on your own.
Uniformity
In the old API, we needed to create different Formatter instances for different types of formatting, such as using NumberFormatter for numbers and DateFormatter for dates.
The new API provides a unified calling interface for each supported type, reducing code complexity as much as possible.
Date.now.formatted()
// 9/30/2021, 2:12 PM
345.formatted(.number.precision(.integerLength(5)))
// 00,345
Date.now.addingTimeInterval(100000).formatted(.relative(presentation: .named))
// tomorrow
Customization Difficulty
The convenience of the new API is built on a foundation of extensive groundwork. Compared to directly setting properties in the old API, the new API adopts a functional programming approach, requiring separate methods to be written for each attribute. While not complex, this increases the workload significantly.
AttributedString
The new API provides AttributedString format support for each convertible type. Through the Field in AttribtedString, one can conveniently generate the desired display style.
For example:
var dateString: AttributedString {
var attributedString = Date.now.formatted(.dateTime
.hour()
.minute()
.weekday()
.attributed
)
let weekContainer = AttributeContainer()
.dateField(.weekday)
let colorContainer = AttributeContainer()
.foregroundColor(.red)
attributedString.replaceAttributes(weekContainer, with: colorContainer)
return attributedString
}
Text(dateString)
Error Rate in Code
In the new API, everything is type-safe. Developers do not need to repeatedly consult documentation; your code can enjoy the benefits of compile-time checks.
For example, in the following code:
Old API:
let dateFormatter:DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
let dateString = dateFormatter.string(from: Date.now)
New API:
let dateString = Date.now.formatted(.iso8601.year().month().day().dateSeparator(.dash).dateTimeSeparator(.space).time(includingFractionalSeconds: false) .timeSeparator(.colon))
If looking solely at the amount of code, the new API doesn’t hold any advantage in this example. However, you don’t have to hesitate between yyyy and YYYY or MM and mm, nor repeatedly check the headache-inducing documentation, reducing the likelihood of making mistakes in the code.
A Transition of Style?
The old API is a product of Objective-C. It’s efficient and useful, but its use in Swift can feel a bit out of place.
The new API is developed entirely for Swift, adopting the currently popular declarative style. Developers only need to declare the fields they want to display, and the system will present them in an appropriate format.
Both styles will coexist in Apple’s development ecosystem for a long time, allowing developers to choose the method that suits them best to achieve the same goal.
Thus, there is no issue of a style transition. Apple is merely filling a gap in the Swift development environment.
Conclusion
The new and old APIs will coexist for a long time.
The new API is not meant to replace the old Formatter API but should be considered as the Swift implementation of the old Formatter. The new API essentially covers most of the functionality of the old API, focusing on improving the developer experience.
Similar situations will continue to occur in the coming years. With the Swift language fundamentally complete, Apple will gradually provide Swift versions of its core frameworks. The introduction of AttributedString at this year’s WWDC also supports this point.
How to Customize the New Formatter
Differences in Customization between New and Old APIs
The old API uses classes, and when creating a custom formatter, we need to create a subclass of Formatter and implement at least the following two methods:
class MyFormatter: Formatter {
// Convert the formatted type to a format type (string)
override func string(for obj: Any?) -> String? {
guard let value = obj as? Double else {return nil}
return String(value)
}
// Convert the formatted type (string) back to the formatted type
override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
guard let value = Double(string) else {return false}
obj?.pointee = value as AnyObject
return true
}
}
If needed, we can also provide an implementation for NSAttributedString
formatting:
override func attributedString(for obj: Any, withDefaultAttributes attrs: [NSAttributedString.Key : Any]? = nil) -> NSAttributedString? {
nil
}
In this approach, data format conversion is completed within a single class.
The new API fully reflects Swift’s characteristics as a protocol-oriented language, using two protocols (FormatStyle
, ParseStrategy
) to define implementations for formatting data and converting from formatted data, respectively.
New Protocols
FormatStyle
Converts the formatted type to a formatted type.
public protocol FormatStyle : 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
}
Although generics are used for output types, because the new API focuses on formatting (not type conversion), FormatOutput is usually String
or AttributedString
.
func format(_ value: Self.FormatInput) -> Self.FormatOutput
is a mandatory method. locale
is used to set regional information for the Formatter, and the output type of the format
method in its return value remains consistent with the original structure. Thus, although the Formatter may provide different language results for different regions, for compatibility, the return result is still String
.
FormatStyle protocol also stipulates that it must satisfy Codable and Hashable.
ParseStrategy
Converts formatted data back to the formatted type.
public protocol ParseStrategy : Decodable, Encodable, Hashable {
/// The type of the representation describing the data.
associatedtype ParseInput
/// The type of the data type.
associatedtype ParseOutput
/// Creates an instance of the `ParseOutput` type from `value`.
func parse(_ value: Self.ParseInput) throws -> Self.ParseOutput
}
The definition of parse
is much easier to understand than the old API’s getObjectValue
.
ParseableFormatStyle
Since FormatStyle
and ParseStrategy
are two independent protocols, Apple also provided the ParseableFormatStyle
protocol, facilitating the implementation of methods for both protocols in a single structure.
public protocol ParseableFormatStyle : FormatStyle {
associatedtype Strategy : ParseStrategy where Self.FormatInput == Self.Strategy.ParseOutput, Self.FormatOutput == Self.Strategy.ParseInput
/// A `ParseStrategy` that can be used to parse this `FormatStyle`'s output
var parseStrategy: Self.Strategy { get }
}
Although theoretically, it’s possible to implement bidirectional conversion in a single structure using
FormatStyle & ParseStrategy
, the official framework only supports Formatters implemented through theParseableFormatStyle
protocol.
Additional Points
Even though the ParseableFormatStyle
protocol does not require outputting AttributedString, in the official new Formatter API, AttributedString output is still provided for each type.
To facilitate the call of Formatter, all official Formatters use the new functionality of Swift 5.5 - extending static member lookup in a generic context.
For example:
extension FormatStyle where Self == IntegerFormatStyle<Int> {
public static var number: IntegerFormatStyle<Int> { get }
}
We should also provide similar definitions for our custom Formatters.
Practical Application
Objective
In this section, we will use the new protocols to implement a Formatter for UIColor. It will achieve the following functionalities:
- Convert to String
UIColor.red.formatted()
// #FFFFFF
- Convert to AttributedString
UIColor.red.formatted(.uiColor.attributed)
- Convert from String to UIColor
let color = try! UIColor("#FFFFFFCC")
// UIExtendedSRGBColorSpace 1 1 1 0.8
- Support chain configuration (prefix, markers, displaying alpha value)
Text(color, format: .uiColor.alpha().mark().prefix)
- Localized
Implementing ParseStrategy
Converting a string to UIColor.
struct UIColorParseStrategy: ParseStrategy {
func parse(_ value: String) throws -> UIColor {
var hexColor = value
if value.hasPrefix("#") {
let start = value.index(value.startIndex, offsetBy: 1)
hexColor = String(value[start...])
}
if hexColor.count == 6 {
hexColor += "FF"
}
if hexColor.count == 8 {
let scanner = Scanner(string: hexColor)
var hexNumber: UInt64 = 0
if scanner.scanHexInt64(&hexNumber) {
return UIColor(
red: CGFloat((hexNumber & 0xff000000) >> 24) / 255,
green: CGFloat((hexNumber & 0x00ff0000) >> 16) / 255,
blue: CGFloat((hexNumber & 0x0000ff00) >> 8) / 255,
alpha: CGFloat(hexNumber & 0x000000ff) / 255
)
}
}
throw Err.wrongColor
}
enum Err: Error {
case wrongColor
}
}
In the demo, we did not implement a very strict ParseStrategy. Any hexadecimal string of length 6 or 8 will be converted to UIColor.
Implementing ParseableFormatStyle
struct UIColorFormatStyle: ParseableFormatStyle {
var parseStrategy: UIColorParseStrategy {
UIColorParseStrategy()
}
private var alpha: Alpha = .none
private var prefix: Prefix = .hashtag
private var mark: Mark = .none
private var locale: Locale = .current
enum Prefix: Codable {
case hashtag
case none
}
enum Alpha: Codable {
case show
case none
}
enum Mark: Codable {
case show
case none
}
init(prefix: Prefix = .hashtag, alpha: Alpha = .none, mark: Mark = .none, locale: Locale = .current) {
self.prefix = prefix
self.alpha = alpha
self.mark = mark
self.locale = locale
}
func format(_ value: UIColor) -> String {
let (prefix, red, green, blue, alpha, redMark, greenMark, blueMark, alphaMark) = Self.getField(value, prefix: prefix, alpha: alpha, mark: mark, locale: locale)
return prefix + redMark + red + greenMark + green + blueMark + blue + alphaMark + alpha
}
}
extension UIColorFormatStyle {
static func getField(_ color: UIColor, prefix: Prefix, alpha: Alpha, mark: Mark, locale: Locale) -> (prefix: String, red: String, green: String, blue: String, alpha: String, redMask: String, greenMark: String, blueMark: String, alphaMark: String) {
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
let formatString = "%02X"
let prefix = prefix == .hashtag ? "#" : ""
let red = String(format: formatString, Int(r * 0xff))
let green = String(format: formatString, Int(g * 0xff))
let blue = String(format: formatString, Int(b * 0xff))
let alphaString = alpha == .show ? String(format: formatString, Int(a * 0xff)) : ""
var redMark = ""
var greenMark = ""
var blueMark = ""
var alphaMark = ""
if mark == .show {
redMark = "Red: "
greenMark = "Green: "
blueMark = "Blue: "
alphaMark = alpha == .show ? "Alpha: " : ""
}
return (prefix, red, green, blue, alphaString, redMark, greenMark, blueMark, alphaMark)
}
}
In ParseableFormatStyle, besides implementing the format
method, we declared properties for different configurations.
Declaring
getField
as a structure method for ease of calling inAttributed
.
With the above code completed, we can now use code to convert between UIColor and String:
let colorString = UIColorFormatStyle().format(UIColor.blue)
// #0000FF
let colorString = UIColorFormatStyle(prefix: .none, alpha: .show, mark: .show).format(UIColor.blue)
// Red:00 Green:00 Blue:FF Alpha:FF
let color = try! UIColorFormatStyle().parseStrategy.parse("#FF3322")
// UIExtendedSRGBColorSpace 1 0.2 0.133333 1
Chain Configuration
extension UIColorFormatStyle {
func prefix(_ value: Prefix = .hashtag) -> Self {
guard prefix != value else { return self }
var result = self
result.prefix = value
return result
}
func alpha(_ value: Alpha = .show) -> Self {
guard alpha != value else { return self }
var result = self
result.alpha = value
return result
}
func mark(_ value: Mark = .show) -> Self {
guard mark != value else { return self }
var result = self
result.mark = value
return result
}
func locale(_ locale: Locale) -> UIColorFormatStyle {
guard self.locale != locale else { return self }
var result = self
result.locale = locale
return result
}
}
Now we have the capability for chain configuration:
let colorString = UIColorFormatStyle().alpha(.show).prefix(.none).format(UIColor.blue)
// 0000FFFF
Localized Support
Since the output type of format
is a String, we need to convert Mark
into corresponding localized text in getField
. Modify getField
as follows:
if mark == .show {
redMark = getLocalizedString(.red, locale: locale)
greenMark = getLocalizedString(.green, locale: locale)
blueMark = getLocalizedString(.blue, locale: locale)
alphaMark = alpha == .show ? getLocalizedString(.alpha, locale: locale) : ""
}
Add the following code to UIColorFormatStyle:
enum MarkTag: String {
case red
case green
case blue
case alpha
}
static let localeString: [String: String] = [
"EN-red": " Red:",
"EN-green": " Green:",
"EN-blue": " Blue:",
"EN-alpha": " Alpha:",
"ZH-red": " 红:",
"ZH-green": " 绿:",
"ZH-blue": " 蓝",
"ZH-alpha": " 透明度:"
]
With this, when the system switches to a region with a corresponding language pack, Mark will display the corresponding content.
# Red:00 Green:00 Blue:FF Alpha:FF
# 红:00 绿:00 蓝:FF 透明度:FF
As of the completion of this article,String(localized:String,locale:Locale)
still has a bug and cannot fetch the corresponding Locale text. The system’s Formatter also has this problem. Normally, we can use the following code to get Chinese mark displays in non-Chinese regionsI misunderstood the new constructor method of String previously. After an explanation from the official,
String(localized:String, locale:Locale)
’slocale
is for setting the locale of formatter in string interpolation. Hence, the original code was modified.
let colorString = UIColorFormatStyle().mark().locale(Locale(identifier: "zh-cn")).format(UIColor.blue)
Setting in SwiftUI:
// Text will automatically call Formatter's locale method
Text(color, format: .uiColor.mark())
.environment(\.locale, Locale(identifier: "zh-cn"))
AttributedString Support
Create a custom Field to facilitate users modifying different areas of AttributedString Style:
enum UIColorAttribute: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
enum Value: String, Codable {
case red
case green
case blue
case alpha
case prefix
case mark
}
static var name: String = "colorPart"
}
extension AttributeScopes {
public struct UIColorAttributes: AttributeScope {
let colorPart: UIColorAttribute
}
var myApp: UIColorAttributes.Type { UIColorAttributes.self }
}
extension AttributeDynamicLookup {
subscript<T>(dynamicMember keyPath: KeyPath<AttributeScopes.UIColorAttributes, T>) -> T where T: AttributedStringKey { self[T.self] }
}
In a few days, I will write a blog post introducing the use of AttributedString and how to customize AttributedKey.
Since formatting UIColor to AttributedString is one-way (no need to convert back from AttributedString to UIColor), Attributed only needs to follow the FormatStyle protocol:
extension UIColorFormatStyle {
var attributed: Attributed {
Attributed(prefix: prefix, alpha: alpha,mark: mark,locale: locale)
}
struct Attributed: Codable, Hashable, FormatStyle {
private var alpha: Alpha = .none
private var prefix: Prefix = .hashtag
private var mark: Mark = .none
private var locale: Locale = .current
init(prefix: Prefix = .hashtag, alpha: Alpha = .none, mark: Mark = .none, locale: Locale = .current) {
self.prefix = prefix
self.alpha = alpha
self.mark = mark
self.locale = locale
}
func format(_ value: UIColor) -> AttributedString {
let (prefix, red, green, blue, alpha, redMark, greenMark, blueMark, alphaMark) = UIColorFormatStyle.getField(value, prefix: prefix, alpha: alpha, mark: mark, locale: locale)
let prefixString = AttributedString(localized: "^[\(prefix)](colorPart:'prefix')", including: \.myApp)
let redString = AttributedString(localized: "^[\(red)](colorPart:'red')", including: \.myApp)
let greenString = AttributedString(localized: "^[\(green)](colorPart:'green')", including: \.myApp)
let blueString = AttributedString(localized: "^[\(blue)](colorPart:'blue')", including: \.myApp)
let alphaString = AttributedString(localized: "^[\(alpha)](colorPart:'alpha')", including: \.myApp)
let redMarkString = AttributedString(localized: "^[\(redMark)](colorPart:'mark')", including: \.myApp)
let greenMarkString = AttributedString(localized: "^[\(greenMark)](colorPart:'mark')" ,including: \.myApp)
let blueMarkString = AttributedString(localized: "^[\(blueMark)](colorPart:'mark')" ,including: \.myApp)
let alphaMarkString = AttributedString(localized: "^[\(alphaMark)](colorPart:'mark')" ,including: \.myApp)
let result = prefixString + redMarkString + redString + greenMarkString + greenString + blueMarkString + blueString + alphaMarkString + alphaString
return result
}
func prefix(_ value: Prefix = .hashtag) -> Self {
guard prefix != value else { return self }
var result = self
result.prefix = value
return result
}
func alpah(_ value: Alpha = .show) -> Self {
guard alpha != value else { return self }
var result = self
result.alpha = value
return result
}
func mark(_ value: Mark = .show) -> Self {
guard mark != value else { return self }
var result = self
result.mark = value
return result
}
func locale<T:FormatStyle>(_ locale: Locale) -> T {
guard self.locale != locale else { return self as! T }
var result = self
result.locale = locale
return result as! T
}
}
}
Unified Support
Add FormatStyle extensions for UIColorFormatStyle for convenience in Xcode:
extension FormatStyle where Self == UIColorFormatStyle.Attributed {
static var uiColor: UIColorFormatStyle.Attributed {
UIColorFormatStyle().attributed
}
}
extension FormatStyle where Self == UIColorFormatStyle {
static var uiColor: UIColorFormatStyle {
UIColorFormatStyle()
}
}
Add convenient constructor methods and formatted
methods to UIColor, maintaining a consistent user experience with official Formatters.
extension UIColor {
func formatted<F>(_ format: F) -> F.FormatOutput where F: FormatStyle, F.FormatInput == UIColor, F.FormatOutput == String {
format.format(self)
}
func formatted<F>(_ format: F) -> F.FormatOutput where F: FormatStyle, F.FormatInput == UIColor, F.FormatOutput == AttributedString {
format.format(self)
}
func formatted() -> String {
UIColorFormatStyle().format(self)
}
convenience init<T:ParseStrategy>(_ value: String, strategy: T = UIColorParseStrategy() as! T) throws where T.ParseOutput == UIColor {
try self.init(cgColor: strategy.parse(value as! T.ParseInput).cgColor)
}
convenience init(_ value: String) throws {
try self.init(cgColor: UIColorParseStrategy().parse(value).cgColor)
}
}
Finished Product
The complete code can be downloaded from Github.
Conclusion
Given that the official Formatters are abundant and feature-rich, most developers may not encounter scenarios where custom Formatters are needed. However, understanding custom Formatter protocols can strengthen our knowledge of native Formatters and improve their usage in our code.