PREFACE
This article draws its inspiration from a project involving a fully custom design system. It’s essential to clarify that the concepts discussed here are tailored for such cases. In a fully custom design system, we diverge from the typical Material Design guidelines, sidestepping conventions like primary/secondary etc. schemes and Google’s material typography system. Instead, we align ourselves closely with the unique designs provided by our design team, complete with their own token names for colors, typography, and more. While these ideas may not be universally applicable, they can still provide valuable insights.
If you’re interested in creating your fully custom design system, I recommend referring to the Android documentation and exploring various resources, including insightful Medium articles. Occasionally, a peek into the Material Library’s source code can also offer inspiration on how Google handles certain design aspects.
INTRODUCTION
To illustrate the concepts discussed in this article, we’ll begin with a straightforward example. Our focus here isn’t on implementing complex composables, but rather on understanding how to style them effectively. We’ll implement a simple view, which looks like this:
This view comprises six distinct composables:
- Secondary —a top-level component requiring different styling for its subcomponents (TertiaryCard).
- Hero — a top-level component requiring different styling for its subcomponents (TertiaryCard).
- TertiaryCard (red)— which further breaks down into smaller composables:
- SubtertiaryCard (green)
- MediatorCard (cyan) — a composable that doesn’t require any styling
- SubsubtertiaryCard (style-specific background)
The scenario we’re examining unfolds as follows:
TertiaryCard can be nested within either the Secondary or Hero card, and its styling should dynamically adapt to its parent card. In order to illustrate this, we intend to apply distinct TextStyle styles to each component when placed within Secondary or Hero cards. Additionally, we’ll showcase configuring a unique background color for the SubsubtertiaryCard composable when it’s nested in a composables tree of either inside a Secondary or Hero component.
As depicted in the provided design image, the hero card will essentially feature a reversed font size for its children compared to the secondary card.
Throughout this article, you’ll encounter the term “Composables Tree” or “Ui tree”, which refers to the tree-like structure of composables that describe our UI. Each tree consists of a root node (either Hero or Secondary) and internal nodes like Tertiary -> Subtertiary -> Mediator -> Subsubtertiary (technically, a leaf node, representing the bottom-level composable).
Styling by arguments
This represents the fundamental approach, which entails incorporating style-related parameters directly into the @Composable function. It is the most direct and uncomplicated method. Furthermore, this approach can be combined with other strategies to enhance reusability.
While it’s possible to reduce the number of parameters by encapsulating specific style-related properties in data classes, Jetpack Compose guidelines discourage this approach:
“Express dependencies in a granular, semantically meaningful way. Avoid grab-bag style parameters and classes, akin to ComponentStyle or ComponentConfiguration.”
Moreover, in an ideal scenario, we wouldn’t need to pass the background parameter (subsubBackgroundColor argument) down to SubsubTertiaryCard. However, in this approach, it’s unavoidable unless we pass the modifier from the top of the tree to SubsubTertiaryCard. But since modifiers should be applied to the top composable in the function, passing multiple modifier parameters is not recommended, as functions MUST NOT accept multiple Modifier parameters.
Advantages:
- Simple and straightforward.
- Reusability: The theming logic is encapsulated within each Composable, making it easy to reuse these components across different parts of your app. You can use the same Composable with different themes, reducing redundancy.
- Modularity: The code is highly modular, with each Composable being responsible for its own theming. This makes it easier to manage and modify styles for individual components without affecting the entire UI.
- Flexibility: This approach allows you to fine-tune the theming of each component independently. You can customize fonts, colors, and background colors at different levels of the hierarchy.
- Explicit Theming: Theming is explicit, as you pass in the desired text styles and background colors as arguments to each Composable. This makes it clear which styles are applied to each component.
Disadvantages:
- Complexity: The code can become quite complex and verbose, especially when you have a deep hierarchy of components. Managing and passing down multiple text styles and background colors can be error-prone and hard to maintain. May also result in a high number of parameters. Also, passing modifiers down the tree can become cumbersome.
- Readability: The code can become less readable when you have a large number of arguments being passed to each Composable. It may be hard to understand the purpose of each argument without detailed documentation.
- Maintainability: As your app grows, managing and updating styles for multiple components can become challenging. It might be difficult to ensure consistent theming throughout the app, and changes to styles may require modifications in many places.
- Limited Theming Scope: While this approach allows for fine-grained theming at the component level, it may not be suitable for global theming changes across the entire app. Making global theming changes would require modifying each Composable individually.
(Slots-based) Caller as the main factory
In this example, we rely on higher-order @composable functions. The top-level component, acting as the root node, defines the appearance of each of the composables down the tree.
Advantages:
- Simplified Parameter Passing: This approach simplifies parameter passing by using lambda functions. You don’t need to explicitly pass multiple text styles and background colors as arguments for each Composable, which can lead to cleaner and more concise code.
- Hierarchical Structure: The code follows a hierarchical structure that reflects the visual hierarchy of components, making it easier to understand the layout and relationships between components.
- Reduced Boilerplate: There’s less boilerplate code because you don’t need to explicitly specify styles and background colors at each level. The modifier and styling are applied directly within each Composable. Offers a clean separation of concerns between styling and composable functions.
- Clearer Composition: The code emphasizes the composition of components, making it clear how each component is constructed and nested within others. This can improve code readability and maintainability.
Disadvantages:
- Limited Theming Flexibility: While this approach simplifies parameter passing, it can be less flexible for complex theming scenarios. If you need to customize styles at different levels within a Composable, you may need to refactor the code to accommodate those changes.
- Readability Complexity: The use of lambda functions for nesting Composables can become complex and less readable as the hierarchy deepens. It may be challenging to keep track of the nesting, especially if there are many components. That requires careful design to ensure the root component handles styling consistently.
- Limited Reusability: While this approach simplifies parameter passing, it may limit the reusability of individual components. Components that rely on nested lambda functions may be less portable and reusable in other parts of the app if we approach this in a wrong way.
- Difficulty with Global Theming: Making global theming changes that affect all components may still require modification of each Composable, as there is no centralized theming configuration.
Separate composable for specific styling (google recommended)
This approach aligns with Jetpack guidelines, recommending separate Composable functions for each styling variant instead of passing style-related parameters or ComponentStyle classes to a Composable. Yes, this means having distinct Composable functions for each Composable further down the tree.
Of course, we can mix it with styleable arguments and create composable wrappers for specific variants, so that we can enhance component reusability while minimizing code duplication.
Advantages:
- Aligns with Jetpack guidelines for library development.
- Simplified Styling: This approach simplifies styling by providing dedicated Composables for each level of the hierarchy. You only need to specify the modifier and style at the top level of each hierarchy, which reduces redundancy and makes the code cleaner.
- Component Specific Styling: Each Composable has its own background color, which makes it easy to customize the styling of individual components. The background color is set at the top level, ensuring consistency within each hierarchy.
- Hierarchical Structure: The code follows a clear hierarchical structure that reflects the visual hierarchy of components. This makes it easier to understand the layout and relationships between components.
- Reusability: The Composables are modular and can be easily reused in different parts of your app. They encapsulate the theming logic, which promotes reusability.
- Performance: This approach is less likely to introduce performance overhead, as you’re not passing complex parameter sets between Composables. Each Composable receives its styling at the top level.
Disadvantages:
- Limited Theming Flexibility: While this approach simplifies styling, it may not provide as much flexibility for customizing text styles and background colors at different levels of the hierarchy. Changes to these styles would require modifications at the top level of each hierarchy or creating another variant.
- Explicit Background Colors: The background colors are explicitly set at the top level of each hierarchy. This can be beneficial for component-specific theming but may become repetitive if you have many similar components with the same background color.
- Maintainability: As the app grows, managing and updating styles for multiple components can become challenging. You’ll need to modify each Composable individually to make global theming changes. Also, creating distinc composable functions for each styling variant is potentially leading to a larger codebase.
- Less Flexibility for Conditional Styling: If you need to conditionally apply different styles to components based on runtime conditions, this approach may require additional logic to handle such scenarios.
- Code Duplication: There is some code duplication for setting background colors in each SubsubTertiaryCard Composable, although it can be justified for component-specific theming.
CompositionLocal
Lastly, CompositionLocal is a valuable tool for passing data through the composables tree. It eliminates the need to pass data explicitly through each composable while ensuring it’s accessible within the entire UI tree. As the documentation states, “CompositionLocal is a tool for passing data down through the Composition implicitly.”
Jetpack compose guidelines discourage using this approach, especially when working on a library, where avoiding implicit inputs provided via CompositionLocal might be essential, as it makes it hard to track where the customisation comes from for users. However, when working on an app this approach may be used.
Lets see the example:
We’ve defined style-related data classes with default values and linked these classes with a LocalThemingExample composition. We’ve also configured styling for specific variants using ReadOnlyComposable functions like secondaryStyle() or heroStyle(). We then make these styles available throughout the composables tree by wrapping the top-level composable with:
CompositionLocalProvider(
LocalThemingExample provides secondaryStyle()
) {
// composables added from this point as root will have access to LocalThemingExample
LocalThemingExample.current.tertiaryTextStyle
}
It would be advantageous if we could somehow limit the scope of LocalThemingExample definitions to specific packages and maintain a well-structured package hierarchy. Unfortunately, Kotlin only provides the ‘internal’ visibility modifier for modules, not packages.
Advantages:
- Centralized Theming Configuration: Theming data is centralized in a
ThemingDataClass
, making it easy to manage and update styles for different parts of the app. This approach promotes consistency in theming. - Cleaner Composable Code: The Composable functions are simplified because they don’t need to accept multiple styling parameters. They can access theming data through
CompositionLocal
. - Hierarchical Structure: The code still maintains a clear hierarchical structure that reflects the visual hierarchy of components, which aids in understanding the layout and relationships between components. It also keeps styling data accessible within the entire UI tree.
- Reusability: Composables can be easily reused with different theming configurations by changing the data provided through
CompositionLocalProvider
. - Flexibility for Customization: This approach provides flexibility for customization at different levels of the hierarchy. You can easily change text styles and background colors by modifying the
ThemingDataClass
and providing different instances throughCompositionLocalProvider
.
Disadvantages:
- Additional Complexity: Introducing
CompositionLocal
adds some complexity to the code, which may not be immediately intuitive for those unfamiliar with the concept. Developers need to understand how to useCompositionLocal
effectively. - Potential for Misuse: While centralizing theming data can be an advantage, it also allows for potential misuse if developers modify the theming data directly in a Composable. This can lead to unintended consequences.
- Performance Overhead: Accessing data through
CompositionLocal
may introduce a small performance overhead compared to direct parameter passing. However, this overhead is typically negligible. - Limited Theming Scope: While this approach simplifies theming for components within a hierarchy, it may not be the best choice for global theming changes across the entire app. Making global theming changes may require modifying the
ThemingDataClass
and all relevantCompositionLocalProvider
instances. - Readability: The code can become less readable when you have multiple
CompositionLocalProvider
instances, especially in deeply nested structures. It may be challenging to trace the source of theming data.
Last Word
In summary, choosing the right approach for styling composable variants in Jetpack Compose depends on your project’s specific requirements and constraints. If simplicity and directness are prioritized, styling by arguments may suffice. However, for larger and more complex projects, following Google’s recommendation of using separate composables for specific styling or relying on CompositionLocal for app development can lead to more maintainable and modular code. Careful consideration of your project’s needs will help you make an informed decision on which approach to adopt.
Imho mix of styleable arguments with a local composition is my fav.