肘子的 Swift 记事本

Apple’s New Formatter API: Comparison of Old and New and How to Customize

Published on

Get weekly handpicked updates on Swift and SwiftUI!

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:

Swift
      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:

Swift
      let number = 3.147
      let numString = number.formatted(.number.precision(.fractionLength(2)).rounded(rule: .up))
      // 3.15

Old API:

Swift
      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:

Swift
        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:

Swift
    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:

Swift
    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:

Swift
    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.

Swift
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:

Swift
    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)

image-20210930142453213

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:

Swift
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:

Swift
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:

Swift
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:

Swift
    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.

Swift
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.

Swift
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.

Swift
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 the ParseableFormatStyle 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:

Swift
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
Swift
UIColor.red.formatted()
// #FFFFFF
  • Convert to AttributedString
Swift
UIColor.red.formatted(.uiColor.attributed)

image-20210930171252694

  • Convert from String to UIColor
Swift
let color = try! UIColor("#FFFFFFCC")
// UIExtendedSRGBColorSpace 1 1 1 0.8
  • Support chain configuration (prefix, markers, displaying alpha value)
Swift
Text(color, format: .uiColor.alpha().mark().prefix)

image-20210930171608519

  • Localized

image-20210930171654956

Implementing ParseStrategy

Converting a string to UIColor.

Swift
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

Swift
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 in Attributed.

With the above code completed, we can now use code to convert between UIColor and String:

Swift
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

Swift
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:

Swift
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:

Swift
        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:

Swift
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.

Swift
# 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 regions

I misunderstood the new constructor method of String previously. After an explanation from the official, String(localized:String, locale:Locale)’s locale is for setting the locale of formatter in string interpolation. Hence, the original code was modified.

Swift
let colorString = UIColorFormatStyle().mark().locale(Locale(identifier: "zh-cn")).format(UIColor.blue)

Setting in SwiftUI:

Swift
// 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:

Swift
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:

Swift
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:

Swift
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.

Swift
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

uicolorFormatter

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.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways