Agent skill
android-compose
Jetpack Compose framework guardrails, patterns, and best practices for AI-assisted development. Use when working with Android Compose projects, or when the user mentions Jetpack Compose. Provides composable patterns, state management, Material 3, navigation, and lifecycle guidelines.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/other/android-compose
Metadata
Additional technical details for this skill
- author
- samuel
- version
- 1.0
- category
- framework
- language
- kotlin
- extensions
- .kt
SKILL.md
Android Jetpack Compose Guide
Applies to: Android SDK 24+, Jetpack Compose 1.5+, Kotlin 1.9+, Material Design 3
Core Principles
- Declarative UI: Describe what the UI should look like, not how to build it step-by-step
- Unidirectional Data Flow: State flows down, events flow up -- always
- Composable Functions Are Cheap: The framework handles when to recompose; keep composables pure
- State Hoisting: Lift state to the nearest common ancestor that needs it
- Lifecycle Awareness: Collect flows with
collectAsStateWithLifecycle(), never rawcollectAsState()
Guardrails
Composable Conventions
- Every composable accepts
modifier: Modifier = Modifieras its first optional parameter - Keep composables small and focused (one responsibility per function)
- Use
@Previewon every reusable component with representative data - Never perform side effects (network, DB, analytics) directly inside a composable body
- Use
LaunchedEffect,DisposableEffect, orSideEffectfor side-effect work - Prefer stateless composables; hoist state to the caller or ViewModel
// BAD: side effects in composition
@Composable
fun UserList(users: List<User>) {
analytics.trackScreenView("user_list") // fires on every recomposition
LazyColumn { ... }
}
// GOOD: side effect in LaunchedEffect
@Composable
fun UserList(users: List<User>) {
LaunchedEffect(Unit) {
analytics.trackScreenView("user_list")
}
LazyColumn { ... }
}
State Management
- Use
StateFlowin ViewModel, collect withcollectAsStateWithLifecycle() - Model UI state as a sealed interface (
Loading,Success,Error) - Never expose
MutableStateFlowpublicly from a ViewModel - Use
rememberfor local composable state;rememberSaveablewhen it must survive config changes - Use
derivedStateOffor expensive computations that depend on other state
// Sealed UI state pattern
sealed interface HomeUiState {
data object Loading : HomeUiState
data class Success(val users: List<User>) : HomeUiState
data class Error(val message: String) : HomeUiState
}
// ViewModel exposes immutable StateFlow
@HiltViewModel
class HomeViewModel @Inject constructor(
private val userRepository: UserRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun loadUsers() {
viewModelScope.launch {
_uiState.value = HomeUiState.Loading
userRepository.getUsers()
.catch { e -> _uiState.value = HomeUiState.Error(e.message ?: "Unknown error") }
.collect { users -> _uiState.value = HomeUiState.Success(users) }
}
}
}
// Screen collects lifecycle-aware
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is HomeUiState.Loading -> LoadingIndicator()
is HomeUiState.Success -> UserList(state.users)
is HomeUiState.Error -> ErrorMessage(state.message, onRetry = viewModel::loadUsers)
}
}
Navigation
- Define routes as a sealed class or sealed interface for type safety
- Never pass complex objects as navigation arguments; pass IDs and load from ViewModel
- Clear back stack correctly with
popUpToandinclusivewhen navigating to auth screens - Use
hiltViewModel()insidecomposable {}blocks for scoped ViewModels
sealed class Screen(val route: String) {
data object Login : Screen("login")
data object Home : Screen("home")
data object Detail : Screen("detail/{userId}") {
fun createRoute(userId: String) = "detail/$userId"
}
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.Home.route) {
composable(Screen.Home.route) {
HomeScreen(onUserClick = { userId ->
navController.navigate(Screen.Detail.createRoute(userId))
})
}
composable(
route = Screen.Detail.route,
arguments = listOf(navArgument("userId") { type = NavType.StringType }),
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: ""
DetailScreen(userId = userId, onBack = { navController.popBackStack() })
}
}
}
Material 3 Theming
- Always wrap the app root in your custom
MaterialThemecomposable - Support dynamic color on Android 12+ with
dynamicLightColorScheme/dynamicDarkColorScheme - Define color tokens in a dedicated
Color.kt; typography inType.kt; theme inTheme.kt - Use
MaterialTheme.colorScheme.*andMaterialTheme.typography.*instead of hardcoded values - Support both light and dark themes from day one
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content)
}
Dependency Injection (Hilt)
- Annotate
Applicationwith@HiltAndroidApp,Activitywith@AndroidEntryPoint - Annotate ViewModels with
@HiltViewModeland use@Inject constructor - Use
hiltViewModel()in composables, never construct ViewModels manually - Organize DI modules by layer:
AppModule,NetworkModule,DatabaseModule
Lifecycle & Effects
| Effect | Trigger | Use for |
|---|---|---|
LaunchedEffect(key) |
On enter + when key changes | One-shot loads, navigation events |
DisposableEffect(key) |
On enter + when key changes (with cleanup) | Listeners, observers, callbacks |
SideEffect |
After every successful recomposition | Syncing compose state to non-compose |
rememberCoroutineScope() |
Manual launch from callbacks | Button clicks launching coroutines |
// One-shot load on screen enter
LaunchedEffect(Unit) { viewModel.loadData() }
// Clean up a listener when leaving composition
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> ... }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Project Structure
app/src/main/java/com/example/myapp/
├── MyApplication.kt # @HiltAndroidApp
├── MainActivity.kt # @AndroidEntryPoint, setContent {}
├── navigation/
│ └── AppNavigation.kt # NavHost, route definitions
├── ui/
│ ├── theme/ # Color.kt, Type.kt, Theme.kt
│ ├── screens/ # Feature screens
│ │ ├── home/
│ │ │ ├── HomeScreen.kt # Composable
│ │ │ └── HomeViewModel.kt # ViewModel + UiState
│ │ └── detail/
│ │ ├── DetailScreen.kt
│ │ └── DetailViewModel.kt
│ └── components/ # Shared composables
│ ├── LoadingIndicator.kt
│ └── ErrorMessage.kt
├── data/
│ ├── model/ # Domain models (@Serializable)
│ ├── remote/ # Retrofit service, DTOs
│ ├── local/ # Room database, DAOs
│ └── repository/ # Repository implementations
└── di/ # Hilt modules
├── AppModule.kt
└── NetworkModule.kt
- ui/screens/: One sub-package per feature, each with
Screen.kt+ViewModel.kt - ui/components/: Reusable composables shared across screens
- data/: Models, repositories, API services; no Compose dependencies here
- di/: Hilt modules providing singletons and scoped dependencies
Key Dependencies
// build.gradle.kts (app)
val composeBom = platform("androidx.compose:compose-bom:2024.01.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")
implementation("io.coil-kt:coil-compose:2.5.0")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.9")
testImplementation("app.cash.turbine:turbine:1.0.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
Testing
ViewModel Unit Tests
- Use
StandardTestDispatcher+Dispatchers.setMain()for coroutine control - Use Turbine for
StateFlowassertions - Use MockK with
coEvery/coVerifyfor suspend function mocking - Reset dispatcher in
@AfterwithDispatchers.resetMain()
@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val userRepository = mockk<UserRepository>()
@Before fun setup() { Dispatchers.setMain(testDispatcher) }
@After fun tearDown() { Dispatchers.resetMain() }
@Test
fun `loadUsers emits Success state on valid data`() = runTest {
val users = listOf(User(id = "1", email = "[email protected]", name = "Alice"))
coEvery { userRepository.getUsers() } returns flowOf(users)
val viewModel = HomeViewModel(userRepository)
advanceUntilIdle()
viewModel.uiState.test {
val state = awaitItem()
assertTrue(state is HomeUiState.Success)
assertEquals(users, (state as HomeUiState.Success).users)
}
}
}
Compose UI Tests
- Use
createComposeRule()for isolated composable tests - Query nodes with
onNodeWithText,onNodeWithTag,onNodeWithContentDescription - Assert with
assertExists(),assertIsDisplayed(),assertIsEnabled() - Perform actions with
performClick(),performTextInput()
class HomeScreenTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun displays_user_name_when_loaded() {
composeTestRule.setContent {
MyAppTheme {
UserCard(user = User("1", "[email protected]", "Alice"), onClick = {}, onDeleteClick = {})
}
}
composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
composeTestRule.onNodeWithText("[email protected]").assertIsDisplayed()
}
}
Commands
# Build
./gradlew assembleDebug # Debug APK
./gradlew assembleRelease # Release APK
./gradlew bundleRelease # AAB for Play Store
# Test
./gradlew test # Unit tests
./gradlew connectedAndroidTest # Instrumented tests on device/emulator
# Quality
./gradlew lint # Android Lint
./gradlew ktlintCheck # Code style check
./gradlew ktlintFormat # Auto-fix formatting
./gradlew detekt # Static analysis
# Install & Run
./gradlew installDebug # Install on connected device
./gradlew clean # Clean build artifacts
Best Practices Summary
Do
- Use
collectAsStateWithLifecycle()for all Flow collection in composables - Use
remember {}for expensive local computations - Use
LaunchedEffectfor one-shot side effects,DisposableEffectfor cleanup - Keep composables stateless; hoist state to ViewModel or caller
- Use sealed interfaces for UI state with exhaustive
whenexpressions - Provide
contentDescriptionon all interactive and decorative icons - Use
Modifierchaining; acceptmodifierparameter in every public composable - Preview every reusable component with
@Preview
Avoid
- Performing I/O, network calls, or analytics tracking directly in composable bodies
- Exposing
MutableStateFloworMutableStatefrom ViewModels - Using
!!operator in production code - Hardcoding colors, dimensions, or strings (use theme tokens and resources)
- Creating ViewModels manually (use
hiltViewModel()) - Passing complex objects through navigation arguments (pass IDs instead)
- Ignoring configuration changes (use
rememberSaveablewhen needed)
References
For detailed UI patterns, animation, testing strategies, performance, and accessibility guidance, see:
- references/patterns.md -- UI composition patterns, animation, performance, accessibility, testing recipes
External References
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?