Dynamic Text in SwiftUI
In the Linear app, users can choose whether names appear as a full name or a display name — depending on your preference, you'd see either @robb or @Robb Böhnke throughout the UI. This is an application-wide preference that is set on linear.app and also affects things like push notifications. As such, the iOS app needs to load the user's preference and take it into account every time a name is displayed.
The obvious approach would be to check the preference wherever a name is displayed. That works, but every new call site is an opportunity to forget this step. I wanted the correct behavior to be the default — the same way SwiftUI's Text automatically picks the right locale, or the way Color adapts to Dark Mode automatically.
SwiftUI achieves this through late binding — Text, Color, and Image defer resolution through the environment until the time the view is rendered instead of when the view is created.
However, while SwiftUI has an API to generate a dynamic Image based on the current state of the environment, there is no equivalent Text.init(content: (EnvironmentValues) -> String) that I could use to derive the name to display based on the user's preference.
Instead, it supports FormatStyle: a protocol that receives the current Locale and converts any value to a String. If I can come up with a way to derive the user preference from the locale, I can make the right behavior automatic after all.
First, I define the type:
public struct UserName: Hashable, Sendable {
var fullName: String
var displayName: String
}
Next, I define a nested FormatStyle that conforms to the Foundation protocol of the same name. This type has access to the users current Locale and can turn the UserName into a String:
extension UserName {
public struct FormatStyle: Foundation.FormatStyle {
public typealias FormatInput = UserName
public typealias FormatOutput = String
var locale: Locale?
public init(locale: Locale? = nil) {
self.locale = locale
}
public func locale(_ locale: Locale) -> UserName.FormatStyle {
var copy = self
copy.locale = locale
return copy
}
public func format(_ value: UserName) -> String {
if locale?.prefersFullNames == true {
value.fullName
} else {
value.displayName
}
}
}
}
But how do I determine the user's preference from the locale alone?
A locale of our own
Luckily, Locales in Foundation can be defined in terms of the BCP 47 identifier. For example, fr-CA identifies Canadian French wheres fr-CH identifies French as it's spoken in Switzerland. BCP 47 identifiers can encode a variety of locale preferences including different scripts, but they can also encode Private Use Subtags that can be used within a private context (like the Linear app!).
Using the private x-linearf subtag, I can encode the user's preference for full names over display names. All I need to do is to transform the system locale by adding the private subtag if necessary.
First I define the necessary methods to update the Locale:
extension Locale {
static let fullNameSubtag = "linearf"
func withFullNameSubtag() -> Self {
var identifier = identifier(.bcp47)
if !identifier.hasPrivateUseSubtags {
identifier += "-x"
}
if !identifier.hasFullNameSubtag {
identifier += "-\(Self.fullNameSubtag)"
}
return .init(identifier: identifier)
}
var prefersFullNames: Bool {
identifier(.bcp47).hasFullNameSubtag
}
}
private extension String {
var hasPrivateUseSubtags: Bool {
subtags.contains("x")
}
var hasFullNameSubtag: Bool {
guard let privateUseIndex = subtags.firstIndex(of: "x") else {
return false
}
return subtags[privateUseIndex...].contains {
$0 == Locale.fullNameSubtag
}
}
private var subtags: [Substring] {
lowercased().split(separator: "-")
}
}
Then I transform the Locale that is currently on the environment for the logged-in view hierarchy of the application:
public extension View {
func prefersFullNames(_ prefersFullNames: Bool = true) -> some View {
transformEnvironment(\.locale) { locale in
if prefersFullNames {
locale = locale.withFullNameSubtag()
}
}
}
}
Since many SwiftUI APIs take Text directly, I make my life a little easier by adding two more convenience extensions on Text and LocalizedStringKey.StringInterpolation:
extension Text {
init(_ userName: UserName) {
self.init(userName, format: UserName.FormatStyle())
}
}
public extension LocalizedStringKey.StringInterpolation {
mutating func appendInterpolation(_ input: UserName) {
appendInterpolation(input, format: UserName.FormatStyle())
}
}
This makes UserName feel like a native part of the system:
Text(user.userName)
Label("Assigned to \(assignee.userName)", systemImage: "person.fill")
Nice! Resolving the preference in the formatter rather than at the call site means there's no step to accidentally forget. A BCP 47 private subtag may be an unusual choice, but it reuses an existing propagation mechanism and matches its intended use. That said, I hope SwiftUI will eventually offer a more direct path to the same result.