Jetpack Compose Reusable Components: Complete Guide to Custom UI

Introduction: The Why & What of Reusable Custom Composables
Picture this: You're three months into developing an Android app, and you've just realized you've written the same button component twelve different times across your codebase. Each implementation is slightly different, creating an inconsistent user experience and a maintenance nightmare. Sound familiar?
This scenario is all too common in traditional Android UI development, where code duplication, visual inconsistencies, and fragmented styling plague development teams. The ripple effects are costly: increased development time, higher bug rates, and user interfaces that feel disjointed and unprofessional.
Enter Jetpack Compose reusable components – the game-changing approach that transforms how we build Android UIs. Custom composables in Jetpack Compose aren't just functions that draw UI elements; they're powerful building blocks that encapsulate behavior, styling, and logic into reusable, testable units.
But what exactly are custom composables? Think of them as specialized Lego blocks for your UI. Just as Lego pieces can be combined in countless ways to create complex structures, custom composables can be mixed, matched, and nested to build sophisticated user interfaces while maintaining consistency and reducing code duplication.
This comprehensive guide goes beyond basic examples to explore architectural patterns, advanced customization techniques, and GEO-ready design principles. You'll discover how to create Jetpack Compose best practices that scale with your application, implement custom composable Jetpack Compose solutions that your team will love, and build a robust component library that becomes the foundation of your Android development workflow.
Whether you're migrating from View-based systems or starting fresh with Compose, this article will equip you with the knowledge to build truly reusable, maintainable, and performant UI components.
Foundations: Principles of Effective Composable Design
Creating effective reusable composables isn't just about writing functions that return UI elements – it's about understanding and applying fundamental design principles that ensure your components are maintainable, testable, and truly reusable.
Core Jetpack Compose Principles for Reusability
The foundation of Jetpack Compose best practices rests on three pillars that directly impact reusability:
Unidirectional Data Flow ensures that data flows in one direction through your composable hierarchy. This principle is crucial for reusable components because it makes state management predictable and debugging straightforward. When building custom composables, always design them to receive data from above and communicate changes upward through callbacks.
State Hoisting is perhaps the most critical principle for reusability. By lifting state out of your composables and making them stateless, you create components that can be used in various contexts without being tied to specific business logic. A well-designed reusable composable should be a pure function of its inputs.
Modifier API provides the extension point that makes your composables truly flexible. By accepting and applying modifiers properly, your components can be customized for different use cases without modification to their core implementation.
SOLID Principles Applied to Composable Design
The SOLID principles, traditionally applied to object-oriented design, translate beautifully to Composable API design:
Single Responsibility Principle: Each composable should have one clear purpose. A CustomButton
should handle button behavior, while a LoadingIndicator
should only manage loading states. This focused approach makes components easier to understand, test, and reuse.
Open/Closed Principle: Your composables should be open for extension but closed for modification. This is achieved through well-designed parameter APIs and the strategic use of slots and modifiers. Users should be able to customize your component's behavior without modifying its source code.
Designing for Testability from the Start
Testable UI components begin with good design decisions. Composables that follow these principles are inherently more testable:
- Accept all external dependencies as parameters
- Avoid direct access to global state or singletons
- Use clear, descriptive parameter names
- Provide sensible defaults for optional parameters
- Separate business logic from UI logic
When your composables are pure functions of their inputs, writing unit tests becomes straightforward. You can test different input combinations and verify the expected behavior without complex setup or mocking.
The Importance of Clear API Design
The API of your composable – its parameters, their types, and their relationships – is the contract between your component and its users. A well-designed API should be:
- Intuitive: Parameters should be named clearly and grouped logically
- Consistent: Similar parameters across different composables should follow the same naming conventions
- Flexible: Provide both simple and advanced usage patterns
- Safe: Use types that prevent common mistakes and provide compile-time safety
Consider this example of API evolution:
// Poor API design
@Composable
fun CustomButton(text: String, color: Int, onClick: () -> Unit)
// Better API design
@Composable
fun CustomButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: ButtonColors = ButtonDefaults.buttonColors()
)
The improved version uses established Compose patterns, provides better defaults, and offers more flexibility while maintaining simplicity for basic use cases.
Building Blocks: Crafting Your First Reusable Custom Composable
Let's put theory into practice by creating a custom composable Jetpack Compose component from scratch. We'll transform a basic button into a flexible, reusable component that demonstrates key principles of effective composable design.
Starting with a Simple UI Element
Consider this basic button implementation you might find scattered throughout an app:
@Composable
fun LoginButton() {
Button(
onClick = { /* hardcoded login logic */ },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF1976D2)
)
) {
Text("Login", color = Color.White)
}
}
This button works, but it's not reusable. It's tightly coupled to login functionality, has hardcoded styling, and can't be customized. Let's transform it step by step.
Step 1: Extract Parameters and Add Flexibility
Our first improvement focuses on making the component parameterizable:
@Composable
fun CustomActionButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF1976D2)
)
) {
Text(
text = text,
color = Color.White
)
}
}
This version addresses the basic reusability requirements: the text and click handler are parameterizable, and we've added the essential modifier
parameter that every reusable composable should have.
Step 2: Leveraging Modifiers Effectively
Modifier best practices dictate that we should accept a modifier parameter and apply it to our root composable. This allows users to customize spacing, sizing, and positioning:
@Composable
fun CustomActionButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: ButtonColors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF1976D2)
)
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier, // Always apply to root element
colors = colors
) {
Text(
text = text,
color = colors.contentColor
)
}
}
Step 3: Implementing Content Slots for Maximum Flexibility
Jetpack Compose slots are powerful patterns that allow users to inject custom content into predefined locations within your composable. Let's add leading and trailing content slots:
@Composable
fun CustomActionButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: ButtonColors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF1976D2)
),
leadingIcon: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier,
colors = colors
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
leadingIcon?.invoke()
Text(
text = text,
color = colors.contentColor
)
trailingContent?.invoke()
}
}
}
Usage Examples
Now our reusable component can be used in multiple contexts:
// Basic usage
CustomActionButton(
text = "Login",
onClick = { performLogin() }
)
// With icon
CustomActionButton(
text = "Save",
onClick = { saveData() },
leadingIcon = {
Icon(Icons.Default.Save, contentDescription = null)
}
)
// With custom styling and trailing content
CustomActionButton(
text = "Share",
onClick = { shareContent() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
),
trailingContent = {
Icon(Icons.Default.ArrowForward, contentDescription = null)
}
)
This progression demonstrates how to evolve a basic UI element into a flexible, reusable component. The key principles applied here – parameterization, modifier support, and content slots – form the foundation of effective composable parameters design.
Advanced Reusability Patterns
Once you've mastered basic composable creation, advanced patterns unlock even greater flexibility and reusability. These techniques enable you to build sophisticated component libraries that adapt to complex requirements while maintaining clean, maintainable code.
Generic Composables: Type-Safe Flexibility
Generic composables Jetpack Compose solutions shine when you need components that work with different data types while maintaining type safety. Consider a reusable card component that displays different types of data:
@Composable
fun <T> DataCard(
item: T,
modifier: Modifier = Modifier,
title: (T) -> String,
subtitle: (T) -> String? = { null },
content: @Composable (T) -> Unit
) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = title(item),
style = MaterialTheme.typography.headlineSmall
)
subtitle(item)?.let { subtitleText ->
Text(
text = subtitleText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(12.dp))
content(item)
}
}
}
This generic approach allows the same component to display user profiles, product information, or any other data type:
// User profile card
DataCard(
item = user,
title = { it.name },
subtitle = { it.email }
) { user ->
Text("Joined: ${user.joinDate}")
}
// Product card
DataCard(
item = product,
title = { it.name },
subtitle = { "$${it.price}" }
) { product ->
AsyncImage(
model = product.imageUrl,
contentDescription = product.name
)
}
Type Constraints for Enhanced Safety
When building generic composables, type constraints ensure that your components work only with appropriate data types:
@Composable
fun <T : Comparable<T>> SortableList(
items: List<T>,
itemContent: @Composable (T) -> Unit,
modifier: Modifier = Modifier,
sortOrder: SortOrder = SortOrder.Ascending
) {
val sortedItems = remember(items, sortOrder) {
when (sortOrder) {
SortOrder.Ascending -> items.sorted()
SortOrder.Descending -> items.sortedDescending()
}
}
LazyColumn(modifier = modifier) {
items(sortedItems) { item ->
itemContent(item)
}
}
}
Higher-Order Composables: Composable Functions as Parameters
Higher-order composables accept other composables as parameters or return composables, enabling powerful composition patterns. They're particularly useful for wrapping common behaviors:
@Composable
fun WithLoading(
isLoading: Boolean,
modifier: Modifier = Modifier,
loadingContent: @Composable () -> Unit = {
CircularProgressIndicator()
},
content: @Composable () -> Unit
) {
Box(modifier = modifier) {
content()
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center
) {
loadingContent()
}
}
}
}
@Composable
fun WithErrorHandling(
error: String?,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
if (error != null) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = error,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onRetry) {
Text("Retry")
}
}
} else {
content()
}
}
These higher-order composables can be combined to create powerful, reusable patterns:
@Composable
fun UserProfile(
userId: String,
viewModel: UserViewModel
) {
val uiState by viewModel.uiState.collectAsState()
WithErrorHandling(
error = uiState.error,
onRetry = { viewModel.loadUser(userId) }
) {
WithLoading(
isLoading = uiState.isLoading
) {
UserProfileContent(user = uiState.user)
}
}
}
Style Configuration Objects
For components requiring extensive customization, composable configuration objects provide a clean API while avoiding parameter explosion:
data class ButtonStyle(
val colors: ButtonColors = ButtonDefaults.buttonColors(),
val elevation: ButtonElevation = ButtonDefaults.buttonElevation(),
val shape: Shape = RoundedCornerShape(8.dp),
val contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
val textStyle: TextStyle = MaterialTheme.typography.labelLarge
)
@Composable
fun StyledButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: ButtonStyle = ButtonStyle(),
enabled: Boolean = true
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = style.colors,
elevation = style.elevation,
shape = style.shape,
contentPadding = style.contentPadding
) {
Text(
text = text,
style = style.textStyle
)
}
}
Composition Over Inheritance
In Compose, we achieve flexibility through composition rather than inheritance. Instead of creating complex component hierarchies, we build functionality by combining simpler components:
@Composable
fun ActionCard(
title: String,
modifier: Modifier = Modifier,
icon: @Composable (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
Card(modifier = modifier) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
icon?.invoke()
if (icon != null) {
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
actions()
}
Spacer(modifier = Modifier.height(12.dp))
content()
}
}
}
This approach creates flexible components that can be customized through composition rather than requiring inheritance hierarchies.
Theming & Styling for Maximum Reusability
Creating reusable components that look great across different themes and brand variations requires thoughtful design and strategic use of Compose's theming system. Jetpack Compose theming goes beyond simple color changes – it's about creating adaptive components that feel native in any design system.
Deep Dive into CompositionLocalProvider
CompositionLocalProvider is the foundation of Compose's theming system, allowing you to provide values that are implicitly available to all composables in a subtree. For reusable components, this means creating theme-aware components that adapt automatically to their environment.
// Custom theme values
data class AppTypography(
val displayLarge: TextStyle,
val displayMedium: TextStyle,
val bodyLarge: TextStyle,
val bodyMedium: TextStyle,
val labelLarge: TextStyle
)
data class AppSpacing(
val extraSmall: Dp = 4.dp,
val small: Dp = 8.dp,
val medium: Dp = 16.dp,
val large: Dp = 24.dp,
val extraLarge: Dp = 32.dp
)
// CompositionLocal definitions
val LocalAppTypography = compositionLocalOf { AppTypography(/* defaults */) }
val LocalAppSpacing = compositionLocalOf { AppSpacing() }
// Theme-aware composable
@Composable
fun ThemedCard(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val typography = LocalAppTypography.current
val spacing = LocalAppSpacing.current
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(spacing.medium)
) {
Text(
text = title,
style = typography.displayMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(spacing.small))
content()
}
}
}
Designing Components for Theme Adaptation
Dynamic UI styling Android requires components that respond intelligently to theme changes. Here's how to build components that adapt seamlessly:
@Composable
fun AdaptiveButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: ButtonVariant = ButtonVariant.Primary,
size: ButtonSize = ButtonSize.Medium
) {
val colors = when (variant) {
ButtonVariant.Primary -> ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
ButtonVariant.Secondary -> ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
)
ButtonVariant.Outline -> ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
}
val contentPadding = when (size) {
ButtonSize.Small -> PaddingValues(horizontal = 12.dp, vertical = 6.dp)
ButtonSize.Medium -> ButtonDefaults.ContentPadding
ButtonSize.Large -> PaddingValues(horizontal = 24.dp, vertical = 16.dp)
}
val textStyle = when (size) {
ButtonSize.Small -> MaterialTheme.typography.labelSmall
ButtonSize.Medium -> MaterialTheme.typography.labelMedium
ButtonSize.Large -> MaterialTheme.typography.labelLarge
}
when (variant) {
ButtonVariant.Outline -> OutlinedButton(
onClick = onClick,
modifier = modifier,
colors = colors,
contentPadding = contentPadding
) {
Text(text = text, style = textStyle)
}
else -> Button(
onClick = onClick,
modifier = modifier,
colors = colors,
contentPadding = contentPadding
) {
Text(text = text, style = textStyle)
}
}
}
enum class ButtonVariant { Primary, Secondary, Outline }
enum class ButtonSize { Small, Medium, Large }
Granular Styling Without API Complexity
The challenge with reusable components is providing extensive customization options without overwhelming the API. Here's a strategy that balances flexibility with simplicity:
// Style configuration approach
data class CardStyle(
val backgroundColor: Color = Color.Unspecified,
val contentColor: Color = Color.Unspecified,
val elevation: Dp = 4.dp,
val cornerRadius: Dp = 8.dp,
val contentPadding: PaddingValues = PaddingValues(16.dp)
) {
companion object {
fun primary() = CardStyle(
backgroundColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
fun surface() = CardStyle(
backgroundColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
fun FlexibleCard(
modifier: Modifier = Modifier,
style: CardStyle = CardStyle(),
content: @Composable ColumnScope.() -> Unit
) {
val backgroundColor = if (style.backgroundColor != Color.Unspecified) {
style.backgroundColor
} else {
MaterialTheme.colorScheme.surface
}
val contentColor = if (style.contentColor != Color.Unspecified) {
style.contentColor
} else {
MaterialTheme.colorScheme.onSurface
}
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = backgroundColor,
contentColor = contentColor
),
elevation = CardDefaults.cardElevation(
defaultElevation = style.elevation
),
shape = RoundedCornerShape(style.cornerRadius)
) {
CompositionLocalProvider(
LocalContentColor provides contentColor
) {
Column(
modifier = Modifier.padding(style.contentPadding),
content = content
)
}
}
}
Accessibility Considerations
Accessible composables must work well with screen readers and other assistive technologies. Build accessibility into your reusable components from the start:
@Composable
fun AccessibleButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false,
contentDescription: String? = null
) {
Button(
onClick = if (loading) { {} } else onClick,
modifier = modifier.semantics {
// Provide custom content description if specified
contentDescription?.let {
this.contentDescription = it
}
// Indicate loading state to screen readers
if (loading) {
stateDescription = "Loading"
}
// Ensure proper role is communicated
role = Role.Button
},
enabled = enabled && !loading
) {
if (loading) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Text(text = "Loading...")
}
} else {
Text(text = text)
}
}
}
This approach ensures that your reusable components are not only visually appealing but also accessible to all users, regardless of their abilities.
Real-World Scenarios & Best Practices
Building reusable components for production applications involves navigating complex state management scenarios, performance considerations, and testing strategies. Let's explore practical approaches that ensure your Jetpack Compose state management and composable performance optimization create robust, maintainable components.
Managing State in Reusable Components
The key to effective state management in reusable components is understanding when to hoist state and when to manage it internally. Here are the primary strategies:
Hoisted State Pattern - Best for components that need to integrate with external business logic:
@Composable
fun SearchableList<T>(
items: List<T>,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
itemContent: @Composable (T) -> Unit,
itemFilter: (T, String) -> Boolean,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
label = { Text("Search") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
val filteredItems = remember(items, searchQuery) {
if (searchQuery.isBlank()) items
else items.filter { itemFilter(it, searchQuery) }
}
LazyColumn {
items(filteredItems) { item ->
itemContent(item)
}
}
}
}
Internal State Pattern - Appropriate for self-contained UI behavior:
@Composable
fun ExpandableCard(
title: String,
modifier: Modifier = Modifier,
initiallyExpanded: Boolean = false,
content: @Composable ColumnScope.() -> Unit
) {
var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
Card(
modifier = modifier.clickable { expanded = !expanded }
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall
)
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess
else Icons.Default.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand"
)
}
AnimatedVisibility(visible = expanded) {
Column {
Spacer(modifier = Modifier.height(12.dp))
content()
}
}
}
}
}
ViewModel Integration Pattern - For complex business logic:
@Composable
fun <T, VM : ViewModel> StatefulDataList(
viewModel: VM,
stateSelector: (VM) -> StateFlow<DataListState<T>>,
onRefresh: VM.() -> Unit,
itemContent: @Composable (T) -> Unit,
modifier: Modifier = Modifier
) {
val state by stateSelector(viewModel).collectAsState()
LaunchedEffect(viewModel) {
viewModel.onRefresh()
}
when (state) {
is DataListState.Loading -> {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is DataListState.Success -> {
SwipeRefresh(
state = rememberSwipeRefreshState(false),
onRefresh = { viewModel.onRefresh() },
modifier = modifier
) {
LazyColumn {
items(state.data) { item ->
itemContent(item)
}
}
}
}
is DataListState.Error -> {
ErrorMessage(
error = state.message,
onRetry = { viewModel.onRefresh() },
modifier = modifier
)
}
}
}
Performance Considerations
Composable performance optimization requires understanding recomposition triggers and implementing appropriate optimization strategies:
Smart Recomposition with derivedStateOf
:
@Composable
fun OptimizedFilteredList<T>(
items: List<T>,
searchQuery: String,
itemContent: @Composable (T) -> Unit,
itemFilter: (T, String) -> Boolean
) {
// Expensive filtering operation is cached and only recalculates
// when items or searchQuery actually change
val filteredItems by remember {
derivedStateOf {
if (searchQuery.isBlank()) {
items
} else {
items.filter { itemFilter(it, searchQuery) }
}
}
}
LazyColumn {
items(
items = filteredItems,
key = { item -> /* provide stable key */ }
) { item ->
itemContent(item)
}
}
}
Strategic Use of key
for List Performance:
@Composable
fun OptimizedTodoList(
todos: List<Todo>,
onToggleTodo: (String) -> Unit,
onDeleteTodo: (String) -> Unit
) {
LazyColumn {
items(
items = todos,
key = { todo -> todo.id } // Stable key prevents unnecessary recomposition
) { todo ->
TodoItem(
todo = todo,
onToggle = { onToggleTodo(todo.id) },
onDelete = { onDeleteTodo(todo.id) }
)
}
}
}
Testing Reusable Composables
Testing Jetpack Compose UI components requires a multi-layered approach covering unit tests, integration tests, and screenshot tests:
Unit Testing Component Logic:
@RunWith(AndroidJUnit4::class)
class CustomButtonTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun customButton_displaysCorrectText() {
composeTestRule.setContent {
CustomActionButton(
text = "Test Button",
onClick = { }
)
}
composeTestRule
.onNodeWithText("Test Button")
.assertIsDisplayed()
}
@Test
fun customButton_callsOnClickWhenPressed() {
var clickCount = 0
composeTestRule.setContent {
CustomActionButton(
text = "Click Me",
onClick = { clickCount++ }
)
}
composeTestRule
.onNodeWithText("Click Me")
.performClick()
assertEquals(1, clickCount)
}
@Test
fun customButton_respectsEnabledState() {
composeTestRule.setContent {
CustomActionButton(
text = "Disabled Button",
onClick = { },
enabled = false
)
}
composeTestRule
.onNodeWithText("Disabled Button")
.assertIsNotEnabled()
}
}
Integration Testing with State:
@Test
fun expandableCard_togglesExpansionOnClick() {
composeTestRule.setContent {
ExpandableCard(
title = "Test Card"
) {
Text("Hidden content")
}
}
// Initially collapsed - content should not be visible
composeTestRule
.onNodeWithText("Hidden content")
.assertDoesNotExist()
// Click to expand
composeTestRule
.onNodeWithText("Test Card")
.performClick()
// Content should now be visible
composeTestRule
.onNodeWithText("Hidden content")
.assertIsDisplayed()
}
Screenshot Testing for Visual Regression:
@Test
fun customButton_screenshotTest() {
composeTestRule.setContent {
MaterialTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CustomActionButton(text = "Primary", onClick = { })
CustomActionButton(text = "Disabled", onClick = { }, enabled = false)
CustomActionButton(
text = "With Icon",
onClick = { },
leadingIcon = {
Icon(Icons.Default.Star, contentDescription = null)
}
)
}
}
}
composeTestRule
.onRoot()
.captureToImage()
.assertAgainstGolden("custom_button_variants")
}
Documentation and Discoverability
Making your components discoverable and easy to use requires comprehensive documentation. Here's a structured approach:
/**
* A flexible, reusable button component that supports various styling options
* and content slots for maximum customization.
*
* @param text The text to display on the button
* @param onClick Callback invoked when the button is clicked
* @param modifier Modifier to be applied to the button
* @param enabled Whether the button is enabled and clickable
* @param colors The colors to use for the button in different states
* @param leadingIcon Optional composable to display before the text
* @param trailingContent Optional composable to display after the text
*
* @sample CustomActionButtonSample
*/
@Composable
fun CustomActionButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: ButtonColors = ButtonDefaults.buttonColors(),
leadingIcon: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null
) {
// Implementation
}
/**
* Sample usage of CustomActionButton showing common use cases
*/
@Composable
private fun CustomActionButtonSample() {
Column {
CustomActionButton(
text = "Save",
onClick = { /* save action */ }
)
CustomActionButton(
text = "Share",
onClick = { /* share action */ },
leadingIcon = {
Icon(Icons.Default.Share, contentDescription = null)
}
)
}
}
Common Pitfalls to Avoid
Understanding Android UI anti-patterns helps prevent common mistakes in reusable component design:
Overly Complex APIs: Resist the temptation to make components that do everything. Instead, focus on single responsibilities:
// DON'T: Kitchen sink approach
@Composable
fun MegaButton(
text: String,
onClick: () -> Unit,
showProgress: Boolean = false,
progressColor: Color = Color.Blue,
animateOnClick: Boolean = true,
animationDuration: Int = 300,
hapticFeedback: Boolean = true,
soundEffect: Boolean = true,
// ... 20 more parameters
)
// DO: Focused, composable approach
@Composable
fun ActionButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
)
@Composable
fun LoadingButton(
text: String,
onClick: () -> Unit,
isLoading: Boolean,
modifier: Modifier = Modifier
)
Tight Coupling to Business Logic: Keep components focused on UI concerns:
// DON'T: Business logic embedded in UI component
@Composable
fun UserProfileCard(userId: String) {
val context = LocalContext.current
var user by remember { mutableStateOf<User?>(null) }
LaunchedEffect(userId) {
// Direct API call in composable - BAD!
user = ApiClient.getUser(userId)
}
// UI implementation
}
// DO: Separate concerns
@Composable
fun UserProfileCard(
user: User?,
isLoading: Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
// Pure UI implementation
}
Ignoring Performance Implications: Always consider the recomposition impact:
// DON'T: Expensive operations in composable body
@Composable
fun ExpensiveList(items: List<String>) {
val processedItems = items.map { item ->
// Expensive operation runs on every recomposition!
processExpensively(item)
}
LazyColumn {
items(processedItems) { item ->
Text(item)
}
}
}
// DO: Cache expensive operations
@Composable
fun OptimizedList(items: List<String>) {
val processedItems = remember(items) {
items.map { processExpensively(it) }
}
LazyColumn {
items(processedItems) { item ->
Text(item)
}
}
}
Your Reusable Component Library: Structuring and Sharing
As your collection of reusable components grows, organizing them effectively becomes crucial for maintainability and team adoption. A well-structured Jetpack Compose UI library not only improves developer productivity but also ensures consistency across your applications.
Organizing Components Within Your Project
Android module structure for component libraries should follow clear separation of concerns and logical grouping:
ui-components/
├── src/main/java/com/yourcompany/ui/
│ ├── components/
│ │ ├── buttons/
│ │ │ ├── ActionButton.kt
│ │ │ ├── LoadingButton.kt
│ │ │ └── IconButton.kt
│ │ ├── cards/
│ │ │ ├── DataCard.kt
│ │ │ ├── ExpandableCard.kt
│ │ │ └── ActionCard.kt
│ │ ├── inputs/
│ │ │ ├── SearchField.kt
│ │ │ ├── FormField.kt
│ │ │ └── DatePicker.kt
│ │ └── layout/
│ │ ├── FlexibleGrid.kt
│ │ ├── ResponsiveLayout.kt
│ │ └── SafeAreaLayout.kt
│ ├── theme/
│ │ ├── Color.kt
│ │ ├── Typography.kt
│ │ ├── Spacing.kt
│ │ └── Theme.kt
│ ├── tokens/
│ │ ├── DesignTokens.kt
│ │ └── ComponentTokens.kt
│ └── utils/
│ ├── Modifiers.kt
│ ├── Extensions.kt
│ └── Preview.kt
Creating Consistent Component APIs
Establish conventions that make your library predictable and easy to use:
// Standard parameter ordering convention
@Composable
fun LibraryComponent(
// Required parameters first
requiredData: String,
requiredCallback: () -> Unit,
// Modifier always after required params
modifier: Modifier = Modifier,
// Optional behavioral parameters
enabled: Boolean = true,
// Optional styling parameters
colors: ComponentColors = ComponentDefaults.colors(),
// Content slots last
leadingContent: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit = {}
)
Building a Design System Foundation
Create a cohesive foundation that all components can build upon:
// Design tokens
object DesignTokens {
object Spacing {
val xs = 4.dp
val sm = 8.dp
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
}
object BorderRadius {
val sm = 4.dp
val md = 8.dp
val lg = 12.dp
val xl = 16.dp
}
object Elevation {
val sm = 2.dp
val md = 4.dp
val lg = 8.dp
}
}
// Component-specific tokens
object ButtonTokens {
val MinHeight = 48.dp
val HorizontalPadding = DesignTokens.Spacing.md
val VerticalPadding = DesignTokens.Spacing.sm
val IconSpacing = DesignTokens.Spacing.sm
val BorderRadius = DesignTokens.BorderRadius.md
}
Sharing Composables Across Teams
For larger organizations, consider these strategies for component distribution:
Internal Package Repository:
// build.gradle.kts (library module)
plugins {
id("com.android.library")
id("kotlin-android")
id("maven-publish")
}
publishing {
publications {
create<MavenPublication>("release") {
from(components["release"])
groupId = "com.yourcompany.ui"
artifactId = "compose-components"
version = "1.0.0"
}
}
}
Documentation and Examples:
// Create comprehensive preview composables
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ActionButtonPreview() {
YourAppTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ActionButton(
text = "Primary Action",
onClick = { }
)
ActionButton(
text = "Secondary Action",
onClick = { },
colors = ButtonDefaults.outlinedButtonColors()
)
ActionButton(
text = "Disabled Action",
onClick = { },
enabled = false
)
}
}
}
Version Management and Breaking Changes
Maintain backward compatibility while evolving your component library:
// Deprecation with migration path
@Deprecated(
message = "Use ActionButton with ButtonStyle parameter instead",
replaceWith = ReplaceWith(
"ActionButton(text, onClick, modifier, ButtonStyle.primary())",
"com.yourcompany.ui.components.ButtonStyle"
),
level = DeprecationLevel.WARNING
)
@Composable
fun PrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
ActionButton(
text = text,
onClick = onClick,
modifier = modifier,
style = ButtonStyle.primary()
)
}
Conclusion: The Future is Composable & Reusable
The journey through building reusable Jetpack Compose components reveals a fundamental shift in how we approach Android UI development. We've moved from the fragmented, inheritance-heavy world of View-based UIs to a composable, function-based paradigm that prioritizes reusability, testability, and maintainability.
The key takeaways from this comprehensive exploration highlight the transformative power of well-designed Jetpack Compose reusable components:
Design Principles Drive Success: Components built on solid foundations – unidirectional data flow, state hoisting, and clear API design – naturally become more reusable and maintainable. The investment in proper architecture pays dividends as your application grows.
Flexibility Through Composition: Rather than creating monolithic components that try to handle every use case, the most successful reusable components embrace the principle of composition. Small, focused components that can be combined in various ways provide greater flexibility than complex, parameter-heavy alternatives.
Performance and Accessibility Are Not Afterthoughts: The most successful component libraries bake performance optimization and accessibility into their core design, not as later additions. Components that respect recomposition boundaries and provide excellent screen reader support create better experiences for all users.
Testing Enables Confidence: Comprehensive testing strategies – from unit tests to screenshot tests – ensure that your reusable components maintain their behavior as they evolve. This testing foundation allows teams to iterate quickly without fear of breaking existing functionality.
The Compose ecosystem continues to evolve rapidly, with new patterns, performance optimizations, and tooling improvements appearing regularly. The Jetpack Compose best practices we've explored provide a solid foundation, but staying engaged with the community ensures your component library remains current and effective.
As you begin implementing these patterns in your own projects, remember that building a great component library is an iterative process. Start small, focus on the components you use most frequently, and gradually expand your library based on real usage patterns and team feedback.
The future of Android development is undeniably composable, and developers who master the art of building reusable, well-designed components will find themselves at a significant advantage. Your users will benefit from more consistent, polished interfaces, and your development teams will enjoy increased productivity and reduced maintenance overhead.
Take Action: Start by identifying three UI patterns that appear repeatedly in your current project. Apply the principles from this guide to transform them into reusable components. Share your experiences with the community, contribute to open-source Compose libraries, and help push the boundaries of what's possible with composable UIs.
The composable revolution is here – and with the knowledge you've gained from this guide, you're well-equipped to lead it within your organization. Next week, we'll dive deep into advanced animation techniques for reusable components, exploring how to create delightful, performant micro-interactions that bring your component library to life.
What components will you build first? Share your plans and challenges in the comments below, and let's continue building the future of Android UI development together.