At its core, Decouple uses context receivers to allow Composable functions to declare compile-time dependencies on other composables they expect to be available. These dependencies are satisfied using regular Kotlin interfaces, allowing applications to easily substitute some components by an alternative version without having to rewrite all components. Because Decouple is interface-based, and not expect
/actual
-based, it is possible to provide multiple implementations of components for a single platform, allowing to build a white-label applications that share their entire codebase but have an entirely different look-and-feel.
A multiplatform component library cannot be as low-level as Jetpack Compose: each platform is implemented completely different, and features of one platform are not necessarily available on other platforms (for example, the concept of Modifier cannot be implemented efficiently on top of CSS). Yet, we still want each platform to provide accessibility guarantees. To achieve this goal, we split the UI creation code in two independent layers:
-
The design layer provides generic components with semantic meaning (e.g.
Button
,SubmitButton
,CancelButton
…) which are implemented differently on each platform to take advantage of platform-specific styling. -
The application layer is responsible for domain-specific components (e.g.
UserList
,PostEditor
), which are created by composing components from the design layer. Because the application layer does not have access to platform-specific details, design layer implementations can be swapped to build the same application on different platforms and visual identities.
To avoid a dependency of the application layer on a specific design layer, Decouple provides interfaces describing components useful in most applications. A user of the library can combine these interfaces to opt in to the components they want to use. Decouple also provides ready-to-use design layers to help developers get started writing multiplatform apps before their design system is formally described, knowing they will be able to painlessly swap component implementations without impacting the application layer later in the development process.
Between these layers, Decouple is in a privileged place to add common functionality to all design systems. For example, Decouple adds automatic progress reporting to any component's events based on coroutines (e.g. a button's onClick
is suspend
and automatically displays a loading indicator while the operation is not done). Decouple also provides a headless testing library used to test the behavior of the application layer and its responses to events, which is a good supplement to platform-specific snapshot testing (efficient to test the design system and accessibility, but inconvenient to test the user's workflow and the validity of displayed data).
Decouple also provides opt-in utilities (called "extras") to interface with other tools useful for all Compose-based projects, such as multiplatform wrappers on top of persistent storage, to remember a page's state between sessions, or helpers to use Arrow error-handling in Compose component events.
Ultimately, Decouple is only limited in the platforms it can support by what Kotlin itself support, and what the Compose compiler and runtime support. In theory, it is possible to create Decouple applications that run on GTK or Qt for Linux-native applications, etc. Of course, these implementations are a big undertaking, and are not planned for the short-term (we do welcome contributions!).
If context receivers are available on all platforms we support when you read this, please report to us that we forgot to update this page!
Until context receivers are available, we are emulating them with composition locals. Composition locals are not type-safe (components can not declare compile-time dependencies on a subset of the available components), and force the existence of a god-interface, UI
, which declares all components of Decouple. This interface has the major downside that any modification to any component (including the introduction of new ones) is a binary breaking change for all users of Decouple, as well as source breaking change for all implementations of the UI
interface. It also stops downstream users to declare their own components (since they would need to be added to the UI
interface).
When context receivers are available, UI
will be removed (or at least discouraged) and all stateful component variants will declare their dependencies using context receivers. Each project using Decouple will be expected to declare its own interface declaring which components it plans to use (implementing them by delegation to provided or custom implementations), thus ensuring component additions in Decouple itself will not lead to breaking changes in user projects, as users can decide when (and if) they introduce them to their component surface.