-
Notifications
You must be signed in to change notification settings - Fork 176
Design Tokens
Design tokens come in three tiers.
- Global tokens are effectively friendly names for constants. These have no semantic meaning on their own.
- Example:
grey 64 = #A3A3A3
,medium icon size = 24 pt
- Example:
- Alias tokens are imbued with semantic meaning. They should be derived from global tokens. Color-based alias tokens will have a number variants for different contexts (e.g. light mode, dark mode, high contrast, etc) and may vary from endpoint to endpoint.
- Example:
neutral background 2 = gray 98 @ light, gray 36 @ dark
- Example:
- Control tokens define how a given control draws itself using alias tokens. This is most likely to be defined in a platform-specific manner.
- Example:
button background = neutral background 2
Our Swift representation of tokens will reflect his hierarchy, with a unidirectional dependency hierarchy. Controls should only depend on Control tokens, without ever directly referring to an alias or global token.
- Example:
Our clients will need to customize these components in two different levels.
The first and simplest level is brand coloring. These apps all use identically-themed controls, with app-appropriate brand coloring replacing our standard blue. This will be provided by a simple color customization API, as described below.
The second, more interesting (and more challenging) level, is a richer themeing support required by some partners. These apps will require near-total customization of components, from colors to fonts to corner radiuses.
To enable this, the aforementioned Control Token classes will be fully customizable, with every property being individually replaceable. See below for more details.
Let’s start with the basic definition of our global tokens. Below is a sample subset of various types of global tokens.
public final class GlobalTokens {
// MARK: - BrandColors
public enum BrandColorsTokens: CaseIterable {
case primary
case shade10
case shade20
case shade30
case tint10
case tint20
case tint30
case tint40
}
lazy public var brandColors: TokenSet<BrandColorsTokens, ColorSet> = .init { token in
switch token {
case .primary:
return DynamicColor(light: ColorValue(0x0078D4), dark: ColorValue(0x0086F0))
case .shade10:
return DynamicColor(light: ColorValue(0x106EBE), dark: ColorValue(0x1890F1))
case .shade20:
return DynamicColor(light: ColorValue(0x005A9E), dark: ColorValue(0x3AA0F3))
case .shade30:
return DynamicColor(light: ColorValue(0x004578), dark: ColorValue(0x6CB8F6))
case .tint10:
return DynamicColor(light: ColorValue(0x2B88D8), dark: ColorValue(0x0074D3))
case .tint20:
return DynamicColor(light: ColorValue(0xC7E0F4), dark: ColorValue(0x004F90))
case .tint30:
return DynamicColor(light: ColorValue(0xDEECF9), dark: ColorValue(0x002848))
case .tint40:
return DynamicColor(light: ColorValue(0xEFF6FC), dark: ColorValue(0x001526))
}
}
// MARK: - BorderRadius
public enum BorderRadiusToken: CaseIterable {
case none
case small
case medium
case large
case xLarge
case circle
}
lazy public var borderRadius: TokenSet<BorderRadiusToken, CGFloat> = .init { token in
switch token {
case .none:
return 0
case .small:
return 2
case .medium:
return 4
case .large:
return 8
case .xLarge:
return 12
case .circle:
return 9999
}
}
// MARK: Initialization
public init() {}
static let shared = GlobalTokens()
}
These tokens can be read or modified by creating an instance of GlobalTokens and using publicly-accessible subscript notation for any given TokenSet
:
let globalTokens = GlobalTokens()
globalTokens.brandColors[.primary] = ColorSet(light: 0xEFF6FC, dark: 0x001526)
Alias tokens are similar to global tokens, with one large change: they should reference global tokens whenever possible. Their goal is to provide semantic meaning for the raw values defined in the global system.
public final class AliasTokens {
// MARK: ForegroundColors
public enum ForegroundColorsTokens: CaseIterable {
case neutral1
case neutral2
case neutral3
}
lazy var foregroundColors: TokenSet<ForegroundColorsTokens, ColorSet> = .init { [weak self] token in
guard let strongSelf = self else { preconditionFailure() }
switch token {
case .neutral1:
return DynamicColor(light: strongSelf.globalTokens.neutralColors[.grey14],
lightHighContrast: strongSelf.globalTokens.neutralColors[.black],
dark: strongSelf.globalTokens.neutralColors[.white],
darkHighContrast: strongSelf.globalTokens.neutralColors[.white])
case .neutral2:
return DynamicColor(light: strongSelf.globalTokens.neutralColors[.grey26],
lightHighContrast: strongSelf.globalTokens.neutralColors[.black],
dark: strongSelf.globalTokens.neutralColors[.grey84],
darkHighContrast: strongSelf.globalTokens.neutralColors[.white])
case .neutral3:
return DynamicColor (light: strongSelf.globalTokens.neutralColors[.grey38],
lightHighContrast: strongSelf.globalTokens.neutralColors[.grey14],
dark: strongSelf.globalTokens.neutralColors[.grey68],
darkHighContrast: strongSelf.globalTokens.neutralColors[.grey84])
}
}
// MARK: BackgroundColors
public enum BackgroundColorsTokens: CaseIterable {
case neutral1
case neutral2
case neutral3
}
public lazy var backgroundColors: TokenSet<BackgroundColorsTokens, ColorSet> = .init { [weak self] token in
guard let strongSelf = self else { preconditionFailure() }
switch token {
case .neutral1:
return DynamicColor(light: strongSelf.globalTokens.neutralColors[.white],
dark: strongSelf.globalTokens.neutralColors[.black],
darkElevated: strongSelf.globalTokens.neutralColors[.grey4])
case .neutral2:
return DynamicColor(light: strongSelf.globalTokens.neutralColors[.grey98],
dark: strongSelf.globalTokens.neutralColors[.grey4],
darkElevated: strongSelf.globalTokens.neutralColors[.grey8])
case .neutral3:
return DynamicColor(light: strongSelf.globalTokens.neutralColors[.grey96],
dark: strongSelf.globalTokens.neutralColors[.grey8],
darkElevated: strongSelf.globalTokens.neutralColors[.grey12])
}
}
// MARK: Initialization
public init(globalTokens: GlobalTokens? = nil) {
if let globalTokens = globalTokens {
self.globalTokens = globalTokens
}
}
lazy var globalTokens: GlobalTokens = FluentTheme.shared.globalTokens
}
Reading and writing values works much the same way for Alias tokens as for Globals.
Control token sets are simply the collection of tokens used by a single control. These should utilize Alias tokens whenever possible, though it may be necessary to reference a global token in cases where no such alias exists.
A default implementation of the component’s theme will exist alongside the component, providing clients the opportunity to subclass and provide custom values for these tokens. Custom Control tokens can be provided for any control either upon instantiation or after presentation via a public state property.
open class CardNudgeTokens: ControlTokens {
open var accentColor: ColorSet {
return globalTokens.brandColors[.shade20]
}
open var accentIconSize: Int {
return globalTokens.iconSize[.xxSmall]
}
}
Tokens for controls contained within another control should be defined in the hosting control’s tokens as optional, then passed down as a custom subclass of the contained control’s tokens to the contained control through override tokens.
open class ListTokens: ControlTokens {
open var dividerColor: DynamicColor? { nil }
}
private class CustomDividerTokens: DividerTokens {
var listTokens: ListTokens
init (listTokens: ListTokens) {
self.listTokens = listTokens
super.init()
}
override var color: DynamicColor {
listTokens.dividerColor ?? super.color
}
}
As an app, the first step towards providing a custom Fluent UI appearance for your app is to subclass the specific Control theme object(s) that you wish to override. Want to change button corner radii? Subclass ButtonTokens and provide an alternate value. Once your custom tokens are available, they can be directly set on a control instance via a public property.
However, there is another way to set properties “globally”: FluentTheme
.
FluentTheme
represent a way to group together a custom set of tokens—global, alias, and control—and apply them holistically to a specific view hierarchy, window, or even the entire app.
For example, let’s say an app wants to remove all corner radii from Fluent. They can do so by overriding all BorderRadius properties on GlobalTokens
like so:
let globalTokens = GlobalTokens()
GlobalTokens.BorderRadiusToken.allCases.forEach { token in
globalTokens.borderRadius[token] = 0
}
Next, this custom instance of GlobalTokens can be used to initialize a FluentTheme:
let fluentTheme = FluentTheme(globalTokens: globalTokens)
The FluentTheme instance can then be attached to a rendering context such as a SwiftUI View or a UIWindow (see below for more details).
From here, our controls will fetch the appropriate FluentTheme from their current context and use its tokens to render. If no relevant tokens can be found, then we will fall back to the FluentUI default values.
Note that all approaches described in the following sections are fully interoperable. In fact, if your app mixes SwiftUI and UIKit, you’ll almost certainly need to implement both solutions. Feel free to provide the same custom theme implementation in both cases to conserve memory.
Our framework provides a simple ViewModifier
called fluentTheme(_:)
which takes a FluentTheme
instance as its only argument. Simply add this view modifier to the highest point in your view hierarchy in which you’d like the custom theme to apply, and we’ll take care of the rest.
public var body: some View {
VStack {
// ...
// some views that contain Fluent components
// ...
}
.fluentTheme(myCustomFluentTheme)
}
Custom themes can be set via a public extension on UIWindow
. Simply set the fluentTheme
property on the window you wish to host your components in, and once again we’ll take care of the rest.
See below for a rough idea of how it’s implemented internally:
protocol FluentThemeable {
var fluentTheme: FluentTheme? { get set }
}
extension UIWindow: FluentThemeable {
private struct Keys {
static var fluentTheme: String = "fluentTheme_key"
}
/// The custom `FluentTheme` to apply to this window.
public var fluentTheme: FluentTheme? {
get {
return objc_getAssociatedObject(self, &Keys.fluentTheme) as? FluentTheme
}
set {
objc_setAssociatedObject(self, &Keys.fluentTheme, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
NotificationCenter.default.post(name: .didChangeTheme, object: nil)
}
}
}