In WWDC 2021’s What’s in Foundation feature, Apple introduced a new Formatter API for Swift. There are already a number of articles on the web explaining the use of the new API. This article will give you another perspective on the design mechanism of the new Formatter API by showing you how to create a Formatter that conforms to the new API. The new and old apis are compared.

The original article was posted on my blog www.fatbobman.com

The demo code for this article can be downloaded on Github

An alternation of old and new or style

What can the new Formatter API do

The new Formatter provides a convenient interface for Swift programmers to render localized format strings in their applications in a more familiar way.

Is the new API better than the old ONE

Both good and bad are relative, and for swift-oriented or swift-only programmers (like myself), the new Formatter is not only easier to learn and use, but also better suited to the increasingly popular declarative programming style. But in terms of overall functionality and efficiency, the new Formatter doesn’t have the edge.

Comparison of old and New apis

Call convenience

If the new API has one big advantage over the old one, it is much more intuitive and convenient to call.

The 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
Copy the code

The new API:

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

The 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(a)let listString = listFormat
                  .string(from:
                              numberlist
                              .compactMap{numberFormat.string(from:NSNumber(value: $0))})?? ""
      // 3.35, 534.35, and 4,546.43
Copy the code

The 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
Copy the code

Even if you don’t know much about the new API, you can quickly assemble the formatting results you want just by using the code’s automatic prompts.

Operation efficiency

In the WWDC video, Apple mentioned several performance improvements with the new API. But Apple isn’t telling you the whole truth.

From my own testing data, the new API is still significantly more efficient (30% to 300%) than using a Formatter instance only once, although there is still an order of magnitude difference compared to reusable Formatter instances.

The old API is recreated every time

    func testDateFormatterLong(a) throws {
        measure {
            for _ in 0..<count {
                let date = Date(a)let formatter = DateFormatter()
                formatter.dateStyle = .full
                formatter.timeStyle = .full
                _ = formatter.string(from: date)
            }
        }
    }
/ / 0.121
Copy the code

Old API, create instance only once

    func testDateFormatterLongCreateOnce(a) throws {
        let formatter = DateFormatter()
        measure {
            for _ in 0..<count {
                let date = Date()
                formatter.dateStyle = .full
                formatter.timeStyle = .full
                _ = formatter.string(from: date)
            }
        }
    }
/ / 0.005
Copy the code

The new API

    func testDateFormatStyleLong(a) 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
Copy the code

With the new API, the more you configure, the more time it takes to execute increases. However, unless there is a very high performance requirement, the performance of the new API can be satisfactory.

Part of the Unit Test code is included in the Demo, so you can Test it yourself.

unity

In the old API, we needed to create different instances of Formatter for different formatting types. For example, use NumberFormatter to format numbers and DateFormatter to format dates.

The new API provides a unified invocation interface for each supported type, minimizing the complexity at the code level

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
Copy the code

Custom difficulty

The ease of calling the new API is based on a lot of cumbersome work. Instead of setting properties directly, the new API takes a functional programming approach, writing Settings individually for each property. It’s not complicated, but it’s a lot more work.

AttributedString

The new API provides AttributedString format support for each convertible type. Fields in AttribtedString make it easy to generate the desired display style.

Such as:

    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)
Copy the code

Code error rate

In the new API, everything is type-safe, developers don’t have to go through the documentation repeatedly, and your code can take advantage of compile-time checks.

Take the following code for example

The 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)
Copy the code

The new API

let dateString = Date.now.formatted(.iso8601.year().month().day().dateSeparator(.dash).dateTimeSeparator(.space).time(includingFractionalSeco nds:false) .timeSeparator(.colon))
Copy the code

If you look at the sheer volume of code, the new API doesn’t have any advantage in this case. But you don’t have to go back and forth between YYYY and YYYY or MM or MM, and you don’t have to go back and forth in a headache document, reducing the chances of making mistakes in code.

Style shift?

