Skip to content

Theming system

Isabella edited this page Aug 7, 2025 · 22 revisions

Theming system

The theming system aims at providing an easy way for developers to find and use colors from designs inside the application. Any colors available inside the Firefox application will be one of the available Firefox colors. Designers then decide which of those colors will be used in their designs, and create different mobile styles. Mobile styles defines how theming is applied inside the application, for example by applying a certain color for actions or icons. Any colors we use in our application needs to be defined in the iOS mobile theme. There's currently light theme and dark theme in our application, but more could eventually come.

How theming works

The ThemeManager manages and saves the current theme. By default it's set to be the OS theme (light or dark) but can be changed overridden by the user in the settings either through brightness or selecting directly the wanted theme. Another way to set the theme is through night mode. By selecting night mode we automatically apply the dark theme on the application (as well as in webviews but that is related to night mode and not the theme itself).

Don't

  • Don't add tokens for new colors in the Theme protocol if it's not in the iOS mobile theme.
  • Don't use Firefox colors directly in code (aka, no FXColors.aColor should ever be referenced directly. Colors needs to be themed).
  • Don't set colors by checking the theme type. Ex: If theme == dark then use color #123456

Do

  • Do ask designers if a color in a design you need to implement is not in the mobile theme. This comment also applies if colors are in the mobile theme but under different tokens - i.e. the designer wants to use one token for light theme and another token for dark theme (this is a no-no).
  • Do use .clear color directly in code, this color isn't themed.
  • For any image that requires theming, use ThemeType getThemedImageName(name:) method. Your image might need renaming to follow convention of adding _dark into the name of a dark themed image. Please use ImageIdentifiers for image name.

How to implement theming in code

A. UIView & UIViewController (UIKit)

Conforming to Themeable and implementing the protocol requirements automatically helps your view subscribe to theme updates in the app (e.g. light mode, dark mode, private mode).

Step 1: Conform to Themeable

Your UIViewController- or UIView-inheriting type should conform to Themeable.

class MyViewController: UIViewController, Notifiable { 
	...
	init(notificationCenter: NotificationProtocol = NotificationCenter.default) {
            ...
            self.notificationCenter = notificationCenter
        }
}

Upon initialization of your type, you should also pass in a NotificationProtocol, which will be used by the Themeable listenForThemeChanges() method. A NotificationProtocol is a thin wrapper for NotificationCenter.default which allows unit tests to mock out the notification center.

Step 2: Add the required properties

You will also need to add the protocol's required properties to your class:

var currentWindowUUID: WindowUUID?
var themeManager: ThemeManager
var themeListenerCancellable: Any?

Optionally, you may override the following properties, which already have default values of false:

var shouldUsePrivateOverride: Bool
var shouldBeInPrivateTheme: Bool

Step 3: Add the required methods

Next, you will need to implement the applyTheme() method. In this method you should apply the current theme to the view hierarchy. For example:

public func applyTheme() {
	let theme = themeManager.getCurrentTheme(for: currentWindowUUID)
	
	titleLabel.textColor = theme.colors.textPrimary
	descriptionTextView.textColor = theme.colors.textSecondary
	descriptionTextView.backgroundColor = .clear
}

Use applyTheme() to setup apply the theme colors to any labels, buttons, background views, spinners, borders, shadows colors, etc.

Step 4: Setup theme observation

And lastly, you need to set up the observers for theme changes.

You will normally call the following in a UIViewController's viewDidAppear method, or a UIView's init method. For example:

override public func viewDidLoad() {
	super.viewDidLoad()
	setupLayout()

	listenForThemeChanges(withNotificationCenter: notificationCenter)
	applyTheme()
}

📝 It is not necessary to remove the theme observer upon deinit.

Step 5: Conform child views to ThemeApplicable

Views held inside a Themeable UIViewController should conform to the ThemeApplicable protocol, which also requires an applyTheme() method implementation.

When a theme update is posted to the NotificationCenter, these views will also automatically update to the new theme.

❗ The developer is responsible for calling applyTheme() once upon child view or cell initialization/configuration in order to set the correct starting theme.

Note: Make sure you don't resolve the themeManager object from UIViews.

Multi-window on iPad

For some additional information about theming with multi-window on iPad, see the section below on supporting multi-window.

B. SwiftUI View

SwiftUI theming works slightly differently by leveraging Apple’s environment values struct (EnvironmentValues). We define values in the struct and to read a value from the structure, we declare a property using the Environment property wrapper and specify the value’s key path.

