| .. | ||
| src | ||
| .gitignore | ||
| build.gradle.kts | ||
| consumer-rules.pro | ||
| README.md | ||
Documentation: core-base/ui Module
The core-base/ui module serves as the foundation for building consistent, cross-platform user
interfaces in Kotlin Multiplatform projects. This documentation explores the inner workings,
implementation details, and best practices for each component.
Architectural Foundation
This module implements the unidirectional data flow pattern within the MVVM architecture, which creates a predictable and testable application structure:
- State flows down: UI receives immutable state snapshots
- Actions flow up: User interactions are sent as discrete actions
- Events are one-shot: Navigation and notifications occur once, not continuously
This pattern helps prevent common UI bugs like inconsistent state, navigation loops, and race conditions by enforcing a strict cycle of state updates.
1. BaseViewModel Implementation (BaseViewModel.kt)
Core Mechanics
The BaseViewModel serves as the cornerstone for UI state management, using Kotlin coroutines and
channels to manage the application's data flow:
abstract class BaseViewModel<S, E, A>(initialState: S) : ViewModel() {
protected val mutableStateFlow: MutableStateFlow<S> = MutableStateFlow(initialState)
private val eventChannel: Channel<E> = Channel(capacity = Channel.UNLIMITED)
private val internalActionChannel: Channel<A> = Channel(capacity = Channel.UNLIMITED)
// Public immutable interfaces
val stateFlow: StateFlow<S> = mutableStateFlow.asStateFlow()
val eventFlow: Flow<E> = eventChannel.receiveAsFlow()
val actionChannel: SendChannel<A> = internalActionChannel
// Initialize action processing
init {
viewModelScope.launch {
internalActionChannel
.consumeAsFlow()
.collect { action -> handleAction(action) }
}
}
protected abstract fun handleAction(action: A)
}
The Channel.UNLIMITED capacity ensures that actions and events won't be dropped if they're emitted
faster than they can be processed, which is crucial for maintaining UI integrity.
Advanced Usage Patterns
Beyond the basic implementation, effective BaseViewModel usage includes:
1. State Splitting
For complex screens, consider splitting state into logical subgroups:
data class ProfileState(
val userData: UserDataState = UserDataState(),
val settings: SettingsState = SettingsState(),
val interaction: InteractionState = InteractionState()
)
data class UserDataState(
val isLoading: Boolean = false,
val user: User? = null,
val error: String? = null
)
data class SettingsState(
val notifications: Boolean = true,
val darkMode: Boolean = false
)
data class InteractionState(
val selectedTab: Tab = Tab.PROFILE,
val isEditMode: Boolean = false
)
This approach makes it easier to update only relevant portions of state and prevents unnecessary recompositions.
2. Action Chaining
For complex operations that require multiple state updates:
override fun handleAction(action: ProfileAction) {
when (action) {
is ProfileAction.UpdateProfile -> {
mutableStateFlow.value = state.copy(
userData = state.userData.copy(isLoading = true)
)
viewModelScope.launch {
try {
val updatedUser = userRepository.updateProfile(action.updates)
sendAction(ProfileAction.ProfileUpdateSuccess(updatedUser))
} catch (e: Exception) {
sendAction(ProfileAction.ProfileUpdateFailure(e.message ?: "Unknown error"))
}
}
}
is ProfileAction.ProfileUpdateSuccess -> {
mutableStateFlow.value = state.copy(
userData = state.userData.copy(
isLoading = false,
user = action.user,
error = null
)
)
sendEvent(ProfileEvent.ShowSuccessMessage("Profile updated successfully"))
}
is ProfileAction.ProfileUpdateFailure -> {
mutableStateFlow.value = state.copy(
userData = state.userData.copy(
isLoading = false,
error = action.message
)
)
sendEvent(ProfileEvent.ShowErrorMessage(action.message))
}
}
}
3. Shared Actions
For actions that need to be processed by multiple ViewModels, define them in a shared location and have each ViewModel handle the subset it cares about:
sealed class AppAction {
object LogOut : AppAction()
data class NetworkStatusChanged(val isConnected: Boolean) : AppAction()
data class ThemeChanged(val isDarkMode: Boolean) : AppAction()
}
// Then in ViewModels, handle relevant actions:
override fun handleAction(action: AppAction) {
when (action) {
is AppAction.ThemeChanged -> {
// Only handle theme changes in this ViewModel
mutableStateFlow.value = state.copy(isDarkMode = action.isDarkMode)
}
else -> {
// Ignore other AppActions
}
}
}
2. Events System (BackgroundEvent.kt, EventsEffect.kt)
Understanding the Event Flow
The events system has several critical components working together:
eventChannel: A backingChannelthat buffers eventseventFlow: PublicFlowfor consuming events onceEventsEffect: A composable that consumes events with lifecycle awarenessBackgroundEvent: A marker interface for events that bypass lifecycle checks
Implementation Details
The EventsEffect composable uses a LaunchedEffect to safely collect events within the
composition lifecycle:
@Composable
fun <E> EventsEffect(
viewModel: BaseViewModel<*, E, *>,
lifecycleOwner: Lifecycle = LocalLifecycleOwner.current.lifecycle,
handler: suspend (E) -> Unit,
) {
LaunchedEffect(key1 = Unit) {
viewModel.eventFlow
.filter {
it is BackgroundEvent ||
lifecycleOwner.currentState.isAtLeast(Lifecycle.State.RESUMED)
}
.onEach { handler.invoke(it) }
.launchIn(this)
}
}
The filter operator is crucial here—it ensures that events are only processed when:
- The event implements the
BackgroundEventinterface, OR - The screen is currently visible (in the
RESUMEDstate)
This prevents navigation events from triggering multiple times during configuration changes or when returning to a screen from the background.
Types of Events
Events typically fall into four categories:
-
Navigation Events: Direct the user to a new screen
data class NavigateTo(val route: String, val popUpTo: String? = null) : UiEvent -
Message Events: Show transient UI like toasts or snackbars
data class ShowMessage(val message: String, val type: MessageType) : UiEvent -
Dialog Events: Display modal UI elements
data class ShowDialog(val title: String, val message: String) : UiEvent -
System Events: Interact with system components like camera or permissions
object RequestCameraPermission : UiEvent, BackgroundEvent
3. Lifecycle Observer (LifecycleEventEffect.kt)
The LivecycleEventEffect composable provides a clean way to observe and respond to Android
lifecycle events within compositions. It uses the DisposableEffect API to ensure proper cleanup.
Implementation Analysis
The implementation uses a clever combination of rememberUpdatedState and DisposableEffect:
@Composable
fun LivecycleEventEffect(
onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit,
) {
val eventHandler = rememberUpdatedState(onEvent)
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
DisposableEffect(lifecycleOwner.value) {
val lifecycle = lifecycleOwner.value.lifecycle
val observer = LifecycleEventObserver { owner, event ->
eventHandler.value(owner, event)
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
}
}
}
The rememberUpdatedState calls are essential: they ensure that if onEvent or thelifecycleOwner
changes during composition, the observer always uses the most current versions without needing to
resubscribe.
Advanced Lifecycle Handling
When working with complex screens that may have their own internal composition lifecycles:
@Composable
fun ComplexScreenWithTabs(viewModel: ComplexViewModel) {
var currentTab by remember { mutableStateOf(Tab.HOME) }
// Main screen lifecycle
LivecycleEventEffect { owner, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
viewModel.trySendAction(ComplexAction.ScreenResumed)
Analytics.logScreenView("ComplexScreen")
}
Lifecycle.Event.ON_PAUSE -> {
viewModel.trySendAction(ComplexAction.ScreenPaused)
}
Lifecycle.Event.ON_DESTROY -> {
viewModel.trySendAction(ComplexAction.SaveState)
}
else -> { /* Ignore other events */
}
}
}
// Tab-specific behavior
when (currentTab) {
Tab.HOME -> HomeTab(
onEnter = { /* Tab-specific enter logic */ },
onExit = { /* Tab-specific exit logic */ }
)
Tab.PROFILE -> ProfileTab(
onEnter = { /* Tab-specific enter logic */ },
onExit = { /* Tab-specific exit logic */ }
)
}
}
This pattern allows separation of screen-level lifecycle events from tab-specific behavior.
4. Navigation Extensions (NavGraphBuilderExtensions.kt, Transition.kt)
The navigation system provides a rich set of transition patterns that create a cohesive, motion-driven navigation experience.
Transition Animation Details
Each transition type is carefully timed and coordinated:
Slide Transitions (450ms):
- Slide content from bottom to top (enter) or top to bottom (exit)
- Used for modal dialogs and bottom sheets
Push Transitions (350ms):
- Horizontal sliding with synchronized fading
- Content slides in from right/out to left for forward navigation
- Content slides in from left/out to right when going back
- Includes a subtle overlap timing to create a natural feeling of depth
Stay Transitions:
- No visible movement to maintain context
- Maintains visibility for the duration of other concurrent transitions
- Uses fade transitions with minimal alpha changes (from 1.0 to 0.99) to keep Compose from optimizing away the animation
Intelligent Transition Handling
The most sophisticated aspect is the handling of nested navigation:
val AnimatedContentTransitionScope<NavBackStackEntry>.isSameGraphNavigation: Boolean
get() = initialState.destination.parent == targetState.destination.parent
This property checks if we're navigating between destinations within the same parent graph, and transitions will only apply within the same graph, allowing for hierarchical navigation patterns.
val fadeIn: EnterTransitionProvider = {
RootTransitionProviders.Enter
.fadeIn(this)
.takeIf { isSameGraphNavigation }
}
By returning null when navigating between different graphs, this allows parent navigators to
define transitions for cross-graph navigation while child navigators handle transitions within their
scope.
5. Image Loading (ImageLoaderExt.kt)
The image loading system abstracts Coil's capabilities across platforms while providing sensible defaults and optimization.
Memory Management
The system intelligently manages memory based on platform constraints:
internal fun rememberDefaultImageLoader(context: PlatformContext): ImageLoader {
return remember(context) {
ImageLoader.Builder(context)
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25) // Use 25% of available memory
.build()
}
.logger(DebugLogger())
.build()
}
}
The maxSizePercent call is crucial—it adapts the cache size to the device's available memory,
ensuring efficient resource usage across a wide range of devices.
Request Optimization
The image request builder includes memory cache optimization:
@Composable
fun rememberImageRequest(
context: PlatformContext,
wallpaper: String,
): ImageRequest {
return remember(wallpaper) {
ImageRequest.Builder(context)
.data(wallpaper)
.memoryCacheKey(wallpaper)
.placeholderMemoryCacheKey(wallpaper)
.build()
}
}
The use of memoryCacheKey and placeholderMemoryCacheKey ensures that images with the same URL
share the same cache entry, reducing memory usage and improving load times.
Common Use Patterns
For profile pictures and avatars:
@Composable
fun CircularProfileImage(url: String, size: Dp = 48.dp) {
val context = LocalPlatformContext.current
val imageLoader = rememberImageLoader(context)
AsyncImage(
model = rememberImageRequest(context, url),
contentDescription = "Profile picture",
imageLoader = imageLoader,
modifier = Modifier
.size(size)
.clip(CircleShape)
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape),
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.placeholder_profile),
error = painterResource(R.drawable.error_profile)
)
}
For background images:
@Composable
fun BackgroundImage(url: String, overlay: Color = Color.Black.copy(alpha = 0.3f)) {
val context = LocalPlatformContext.current
val imageLoader = rememberImageLoader(context)
Box {
AsyncImage(
model = rememberImageRequest(context, url),
contentDescription = null,
imageLoader = imageLoader,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.FillBounds
)
// Overlay for better text visibility
Box(
modifier = Modifier
.fillMaxSize()
.background(overlay)
)
}
}
6. Performance Monitoring (JankStatsExtensions.kt)
Performance monitoring is crucial for delivering smooth UIs. The JankStats integration helps identify and address UI performance issues.
Jank Detection Mechanism
"Jank" refers to frames that take longer than 16.67ms (for 60fps) to render, causing visible stuttering. The Android-specific implementation uses the Metrics API:
@Composable
actual fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) {
TrackJank(scrollableState) { metricsHolder ->
snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress ->
metricsHolder.state?.apply {
if (isScrollInProgress) {
putState(stateName, "Scrolling=true")
} else {
removeState(stateName)
}
}
}
}
}
When scrolling starts, the system marks the current frames with the provided state name. This allows the performance tools to attribute jank to specific UI interactions.
Performance Optimization Strategies
To minimize jank in scrolling lists:
- Minimize composition cost: Use
keyfor list items to prevent unnecessary recomposition - Avoid nested scrolling: Nested scrollable containers can compound performance issues
- Lazy loading: Only load visible items and maintain a reasonable buffer
- Pre-compute complex layouts: Calculate layout parameters ahead of time
- Bitmap caching: For complex images, pre-compute and cache bitmaps
- Avoid allocation in scroll: Don't create new objects during scrolling
Example of a performance-optimized list:
@Composable
fun OptimizedList(items: List<ListItem>) {
val listState = rememberLazyListState()
// Track scrolling performance
TrackScrollJank(listState, "main_list")
// Pre-compute expensive stuff
val coloredItems = remember(items) {
items.map { it.copy(color = calculateComplexColor(it)) }
}
LazyColumn(state = listState) {
items(
items = coloredItems,
key = { it.id } // Stable key for efficient updates
) { item ->
// Cached layout calculation
val layoutInfo = remember(item.id) {
calculateLayout(item)
}
ListItemRow(
item = item,
layoutInfo = layoutInfo,
modifier = Modifier.animateItemPlacement()
)
}
}
}
7. Cross-Platform Sharing (ShareUtils.kt)
The ShareUtils object provides a unified API for sharing content, with platform-specific
implementations handling the technical details.
Platform Implementation Details
Android Implementation:
- Uses Android's
Intentsystem withACTION_SEND - For images, first saves to cache directory, then creates a
FileProviderURI - Requires a valid Activity context from
activityProvider
iOS (Native) Implementation:
- Uses
UIActivityViewControllerfor sharing - Requires the root view controller from
UIApplication.sharedApplication()
Desktop/JS/WASM Implementations:
- Use
FileKitto save content to disk since direct sharing is less standardized - For images, converts
ImageBitmapto pixel data before saving
Security Considerations
The Android implementation includes important security features:
private suspend fun saveImage(image: Bitmap, context: Context): Uri? {
return withContext(Dispatchers.IO) {
try {
val imagesFolder = File(context.cacheDir, "images")
// ... save image ...
// Use FileProvider for secure content sharing
FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
} catch (e: IOException) {
Log.d("saving bitmap", "saving bitmap error ${e.message}")
null
}
}
}
Using FileProvider instead of direct file URIs is essential for API 24+ compatibility and
security. The provider creates content URIs that grant temporary access to the files being shared,
without exposing file system paths.
8. String Extensions (StringExt.kt)
The capitalizeEachWord extension demonstrates how simple utility functions can improve code
clarity and consistency.
val String.capitalizeEachWord: String
get() = this.split(" ").joinToString(" ") { word ->
word.takeIf { it.isNotEmpty() }
?.let { it.first().uppercase() + it.substring(1).lowercase() }
?: ""
}
This implementation handles edge cases like:
- Empty strings
- Words containing only a single character
- Strings with multiple consecutive spaces
For multi-lingual applications, consider extending this with locale-aware capitalization:
fun String.capitalizeEachWordWithLocale(locale: Locale): String {
return this.split(" ").joinToString(" ") { word ->
word.takeIf { it.isNotEmpty() }
?.let { it.replaceFirstChar { char -> char.titlecase(locale) } }
?: ""
}
}
9. Reporting Drawn State (ReportDrawnExt.kt)
The ReportDrawnWhen composable is an important performance optimization that tells the system when
content is considered meaningfully drawn.
Platform-Specific Implementations
On Android, the implementation delegates to the Android Compose implementation:
@Composable
actual fun ReportDrawnWhen(block: () -> Boolean) {
androidx.activity.compose.ReportDrawnWhen { block() }
}
On other platforms, the implementation is a no-op, preserving the API surface without requiring platform-specific functionality:
@Composable
actual fun ReportDrawnWhen(block: () -> Boolean) {
// No-op implementation
}
Performance Impact
This composable has a significant performance impact on initial screen rendering. Android uses this signal to:
- Mark the activity as drawn for launcher animations
- Complete "warm start" timing measurements
- Report performance metrics to developer tools
A common pattern is to report drawn status once critical content is visible, even if background loading continues:
@Composable
fun NewsScreen(viewModel: NewsViewModel) {
val state by viewModel.stateFlow.collectAsState()
Column {
TopBar()
when (val currentState = state) {
is Loading -> LoadingIndicator()
is Success -> {
NewsList(currentState.headlines)
// Asynchronously load recommended stories
LaunchedEffect(Unit) {
viewModel.trySendAction(LoadRecommendations)
}
}
is Error -> ErrorView(currentState.message)
}
}
// Report as drawn once headlines are loaded, even if recommendations are still loading
ReportDrawnWhen {
state is Success
}
}
10. Shared Element Transitions (SharedElementExt.kt)
The shared element transition system enables smooth visual continuity between screens using Material 3's shared element transitions.
Implementation Details
The system uses composition locals to provide access to animation scopes:
val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
These locals enable any composable in the hierarchy to participate in transitions regardless of their depth in the UI tree.
Advanced Transition Patterns
Beyond basic image transitions, shared elements can be used for:
-
Expanding Cards: A card expands into a full screen detail view
Card( modifier = Modifier .sharedElement( scope = sharedTransitionScope, state = rememberSharedContentState(key = "card-${item.id}") ) ) { // Card content } // In detail screen: Surface( modifier = Modifier .fillMaxSize() .sharedElement( scope = sharedTransitionScope, state = rememberSharedContentState(key = "card-${item.id}") ) ) { // Detail content } -
Text Transitions: Text elements that move and resize
Text( text = item.title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.sharedElement( scope = sharedTransitionScope, state = rememberSharedContentState(key = "title-${item.id}") ) ) // In detail screen: Text( text = item.title, style = MaterialTheme.typography.headlineLarge, modifier = Modifier.sharedElement( scope = sharedTransitionScope, state = rememberSharedContentState(key = "title-${item.id}") ) ) -
Color Transitions: Smoothly changing colors between screens
Box( modifier = Modifier .background(item.color) .sharedElement( scope = sharedTransitionScope, state = rememberSharedContentState(key = "color-${item.id}") ) )
Comprehensive Testing Strategy
A robust testing strategy ensures the module's reliability across platforms.
Unit Testing ViewModels
Test ViewModels by verifying state changes, action handling, and event emission:
@Test
fun `when profile loaded successfully, state updated and success event emitted`() = runTest {
// Given
val repository = FakeUserRepository()
val viewModel = ProfileViewModel(repository)
val events = mutableListOf<ProfileEvent>()
val job = launch { viewModel.eventFlow.collect { events.add(it) } }
// When
viewModel.trySendAction(ProfileAction.LoadProfile("user123"))
// Then
assertEquals(false, viewModel.stateFlow.value.userData.isLoading)
assertNotNull(viewModel.stateFlow.value.userData.user)
assertEquals("user123", viewModel.stateFlow.value.userData.user?.id)
assertEquals(1, events.size)
assertTrue(events[0] is ProfileEvent.ProfileLoaded)
job.cancel()
}
Testing Composables
Use the Compose testing library to verify UI behavior:
@Test
fun profileScreen_showsUserData_whenProvided() {
// Given
val user = User("123", "Jane Doe", "jane@example.com")
val state = ProfileState(userData = UserDataState(user = user))
// When
composeTestRule.setContent {
MaterialTheme {
ProfileScreen(state = state, onAction = {})
}
}
// Then
composeTestRule.onNodeWithText("Jane Doe").assertIsDisplayed()
composeTestRule.onNodeWithText("jane@example.com").assertIsDisplayed()
}
Integration Testing
Test component interactions using fake implementations:
@Test
fun navigationEvents_triggerCorrectNavigation() {
// Given
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
composeTestRule.setContent {
NavigationTestHost(navController = navController) {
val viewModel = ProfileViewModel(FakeUserRepository())
ProfileScreen(viewModel = viewModel)
// Set up event observation
EventsEffect(viewModel) { event ->
when (event) {
is ProfileEvent.NavigateToSettings -> {
navController.navigate("settings")
}
}
}
}
}
// When - click settings button
composeTestRule.onNodeWithContentDescription("Settings").performClick()
// Then - verify navigation occurred
assertEquals("settings", navController.currentDestination?.route)
}
By combining these testing approaches, you can ensure the core-base/ui module functions correctly across all supported platforms and integration points.