Building a Professional Horizontal Scrollable Table with Sticky Columns & Animations in Jetpack Compose
Building a Professional Horizontal Scrollable Table with Sticky Columns & Animations
A beginner-friendly guide to mastering dual-axis scrolling, synchronized headers, and reactive micro-interactions on Android.
When displaying tabular data on mobile screens (such as student rosters, financial reports, or schedules), limited screen width is almost always the bottleneck.
1. The Core Architecture: Synchronized Dual Scrolling
A common beginner mistake is giving each row its own independent horizontal scroll state. This causes rows to misalign when scrolled.
The solution is simple: Instantiate one single ScrollState and share it across the header and all rows.
// 1. Hoist a single shared horizontal scroll state in the parent composable
val sharedHorizontalScrollState = rememberScrollState()
Column {
// 2. Pass it into the Header Row
TableHeaderRow(horizontalScrollState = sharedHorizontalScrollState)
// 3. Pass it to every row in LazyColumn
LazyColumn {
items(studentList) { student ->
TableRow(
student = student,
horizontalScrollState = sharedHorizontalScrollState
)
}
}
}
2. Implementing the "Sticky / Frozen" First Column
To lock the primary column in place while allowing other attributes to scroll smoothly, place the frozen item outside the Modifier.horizontalScroll() modifier inside the same parent Row:
Row(modifier = Modifier.fillMaxWidth().height(56.dp)) {
// --- PART A: FROZEN / STICKY COLUMN (Fixed width, doesn't scroll) ---
Box(
modifier = Modifier
.width(136.dp)
.fillMaxHeight()
.background(Color(0xFF1E293B))
) {
Text("Student Name", color = Color.White)
}
// --- PART B: SCROLLABLE ATTRIBUTES (Takes remaining space and scrolls) ---
Row(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.horizontalScroll(sharedHorizontalScrollState) // <-- Shared state hook
) {
Cell(text = "Roll No", width = 120.dp)
Cell(text = "Attendance", width = 120.dp)
Cell(text = "Start Time", width = 120.dp)
Cell(text = "End Time", width = 120.dp)
Cell(text = "Grade", width = 90.dp)
Cell(text = "Score", width = 100.dp)
}
}
3. Adding Attractive Animations & Micro-Interactions
Micro-interactions make your Android UI feel responsive, fluid, and delightful.
A. Animated Sort Arrow (Spring Physics)
When sorting the table, animate the icon's rotation using spring dynamics:
val arrowRotation by animateFloatAsState(
targetValue = if (sortAscending) 0f else 180f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
)
Canvas(modifier = Modifier.size(14.dp).rotate(arrowRotation)) {
// Draw arrow path
}
B. Interactive Attendance Toggle with Color Transitions
Tapping the attendance status smoothly transitions both background and text color between green (Present) and red (Absent):
val targetBg = if (isPresent) EmeraldSuccessBg else RoseDangerBg
val targetColor = if (isPresent) EmeraldSuccess else RoseDanger
val animatedBg by animateColorAsState(targetBg, tween(250))
val animatedColor by animateColorAsState(targetColor, tween(250))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(animatedBg)
.clickable { onToggle() }
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Text(
text = if (isPresent) "Present" else "Absent",
color = animatedColor,
fontWeight = FontWeight.SemiBold
)
}
C. Tactile Touch Press Scale Effect
Compress the row slightly (0.985x) on touch down using MutableInteractionSource:
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.985f else 1f,
animationSpec = spring(stiffness = Spring.StiffnessMedium)
)
Row(
modifier = Modifier
.scale(scale)
.clickable(interactionSource = interactionSource, indication = null) { onRowClick() }
) { ... }
4. Summary: Best Practices for Beginners
| Pattern | Compose Best Practice |
|---|---|
| Synchronized Columns | Pass one shared ScrollState into Modifier.horizontalScroll() for both headers and rows. |
| Sticky Header / Columns | Place fixed-width items outside the scrollable container within the same parent row. |
| State-Driven Colors | Use animateColorAsState() for natural, automated color morphing. |
| Tactile Feedback | Use animateFloatAsState with spring() tied to interactionSource.collectIsPressedAsState(). |
| Performance | Use LazyColumn to efficiently recycle off-screen views for large datasets. |
Frequently Asked Questions (FAQ)
A: Always hoist a single ScrollState using rememberScrollState() in your parent composable and pass that identical instance to both the header and all rows via Modifier.horizontalScroll(sharedScrollState).
A: Yes! Simply group your frozen column composables inside a Row placed before the horizontally scrollable Row within the parent layout.