As Android devices evolve, supporting various form factors like tablets and foldable screens becomes crucial. Jetpack Compose, Google’s modern UI toolkit, offers the flexibility and tools needed to create responsive and adaptive UIs that cater to these diverse screen sizes. This blog post explores how to leverage Jetpack Compose to build robust UIs for tablets and foldables.
Understanding Adaptive UI Design
Adaptive UI design is an approach that ensures your application looks and functions optimally across different screen sizes and orientations. Key principles include:
- Responsive Layouts: UIs that dynamically adjust to the available screen space.
- Adaptive Components: UI elements that change their behavior based on screen size.
- Continuity: Maintaining a consistent user experience across configuration changes and device states.
Key Considerations for Tablets and Foldables
- Screen Size: Tablets and foldables offer significantly more screen real estate than smartphones.
- Screen Ratio: Foldables often have unique aspect ratios, particularly when folded.
- Configuration Changes: Foldables undergo dramatic configuration changes (e.g., folded/unfolded) that impact the UI.
- Multi-window Support: Both tablets and foldables frequently support multi-window mode, which affects the available screen size for your app.
Jetpack Compose Features for Adaptive UIs
1. ConstraintLayout
ConstraintLayout is a powerful layout that allows you to define relationships between UI elements, making it suitable for creating complex, responsive designs.
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.constraintlayout.compose.ConstraintLayout
import androidx.compose.constraintlayout.compose.ConstraintSet
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
@Composable
fun ConstraintLayoutExample() {
val constraints = ConstraintSet {
val text1 = createRefFor("text1")
val text2 = createRefFor("text2")
constrain(text1) {
top.linkTo(parent.top, margin = 16.dp)
start.linkTo(parent.start, margin = 16.dp)
}
constrain(text2) {
top.linkTo(text1.bottom, margin = 8.dp)
start.linkTo(parent.start, margin = 16.dp)
}
}
ConstraintLayout(constraints, modifier = Modifier) {
Text("Text 1", modifier = Modifier.layoutId("text1"))
Text("Text 2", modifier = Modifier.layoutId("text2"))
}
}
@Preview(showBackground = true)
@Composable
fun ConstraintLayoutExamplePreview() {
ConstraintLayoutExample()
}
2. Window Size Class
The Window Size Class feature allows you to categorize the screen size into predefined classes (compact, medium, expanded) and adapt your UI accordingly. You will need `androidx.compose.material3:material3-window-size-class` dependency in your `build.gradle`:
dependencies {
implementation("androidx.compose.material3:material3-window-size-class:1.1.2")
}
And here’s an example:
import androidx.compose.material.Text
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.res.stringResource
import androidx.compose.material.Surface
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Scaffold
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.TextStyle
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun AdaptiveLayoutExample() {
val windowSizeClass = calculateWindowSizeClass(LocalContext.current as android.app.Activity)
Surface(color = Color.LightGray, modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
Text(
text = "Adaptive Layout Example",
modifier = Modifier.padding(16.dp),
style = TextStyle(fontSize = 20.sp)
)
},
content = { padding ->
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
Text(
text = "Compact Screen",
modifier = Modifier.padding(padding),
style = TextStyle(fontSize = 16.sp)
)
}
WindowWidthSizeClass.Medium -> {
Text(
text = "Medium Screen",
modifier = Modifier.padding(padding),
style = TextStyle(fontSize = 16.sp)
)
}
WindowWidthSizeClass.Expanded -> {
Text(
text = "Expanded Screen",
modifier = Modifier.padding(padding),
style = TextStyle(fontSize = 16.sp)
)
}
else -> {
Text(
text = "Unknown Screen Size",
modifier = Modifier.padding(padding),
style = TextStyle(fontSize = 16.sp)
)
}
}
}
)
}
}
@Preview(showBackground = true)
@Composable
fun AdaptiveLayoutExamplePreview() {
AdaptiveLayoutExample()
}
3. Adaptive Navigation
Implement different navigation patterns based on the screen size. For instance, use a bottom navigation bar on phones and a navigation rail or drawer on tablets and foldables.
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun AdaptiveNavigationExample() {
val windowSizeClass = calculateWindowSizeClass(LocalContext.current as android.app.Activity)
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
BottomNavigationBar()
}
else -> {
NavigationRailExample()
}
}
}
@Composable
fun BottomNavigationBar() {
BottomNavigation {
BottomNavigationItem(
icon = { Icon(Icons.Filled.Home, contentDescription = "Home") },
selected = true,
onClick = {}
)
BottomNavigationItem(
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
selected = false,
onClick = {}
)
}
}
@Composable
fun NavigationRailExample() {
NavigationRail {
NavigationRailItem(
icon = { Icon(Icons.Filled.Home, contentDescription = "Home") },
selected = true,
onClick = {}
)
NavigationRailItem(
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
selected = false,
onClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun AdaptiveNavigationExamplePreview() {
AdaptiveNavigationExample()
}
4. Handling Configuration Changes
For foldable devices, handle configuration changes gracefully by updating the UI when the device folds or unfolds. Use rememberSaveable to preserve state across configuration changes.
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@Composable
fun FoldableConfigurationExample() {
var count by rememberSaveable { mutableStateOf(0) }
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Counter: $count")
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
@Preview(showBackground = true)
@Composable
fun FoldableConfigurationExamplePreview() {
FoldableConfigurationExample()
}
Example: Master-Detail Layout for Tablets
A common pattern for tablets is the master-detail layout, where a list (master) is displayed on one side and the details of the selected item (detail) are shown on the other side.
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Card
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.ui.platform.LocalContext
data class Item(val id: Int, val name: String, val description: String)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun MasterDetailLayout(items: List- ) {
val windowSizeClass = calculateWindowSizeClass(LocalContext.current as android.app.Activity)
var selectedItem by remember { mutableStateOf
- (null) }
if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded) {
Row {
MasterList(
items = items,
onItemSelected = { selectedItem = it },
modifier = Modifier.weight(0.5f)
)
DetailView(
item = selectedItem,
modifier = Modifier.weight(0.5f)
)
}
} else {
MasterList(
items = items,
onItemSelected = { selectedItem = it }
)
selectedItem?.let {
DetailView(item = it)
}
}
}
@Composable
fun MasterList(items: List
- , onItemSelected: (Item) -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
items.forEach { item ->
Card(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onItemSelected(item) }) {
Text(text = item.name, modifier = Modifier.padding(8.dp))
}
}
}
}
@Composable
fun DetailView(item: Item?, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
Text(text = item?.name ?: "No Item Selected")
Text(text = item?.description ?: "")
}
}
@Preview(showBackground = true)
@Composable
fun MasterDetailLayoutPreview() {
val items = listOf(
Item(1, "Item 1", "Description 1"),
Item(2, "Item 2", "Description 2"),
Item(3, "Item 3", "Description 3")
)
MasterDetailLayout(items = items)
}
Testing Your Adaptive UI
- Android Emulator: Configure emulator instances with different screen sizes and aspect ratios.
- Physical Devices: Test your app on actual tablets and foldable devices.
- Layout Inspector: Use Android Studio’s Layout Inspector to verify that your UI adapts correctly to different screen sizes.
Conclusion
Jetpack Compose offers the tools and flexibility to build adaptive UIs that look great and function seamlessly on tablets and foldable devices. By leveraging ConstraintLayout, Window Size Classes, adaptive navigation, and proper handling of configuration changes, you can create user experiences that cater to the unique characteristics of these devices. Prioritize testing on various screen sizes to ensure your app meets the diverse needs of your users.