Remote styling in iOS apps is becoming increasingly important for modern development workflows. Picture this: you’re finalizing a banking app with remote UI configuration capabilities. From the project’s inception, you’ve implemented a robust remote styling system with SwiftUI view modifiers that make UI updates seamless. Your codebase includes well-structured style abstractions, accessibility compliance, comprehensive documentation, and even snapshot tests for quality assurance. Perfect!
And then the marketing department drops a bomb: they want to completely redesign the app’s colors and typography to align it with the bank’s rebranding initiative. Hope you don’t have any plans for the weekend…
Naturally, if you’ve implemented a proper style guide, the work will go much faster. Essentially, you’d only need to update style definitions in one place. But wouldn’t it be even more convenient to update the application’s style without touching the code? For example, by editing a JSON file containing the application’s style guide? And what if such a file was located outside of the application? Think of all the possibilities this could offer to the business…
In this post, we’ll explore the differences between a Design System and a Style Guide, and the benefits of implementing them in our apps. Next, we’ll make the app style dynamic and updatable from an external “source of truth“. Finally we’ll cover the cons of the solution as well. So, without further ado, let’s dive in!
Why implement dynamic styling in our app?
The short answer is: to be able to affect the UX / UI of the app without uploading updates to the App Store. Within reason of course, unless we risk the app being removed from the store. This idea must feel enticing, even for a person fortunate enough to work on the apps whose UI changed rarely. But what specific advantages does dynamic styling offer? Let’s explore:
- Future-proofing app designs:
Dynamic styling allows you to push visual changes to your app without going through the App Store review process. This means faster iterations and more responsive design adjustments. It’s especially useful when part of your app contains HTML content. Imagine pushing a change to GitHub and seeing the UI of the app on your phone update within minutes. - Enabling A/B testing:
When comparing high-profile apps like Spotify on different devices, you might notice subtle differences in some screens. Colors may not match exactly, or fonts might be slightly larger, etc. If that happens to you, don’t worry – you’re likely part of an experiment. Dynamic styling unlocks new possibilities for A/B testing design elements. You can experiment with various visual styles to optimize user engagement and conversion rates in real-time. Ever wondered how your favorite app would look with yellow call-to-action buttons? Maybe you’ll find out one day - Efficient White-Labeling:
White-labeling is a strategy to build distinctive, branded apps for multiple clients using a single codebase. Think WordPress or other popular blog and e-commerce platforms. Now, imagine the time you could save if UI changes didn’t require touching the app code. What if the app design (e.g., in Figma) served as the “single source of truth” for application branding? If every client-approved design version could be immediately exported as an app style and applied to the app already published in the App Store? Sounds good, doesn’t it? - Regional Customization:
Imagine customizing your app’s appearance based on the user’s location. But why stop there? Why not tailor the app to specific user demographics or even the time of year? Currently, the highest level of customization you’ll see in most apps is… a different app icon during Christmas.
Sounds great, but there must be a catch… If dynamic styling were simple, we’d see it in almost every app on the App Store. Yet only a handful of apps use this option. Why is that? Primarily because most projects don’t really need it. Designs don’t change that often, and when they do, you’d likely have to change something else in the app anyway, resulting in the need to submit it to the App Store. Moreover, the potential revenue from tailoring the app to particular demographics or regions would have to outweigh the cost of implementing dynamic styling. And this is true only for high-profile apps like Spotify.
Start with the Fundamentals: Building Your Design System
Before we can start saving the business hundreds of thousands of dollars in unspent app maintenance costs, we need to lay some groundwork first. Specifically, we need to define the basic building blocks that will compose the app’s UI/UX. To do this, we’ll start with the most fundamental definitions: How can we describe the app’s colors? What defines the text we use in the app? And so on.
"designSystem": {
"colors": {
"primary": {
"lightModeValue": "#11A4D6",
"darkModeValue": "#11A4D6"
},
"background": {
"lightModeValue": "#FEFFFF",
"darkModeValue": "#FEFFFF"
}
},
"fonts": {
"header": {
"fontName": "Inter",
"fontSize": 32,
"fontWeight": "heavy"
},
"body": {
"fontName": "Inter",
"fontSize": 16,
"fontWeight": "regular"
}
}
}
As you can see, these colors and font definitions are distinctly identifiable and reusable – almost like tokens. In fact, that’s exactly what they are: Design Tokens.
By tokenizing a sufficient number of UI elements (such as spacings, gradients, shapes, etc.), you can create a set of building blocks broad enough to compose the entire app’s UI, regardless of its size or complexity.
Going even further, we can tokenize a significant portion of the app’s UX—including animations:
"animations": {
"easeIn": {
"long": 1,
"medium": 0.5,
"short": 0.25
},
"linear": {
"long": 1,
"medium": 0.5,
"short": 0.25
}
}
Now, all we need to do is create the proper model in the app that represents the Design System we’ve just created:
public struct AppDesignSystem: Equatable, Codable {
public let colors: AppColors
public let fonts: AppFonts
public init(colors: AppColors, fonts: AppFonts) {
self.colors = colors
self.fonts = fonts
}
}
where:
public struct AppFont: Equatable, Codable {
public let fontName: AppFont.Name
public let fontSize: CGFloat
public let fontWeight: AppFontWeight
public init(fontName: AppFont.Name, fontSize: CGFloat, fontWeight: AppFontWeight) {
self.fontName = fontName
self.fontSize = fontSize
self.fontWeight = fontWeight
}
}
public extension AppFont {
enum Name: String, Equatable, Codable {
case inter = "Inter"
}
var font: Font {
.custom("\\(fontName.rawValue.capitalized)-\\(fontWeight.rawValue.capitalized)", size: fontSize)
}
}
Naturally, if you want to use a custom font (like Inter
in the example), you need to embed it in the app and name it properly, e.g., Inter-Heavy
or Inter-Regular
.
But hold on… Wouldn’t this make the app typography rigid and changeable only by modifying the app code? Doesn’t it defy the purpose of Dynamic Styling?
Of course it does, but let’s be realistic here. It’s highly unlikely that designers would decide to change the app’s primary typography without a major rebranding. In such a case, the Design System itself would need to be rewritten, resulting in changes to the app code anyway.
In addition, it’s worth remembering one thing: starting small. It’s surprisingly easy to get carried away and keep adding tokens to your Design System
. For example, quantizing margins and paddings seems like a reasonable idea, but must it be added as part of the MVP? I’m not so sure about that…
Crafting the App's Visual Identity: The Style Guide
Okay, so you’ve assembled a collection of basic building blocks for your app UI. What’s next? Well… let’s build some UI with it!
Let’s start with something really simple: app labels. What Design Tokens
can we use to sufficiently describe text in the app? How about starting with a font and a foreground color:
"components": {
"text": {
"body": {
"color": "foreground",
"font": "body"
},
"caption": {
"color": "foreground",
"font": "caption"
},
...
}
}
… and into Swift:
public struct AppTextStyle: Equatable, Codable {
public let font: String
public let color: String
public init(font: String, color: String) {
self.font = font
self.color = color
}
}
… where font
and color
are names of corresponding tokens defined in the Design System
. That makes sense, right?
Looks good, but is it enough? What about other UI parameters like text decoration, letter spacing, and margins?
This is where common sense comes into play. Remember when we discussed “starting small“? Realistically, if we tried to account for every single Text
parameter in our Style Guide
from the start, we might never ship the feature at all!
In my opinion, it’s far better to define only the essential style parameters (like font and text color) and add e.g. necessary paddings statically – directly in the view. While not perfect, gradually moving static UI parameters from Views
into the Design System
will allow you to show quick and measurable progress to the business and boost their confidence in the project. In addition, you might be surprised to discover that some seemingly obvious UI parameters will never make it into the dynamic styling. They simply won’t be used often enough to justify the work.
Applying the Style Guide to UI Components
Great, but how can we translate the style we’ve just defined into a language SwiftUI “understands”? Well, the choice seems obvious: let’s create a dedicated ViewModifier
and use it to apply the properties defined in the StyleGuide
to a Text
component:
public struct AppTextModifier: ViewModifier {
public let styleGuide: StyleGuide
public init(styleGuide: StyleGuide) {
self.styleGuide = styleGuide
}
public func body(content: Content) -> some View {
content
.font(styleGuide.font.font)
.foregroundColor(styleGuide.color.color)
}
}
public extension Text {
@ViewBuilder func appTextStyleFor(
_ type: AppTextType, // (1)
appStyle: AppStyle // (2)
) -> some View {
if let style = appStyle.getTextStyle(for: type) {
modifier(AppTextModifier(styleGuide: styleGuide))
}
}
}
We need to provide two parameters to apply styling to a given Text
: the text type (1) and the current application style (2). There’s a convenience method defined in AppStyle
that extracts a portion of the style describing a particular type of Text
:
public extension AppStyle {
...
func getTextStyle(for labelType: AppTextType) -> AppTextModifier.StyleGuide? {
...
case .title:
return AppTextModifier.StyleGuide(
font: font ?? designSystem.fonts.title,
color: color ?? designSystem.colors.text500
)
...
}
...
}
As a result, we obtain a portion of the AppStyle
tailored specifically for this particular Text
type:
public extension AppTextModifier {
struct StyleGuide: Equatable {
public let font: AppFont
public let color: AppColor
public init(font: AppFont, color: AppColor) {
self.font = font
self.color = color
}
}
}
The final step is to transform our Design System
tokens (e.g. AppFont
and AppColor
) into properties that SwiftUI ViewModifier
can utilize. But I think we’ve already done that, haven’t we? Remember how we defined AppFont
when composing the Design System
?
public extension AppFont {
...
var font: Font {
.custom("\\(fontName.rawValue.capitalized)-\\(fontWeight.rawValue.capitalized)", size: fontSize)
}
}
And to use our newly defined text style we can call:
struct LobbyView: View {
let appStyle: AppStyle
...
var body: some View {
Text("Lobby View")
.appTextStyleFor(.title, appStyle: appStyle)
And just like that, all the pieces of the puzzle fell into place. We can now define the Design System
in a JSON file, use it to generate the actual Design System
, and finally apply it to the UI components in the app. It’s both neat and elegant.
Establishing the Single Source of Truth for the app style
We’re making great progress! We’ve defined the Design System
and built the Style Guide
based on it. We’ve also implemented the code that translates UI component styles from a JSON file into a description usable in SwiftUI ViewModifiers
. However, the style we’re feeding to the UI components has one fatal limitation – it’s static.
So, how can we ensure that when the Design System
or the Style Guide
gets updated, the UI redraws itself?
Enter the AppStyleProvider
– a “Single Source of Truth” for the application style:
public protocol AppStyleProvider: Observable {
var appStyle: AppStyle { get }
func refreshStyles() async
}
and:
@Observable // (1)
public final class LiveAppStyleProvider: AppStyleProvider {
private let appStyleSynchroniser: AppStyleSynchroniser
public private(set) var appStyle: AppStyle // (2)
public init(appStyleSynchroniser: AppStyleSynchroniser, initialAppStyle: AppStyle) {
self.appStyleSynchroniser = appStyleSynchroniser
appStyle = initialAppStyle
}
}
As you can see, the AppStyleProvider
is surprisingly simple. And that’s exactly how it should be – a straightforward provider of up-to-date styles that SwiftUI Views
can utilize.
But how can we actually leverage this concept in our views?
The most straightforward approach is to inject the AppStyleProvider
directly into a View
. Since it’s Observable
(1 – above), any updates to the appStyle
property (2 – above) will trigger a redraw of the View
:
struct EmailPasswordLoginView: View {
let viewModel: EmailPasswordLoginViewModel
let appStyleProvider: AppStyleProvider
...
var body: some View {
ZStack {
VStack(spacing: 10) {
Text("Sign In")
.appTextStyleFor(.title, appStyle: appStyleProvider.appStyle)
...
Button("Send") {
...
}
.appButtonStyleFor(.primary, appStyle: appStyleProvider.appStyle)
...
}
}
...
}
}
While this approach is simple, it does have some drawbacks:
- It requires some boilerplate code to assign the style to a
View
. - The
AppStyleProvider
needs to be passed to eachView
we want to style.
Surely there’s a more elegant solution…
How about leveraging the powerful, built-in Dependency Injection system available in all SwiftUI Views? Yes, I’m talking about @Environment
. This powerful tool can greatly simplify our styling approach.
Let’s start by updating the AppTextModifier
to fetch its style directly from the source:
public struct AppTextModifier: ViewModifier {
public let textType: AppTextType
@Environment(\\.appStyleProvider) private var appStyleProvider
public init(textType: AppTextType) {
self.textType = textType
}
public func body(content: Content) -> some View {
...
}
}
… which substantially simplifies how we can use the modifier:
Text("Sign In")
.appTextStyleFor(.title)
The final step is to inject the AppStyleProvider
into the @Environment
. The ideal place for this is our top-level UI component – the App. By doing so, we ensure the app style cascades to all views downstream. Elegant, isn’t it?
@main
struct MyApp: App {
@StateObject private var appStyleProvider = AppStyleProviderKey.defaultValue
var body: some Scene {
WindowGroup {
ContentView()
.environment(\\.appStyleProvider, appStyleProvider)
}
}
}
and:
private struct AppStyleProviderKey: EnvironmentKey {
static let defaultValue: AppStyleProvider = LiveAppStyleProvider(
appStyleSynchroniser: LiveAppStyleSynchroniser(...),
initialAppStyle: AppStyle.defaultStyle
)
}
extension EnvironmentValues {
var appStyleProvider: AppStyleProvider {
get { self[AppStyleProviderKey.self] }
set { self[AppStyleProviderKey.self] = newValue }
}
}
Wow! It’s looking better and better! Not only we have a set of basic building blocks we can use to compose look and feel of our app, but we’ve also established a mechanism for dynamically updating it from the “Single Source of Truth” (the AppStyleProvider
).
But what if we want to take it a step further? What if we could style our app from a remote source, allowing for real-time updates to the app’s look and feel? Let’s explore how we can achieve this.
Implementing Remote App Styling
And finally, the last piece of the puzzle – being able to update the app style from an external source. We’ve come a long way!
Unfortunately, the app can’t solely rely on remote styling. It must be released with a built-in initial style. Countless scenarios could render the remote style unusable: poor internet connectivity, server-side file corruption, and more. In these cases, the app needs a default style to fall back on.
Having addressed that, let’s consider how the style update process would work. The simplest approach would be to replace the initial app style with the downloaded one, right? Correct, but is this the most efficient option? Do we really need to download the entire JSON file when it’s likely that updates would only affect certain sections?
Of course not! Let’s consider a more efficient approach:
- Have the backend generate only the updated or new sections of the app style document.
- Allow the app to download these updates during startup.
- Merge the initial style document with the downloaded updates.
- If any of these steps fail, fall back to using the initial app style for UI rendering.
Makes sense, right? Let’s implement that:
public final class LiveAppStyleSynchroniser: AppStyleSynchroniser {
...
@MainActor public func synchroniseStyles(currentStyle: AppStyle) async -> AppStyle {
do {
let request = FetchStylesUpdateRequest()
let response = try await networkModule.perform(request: request)
return update(currentStyle: currentStyle, with: response.data)
} catch {
print("🔴 Style update download error: \\(error)")
return currentStyle
}
}
}
And now we can extend our AppStyleProvider
with capacity to synchronize style from the external source of truth:
extension LiveAppStyleProvider: AppStyleProvider {
public func refreshStyles() async {
appStyle = await appStyleSynchroniser.synchroniseStyles(currentStyle: appStyle)
}
}
Hold on a minute… We’ve covered fetching style updates from the web, but how exactly do we merge these updates with the existing style? There are a couple of ways to do this. My personal favorite? Good old-fashioned JSON merging:
public enum JSONMerger {
public static func mergeDictionary(
_ base: [String: AnyHashable],
with merging: [String: AnyHashable],
restrictedKeys: [String]
) -> [String: AnyHashable] {
var baseCopy = base
for (key, value) in merging { // (1)
if let baseValue = base[key],
let baseValueDict = baseValue as? [String: AnyHashable],
let mergingValueDict = value as? [String: AnyHashable],
!restrictedKeys.contains(key) { // (4)
baseCopy[key] = mergeDictionary( // (3)
baseValueDict,
with: mergingValueDict,
restrictedKeys: restrictedKeys
)
} else {
baseCopy[key] = value // (2)
}
}
return baseCopy
}
}
However, there’s one important exception – enum
cases with associated values. For example, let’s look at how we define an app button’s background shape:
public enum AppButtonShape: Equatable, Codable {
...
case roundedRectangle(cornerRadius: CGFloat)
case circle
}
… encodes into:
"buttonBackgroundShape": {
"roundedRectangle": {
"cornerRadius": 8
}
}
or this:
"buttonBackgroundShape": "circle"
As a result, when updating a style property defined as an enum
, we must assume we’ll need to replace it entirely with the updated value, regardless if it’s a dictionary or not. We can achieve this by defining a set of “restricted keys” (4). Whenever the JSONMerger
encounters such a key, it simply replaces the initial style property with the updated one.
When you think about it, this makes perfect sense. If we ever wanted to change the background of a given app button, we’d most likely want to replace the entire background definition (e.g., from a circular one to a rounded rectangle, etc.).
Finally, we can decode the resulting JSON into the new AppStyle
:
private extension LiveAppStyleSynchroniser {
func join(currentStyle: AppStyle, with styleUpdateData: Data?) -> AppStyle {
let currentStyleDict = currentStyle.dictionary
let styleUpdateDict = try? JSONSerialization.jsonObject(with: styleUpdateData ?? Data(), options: [])
if let currentStyleDict = currentStyleDict as? [String: AnyHashable],
let styleUpdateDict = styleUpdateDict as? [String: AnyHashable] {
let mergedStyleDict = JSONMerger.mergeDictionary(
currentStyleDict,
with: styleUpdateDict,
restrictedKeys: JSONMerger.RestrictedStyleKeys.allCases.map(\\.rawValue)
)
let mergedStyleData = try? JSONSerialization.data(withJSONObject: mergedStyleDict, options: [])
do {
let mergedStyle = try JSONDecoder().decode(AppStyle.self, from: mergedStyleData ?? Data())
return mergedStyle
} catch {
logWarning("🔴 AppStyleSynchroniser:update() - Failed to decode merged style json: \\(error)")
return currentStyle
}
}
return currentStyle
}
}
As a result, we’ve created a simple, scalable, and robust method for updating the app’s style from an external source of truth!
Summary
Congratulations! Your app’s UI is now driven by the Design System and Style Guide. Branding changes have become a breeze to implement. Even better, these changes can be applied remotely, leveraging the Single Source of Truth concept. You can now tailor the UI for different users based on factors like location or demographics, or conduct UI experiments through A/B testing. The sky is the limit!
However, we must revisit the question posed at the beginning of this blog post: “If it’s so simple, why hasn’t everyone implemented it?”. At this point, I think you already know the answer. While implementing remote styling in a mobile app might seem simple, it’s definitely not an easy task…
So, is there anything we can do to maximize our chance of succeeding in implementing the dynamic app styling? Sure!
Start integrating dynamic styling into your app architecture early on. You don’t need to implement everything at once – begin with the Design System
, then add the Style Guide
. Most experienced designers will likely develop some form of Design System
anyway. Next, focus on implementing the App Style Provider
to ensure your app views follow a “Single Source of Truth“. When convincing stakeholders, compare it to replacing an analytics client in a working application. The earlier you implement this in development, the fewer views you’ll need to update later. If there are concerns about regressions, consider adding Snapshot Tests for your views before making changes.
What scope should we consider for the MVP? At first glance, the vast majority of style parameters seem important. After all, how could you efficiently handle app margins and padding without a set of reusable spacings? But if you look closer, what real business case would actually require changing margins or padding in a view? Exactly… Being pragmatic is key here. Invest time asking your Product Owner about practical use cases where they would modify app style remotely. These – and only these – should define the MVP scope of dynamic app styling.
Finally, let’s discuss the drawbacks of dynamic styling. Obviously, the Dynamic Styling might only be suitable for some apps. As we discussed, implementing a fully dynamic style guide costs a lot in time and resources. Moreover, this cost often must be paid up-front, before the app starts generating substantial revenue. As a result, few businesses would rationalize such an expense. A less obvious disadvantage is the potential App Store compliance risk. Dynamic styling can sometimes conflict with app submission guidelines, particularly regarding app appearance consistency (section 2.3.1). Significant visual changes through remote updates might lead to app rejection or removal if they deviate too much from the submitted screenshots. Dynamic styling also adds another layer of complexity to the app architecture, requiring additional testing, error handling, and fallback mechanisms. Teams must maintain both local and remote style configurations and ensure they stay synchronized. This doesn’t even account for the additional services needed to synchronize app styles across platforms.
Is it worth implementing the dynamic styling after all? I’m afraid only you can answer this question…
Don’t miss any new blogposts!
By subscribing, you agree with our privacy policy and our terms and conditions.
Disclaimer: All memes used in the post were made for fun and educational purposes only
They were not meant to offend anyone
All copyrights belong to their respective owners
The thumbnail was generated with text2image
Some images were generated using Leonardo AI
Audio version generated with TTS Maker
The post How to Create Dynamic iOS Apps: Remote Styling with SwiftUI first appeared on Swift and Memes.