Tired of Clunky Android UI? One Developer’s Side Project Aims to Fix It

Project Links

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:

  1. Advanced Shadow Effects: I wanted to implement designs with detailed shadow specifications that required granular control over offset, blur, spread, and color.
  2. Nuanced Component States: My designs called for more detailed state representations with specific visual treatments for each interaction state.
  3. Modular Architecture: I was looking for ways to make behavior and appearance more modular so I could iterate on each independently.
  4. 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:

  1. Separated behavior from visual styling: This experimental approach allowed for more modular development where I could iterate on each aspect independently.
  2. Provided detailed control over visual elements: I wanted to explore how to create fine-grained control over visual details without compromising on maintainability.
  3. Embraced composition over configuration: My exploration focused on smaller building blocks that could be composed together rather than configuring monolithic components.
  4. 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:

  1. Normal state with configurable shadow
  2. Pressed state with visual feedback through shadow changes
  3. Disabled state with appropriate visual treatment
  4. 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:

  1. More compositional than configurational
  2. More explicit than implicit
  3. More adaptive than fixed
  4. 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:

Contributing

Contributions to AtomicKit are welcome! Please feel free to submit issues, feature requests, or pull requests to the GitHub repository.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.