Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component...
npx skills add HuxleyMc/Android-Skills --skill "responsive-layouts"
Install specific skill from multi-skill repository
# Description
Guides responsive UI development for phones, tablets, foldables, and desktop. Use when supporting multiple screen sizes, implementing adaptive layouts, handling foldable devices, or optimizing for large screens. Covers window size classes, multi-pane layouts, foldable support, and input adaptations.
# SKILL.md
name: responsive-layouts
description: Guides responsive UI development for phones, tablets, foldables, and desktop. Use when supporting multiple screen sizes, implementing adaptive layouts, handling foldable devices, or optimizing for large screens. Covers window size classes, multi-pane layouts, foldable support, and input adaptations.
tags: ["android", "responsive", "adaptive", "tablet", "foldable", "desktop", "compose", "large-screens"]
difficulty: intermediate
category: ui
version: "1.0.0"
last_updated: "2025-01-29"
Responsive Layouts
Quick Start
Add dependencies:
dependencies {
// Window size classes
implementation("androidx.compose.material3:material3-window-size-class:1.2.0")
// Foldable support
implementation("androidx.window:window:1.2.0")
// Navigation for large screens
implementation("androidx.navigation:navigation-compose:2.7.6")
}
Device categories:
| Device | Width | Height | Layout Strategy |
|---|---|---|---|
| Phone (compact) | <600dp | <480dp | Single pane, bottom nav |
| Phone (medium) | 600-840dp | 480-900dp | Single/dual pane adaptive |
| Tablet (expanded) | 840-1200dp | 900dp+ | Dual pane, side nav |
| Desktop/large tablet | >1200dp | >900dp | Multi-pane, permanent nav |
| Foldable (folded) | Similar to phone | - | Single pane |
| Foldable (unfolded) | Similar to tablet | - | Dual pane |
Core Patterns
Window Size Classes
Calculate size class:
@Composable
fun MyApp() {
val windowSizeClass = calculateWindowSizeClass()
// Use size class for adaptive behavior
ResponsiveLayout(windowSizeClass = windowSizeClass)
}
@Composable
fun ResponsiveLayout(windowSizeClass: WindowSizeClass) {
// Compact = phone portrait
// Medium = phone landscape / small tablet
// Expanded = tablet / desktop
val widthClass = windowSizeClass.widthSizeClass
val heightClass = windowSizeClass.heightSizeClass
when (widthClass) {
WindowWidthSizeClass.Compact -> PhoneLayout()
WindowWidthSizeClass.Medium -> TabletLayout()
WindowWidthSizeClass.Expanded -> DesktopLayout()
}
}
Adaptive content:
@Composable
fun ListDetailScreen(
items: List<Item>,
selectedItem: Item?,
onItemSelected: (Item) -> Unit
) {
val windowSizeClass = calculateWindowSizeClass()
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
// Phone: Single pane with navigation
if (selectedItem == null) {
ItemListScreen(items, onItemSelected)
} else {
ItemDetailScreen(selectedItem)
}
}
else -> {
// Tablet/Desktop: Dual pane
Row {
ItemListPane(
items = items,
selectedItem = selectedItem,
onItemSelected = onItemSelected,
modifier = Modifier.weight(1f)
)
ItemDetailPane(
item = selectedItem,
modifier = Modifier.weight(2f)
)
}
}
}
}
Multi-Pane Layouts
List-detail layout:
@Composable
fun ListDetailLayout(
items: List<Email>,
selectedId: String?,
onSelect: (String) -> Unit,
onBack: () -> Unit
) {
val windowSizeClass = calculateWindowSizeClass()
val isExpanded = windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact
if (isExpanded) {
// Dual pane for large screens
Row(modifier = Modifier.fillMaxSize()) {
EmailList(
emails = items,
selectedId = selectedId,
onSelect = onSelect,
modifier = Modifier.width(360.dp)
)
VerticalDivider()
val selectedEmail = items.find { it.id == selectedId }
if (selectedEmail != null) {
EmailDetail(
email = selectedEmail,
modifier = Modifier.weight(1f)
)
} else {
EmptyDetailPane(modifier = Modifier.weight(1f))
}
}
} else {
// Single pane for phones
val selectedEmail = items.find { it.id == selectedId }
if (selectedEmail == null) {
EmailList(
emails = items,
selectedId = selectedId,
onSelect = onSelect
)
} else {
EmailDetail(
email = selectedEmail,
onBack = onBack
)
}
}
}
Feed with supporting pane:
@Composable
fun FeedLayout(posts: List<Post>) {
val windowSizeClass = calculateWindowSizeClass()
val showSupportingPane = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded
Row(modifier = Modifier.fillMaxSize()) {
// Main content
PostFeed(
posts = posts,
modifier = if (showSupportingPane) Modifier.weight(2f) else Modifier.fillMaxSize()
)
// Supporting pane only on large screens
if (showSupportingPane) {
VerticalDivider()
SupportingPane(
modifier = Modifier.weight(1f)
)
}
}
}
Adaptive navigation:
@Composable
fun AdaptiveNavigation(
destinations: List<Destination>,
selected: Destination,
onSelect: (Destination) -> Unit
) {
val windowSizeClass = calculateWindowSizeClass()
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
// Bottom nav for phones
NavigationBar {
destinations.forEach { destination ->
NavigationBarItem(
icon = { Icon(destination.icon, null) },
label = { Text(destination.label) },
selected = destination == selected,
onClick = { onSelect(destination) }
)
}
}
}
else -> {
// Side rail for tablets
NavigationRail {
destinations.forEach { destination ->
NavigationRailItem(
icon = { Icon(destination.icon, null) },
label = { Text(destination.label) },
selected = destination == selected,
onClick = { onSelect(destination) }
)
}
}
}
}
}
Foldable Support
WindowInfoTracker:
class FoldableHelper(context: Context) {
private val windowInfoTracker = WindowInfoTracker.getOrCreate(context)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
fun getWindowLayoutInfo(activity: Activity): Flow<WindowLayoutInfo> {
return windowInfoTracker.windowLayoutInfo(activity)
}
}
@Composable
fun FoldableAwareLayout(activity: Activity) {
val windowLayoutInfo by rememberUpdatedState(
WindowInfoTracker.getOrCreate(LocalContext.current)
.windowLayoutInfo(activity)
.collectAsState(initial = null).value
)
val foldingFeature = windowLayoutInfo?.displayFeatures
?.filterIsInstance<FoldingFeature>()
?.firstOrNull()
when {
foldingFeature == null -> {
// Regular device
NormalLayout()
}
foldingFeature.state == FoldingFeature.State.FLAT &&
foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL -> {
// Unfolded horizontal fold (tabletop mode)
TableTopLayout(foldingFeature = foldingFeature)
}
foldingFeature.state == FoldingFeature.State.HALF_OPENED &&
foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL -> {
// Half-open horizontal (book mode)
BookModeLayout(foldingFeature = foldingFeature)
}
else -> {
// Other fold states
FoldableLayout(foldingFeature = foldingFeature)
}
}
}
Avoid fold crease:
@Composable
fun AvoidFoldCrease(
foldingFeature: FoldingFeature,
content: @Composable () -> Unit
) {
val foldBounds = foldingFeature.bounds
Box(
modifier = Modifier
.fillMaxSize()
.padding(
top = if (foldBounds.top > 0) foldBounds.height.dp else 0.dp,
bottom = if (foldBounds.bottom < LocalConfiguration.current.screenHeightDp)
foldBounds.height.dp else 0.dp
)
) {
content()
}
}
Dual screen with fold:
@Composable
fun DualScreenReader(
article: Article,
foldingFeature: FoldingFeature
) {
Row(modifier = Modifier.fillMaxSize()) {
// Left pane: Article list
Box(
modifier = Modifier
.weight(1f)
.padding(end = if (foldingFeature.isSeparating) 0.dp else 16.dp)
) {
ArticleList(article = article)
}
// Visual separator at fold
if (foldingFeature.isSeparating) {
VerticalDivider()
}
// Right pane: Reading content
Box(
modifier = Modifier.weight(1f)
) {
ArticleContent(article = article)
}
}
}
Adaptive Grids
Responsive grid:
@Composable
fun AdaptiveGrid(
items: List<Product>,
modifier: Modifier = Modifier
) {
val windowSizeClass = calculateWindowSizeClass()
val columns = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> 2
WindowWidthSizeClass.Medium -> 3
WindowWidthSizeClass.Expanded -> 4
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
) {
items(items, key = { it.id }) { product ->
ProductCard(product = product)
}
}
}
Adaptive staggered grid:
@Composable
fun AdaptiveStaggeredGrid(
photos: List<Photo>,
modifier: Modifier = Modifier
) {
val windowSizeClass = calculateWindowSizeClass()
val configuration = LocalConfiguration.current
// Consider both width and height
val columns = when {
windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact -> 2
windowSizeClass.widthSizeClass == WindowWidthSizeClass.Medium -> 3
windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded -> 4
else -> 2
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(columns),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalItemSpacing = 16.dp,
modifier = modifier
) {
items(photos, key = { it.id }) { photo ->
PhotoCard(photo = photo)
}
}
}
Responsive Typography & Spacing
Responsive text:
@Composable
fun ResponsiveTitle(text: String) {
val windowSizeClass = calculateWindowSizeClass()
val style = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> MaterialTheme.typography.headlineSmall
WindowWidthSizeClass.Medium -> MaterialTheme.typography.headlineMedium
WindowWidthSizeClass.Expanded -> MaterialTheme.typography.headlineLarge
}
Text(
text = text,
style = style
)
}
Responsive padding:
@Composable
fun ResponsivePadding(content: @Composable () -> Unit) {
val windowSizeClass = calculateWindowSizeClass()
val padding = when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> 16.dp
WindowWidthSizeClass.Medium -> 24.dp
WindowWidthSizeClass.Expanded -> 32.dp
}
Box(modifier = Modifier.padding(padding)) {
content()
}
}
Common Patterns
Drag and Drop (Desktop/Multi-window)
@Composable
fun DraggableItem(
item: Item,
onDragStart: () -> Unit,
onDragEnd: (DropTarget) -> Unit
) {
var isDragging by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.pointerInput(item) {
detectDragGestures(
onDragStart = { onDragStart() },
onDragEnd = { onDragEnd(calculateDropTarget()) }
) { change, dragAmount ->
change.consume()
// Update position
}
}
.alpha(if (isDragging) 0.5f else 1f)
) {
ItemContent(item)
}
}
@Composable
fun DropTarget(
onItemDropped: (Item) -> Unit,
content: @Composable () -> Unit
) {
var isHovered by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.border(
width = if (isHovered) 2.dp else 0.dp,
color = MaterialTheme.colorScheme.primary,
shape = MaterialTheme.shapes.medium
)
.pointerInput(Unit) {
// Handle drop events
}
) {
content()
}
}
Keyboard & Mouse Support
Keyboard shortcuts:
@Composable
fun KeyboardShortcutsHandler(
onNewDocument: () -> Unit,
onSave: () -> Unit,
onSearch: () -> Unit
) {
val focusManager = LocalFocusManager.current
Box(
modifier = Modifier
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) {
when {
event.isCtrlPressed && event.key == Key.N -> {
onNewDocument()
true
}
event.isCtrlPressed && event.key == Key.S -> {
onSave()
true
}
event.isCtrlPressed && event.key == Key.F -> {
onSearch()
true
}
else -> false
}
} else {
false
}
}
.focusable()
.focusRequester(FocusRequester())
)
}
Right-click context menu:
@Composable
fun ContextMenuItem(
item: Item,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
Box(
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onLongPress = { showMenu = true },
onSecondaryTap = { showMenu = true } // Right-click
)
}
) {
ItemContent(item)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("Edit") },
onClick = {
onEdit()
showMenu = false
}
)
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
onDelete()
showMenu = false
}
)
}
}
}
Hover effects:
@Composable
fun HoverableCard(
content: @Composable () -> Unit
) {
var isHovered by remember { mutableStateOf(false) }
val elevation by animateDpAsState(
targetValue = if (isHovered) 8.dp else 1.dp
)
Card(
elevation = CardDefaults.cardElevation(elevation),
modifier = Modifier
.pointerInput(Unit) {
detectHoverEvents(
onEnter = { isHovered = true },
onExit = { isHovered = false }
)
}
) {
content()
}
}
Configuration Changes
Save state across resize:
@Composable
fun ResponsiveActivity() {
// Save scroll position across configuration changes
val listState = rememberLazyListState()
// Save in saved state handle for process death
val savedStateHandle = rememberSaveable { mutableStateOf(0) }
val windowSizeClass = calculateWindowSizeClass()
// Remember previous size class to detect changes
var previousSizeClass by remember { mutableStateOf(windowSizeClass.widthSizeClass) }
LaunchedEffect(windowSizeClass) {
if (previousSizeClass != windowSizeClass.widthSizeClass) {
// Handle transition (e.g., restore scroll position)
previousSizeClass = windowSizeClass.widthSizeClass
}
}
ResponsiveContent(
listState = listState,
windowSizeClass = windowSizeClass
)
}
Examples (Input → Output)
Complete Adaptive App
Input request: "Create an email app that adapts from phone to tablet to desktop"
Output:
@Composable
fun EmailApp() {
val windowSizeClass = calculateWindowSizeClass()
val navController = rememberNavController()
// Track selected email for dual-pane layout
var selectedEmailId by rememberSaveable { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact) {
CenterAlignedTopAppBar(
title = { Text("Email") }
)
}
},
bottomBar = {
if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact) {
BottomNavBar(navController)
}
}
) { padding ->
Row(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Side navigation for larger screens
if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact) {
PermanentNavigationDrawer(
navController = navController,
modifier = Modifier.width(280.dp)
)
VerticalDivider()
}
// Main content area
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
// Phone: Single pane navigation
NavHost(navController, startDestination = "inbox") {
composable("inbox") {
EmailListScreen(
onEmailClick = { emailId ->
navController.navigate("detail/$emailId")
}
)
}
composable("detail/{emailId}") { backStack ->
val emailId = backStack.arguments?.getString("emailId")
EmailDetailScreen(
emailId = emailId,
onBack = { navController.popBackStack() }
)
}
}
}
else -> {
// Tablet/Desktop: Dual pane
Row {
// List pane
Box(modifier = Modifier.weight(1f)) {
EmailListScreen(
selectedId = selectedEmailId,
onEmailClick = { emailId ->
selectedEmailId = emailId
}
)
}
VerticalDivider()
// Detail pane
Box(modifier = Modifier.weight(2f)) {
if (selectedEmailId != null) {
EmailDetailScreen(emailId = selectedEmailId)
} else {
EmptyDetailPlaceholder()
}
}
}
}
}
}
}
}
@Composable
fun EmailListScreen(
selectedId: String? = null,
onEmailClick: (String) -> Unit
) {
val emails = remember { getSampleEmails() }
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(emails, key = { it.id }) { email ->
EmailListItem(
email = email,
isSelected = email.id == selectedId,
onClick = { onEmailClick(email.id) }
)
}
}
}
Foldable Optimized Reader
Input request: "Create a reading app optimized for foldable devices in tabletop mode"
Output:
@Composable
fun BookReader(
book: Book,
currentPage: Int,
onPageChange: (Int) -> Unit
) {
val activity = LocalContext.current as Activity
val windowLayoutInfo = WindowInfoTracker.getOrCreate(activity)
.windowLayoutInfo(activity)
.collectAsState(initial = null).value
val foldingFeature = windowLayoutInfo?.displayFeatures
?.filterIsInstance<FoldingFeature>()
?.firstOrNull()
val isTableTopMode = foldingFeature?.let {
it.state == FoldingFeature.State.HALF_OPENED &&
it.orientation == FoldingFeature.Orientation.HORIZONTAL
} ?: false
if (isTableTopMode && foldingFeature != null) {
// Tabletop mode: Controls on bottom half, content on top
TableTopLayout(
book = book,
currentPage = currentPage,
onPageChange = onPageChange,
foldBounds = foldingFeature.bounds
)
} else {
// Normal mode
StandardReader(
book = book,
currentPage = currentPage,
onPageChange = onPageChange
)
}
}
@Composable
fun TableTopLayout(
book: Book,
currentPage: Int,
onPageChange: (Int) -> Unit,
foldBounds: Rect
) {
val density = LocalDensity.current
val foldHeight = with(density) { foldBounds.height.toDp() }
Column(modifier = Modifier.fillMaxSize()) {
// Top half: Reading content
Box(
modifier = Modifier
.weight(1f)
.padding(bottom = foldHeight / 2)
) {
PageContent(
page = book.pages[currentPage],
modifier = Modifier.fillMaxSize()
)
}
// Bottom half: Controls
Box(
modifier = Modifier
.weight(1f)
.padding(top = foldHeight / 2),
contentAlignment = Alignment.Center
) {
ReaderControls(
currentPage = currentPage,
totalPages = book.pages.size,
onPrevious = { onPageChange(currentPage - 1) },
onNext = { onPageChange(currentPage + 1) },
onPageSelect = onPageChange
)
}
}
}
Best Practices
- Use window size classes: Don't hardcode breakpoints, use standard size classes
- Test on real devices: Emulators don't capture all foldable behaviors
- Support all orientations: Tablets are often used in landscape
- Minimum touch targets: 48dp regardless of screen size
- Content first: Don't just stretch content, adapt layout
- Save scroll position: Restore state when switching layouts
- Keyboard navigation: Support Tab navigation for desktop
- Right-click menus: Add context menus for mouse users
- Responsive text: Scale typography appropriately
- Handle configuration changes: Resize triggers config change
Resources
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.