Formatting in UI

Tue, 14 Jun 2022 00:00:00 +0000

An easy way to share code across views and test your UI

Writing UI often come with the need of formatting data to display it to the user.

We usually rely on formatters provided by Apple to do so such as DateFormatter and now Date.formatted. However they are unable to format application dedicated objects like contacts, books, users or players for instance.

In these situations it become clear we need to implement our own logic to handle this cases. But how?

Let's start by having a look on the most common way of solving this problem and how using formatter instead can simplify our coder life.

The classic way: ViewData 🎻

Back in ViewModel and Separation of Concern I presented the ViewData approach. That's the classic way 🎻.

Let's reuse the example from this article:

class BookViewData {
    private let book: Book
    private let formatter = DateFormatter()

    var title: String { book.title }
    var publishedAt: String { formatter.string(from: book.publishedAt)! }
}

Let's now imagine a more complex scenario where publishedAt need some tweaking and title is actually a concatenation of two attributes:

class BookViewData {
  private let book: Book
  private lazy var formatter: DateFormatter = {
    let formatter = DateFormatter()

    formatter.setLocalizedDateFormatFromTemplate("EEEE, MMM d, yyyy")

    return formatter
  }()

  var title: String {
    [book.title.capizalized, book.subtitle.capitalized].joined(separator: " - ")
  }
  var publishedAt: String { formatter.string(from: book.publishedAt)! }
}

This is perfect valid code and not too complex. It's even testable ✅. Is it? 🧐

Testing time

class BookViewDataTests: XCTestCase {
  func test_publishedAt_returnFormattedDate() {
    let viewData = BookViewData(book: ...)

    XCTAssertEqual(viewData.publishedAt, "2022 June 7th")
  }
}

On my French computer this test will fail 💥. Why? Because we're implicitly relying on DateFormatter.locale and not enforcing it during testing. So our tests will fail on computer where the locale do not match the implicit one.

All your data are belong to us

Let's now say we need to display our books as a list. Obviously we'll need to show the title. How should we do that?

One might create a BookRowViewData tailored for row displaying. ViewData are supposed to be tailored to the view they are used in so it would make sense:

class BookRowViewData {
  private let book: Book
  private lazy var formatter: DateFormatter = {
    let formatter = DateFormatter()

    formatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")

    return formatter
  }()

  var title: String {
    [book.title.capizalized, book.subtitle.capitalized].joined(separator: " - ")
  }
  var publishedAt: String { formatter.string(from: book.publishedAt)! }
}

But while working it occur a lot of code duplication. On the other hand it allow us to configure each attribute slightly differently like publishedAt using a different format.

To avoid duplication we could put everything in one single BookViewData and sharing it across views:

class BookViewData {
  private let book: Book
  private lazy var dayFormatter: DateFormatter = {
    let formatter = DateFormatter()

    formatter.setLocalizedDateFormatFromTemplate("EEEE, MMM d, yyyy")

    return formatter
  }()

  private lazy var monthFormatter: DateFormatter = {
    let formatter = DateFormatter()

    formatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")

    return formatter
  }()

  var title: String {
    [book.title.capizalized, book.subtitle.capitalized].joined(separator: " - ")
  }
  var publishedAtDay: String { dayFormatter.string(from: book.publishedAt)! }
  var publishedAtMonth: String { month.string(from: book.publishedAt)! }
}

However now the view and its ViewData are not tighed together as we said just before. As such we don't know which attributes are used by a view.

This indicate one thing 🕵️‍♀️: formatting is not so much related to a view rather than a model. So instead let's transform our Data class into a Formatter.

The magic way: Formatters 🧙‍♂️

We'll first start by creating a date formatter tailored to our app:

class DateFormatter {
  enum Format: String {
    case byDay = "EEEE, MMM d, yyyy"
    case byMonth = "MMMM yyyy"
  }

  static func string(_ date: Date, to format: Format) -> String {
    let formatter = DateFormatter()

    formatter.setLocalizedDateFormatFromTemplate(format.rawValue)

    return formatter.string(from: date)
  }
}

By doing a custom date formatter we can now display our book date by day or month depending on the screen. But most important the logic can now be reused for any date in our app 💪.

What about title? This is something very specific to book so we'll make a BookFormatter to handle this case:

class BookFormatter {
  static func string(title book: Book) -> String {
    [book.title.capizalized, book.subtitle.capitalized].joined(separator: " - ")
  }
}

While very similar to BookViewData our BookFormatter is decoupled from unrelated formatters (date). Also by using static any BookFormatter function is now independent one from another. What about testing?

Testing

Let's reuse our previous test and rewrite it using our new formatter:

class dateFormatterTestsTests: XCTestCase {
  func test_string_byDay_returnStringWithDay() {
    XCTAssertEqual(DateFormatter.string(Date(), format: .byDay), "2022 June 7th")
  }
}

That test still won't work on my computer 💥😬

To fix it we'll define the locale to use when formatting and set it in our test:

class DateFormatter {
  static func string(_ date: Date, to format: Format, locale: Locale = .current) -> String {
    let formatter = DateFormatter()

    formatter.locale = locale
    formatter.setLocalizedDateFormatFromTemplate(format.rawValue)

    return formatter.string(from: date)
  }
}

class dateFormatterTestsTests: XCTestCase {
  func test_string_byDay_returnStringWithDay() {
    XCTAssertEqual(DateFormatter.string(Date(), format: .byDay, locale: Locale(identifier: "en_US")), "2022 June 7th")
  }
}

And now everything work 💪.

Composition

We saw some basic formatters usage. In most apps however you might have more complex use cases.

For instance in my current work we need to display a participant: it can be either a phone number or a app user. However we also need to be able to explicitly display a user or a phone number.

How did we handle that? By making 3 formatters: one being just a composition of the two others.

class UserFormatter {
  static func string(_ user: User) -> String {
    // format the user, possibly with something like PersonNameComponentsFormatter
  }
}

class PhoneNumberFormatter {
  /// display a phone number in a e164 human readable way. Example: +33 1 02 03 04 05
  static func string(_ numbr: PhoneNumber) -> String {
  }
}


class ParticipantFormatter {
  static func string(_ participant: Participant) -> String {
    switch participant {
      case .user(let user):
        return UserFormatter.string(user)
      case .number(let phoneNumber):
        return PhoneNumberFormatter(phoneNumber)
    }
  }
}

Thanks to this approach we now have a testable code and no more formatting duplication code 🚀.

Format style

Starting with iOS 15 you can use formatted on some elements like Date which can save you time by not needing to make a formatter.

Under the hood Date.formatted use a new protocol: FormatStyle. You can use it to create your own formatter following a Swift/SwiftUI convention. Our participant formatter might be re-written like this:

extension Participant {
  enum FormatStyle {
    struct Name: FormatStyle {
      func format(_ participant: Participant) -> String {
        switch participant {
          case .user(let user):
            return User.FormatStyle.Name.format(user)
          case .number(let phoneNumber):
            return PhoneNumber.FormatStyle.Name.format(phoneNumber)
        }
      }
    }
  }
}

As it's more verbose I would be cautious about using it. But that might come in handy in some cases 🙂

Conclusion

ViewData and Formatters are slightly similar: take the former, change the naming and use static functions and you get the later.

However formatters give a complete different perspective about UI problem by being isolated logic and allowing us to compose them whenever needed.

In the end they are an easy and yet very efficient way to share UI formatting across your views. Showing errors for instance become a piece of cake 🍰 using this strategy.