Implementing Theming Engine for White-Label Mobile App
Static resources in xcconfig and flavors work when tenants known at compile-time and few in count. With 20+ tenants or runtime theme changes (seasonal sales or user selection), need dynamic Theming Engine—system applying design tokens to UI without recompile.
Design Tokens Concept
Theming Engine works with design tokens—named variables for all visual parameters: colors, fonts, sizes, corner radii, shadows. Loaded from config, applied globally.
Config structure (JSON from backend or bundled):
{
"tenant": "brand_b",
"version": "2",
"colors": {
"primary": "#1A73E8",
"secondary": "#FB8C00",
"background": "#FFFFFF",
"error": "#B00020"
},
"typography": {
"font_family": "Inter",
"scale_factor": 1.0
},
"shape": {
"card_corner_radius": 12,
"button_corner_radius": 8
}
}
Implementation on Android (Jetpack Compose)
Jetpack Compose makes dynamic theming significantly easier than XML: MaterialTheme accepts ColorScheme and Typography as parameters, applying to entire component tree.
data class TenantTheme(
val colors: TenantColors,
val typography: TenantTypography,
val shapes: TenantShapes
)
@Composable
fun TenantThemedApp(
theme: TenantTheme,
content: @Composable () -> Unit
) {
val colorScheme = lightColorScheme(
primary = Color(android.graphics.Color.parseColor(theme.colors.primary)),
secondary = Color(android.graphics.Color.parseColor(theme.colors.secondary)),
background = Color(android.graphics.Color.parseColor(theme.colors.background))
)
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
Usage in Activity:
setContent {
val theme by themeViewModel.tenantTheme.collectAsState()
TenantThemedApp(theme = theme) {
AppNavHost()
}
}
On tenantTheme change entire UI redraws automatically—main declarative advantage.
Runtime Font Loading
Custom brand fonts must load before first render. Downloadable Fonts API or manual via Coil:
class FontLoader(private val context: Context) {
suspend fun loadFont(fontUrl: String): Typeface? = withContext(Dispatchers.IO) {
val cacheFile = File(context.cacheDir, "fonts/${fontUrl.md5()}.ttf")
if (!cacheFile.exists()) {
downloadFont(fontUrl, cacheFile)
}
Typeface.createFromFile(cacheFile)
}
}
Font cached after first load—next start reads from cache without network request.
Implementation on iOS (SwiftUI)
struct TenantTheme {
let primary: Color
let secondary: Color
let background: Color
let cardCornerRadius: CGFloat
let buttonCornerRadius: CGFloat
let fontFamily: String
static let `default` = TenantTheme(
primary: .blue,
secondary: .orange,
background: .white,
cardCornerRadius: 12,
buttonCornerRadius: 8,
fontFamily: "SF Pro"
)
}
struct TenantThemeKey: EnvironmentKey {
static let defaultValue = TenantTheme.default
}
extension EnvironmentValues {
var tenantTheme: TenantTheme {
get { self[TenantThemeKey.self] }
set { self[TenantThemeKey.self] = newValue }
}
}
@main
struct MyApp: App {
@StateObject private var themeStore = ThemeStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.tenantTheme, themeStore.currentTheme)
}
}
}
Usage in any component:
struct PrimaryButton: View {
@Environment(\.tenantTheme) var theme
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.foregroundColor(.white)
.background(theme.primary)
.cornerRadius(theme.buttonCornerRadius)
}
}
}
No hardcoded colors in components—only via theme.
Runtime Theme Loading
class ThemeStore: ObservableObject {
@Published var currentTheme: TenantTheme = .default
func loadTheme(tenantId: String) async {
do {
if let cached = ThemeCache.load(tenantId: tenantId) {
await MainActor.run { currentTheme = cached }
}
let dto = try await api.fetchTheme(tenantId: tenantId)
let theme = TenantTheme(from: dto)
ThemeCache.save(theme, tenantId: tenantId)
await MainActor.run { currentTheme = theme }
} catch {
// Fallback to default, don't crash
}
}
}
Stale-while-revalidate pattern: show cached immediately, update in background.
React Native / Flutter
Flutter: ThemeData in MaterialApp parametrized similarly. Full control via InheritedWidget or Riverpod Provider. Runtime font loading via FontLoader API.
React Native: ThemeContext via React Context API, StyleSheet.create called with tokens. Hot-reload theme without restart via useContext(ThemeContext) in components.
Theme Versioning
Important: theme is versioned data. On contract update (new token added) old cached themes must validate:
data class ThemeDto(
val version: Int,
val colors: Map<String, String>
)
fun ThemeDto.toTenantTheme(): TenantTheme? {
if (version < MIN_SUPPORTED_VERSION) return null
return TenantTheme(
primary = colors["primary"]?.let { Color.parseColor(it) }
?: return null,
)
}
Invalid theme—fallback to bundled default, don't show broken UI.
Process
UI Audit: inventory all colors, fonts, radii. Identify hardcode.
Token Design: with designer—which params change between brands.
ThemeProvider Implementation: Environment-based application, API loading.
Component Refactoring: hardcode replacement with tokens. Visual test coverage.
Runtime Theme Switch Testing: all components redraw correctly, fonts load without flicker.
Timeline
Theming Engine for new app from scratch (Compose or SwiftUI)—2–3 weeks. Refactoring existing app with hardcoded colors—3–6 weeks depending on codebase. Cost calculated individually.