The old API was an outgrowth of Objc, and it was efficient and easy to use, but it was incongruous to use in Swift.

The new API was developed entirely for Swift and adopts the currently popular declarative style. All the developer needs to do is declare the fields that need to be displayed, and the system will render them in the appropriate format.

Both styles will coexist in Apple’s development ecosystem for a long time, and developers will be able to achieve the same goals in whatever way suits them.

So there is no style shift, apple has just added a missing piece to the Swift development environment.

conclusion

The old and new apis will coexist for a long time.

The new API is not a replacement for the old Formatter API, but rather a Swift implementation of the old Formatter. The new API basically covers most of the functionality of the old API, focusing on improving the user experience for developers.

A similar scenario is likely to play out over the next few years, with Apple gradually offering Swift versions of its core framework once the Swift language level is basically complete. The AttributedString released at WWDC also supports this.

How to customize the new Formatter

Differences in customization between old and new apis

The old API was implemented as a class. To create a custom Formatter, we need to create a subclass of Formatter and implement at least two methods:

class MyFormatter:Formatter {
   // Convert the formatted type to the format type (string)
    override func string(for obj: Any?) -> String? {guard let value = obj as? Double else {return nil}
        return String(value)
    }

   // Converts 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}}Copy the code

We can also provide a formatted implementation of NSAttributedString if needed

    override func attributedString(for obj: Any.withDefaultAttributes attrs: [NSAttributedString.Key : Any]? = nil) -> NSAttributedString? {
        nil
    }
Copy the code

Formatting transformations of data are done within a class definition.

The new API fully embodies the characteristics of Swift as a protocol-oriented language, using two protocols (FormatStyle and ParseStrategy) that define the implementation of formatting data and converting from formatting.

The new agreement

FormatStyle

Converts a 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
}
Copy the code

Although generics are used for export types, since the new API focuses on formatting (rather than casting), the FormatOutpu is usually a String or AttributedString.

Func format(_ value: self. FormatInput) -> self. FormatOutput is mandatory. Locale is used to set locale information for Formatter. The output type of the format method in the return value is the same as the original structure. So, although Formatter provides different language returns for different regions, the result is still a String for compatibility.

The FormatStyle protocol dictates that it must satisfy both Codable and Hashable.

ParseStrategy

Converts formatted data 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
}
Copy the code

Parse’s definition is much easier to understand than the old API’s getObjectValue.

ParseableFromatStyle

Since FormatStyle and ParseStrategy are two separate protocols, Apple also provides the ParseableFromatStyle protocol, which makes it easy to implement both protocols in one 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}}Copy the code

Although it is theoretically possible to implement bidirectional transformations in a structure via FormatStyle&ParseStrategy, the official framework only supports formatters implemented via the ParseableFromatStyle protocol.

other

Although the ParseableFromatStyle protocol does not require that AttributedString be output, the official new Formatter API provides AttributedString output for each type.

To facilitate Formatter calls, all official Formatters use Swift 5.5’s new feature to extend static member lookup in a generic context

For example,

extension FormatStyle where Self= =IntegerFormatStyle<Int> {
    public static var number: IntegerFormatStyle<Int> { get}}Copy the code

We’d better provide a similar definition for our custom Formatter

In actual combat

The target

In this section, we’ll implement the Formatter for UIColor using a new protocol that will do the following:

  • Convert a String
UIColor.red.formatted()
// #FFFFFF
Copy the code
  • Convert AttributedString
UIColor.red.formatted(.uiColor.attributed)
Copy the code

  • Convert from String to UIColor
let color = try! UIColor("#FFFFFFCC")
// UIExtendedSRGBColorSpace 1 1 1 0.8
Copy the code
  • Support for chained configuration (prefix, markup, transparency or not)
Text(color, format: .uiColor.alpah().mark().prefix)
Copy the code

  • localized

Implement ParseStrategy

Convert strings 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
    }
}
Copy the code

