WHAT WAS THE REAL-WORLD CONTEXT BEHIND THIS JETPACK COMPOSE TABROW CHALLENGE?
During a recent project for an enterprise FinTech mobile application, we needed to revamp the main dashboard’s navigation architecture. The requirement dictated a highly dynamic category filter for financial accounts and transaction histories. The design system called for a versatile jetpack compose tabrow that behaved intelligently based on the device’s screen size and localization constraints.
While working on this module, we realized the standard Compose foundation components were too rigid for our hybrid requirements. The tabs contained both text labels and dynamic notification badges. If the device screen was wide enough (like on a tablet or a large phone), the tabs needed to distribute evenly across the available space. However, if the user rotated the device or the localized text was too long, the tabs had to gracefully degrade into a horizontally scrollable row, ensuring that each tab maintained its intrinsic width.
We encountered a situation where the initial implementation, although functional, suffered from severe performance bottlenecks. It relied on early text measurement and frequent recompositions to handle the dynamic layout changes. In production, this approach caused noticeable UI jank on lower-end Android devices. This challenge inspired this article, providing a blueprint for optimizing an adaptive jetpack compose tabrow so other engineering teams can avoid the pitfalls of manual UI measurement loops.
WHERE DID THIS ADAPTIVE TABROW ISSUE APPEAR IN THE ARCHITECTURE?
The issue surfaced deep within our shared UI component library, a critical layer used across multiple modules of the FinTech platform. Every time a user switched between account portfolios, the dynamic tab component was instantiated. Because the financial data was continuously streaming via WebSockets, badge counts on the tabs updated frequently.
In our architecture, the tab row acted as the primary navigation anchor. The business use case demanded that partially hidden tabs automatically scroll into full view when selected, guaranteeing a seamless user experience. However, because this component was root-level, any layout inefficiency here cascaded down, causing the entire dashboard to drop frames during composition. Decision-makers scaling mobile platforms must recognize that inefficient core components directly impact user retention. When you look to hire app developer to create a mobile app for enterprise scale, they must understand how to navigate Compose’s three phases: Composition, Layout and Drawing.
WHAT WENT WRONG WITH THE INITIAL JETPACK COMPOSE TABROW IMPLEMENTATION?
The initial code we inherited was a textbook example of fighting against Jetpack Compose rather than working with it. The symptoms were obvious: CPU spikes during tab selection and slight delays when rendering the dashboard.
Upon reviewing the code, we identified several severe architectural oversights:
- Manual Text Measurement: The implementation used
rememberTextMeasurer()to calculate the maximum required width of the text and badges. It hardcoded padding values (like32.dpand24.dp) directly into the measurement logic. If a designer updated the spacing in theTabItem, the measurement logic would silently fail, leading to misaligned tabs. - Delayed Composition via BoxWithConstraints: Wrapping the entire logic in
BoxWithConstraintsforced Compose to defer the composition of the tabs until the layout phase was complete. This is notoriously expensive when used unnecessarily. - State-Driven Layout Thrashing: The most critical flaw was using the
onPlacedmodifier to write layout coordinates into amutableStateMapOf. Writing to state inside a layout phase immediately triggers a recomposition. This created a “Read-Write loop,” where rendering the tabs caused a state change, which forced a re-render, continuously degrading performance.
HOW DID WE APPROACH THE SOLUTION FOR AN EFFICIENT JETPACK COMPOSE TABROW?
To eliminate the unnecessary measurement and layout work, we needed an idiomatic Jetpack Compose approach. We stepped back and evaluated how Compose naturally handles varying dimensions. We evaluated several potential architectures.
COULD WE USE THE STANDARD SCROLLABLETABROW?
We initially considered reverting to the official ScrollableTabRow provided by the Compose Material library. While it excels at handling scrolling tabs and animating indicators, it strictly forces all items to their intrinsic widths. It does not stretch tabs to equally share the screen width when space is abundant. Customizing its internal layout mechanism to support this hybrid behavior required overriding too much internal logic, rendering it unmaintainable.
COULD WE OPTIMIZE THE EXISTING BOXWITHCONSTRAINTS?
We looked at keeping BoxWithConstraints but removing the manual TextMeasurer. We thought about measuring the maximum intrinsic widths of the composables directly. However, keeping BoxWithConstraints still meant paying the deferred composition penalty. Plus, calculating scroll bounds manually for the auto-scroll feature remained a fragile, math-heavy burden.
COULD WE USE A CUSTOM SUBCOMPOSELAYOUT?
The optimal approach we chose was utilizing a custom SubcomposeLayout combined with Jetpack Compose’s native BringIntoViewRequester. A SubcomposeLayout allows us to measure the tabs with infinite width constraints first. If the sum of their required widths is less than the screen width, we render a standard Row with Modifier.weight(1f). If they exceed the width, we render a scrollable row. This entirely removes the need for rememberTextMeasurer() and hardcoded paddings. When companies look to hire android developers for enterprise apps, they should ensure the team understands when to utilize SubcomposeLayout for complex, constraint-based UI decisions.
WHAT DOES THE FINAL IMPLEMENTATION OF OUR ADAPTIVE JETPACK COMPOSE TABROW LOOK LIKE?
Our final solution achieved idiomatic purity. We eliminated manual bounds tracking and text measuring. Instead, we leaned heavily on built-in Compose mechanics. To handle the scroll-into-view behavior, we implemented BringIntoViewRequester, which inherently understands the scroll viewport and calculates the precise offset needed without any manual math.
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.Badge
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
data class TabData(
val label: String,
val badgeCount: Int? = null,
)
@Composable
fun AdaptiveTabRow(
tabs: List<TabData>,
selectedIndex: Int,
onSelectedIndexChange: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
if (tabs.isEmpty()) return
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
// Maintain a requester for each tab to handle automatic scrolling
val viewRequesters = remember(tabs.size) {
List(tabs.size) { BringIntoViewRequester() }
}
LaunchedEffect(selectedIndex) {
// Automatically scroll the selected tab into view natively
viewRequesters.getOrNull(selectedIndex)?.bringIntoView()
}
SubcomposeLayout(modifier = modifier) { constraints ->
// 1. Measure intrinsic sizes without bounds
val measurables = subcompose("measure_tabs") {
tabs.forEachIndexed { index, tab ->
TabItem(
tab = tab,
selected = index == selectedIndex,
onClick = {},
sharesWidthEvenly = false
)
}
}
val unboundedConstraints = Constraints()
val placeables = measurables.map { it.measure(unboundedConstraints) }
val totalIntrinsicWidth = placeables.sumOf { it.width }
val sharesWidthEvenly = totalIntrinsicWidth <= constraints.maxWidth
// 2. Subcompose actual UI based on the calculation
val finalMeasurables = subcompose("render_tabs") {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.then(
if (sharesWidthEvenly) Modifier
else Modifier.horizontalScroll(scrollState)
)
.selectableGroup(),
verticalAlignment = Alignment.CenterVertically,
) {
tabs.forEachIndexed { index, tab ->
TabItem(
tab = tab,
selected = index == selectedIndex,
onClick = {
onSelectedIndexChange(index)
coroutineScope.launch {
viewRequesters[index].bringIntoView()
}
},
sharesWidthEvenly = sharesWidthEvenly,
modifier = Modifier
.bringIntoViewRequester(viewRequesters[index])
.then(if (sharesWidthEvenly) Modifier.weight(1f) else Modifier)
)
}
}
}
val layoutPlaceable = finalMeasurables.first().measure(constraints)
layout(layoutPlaceable.width, layoutPlaceable.height) {
layoutPlaceable.placeRelative(0, 0)
}
}
}
@Composable
private fun TabItem(
tab: TabData,
selected: Boolean,
onClick: () -> Unit,
sharesWidthEvenly: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxHeight()
.selectable(
selected = selected,
role = Role.Tab,
onClick = onClick,
)
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = if (sharesWidthEvenly) Modifier.weight(1f, fill = false) else Modifier,
text = tab.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
tab.badgeCount?.let { count ->
Badge(
modifier = Modifier.defaultMinSize(minWidth = 24.dp)
) {
Text(text = count.toString(), maxLines = 1)
}
}
}
}
By migrating to BringIntoViewRequester, we delegated all scroll offset logic to the system. By using SubcomposeLayout, we achieved exact intrinsic measurements without tightly coupling our code to text rendering behaviors.
WHAT ARE THE CORE LESSONS FOR ENGINEERING TEAMS BUILDING JETPACK COMPOSE TABROWS?
This architectural pivot offers several crucial lessons for enterprise development teams:
- Avoid Manual Text Measurers for Layout:
rememberTextMeasureris powerful for canvas drawing, but using it to calculate layout sizes breaks encapsulation. When paddings or fonts change, manual calculations fail. - Never Write State in the Layout Phase: Using
onPlacedto update state variables (like tab boundaries) causes continuous recompositions. Layout phases should strictly read state, never write it. - Leverage BringIntoViewRequester: For any scrollable component requiring automatic focus or visibility (like a jetpack compose tabrow), rely on Compose’s native relocation APIs instead of calculating pixel boundaries manually.
- SubcomposeLayout is a Scalpel: Use
SubcomposeLayoutwhen the composition of child elements fundamentally depends on the available constraints or the intrinsic sizes of other children. - Partner with Experienced Architects: Complex UI optimization requires deep knowledge of rendering phases. When organizations look to hire mobile developers for native architecture, they must validate their understanding of UI thread performance and layout mechanics.
HOW CAN WE SUMMARIZE THIS JETPACK COMPOSE TABROW JOURNEY?
Creating an adaptive jetpack compose tabrow highlighted the difference between functional code and idiomatic, production-ready code. By discarding manual coordinate tracking and pre-measuring techniques, we built a UI component that scales seamlessly from small phones to large tablets without sacrificing rendering speed. If your organization is facing complex UI architecture challenges or you need to augment your team with senior engineering talent, contact us to explore how our dedicated development teams deliver high-performance solutions.
Social Hashtags
#JetpackCompose #AndroidDevelopment #Kotlin #AndroidDev #ComposeUI #SubcomposeLayout #AndroidPerformance #MobileAppDevelopment #ResponsiveUI #AndroidDevelopers #SoftwareEngineering #UIOptimization #FinTechDevelopment #AppDevelopment #KotlinDevelopment
Frequently Asked Questions
BoxWithConstraints defers the composition of its internal children until the layout phase because it needs to know the exact pixel constraints first. If used heavily in deep UI trees, it delays rendering and can cause frame drops. It should only be used when the UI structure explicitly changes based on available width/height.
Use SubcomposeLayout when you cannot compose standard components without first knowing the exact measured size of another component. In our jetpack compose tabrow, we needed to know the unconstrained width of all tabs before deciding whether to place them in an equally weighted row or a scrollable one.
BringIntoViewRequester communicates natively with the parent scrollable container. Instead of manually calculating where an item exists on the screen and telling the ScrollState to animate to a specific integer, the requester asks the system to make its attached layout node fully visible, handling all the offset mathematics internally.
In standard Compose Material, a TabRow distributes all tabs evenly across the available width regardless of their content size. A ScrollableTabRow allows tabs to size themselves according to their content and scroll horizontally. An adaptive hybrid, as demonstrated here, automatically switches between these two behaviors based on screen constraints.
Success Stories That Inspire
See how our team takes complex business challenges and turns them into powerful, scalable digital solutions. From custom software and web applications to automation, integrations, and cloud-ready systems, each project reflects our commitment to innovation, performance, and long-term value.

US SaaS Platform Cut Manual Ops by 70% After Hiring WeblineGlobal’s n8n Automation Pod

Recruitment Operations Automation with Offshore Software Dev Team



