Note: This is currently only available in iOS 14.0 hence the use of if #available(iOS 14.0, *) below.

Below is a sample with steps on how to add theming for SwiftUI.

import Foundation
import SwiftUI
import Shared

struct SampleView: View, ThemeApplicable {
    /// Step 1:
    /// Add SwiftUI Theming using `themeType` Environment variable
    @Environment(\.themeType) var themeVal

    /// Step 2:
    /// Add required state variables for associated colors that require
    /// theme updates and define default value
    @State var btnColor: Color = .green

    var body: some View {
        VStack(spacing: 11) {
            Button("SampleButton") {
                // Do some work
                print("Sample Button Pressed")
            }
            /// Step 3:
            /// Assign state color variables to the view
            .foregroundColor(btnColor)
        }
        .onAppear {
            /// Step 4:
            /// If there is an initial theme update then we perform it here
            applyTheme(theme: themeVal.theme)
        }
        .onChange(of: themeVal) { val in
            /// Step 5:
            /// When the themeType gets updated we
            /// listen to the change via `onChange` method
            /// attached to the whole view and perform
            /// required updates to the state color variables
            applyTheme(theme: val.theme)
        }
    }

    /// Step 6:
    /// For better cleanup add applyTheme method using ThemeApplicable protocol
    func applyTheme(theme: Theme) {
        let color = theme.colors
        btnColor = Color(color.actionPrimary)
    }
}

Supporting Multiple iPad Windows

Firefox on iPadOS now supports multiple browser windows. In order for new UI (and its related theme updates) to work across multiple windows, there are few considerations to keep in mind.

The general theme settings (Light-vs-Dark, System-vs-Manual etc.) apply to all iPad windows app-wide. However, the visual UI changes for private vs non-private browsing are also handled via the ThemeManager. Because private browsing is a "per-window" feature (any individual window can enter private browsing separately from another), it means that when your UI is updated for a theme change, it now needs to know which window it belongs to.

In general, most new UI should not require any special changes to work across multiple windows. However, there are a few important things to know:

  • In order for a view to be updated correctly by listenForThemeChange (and updateThemeApplicableSubviews, which in turn calls applyTheme()), the views need to know which window (UUID) they will be presented in.
  • This UUID is supplied via the currentWindowUUID property defined by the ThemeUUIDIdentifiable protocol.
  • Support for this is provided automatically for all UIViews via an extension (UIView+ThemeUUIDIdentifiable.swift), but it requires that the view is installed in a UIWindow. (It does not have to be visible, just part of the view hierarchy.)
  • In cases where your view may not be installed in a UIWindow at the time of a theme change, the UUID will be obtained in one of two other ways:
    • The view controller that subscribes the view to listenForThemeChange will be queried via its currentWindowUUID property
    • or the view itself may adopt the InjectedThemeUUIDIdentifiable protocol to provide an explicit UUID (typically injected)

History of Themeable for UIKit

As of the summer of 2025, the Themeable protocol's underlying implementation was updated to resolve Swift 6.0 strict concurrency errors related to under-specified protocol conformance. View controllers and views are now isolated to the main actor, so the compiler was not happy that the properties and methods of Themeable were not also explicitly isolated.

The previous implementation of Themeable used one of the older NotificationCenter addObserver() methods (see the Apple docs), which required developers to remember to call removeObserver() before their class was deallocated. In practice, developers rarely remembered to do this, and when they did, it was not done correctly (e.g. calling removeObserver() on self instead of on the themeObserver property registered by the protocol).

The new implementation of Themeable no longer causes strict concurrency warnings for Swift 6.0, and it also does not require developers to remember to call removeObserver() when a listening type is deallocated.

📝 In the future, Themeable's underlying implementation should be improved, especially for use with SwiftUI.

Background Implementation Details of Themeable for UIKit

The old implementation of Themeable required developers to remember to remove the theme observer when the conforming class was deallocated. The new Themeable implementation has changed its underlying theme observer implementation to use the Combine variant of addObserver() (see the Apple docs). Now, a developer only has to store the publisher's themeListenerCancellable property and does not have to remember to remove the observer before the observing type is deallocated. The theme observation ends automatically when the parent type is deallocated and the Cancellable no longer holds a strong reference to the publisher.

You might also note that the themeListenerCancellable is an Any type instead of a Cancellable. This type erasure avoids developers having to import Combine in our view-related files.

Clone this wiki locally