In the Demo, we did not implement a very strict ParseStrategy. Any hexadecimal string of length 6 or 8 will be converted to UIColor.

Implement ParseableFromatStyle

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

Copy the code

In ParseableFromatStyle, in addition to implementing the Format method, we declare properties for different configurations.

Declare the getField method as a structural method for subsequent derivative calls

After completing the above code, we are ready to convert between UIColor and String using code:

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
Copy the code

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 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(_ locale: Locale) -> UIColorFormatStyle {
        guard self.locale ! = locale else { return self }
        var result = self
        result.locale = locale
        return result
    }
}
Copy the code

Now we have the ability to chain configuration.

let colorString = UIColorFormatStyle().alpah(.show).prefix(.none).format(UIColor.blue)
// 0000FFFF
Copy the code

Localized support

Since format’s output type is String, we need to convert Mark to the text of the corresponding field in getField. Make the following changes in getField:

        if mark = = .show {
            redMark = String(localized: "UIColorRedMark", locale: locale)
            greenMark = String(localized: "UIColorGreenMark", locale: locale)
            blueMark = String(localized: "UIColorBlueMark", locale: locale)
            alphaMark = alpha = = .show ? String(localized: "UIColorAlphaMark", locale: locale) : ""
        }
Copy the code

Create Localizable. Strings file in your project and add the corresponding text:

// English
"UIColorRedMark" = " Red:";
"UIColorGreenMark" = " Green:";
"UIColorBlueMark" = " Blue:";
"UIColorAlphaMark" = " Alpha:";
Copy the code

At this point, when the system switches to a locale that has the corresponding language pack, Mark will display the corresponding content

# Red:00 Green:00 Blue:FF Alpha:FF# red:00Green:00Blue:FFTransparency:FF
Copy the code

As of this paper is completed, the String (localized: String, locale: locale) still has bugs, unable to get the locale to the corresponding characters. The system Formatter also has this problem. Normally, we can use the following code to get a Chinese mark display in a non-Chinese area

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

AttributedString support

Create a custom Field so that the user can change the Style of different areas of the AttributedString

enum UIColorAttirbute: 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: UIColorAttirbute
    }

    var myApp: UIColorAttributes.Type { UIColorAttributes.self}}extension AttributeDynamicLookup {
    subscript<T> (dynamicMember keyPath: KeyPath<AttributeScopes.UIColorAttributes.T>) -> T where T: AttributedStringKey { self[T.self]}}Copy the code

I’ll write a blog post about AttributedString in a few days, and how to customize AttributedKey

Since formatting UIColor to An AttributedString is one-way (no conversion from AttribuedString back to UIColor is required), the view simply follows 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')", locale: locale, including: \.myApp)
            let greenMarkString = AttributedString(localized: "^ [\(greenMark)](colorPart:'mark')", locale: locale, including: \.myApp)
            let blueMarkString = AttributedString(localized: "^ [\(blueMark)](colorPart:'mark')", locale: locale, including: \.myApp)
            let alphaMarkString = AttributedString(localized: "^ [\(alphaMark)](colorPart:'mark')", locale: locale, 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}}}Copy the code

Unity support

Add the FormatStyle extension for UIColorFormatStyle for easy use 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()}}Copy the code

Add a convenient constructor and formatted method to UIColor to keep the experience consistent with the official Formatter.

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(a) -> String {
        UIColorFormatStyle().format(self)}convenience init<T:ParseStrategy> (_ value: String.strategy: T = UIColorParseStrategy(a)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)
    }
}

Copy the code

The finished product

You can download the entire code on Github.

conclusion

Given the wide variety and richness of formatters available, most developers probably won’t encounter a situation where they need to customize a Formatter. However, by understanding the custom Formatter protocol, we can strengthen our understanding of native Formatters and make better use of them in our code.

I hope this article will be helpful to you.

The original article was posted on my blog www.fatbobman.com

Welcome to subscribe to my public number: Elbow Swift Notepad