Project Links
- AtomicKit on GitHub – The official repository for AtomicKit
Disclaimer
Important Note: AtomicKit is a personal side project developed in my free time as an exploration of alternative UI component architectures. The views, approaches, and opinions expressed in this article are my own and do not represent the official stance or best practices of my employer. This project should be viewed as an experimental exploration rather than a critique of existing frameworks or libraries.
Exploring Design Implementation Challenges
When implementing modern design patterns with Jetpack Compose, I encountered several interesting challenges that led me to explore alternative component architectures:
- Advanced Shadow Effects: I wanted to implement designs with detailed shadow specifications that required granular control over offset, blur, spread, and color.
- Nuanced Component States: My designs called for more detailed state representations with specific visual treatments for each interaction state.
- Modular Architecture: I was looking for ways to make behavior and appearance more modular so I could iterate on each independently.
- Form Factor Adaptations: Working across multiple device types required components that could intuitively adapt to different screen configurations.
Building from First Principles
These implementation challenges inspired me to explore an alternative approach. During my free time, I began experimenting with building components that:
- Separated behavior from visual styling: This experimental approach allowed for more modular development where I could iterate on each aspect independently.
- Provided detailed control over visual elements: I wanted to explore how to create fine-grained control over visual details without compromising on maintainability.
- Embraced composition over configuration: My exploration focused on smaller building blocks that could be composed together rather than configuring monolithic components.
- Adapted to different form factors by design: I wanted to explore patterns for making responsive behavior a core consideration rather than an add-on.
Design Philosophy in Practice
Looking at specific components in the repository helps illustrate how these design principles are implemented in practice:
Separation of Behavior and Presentation
The CustomTopAppBar
component demonstrates this separation clearly:
@Composable
fun CustomTopAppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: @Composable (() -> Unit)? = null,
actions: @Composable (RowScope.() -> Unit)? = null,
backgroundColor: Color = Color(0xFFFFFFFF),
contentColor: Color = Color(0xFF1F2937),
elevation: Dp = 4.dp,
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp),
height: Dp = 56.dp,
shape: Shape = RoundedCornerShape(bottomStart = 0.dp, bottomEnd = 0.dp),
centerTitle: Boolean = false,
scrollBehavior: AppBarScrollBehavior? = null
) {
// Calculate current state based on scroll
val scrollFraction = scrollBehavior?.scrollFraction ?: 0f
// Animate elevation based on scroll
val appBarElevation by animateDpAsState(
targetValue = if (scrollBehavior != null) {
if (scrollFraction > 0f) elevation else 0.dp
} else {
elevation
},
label = "appBarElevation"
)
// Implementation details...
}
The AppBarScrollBehavior
interface is particularly interesting:
interface AppBarScrollBehavior {
/**
* The fraction of scrolling applied to the app bar (0f to 1f).
*/
val scrollFraction: Float
}
This pattern allows the scrolling behavior to be completely separate from the visual aspects of the app bar. You can implement different scroll behaviors without modifying the app bar’s appearance code.
Detailed Visual Customization
The BoxShadow
implementation showcases the detailed control over visual elements:
// BoxShadow class definition
data class BoxShadow(
val offsetX: Dp = 0.dp,
val offsetY: Dp = 0.dp,
val blurRadius: Dp = 0.dp,
val spreadRadius: Dp = 0.dp,
val color: Color = Color(0x00000000) // Transparent defaults
)
// Extension function to apply the shadow
fun Modifier.drawWithBoxShadow(shadow: BoxShadow, shape: Shape): Modifier {
return this.drawBehind {
drawIntoCanvas { canvas ->
val paint = Paint()
val frameworkPaint = paint.asFrameworkPaint()
// Configure the shadow paint
frameworkPaint.color = shadow.color.toArgb()
// The shadow properties - X offset, Y offset, Blur, Color
frameworkPaint.setShadowLayer(
shadow.blurRadius.toPx(),
shadow.offsetX.toPx(),
shadow.offsetY.toPx(),
shadow.color.copy(alpha = 0.7f).toArgb()
)
// Apply the spread radius by adjusting the drawing area
val spreadOffset = shadow.spreadRadius.toPx()
// More implementation details...
}
}
}
This implementation goes beyond simple elevation to provide CSS-like box shadow capabilities, allowing for precise control over every aspect of shadows.
Exploring Custom Shadow Implementation
One area I was particularly interested in exploring was implementing more detailed shadow effects for card designs:
// Exploring more detailed shadow control
CustomCard(
boxShadow = BoxShadow(
offsetX = 2.dp,
offsetY = 6.dp,
blurRadius = 8.dp,
spreadRadius = -2.dp,
color = Color(0x301E293B)
)
) { /* content */ }
This exploration wasn’t just about aesthetics – I was interested in how detailed shadow control could better communicate hierarchy, interaction states, and focus in complex interfaces.
Form Factor Adaptation
The responsive layout components show the approach to adaptable interfaces:
class ScreenSizeRange private constructor(
val minWidth: Float = 0f,
val maxWidth: Float? = null,
val minHeight: Float = 0f,
val maxHeight: Float? = null,
val predicate: ((widthDp: Float, heightDp: Float) -> Boolean)? = null
) {
companion object {
// Predefined screen size ranges
val COMPACT = create(0f, 600f)
val MEDIUM = create(600f, 840f)
val EXPANDED = create(840f, null)
// Portrait vs landscape
val PORTRAIT = createWithPredicate { width, height -> height > width }
val LANDSCAPE = createWithPredicate { width, height -> width >= height }
// Other predefined ranges...
}
fun matches(widthDp: Float, heightDp: Float): Boolean {
val widthMatches = widthDp >= minWidth && (maxWidth == null || widthDp <= maxWidth)
val heightMatches = heightDp >= minHeight && (maxHeight == null || heightDp <= maxHeight)
val baseMatches = widthMatches && heightMatches
return if (predicate != null) {
baseMatches && predicate.invoke(widthDp, heightDp)
} else {
baseMatches
}
}
}
Using it with the ResponsiveLayout
component creates a powerful system for adapting to different screen sizes:
@Composable
fun ResponsiveLayout(
ranges: Map<ScreenSizeRange, @Composable () -> Unit>,
modifier: Modifier = Modifier,
defaultContent: (@Composable () -> Unit)? = null
) {
Box(modifier = modifier) {
LocalScreenDimensions.current?.let { dimensions ->
// Find the first matching range
val matchingContent = ranges.entries.firstOrNull { (range, _) ->
range.matches(dimensions.widthDp, dimensions.heightDp)
}?.value ?: defaultContent
// Render matching content if found
matchingContent?.invoke()
}
}
}
This enables declarative, responsive layouts where each screen size range has dedicated content.
Experimenting with Responsive Design Patterns
As I worked with Android’s diverse device ecosystem, I became interested in exploring systematic approaches to responsive design:
ResponsiveLayout(
ranges = mapOf(
// Mobile layout (0-599dp width)
rememberScreenSizeRange(0.dp, 599.dp) to {
MobileLayout()
},
// Desktop layout (900dp+ width)
rememberScreenSizeRange(900.dp, null) to {
DesktopLayout()
}
)
)
This pattern experiment aimed to simplify the implementation of adaptive layouts and reduce conditional logic throughout the codebase.
Enhanced State Management
The CustomButton
component shows how state management is approached:
@Composable
fun CustomButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = RoundedCornerShape(8.dp),
backgroundColor: Color = Color(0xFF3B82F6), // Blue color
contentColor: Color = Color.White,
disabledBackgroundColor: Color = Color(0xFFBFDBFE), // Light blue
disabledContentColor: Color = Color(0xFF64748B), // Slate gray
boxShadow: BoxShadow = BoxShadow(
offsetY = 2.dp,
blurRadius = 4.dp,
color = Color(0x40000000) // 25% black
),
pressedBoxShadow: BoxShadow = BoxShadow(
offsetY = 1.dp,
blurRadius = 2.dp,
color = Color(0x20000000) // 12.5% black
),
disabledBoxShadow: BoxShadow? = BoxShadow(
offsetY = 0.dp,
blurRadius = 0.dp,
spreadRadius = 0.dp,
color = Color.Transparent
),
// Other parameters...
) {
val isPressed by interactionSource.collectIsPressedAsState()
// Animate background color based on state
val bgColor by animateColorAsState(
targetValue = when {
!enabled -> disabledBackgroundColor
isPressed -> backgroundColor.copy(alpha = 0.7f)
else -> backgroundColor
},
label = "buttonBackgroundColor"
)
// Determine which shadow to use based on state
val currentShadow by animateValueAsState(
targetValue = when {
!enabled -> disabledBoxShadow ?: BoxShadow(color = Color.Transparent)
isPressed -> pressedBoxShadow
else -> boxShadow
},
typeConverter = BoxShadowVectorConverter,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "boxShadow"
)
// Button implementation...
}
This approach provides explicit handling of each state (normal, pressed, disabled) with dedicated visual configurations for each, while smoothly animating between states.
Enhanced State Management Exploration
Another interesting area for exploration was creating more expressive component state models. With CustomButton
, I experimented with clearly defined states:
- Normal state with configurable shadow
- Pressed state with visual feedback through shadow changes
- Disabled state with appropriate visual treatment
- Loading state to indicate processing
This experimental approach aimed to explore how more detailed state models could provide clearer user feedback without requiring complex external state management.
Adaptive Navigation
The AdaptiveNavigation
component demonstrates cross-form-factor adaptation:
@Composable
fun AdaptiveNavigation(
items: List<NavItem>,
selectedItem: NavItem,
onItemSelected: (NavItem) -> Unit,
modifier: Modifier = Modifier,
config: AdaptiveNavConfig = AdaptiveNavConfig(),
header: @Composable (BoxScope.() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit
) {
// States for responsive behavior
var isSidebarExpanded by remember { mutableStateOf(config.expandSidebarByDefault) }
ResponsiveLayout(
ranges = mapOf(
// Small screens: Bottom navigation
rememberScreenSizeRange(0.dp, config.breakpoint - 1.dp) to {
BottomNavLayout(
items = items,
selectedItem = selectedItem,
onItemSelected = onItemSelected,
content = content,
config = config,
modifier = modifier
)
},
// Large screens: Sidebar navigation
rememberScreenSizeRange(config.breakpoint, null) to {
SideNavLayout(
items = items,
selectedItem = selectedItem,
onItemSelected = onItemSelected,
isSidebarExpanded = isSidebarExpanded,
onSidebarExpandToggle = { isSidebarExpanded = !isSidebarExpanded },
header = header,
content = content,
config = config,
modifier = modifier
)
}
)
)
}
This component automatically switches between bottom navigation (for phones) and sidebar navigation (for tablets/desktops) based on screen width, with the breakpoint being configurable. It also handles the expanded/collapsed state of the sidebar for larger screens.
The AdaptiveNavConfig
provides detailed customization:
data class AdaptiveNavConfig(
val navBackgroundColor: Color = Color(0xFF1E293B),
val activeColor: Color = Color(0xFF6366F1),
val inactiveColor: Color = Color(0xFFCBD5E1),
val badgeColor: Color = Color(0xFFEF4444),
val navItemHeight: Dp = 56.dp,
val breakpoint: Dp = 600.dp,
val sidebarWidth: Dp = 240.dp,
val sidebarCollapsedWidth: Dp = 72.dp,
val bottomNavHeight: Dp = 64.dp,
val expandSidebarByDefault: Boolean = true
)
This approach handles a common UI pattern that traditionally requires significant manual implementation to work across different form factors.
Adaptive Navigation Exploration
I was particularly interested in exploring navigation patterns that could adapt to different form factors. My experiments led to components like:
AdaptiveNavigation(
items = navItems,
selectedItem = currentNavItem,
onItemSelected = { /* handle navigation */ },
config = AdaptiveNavConfig(
breakpoint = 840.dp // Switch between bottom and side navigation
)
)
This experiment allowed me to explore ways to automatically handle the transition between bottom navigation (optimal for phones) and side navigation (better for tablets and desktops).
Practical Component Patterns
My side project also explored common UI patterns like handling different list states:
EnhancedLazyColumn(
listState = listState,
pullRefreshConfig = PullRefreshConfig(enabled = true),
onRefresh = { /* refresh data */ },
loadingContent = { /* custom loading UI */ },
emptyContent = { /* custom empty state */ },
errorContent = { /* custom error state */ }
) {
// List items
}
This exploration looked at how to encapsulate commonly repeated patterns for loading, empty, and error states for lists, potentially reducing boilerplate while maintaining consistency.
Swipeable List Items
The SwipeableListItem
component demonstrates a pattern for interactive list items:
@Composable
fun SwipeableListItem(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
swipeConfig: SwipeConfig = SwipeConfig()
) {
val density = LocalDensity.current
val coroutineScope = rememberCoroutineScope()
// Store item width in pixels
var itemWidthPx by remember { mutableStateOf(0f) }
var maxSwipeWidthPx by remember { mutableStateOf(0f) }
// Current offset of the content
val offsetX = remember { Animatable(0f) }
// Calculate thresholds for activating actions
val leftMaxPx = if (swipeConfig.leftActions.isEmpty()) 0f else maxSwipeWidthPx
val rightMaxPx = if (swipeConfig.rightActions.isEmpty()) 0f else maxSwipeWidthPx
val leftThresholdPx = leftMaxPx * swipeConfig.threshold
val rightThresholdPx = rightMaxPx * swipeConfig.threshold
// Swipe state tracking
val leftSwipeProgress = if (leftMaxPx > 0f) (offsetX.value / leftMaxPx).coerceIn(0f, 1f) else 0f
val rightSwipeProgress = if (rightMaxPx > 0f) (-offsetX.value / rightMaxPx).coerceIn(0f, 1f) else 0f
// Implementation with swipe gesture handling, action triggering, etc.
Box(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.onGloballyPositioned { coordinates ->
itemWidthPx = coordinates.size.width.toFloat()
}
) {
// Left actions (revealed when swiping right)
if (swipeConfig.leftActions.isNotEmpty()) {
// Action rendering
}
// Right actions (revealed when swiping left)
if (swipeConfig.rightActions.isNotEmpty()) {
// Action rendering
}
// Main content with swipe behavior
Box(
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.fillMaxWidth()
.then(
if (swipeConfig.swipeEnabled) {
Modifier.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
// Drag handling
},
onDragStopped = {
// Action triggering
}
)
} else {
Modifier
}
)
) {
content()
}
}
}
This implementation provides a fully customizable swipeable list item with support for multiple actions on either side, customizable thresholds for triggering actions, and smooth animations.
Design Philosophy
At its core, my AtomicKit exploration reflects a curiosity about whether UI development could be:
- More compositional than configurational
- More explicit than implicit
- More adaptive than fixed
- Focused on developer experience without sacrificing user experience
This side project represents one perspective among many on component architecture. Different approaches have different strengths, and AtomicKit is simply my personal exploration of alternatives that might be valuable in certain contexts.
Future Exploration Areas
Some areas I’m interested in exploring further include:
1. Theme Interoperability
How custom components can better integrate with both Material themes and custom design systems while maintaining flexibility.
2. Accessibility Considerations
Ways to ensure that customized components maintain or enhance accessibility, with better semantic properties and support for accessibility settings.
3. Animation Patterns
Exploring animation patterns that can make common transitions more approachable while maintaining performance.
4. Form Validation Patterns
Looking at ways to streamline form validation and error handling through component design.
Related Resources
To explore similar approaches and official Jetpack Compose resources:
- Official Jetpack Compose Samples – Google’s repository of sample applications showcasing Compose patterns
- Accompanist – A collection of extension libraries for Jetpack Compose by Google
- Awesome Jetpack Compose – A curated list of Compose libraries, projects and resources
Contributing
Contributions to AtomicKit are welcome! Please feel free to submit issues, feature requests, or pull requests to the GitHub repository.