Themes
Doodle apps can use Theme
s to create a consistent look and behavior across their Views. Doodle has built-in support for a Native and Basic theme (which has a light and dark variant). The NativeTheme
(Web, Desktop) styles controls like buttons, text fields, and scroll panels using the system default styles and behaviors. The BasicTheme
provides a customizable foundation to further build on.
The following app shows a List of numbers that are visualized as custom Labels. These are all themed dynamically based on the OS dark vs light setting and the SpinButton control that manages which theme is selected.
Change the dark/light setting in your OS to see the app change in realtime.
- App
- Helpers
- Launcher
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.MultiSelectionModel
import io.nacular.doodle.controls.list.List
import io.nacular.doodle.controls.panels.ScrollPanel
import io.nacular.doodle.controls.toString
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.center
import io.nacular.doodle.docs.utils.controlBackgroundColor
import io.nacular.doodle.docs.utils.highlightingTextVisualizer
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.centered
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.theme.ThemePicker
import io.nacular.doodle.theme.basic.BasicTheme
import io.nacular.doodle.theme.basic.DarkBasicTheme
import io.nacular.doodle.theme.native.NativeTheme
import io.nacular.doodle.theme.plus
import io.nacular.doodle.user.UserPreferences
import io.nacular.doodle.user.UserPreferences.ColorScheme
import io.nacular.doodle.user.UserPreferences.ColorScheme.Dark
import io.nacular.doodle.utils.Dimension.Height
import io.nacular.doodle.utils.Resizer
class ThemeApp(
display : Display,
private val themeManager : ThemeManager,
nativeTheme : NativeTheme,
basicTheme : BasicTheme,
basicDarkTheme : DarkBasicTheme,
private val userPreferences: UserPreferences
): Application {
private val darkTheme = nativeTheme + basicDarkTheme
private val lightTheme = nativeTheme + basicTheme
// Monitor changes to the OS color scheme
private val colorSchemeChanged = { _: UserPreferences, _: ColorScheme, new: ColorScheme ->
themeManager.selected = when (new) {
Dark -> darkTheme
else -> lightTheme
}
}
private val themePicker = setupThemeControls()
private val list = List(
progression = 1..1000,
itemVisualizer = toString(highlightingTextVisualizer()),
selectionModel = MultiSelectionModel(),
fitContent = setOf(Height),
).apply {
cellAlignment = {
it.left eq 2
it.centerY eq parent.centerY
}
}
init {
display.children += themePicker
display.children += ScrollPanel(list).apply {
bounds = Rectangle(300, 200).centered(display.center)
contentWidthConstraints = { it eq this@apply.width - verticalScrollBarWidth }
Resizer(this).apply { movable = false }
}
display.layout = constrain(themePicker) {
it.top eq parent.bottom - it.height.readOnly - 10
it.centerX eq parent.centerX
}
display.fill(controlBackgroundColor.paint)
}
//sampleStart
private fun setupThemeControls(): ThemePicker {
userPreferences.colorSchemeChanged += colorSchemeChanged
themeManager.themes += setOf(lightTheme, darkTheme)
themeManager.selected = when (userPreferences.colorScheme) {
Dark -> darkTheme
else -> lightTheme
}
return ThemePicker(themeManager).apply { size = Size(280, 30) }
}
//sampleEnd
override fun shutdown() {
userPreferences.colorSchemeChanged -= colorSchemeChanged
}
}
package themes
import io.nacular.doodle.controls.IndexedItem
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.controls.theme.CommonLabelBehavior
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.text.StyledText
import io.nacular.doodle.theme.Modules.Companion.bindBehavior
import io.nacular.doodle.theme.basic.BasicTheme.BasicThemeConfig
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicThemeModule
import io.nacular.doodle.utils.PropertyObservers
import io.nacular.doodle.utils.PropertyObserversImpl
import io.nacular.doodle.utils.observable
import org.kodein.di.instance
import io.nacular.doodle.theme.basic.BasicTheme as BasicThemeType
//sampleStart
/**
* Visualizer that converts a String into a HighlightingLabel.
*/
fun highlightingTextVisualizer() = itemVisualizer<String, IndexedItem> { item, previous, context ->
when (previous) {
is HighlightingLabel -> previous.apply {
text = item
selected = context.selected
}
else -> HighlightingLabel(StyledText(item), context.selected)
}
}
/**
* Simple Label that tracks whether it is selected.
*/
class HighlightingLabel(styledText: StyledText, selected: Boolean): Label(styledText) {
val selectedChanged: PropertyObservers<HighlightingLabel, Boolean> by lazy { PropertyObserversImpl(this) }
var selected by observable(selected, selectedChanged as PropertyObserversImpl)
}
/**
* Custom module that installs a special Label Behavior into HighlightingLabels whenever the BasicTheme is selected.
* This behavior ensures the foreground is White whenever the label is selected, and the theme default otherwise.
*/
val highlightingLabelBehavior by lazy {
basicThemeModule(name = "HighlightingLabelBehavior") {
bindBehavior<HighlightingLabel>(BasicThemeType::class) { label ->
label.behavior = object: CommonLabelBehavior(textMetrics = instance()) {
private val selectedChanged = { _: HighlightingLabel, _: Boolean, _: Boolean ->
label.foregroundColor = foregroundColor
}
override val foregroundColor get() = when {
label.selected -> White
else -> instance<BasicThemeConfig>().foregroundColor
}
override fun install(view: Label) {
super.install(view)
label.selectedChanged += selectedChanged
}
override fun uninstall(view: Label) {
label.selectedChanged -= selectedChanged
super.uninstall(view)
}
}
}
}
}
//sampleEnd
package themes
import io.nacular.doodle.application.Modules.Companion.UserPreferencesModule
import io.nacular.doodle.application.application
import io.nacular.doodle.docs.apps.ThemeApp
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.theme.basic.BasicTheme.Companion.BasicTheme
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicButtonBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicLabelBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicListBehavior
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicSpinButtonBehavior
import io.nacular.doodle.theme.basic.DarkBasicTheme.Companion.DarkBasicTheme
import io.nacular.doodle.theme.native.NativeTheme.Companion.NativeTheme
import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeScrollPanelBehavior
import org.kodein.di.instance
fun launch() {
//sampleStart
val appModules = listOf(
BasicTheme,
NativeTheme,
DarkBasicTheme,
UserPreferencesModule,
basicListBehavior(),
basicLabelBehavior(),
basicButtonBehavior(foregroundColor = Black, insets = 12.0),
basicSpinButtonBehavior(incrementA11yLabel = "Increment", decrementA11yLabel = "Decrement"),
highlightingLabelBehavior,
nativeScrollPanelBehavior(),
)
application(modules = appModules) {
ThemeApp(
display = instance(),
basicTheme = instance(),
nativeTheme = instance(),
themeManager = instance(),
basicDarkTheme = instance(),
userPreferences = instance()
)
}
//sampleEnd
}
You will need to add the Themes
library to your app's dependencies.
- Kotlin
- Groovy
build.gradle.kts
dependencies {
implementation ("io.nacular.doodle:themes:$doodleVersion")
}
build.gradle
//sampleStart
dependencies {
implementation "io.nacular.doodle:themes:$doodle_version"
}
//sampleEnd
How themes work
Themes implement a simple interface that allows them to process the entire View graph and apply style and behavior changes. The API is as follows:
package themes
import io.nacular.doodle.core.View
import io.nacular.doodle.theme.Scene
import io.nacular.doodle.theme.Theme
//sampleStart
class MyTheme: Theme {
override fun install(scene: Scene) {
//
}
override fun install(view: View) {
//
}
}
//sampleEnd
Doodle calls the install
method when applying a Theme for the first time, and when Views are added to the Display after installation. It provides the Display
and a sequence of displayed Views. The Theme is free to customize both.
ThemeManager
Themes are handles by the ThemeManager
. It provides an API for selecting the active Theme. Inject it into your app to work with Themes.
package themes
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.theme.native.NativeTheme.Companion.NativeTheme
import org.kodein.di.instance
//sampleStart
class SomeApp(display: Display, manager: ThemeManager, theme: Theme): Application {
init {
manager.selected = theme
// ...
}
override fun shutdown() {}
}
fun main() {
application(modules = listOf(NativeTheme)) {
SomeApp(display = instance(), manager = instance(), theme = instance())
}
}
//sampleEnd
This app installs the NativeTheme
, which is available in a bundle of the same name. That bundle also includes the common ThemeModule
, which provides access to the ThemeManager
.
Bundle size
Themes can lead to larger bundle sizes than expected depending on how they are implemented. Take the following for example.
package themes
import io.nacular.doodle.controls.ProgressBar
import io.nacular.doodle.controls.buttons.PushButton
import io.nacular.doodle.controls.range.Slider
import io.nacular.doodle.core.View
import io.nacular.doodle.theme.Scene
import io.nacular.doodle.theme.Theme
//sampleStart
class NaiveTheme: Theme {
override fun install(scene: Scene) = scene.forEachView {
install(it)
}
override fun install(view: View) {
when (view) {
is Slider<*> -> { /* it.behavior = ... */ }
is PushButton -> { /* it.behavior = ... */ }
is ProgressBar -> { /* it.behavior = ... */ }
// ...
}
}
}
//sampleEnd
Themes defined this way are not very portable due to their heavy bundle cost
package themes
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.buttons.PushButton
import io.nacular.doodle.core.Display
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
//sampleStart
/**
* [NaiveTheme]'s static implementation leads to lots of dependencies that are
* not used in [SimpleApp]. This includes the View and Behavior classes it uses.
*/
class SimpleApp(display: Display, manager: ThemeManager, theme: Theme): Application {
init {
manager.selected = theme
display += PushButton()
}
override fun shutdown() {}
}
//sampleEnd
Dynamic themes
Doodle addresses this concern with the DynamicTheme
. This Theme uses dependency injection to discover the set of Behaviors that have been installed via Kodein Modules. It can filter that list down to those Behaviors associated with it. This avoids hard dependencies on Views or Behaviors as a result.
DynamicThemes require explicit Behavior registration to work. The built-in Themes define a Module per Behavior to allow arbitrary groupings within apps. These modules are defined using bindBehavior
, which takes a Theme
class and binds a DynamicTheme
that includes behaviors associated with that class.
package themes.dynamic
import io.nacular.doodle.application.application
import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeButtonBehavior
import org.kodein.di.instance
import themes.SimpleApp
//sampleStart
fun main() {
// DynamicThemes require a list of Behavior modules since the
// Theme itself is essentially a Behavior filter.
// These modules are added to the list of app modules as follows
application(modules = listOf(nativeButtonBehavior(), /* other modules */)) {
// The nativeButtonBehavior module also makes ThemeManager and Theme
// available by default; so there's no need to use additional modules
// for them.
SimpleApp(display = instance(), manager = instance(), theme = instance())
}
}
//sampleEnd
This app no longer has extraneous dependencies on things like ProgressBar
and its Behavior
.
Include behavior modules like this so they can be used as part of a dynamic Theme
.