Mobile Android Design
Seth HobsonCommunityCreated by Seth Hobson
- Skill
- Tool integration
- Software development
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a comprehensive guide to building native Android interfaces using Jetpack Compose and Material Design 3. It covers essential UI components, navigation patterns, and best practices for creating responsive, accessible, and performant Android applications. The content includes practical code examples for common UI elements like lists, forms, and navigation structures, alongside guidance on state management and theming.
What it does
- Provides patterns for implementing Material Design 3 components and theming.
- Demonstrates building UI layouts using Jetpack Compose.
- Explains type-safe navigation implementation with Navigation Compose.
- Offers best practices for state management and recomposition performance.
- Includes examples for common UI patterns like lazy lists, swipe-to-dismiss, and forms.
- Covers accessibility considerations for Android interface design.
When to use it
- When designing or building native Android application interfaces.
- When implementing modern UI components using Jetpack Compose.
- When migrating or adopting Material Design 3 guidelines in an Android project.
- When setting up navigation flows in a Compose-based Android app.
- When optimizing Android UI for different screen sizes and configurations.
Advantages
- Promotes consistent UI design using official Material 3 standards.
- Provides ready-to-use code snippets for common Android UI components.
- Encourages best practices for performance and state management in Compose.
- Facilitates the creation of adaptive and accessible Android layouts.
Usage examples
- “How do I implement a list with swipe-to-dismiss functionality in Jetpack Compose?”
- The skill provides a SwipeableItem component example using SwipeToDismissBox and rememberSwipeToDismissBoxState to handle deletion logic.
- “Show me how to set up type-safe navigation between screens in my Android app.”
- The skill demonstrates using @Serializable objects and data classes with NavHost and composable functions to manage navigation routes.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/wshobson/mobile-android-design/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip mobile-android-design.zip -d .agents/skills
Documentation by Seth Hobson
Android Mobile Design
Master Material Design 3 (Material You) and Jetpack Compose to build modern, adaptive Android applications that integrate seamlessly with the Android ecosystem.
When to Use This Skill
Designing Android app interfaces following Material Design 3
Building Jetpack Compose UI and layouts
Implementing Android navigation patterns (Navigation Compose)
Creating adaptive layouts for phones, tablets, and foldables
Using Material 3 theming with dynamic colors
Building accessible Android interfaces
Implementing Android-specific gestures and interactions
Designing for different screen configurations
Detailed section: Core Concepts
Originally a 9201-byte section in this SKILL.md. Moved to references/details.md to fit Codex's 8 KB skill body cap.
Quick Start Component
@Composable
fun ItemListCard(
item: Item,
onItemClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = onItemClick,
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = item.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}Best Practices
Use Material Theme: Access colors via
MaterialTheme.colorSchemefor automatic dark mode supportSupport Dynamic Color: Enable dynamic color on Android 12+ for personalization
Adaptive Layouts: Use
WindowSizeClassfor responsive designsContent Descriptions: Add
contentDescriptionto all interactive elementsTouch Targets: Minimum 48dp touch targets for accessibility
State Hoisting: Hoist state to make components reusable and testable
Remember Properly: Use
rememberandrememberSaveableappropriatelyPreview Annotations: Add
@Previewwith different configurations
Common Issues
Recomposition Issues: Avoid passing unstable lambdas; use
rememberState Loss: Use
rememberSaveablefor configuration changesPerformance: Use
LazyColumninstead ofColumnfor long listsTheme Leaks: Ensure
MaterialThemewraps all composablesNavigation Crashes: Handle back press and deep links properly
Memory Leaks: Cancel coroutines in
DisposableEffect
Files
- ATTRIBUTION.mdAdded by AutoGineer981 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md3.7 KiB
- references/android-navigation.md19.5 KiB
- references/compose-components.md22.8 KiB
- references/details.md9.0 KiB
- references/material3-theming.md15.8 KiB
references/compose-components.md
Jetpack Compose Component Library
Lists and Collections
Basic LazyColumn
@Composable
fun ItemList(
items: List<Item>,
onItemClick: (Item) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = items,
key = { it.id }
) { item ->
ItemRow(
item = item,
onClick = { onItemClick(item) }
)
}
}
}Pull to Refresh
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RefreshableList(
items: List<Item>,
isRefreshing: Boolean,
onRefresh: () -> Unit
) {
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
state = pullToRefreshState,
isRefreshing = isRefreshing,
onRefresh = onRefresh
) {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(items) { item ->
ItemRow(item = item)
}
}
}
}Swipe to Dismiss
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeableItem(
item: Item,
onDelete: () -> Unit
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onDelete()
true
} else {
false
}
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.onErrorContainer
)
}
}
) {
ItemRow(item = item)
}
}Sticky Headers
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GroupedList(
groups: Map<String, List<Item>>
) {
LazyColumn {
groups.forEach { (header, items) ->
stickyHeader {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Text(
text = header,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
items(items, key = { it.id }) { item ->
ItemRow(item = item)
}
}
}
}Forms and Input
Text Fields
@Composable
fun LoginForm(
onLogin: (email: String, password: String) -> Unit
) {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
var emailError by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = email,
onValueChange = {
email = it
emailError = if (it.isValidEmail()) null else "Invalid email"
},
label = { Text("Email") },
placeholder = { Text("[email protected]") },
leadingIcon = { Icon(Icons.Default.Email, null) },
isError = emailError != null,
supportingText = emailError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
leadingIcon = { Icon(Icons.Default.Lock, null) },
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
if (passwordVisible) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = "Toggle password visibility"
)
}
},
visualTransformation = if (passwordVisible)
VisualTransformation.None
else
PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { onLogin(email, password) },
modifier = Modifier.fillMaxWidth(),
enabled = email.isNotEmpty() && password.isNotEmpty() && emailError == null
) {
Text("Sign In")
}
}
}Search Bar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchableScreen(
items: List<Item>,
onItemClick: (Item) -> Unit
) {
var query by rememberSaveable { mutableStateOf("") }
var expanded by rememberSaveable { mutableStateOf(false) }
val filteredItems = remember(query, items) {
if (query.isEmpty()) items
else items.filter { it.name.contains(query, ignoreCase = true) }
}
SearchBar(
query = query,
onQueryChange = { query = it },
onSearch = { expanded = false },
active = expanded,
onActiveChange = { expanded = it },
placeholder = { Text("Search items") },
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { query = "" }) {
Icon(Icons.Default.Clear, "Clear search")
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (expanded) 0.dp else 16.dp)
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp)
) {
items(filteredItems) { item ->
ListItem(
headlineContent = { Text(item.name) },
supportingContent = { Text(item.description) },
modifier = Modifier.clickable {
onItemClick(item)
expanded = false
}
)
}
}
}
}Selection Controls
@Composable
fun SettingsScreen() {
var notificationsEnabled by rememberSaveable { mutableStateOf(true) }
var selectedOption by rememberSaveable { mutableStateOf(0) }
var expandedDropdown by remember { mutableStateOf(false) }
var selectedLanguage by rememberSaveable { mutableStateOf("English") }
val languages = listOf("English", "Spanish", "French", "German")
Column {
// Switch
ListItem(
headlineContent = { Text("Enable Notifications") },
supportingContent = { Text("Receive push notifications") },
trailingContent = {
Switch(
checked = notificationsEnabled,
onCheckedChange = { notificationsEnabled = it }
)
}
)
HorizontalDivider()
// Radio buttons
Column {
Text(
"Theme",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleSmall
)
listOf("System", "Light", "Dark").forEachIndexed { index, option ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedOption == index,
onClick = { selectedOption = index },
role = Role.RadioButton
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedOption == index,
onClick = null
)
Spacer(Modifier.width(16.dp))
Text(option)
}
}
}
HorizontalDivider()
// Dropdown
ExposedDropdownMenuBox(
expanded = expandedDropdown,
onExpandedChange = { expandedDropdown = it },
modifier = Modifier.padding(16.dp)
) {
OutlinedTextField(
value = selectedLanguage,
onValueChange = {},
readOnly = true,
label = { Text("Language") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDropdown) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expandedDropdown,
onDismissRequest = { expandedDropdown = false }
) {
languages.forEach { language ->
DropdownMenuItem(
text = { Text(language) },
onClick = {
selectedLanguage = language
expandedDropdown = false
},
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
)
}
}
}
}
}Dialogs and Bottom Sheets
Alert Dialog
@Composable
fun DeleteConfirmationDialog(
itemName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
title = {
Text("Delete Item?")
},
text = {
Text("Are you sure you want to delete \"$itemName\"? This action cannot be undone.")
},
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}Modal Bottom Sheet
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OptionsBottomSheet(
onDismiss: () -> Unit,
onOptionSelected: (String) -> Unit
) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
dragHandle = { BottomSheetDefaults.DragHandle() }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
) {
Text(
"Options",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.titleMedium
)
listOf(
Triple(Icons.Default.Share, "Share", "share"),
Triple(Icons.Default.Edit, "Edit", "edit"),
Triple(Icons.Default.FileCopy, "Duplicate", "duplicate"),
Triple(Icons.Default.Delete, "Delete", "delete")
).forEach { (icon, label, action) ->
ListItem(
headlineContent = { Text(label) },
leadingContent = {
Icon(
icon,
contentDescription = null,
tint = if (action == "delete")
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
)
},
modifier = Modifier.clickable { onOptionSelected(action) }
)
}
}
}
}Date and Time Pickers
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimePickerExample() {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
val datePickerState = rememberDatePickerState()
val timePickerState = rememberTimePickerState()
Column(modifier = Modifier.padding(16.dp)) {
OutlinedButton(onClick = { showDatePicker = true }) {
Icon(Icons.Default.CalendarToday, null)
Spacer(Modifier.width(8.dp))
Text(
datePickerState.selectedDateMillis?.let {
SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
.format(Date(it))
} ?: "Select Date"
)
}
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { showTimePicker = true }) {
Icon(Icons.Default.Schedule, null)
Spacer(Modifier.width(8.dp))
Text(
String.format("%02d:%02d", timePickerState.hour, timePickerState.minute)
)
}
}
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = { showDatePicker = false }) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text("Cancel")
}
}
) {
DatePicker(state = datePickerState)
}
}
if (showTimePicker) {
AlertDialog(
onDismissRequest = { showTimePicker = false },
confirmButton = {
TextButton(onClick = { showTimePicker = false }) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) {
Text("Cancel")
}
},
text = {
TimePicker(state = timePickerState)
}
)
}
}Loading States
Progress Indicators
@Composable
fun LoadingStates() {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Indeterminate circular
CircularProgressIndicator()
// Determinate circular
CircularProgressIndicator(
progress = { 0.7f },
strokeWidth = 4.dp
)
// Indeterminate linear
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
// Determinate linear
LinearProgressIndicator(
progress = { 0.7f },
modifier = Modifier.fillMaxWidth()
)
}
}Skeleton Loading
@Composable
fun SkeletonLoader(
modifier: Modifier = Modifier
) {
val infiniteTransition = rememberInfiniteTransition(label = "skeleton")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 0.7f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
Column(
modifier = modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
repeat(5) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
modifier = Modifier
.height(16.dp)
.fillMaxWidth(0.7f)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
)
Box(
modifier = Modifier
.height(12.dp)
.fillMaxWidth(0.5f)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
)
}
}
}
}
}Content Loading Pattern
@Composable
fun <T> AsyncContent(
state: AsyncState<T>,
onRetry: () -> Unit,
content: @Composable (T) -> Unit
) {
when (state) {
is AsyncState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is AsyncState.Success -> {
content(state.data)
}
is AsyncState.Error -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(16.dp))
Text(
"Something went wrong",
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(8.dp))
Text(
state.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Button(onClick = onRetry) {
Text("Try Again")
}
}
}
}
}
sealed class AsyncState<out T> {
object Loading : AsyncState<Nothing>()
data class Success<T>(val data: T) : AsyncState<T>()
data class Error(val message: String) : AsyncState<Nothing>()
}Animations
Animated Visibility
@Composable
fun ExpandableCard(
title: String,
content: String
) {
var expanded by rememberSaveable { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.clickable { expanded = !expanded }
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Icon(
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand"
)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Text(
text = content,
modifier = Modifier.padding(top = 12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}Animated Content
@Composable
fun AnimatedCounter(count: Int) {
AnimatedContent(
targetState = count,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { -it } + fadeIn() togetherWith
slideOutVertically { it } + fadeOut()
} else {
slideInVertically { it } + fadeIn() togetherWith
slideOutVertically { -it } + fadeOut()
}.using(SizeTransform(clip = false))
},
label = "counter"
) { targetCount ->
Text(
text = "$targetCount",
style = MaterialTheme.typography.displayMedium
)
}
}Gesture-Based Animation
@Composable
fun SwipeableCard(
onSwipeLeft: () -> Unit,
onSwipeRight: () -> Unit,
content: @Composable () -> Unit
) {
var offsetX by remember { mutableFloatStateOf(0f) }
val animatedOffset by animateFloatAsState(
targetValue = offsetX,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "offset"
)
Card(
modifier = Modifier
.fillMaxWidth()
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
when {
offsetX > 200f -> {
onSwipeRight()
offsetX = 0f
}
offsetX < -200f -> {
onSwipeLeft()
offsetX = 0f
}
else -> offsetX = 0f
}
},
onHorizontalDrag = { _, dragAmount ->
offsetX += dragAmount
}
)
}
) {
content()
}
}Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- Seth Hobson
- Publisher
- Seth Hobson (Community)
- Source
- wshobson/agents · plugins/ui-design/skills/mobile-android-design · commit 4bde316
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Seth Hobson.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download mobile-android-design.zip