Fix topAppBar flicker when text is long (#6098)

This commit is contained in:
David Perez 2025-10-30 15:32:13 -05:00 committed by GitHub
parent dd1dbd0b97
commit 9ddfd376a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -20,11 +20,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastMap
import com.bitwarden.ui.R import com.bitwarden.ui.R
import com.bitwarden.ui.platform.base.util.bottomDivider import com.bitwarden.ui.platform.base.util.bottomDivider
import com.bitwarden.ui.platform.base.util.mirrorIfRtl import com.bitwarden.ui.platform.base.util.mirrorIfRtl
@ -38,12 +41,15 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
/** /**
* Represents a Bitwarden styled [TopAppBar] that assumes the following components: * Represents a Bitwarden styled [TopAppBar] that assumes the following components:
* *
* - a single navigation control in the upper-left defined by [navigationIcon], * @param title The title to display in the app bar.
* [navigationIconContentDescription], and [onNavigationIconClick]. * @param scrollBehavior The [TopAppBarScrollBehavior] to apply to the app bar.
* - a [title] in the middle. * @param navigationIcon The icon to be displayed for the navigation icon button.
* - a [actions] lambda containing the set of actions (usually icons or similar) to display * @param navigationIconContentDescription The content description of the navigation icon button.
* in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in * @param onNavigationIconClick The click action to occur when the navigation icon button is tapped.
* defining the layout of the actions. * @param modifier The [Modifier] applied to the app bar.
* @param windowInsets The window insets to apply to the app bar.
* @param dividerStyle Applies a bottom divider based on the [TopAppBarDividerStyle] provided.
* @param actions A [Composable] lambda of action to display in the app bar.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -77,17 +83,16 @@ fun BitwardenTopAppBar(
/** /**
* Represents a Bitwarden styled [TopAppBar] that assumes the following components: * Represents a Bitwarden styled [TopAppBar] that assumes the following components:
* *
* - an optional single navigation control in the upper-left defined by [navigationIcon]. * @param title The title to display in the app bar.
* - a [title] in the middle. * @param scrollBehavior The [TopAppBarScrollBehavior] to apply to the app bar.
* - a [actions] lambda containing the set of actions (usually icons or similar) to display * @param navigationIcon The option [NavigationIcon] to display the navigation icon button.
* in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in * @param modifier The [Modifier] applied to the app bar.
* defining the layout of the actions. * @param windowInsets The window insets to apply to the app bar.
* - if the title text causes an overflow in the standard material [TopAppBar] a [MediumTopAppBar] * @param dividerStyle Applies a bottom divider based on the [TopAppBarDividerStyle] provided.
* will be used instead, droping the title text to a second row beneath the [navigationIcon] and * @param actions A [Composable] lambda of action to display in the app bar.
* [actions]. * @param minimumHeight The minimum height of the app bar.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable @Composable
fun BitwardenTopAppBar( fun BitwardenTopAppBar(
title: String, title: String,
@ -100,87 +105,179 @@ fun BitwardenTopAppBar(
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
minimumHeight: Dp = 48.dp, minimumHeight: Dp = 48.dp,
) { ) {
var titleTextHasOverflow by remember { var titleTextHasOverflow by remember(key1 = title) { mutableStateOf(false) }
mutableStateOf(false) // Without this sub-compose layout, there would be flickering when displaying the
} // MediumTopAppBar because the regular TopAppBar would be displayed first.
SubcomposeLayout(modifier = modifier) { constraints ->
val navigationIconContent: @Composable () -> Unit = remember(navigationIcon) { // We assume a regular TopAppBar and only if it is overflowing do we use a MediumTopAppBar.
{ // Once we determine the overflow is occurring, we will not measure the regular one again
navigationIcon?.let { // unless the title changes or a configuration change occurs.
BitwardenStandardIconButton( val placeables = if (titleTextHasOverflow) {
painter = it.navigationIcon, this
contentDescription = it.navigationIconContentDescription, .subcompose(
onClick = it.onNavigationIconClick, slotId = "mediumTopAppBarContent",
modifier = Modifier content = {
.testTag(tag = "CloseButton") InternalMediumTopAppBar(
.mirrorIfRtl(), title = title,
) windowInsets = windowInsets,
} scrollBehavior = scrollBehavior,
} navigationIcon = navigationIcon,
} minimumHeight = minimumHeight,
val customModifier = modifier actions = actions,
.testTag(tag = "HeaderBarComponent") dividerStyle = dividerStyle,
.scrolledContainerBottomDivider( )
topAppBarScrollBehavior = scrollBehavior,
enabled = when (dividerStyle) {
TopAppBarDividerStyle.NONE -> false
TopAppBarDividerStyle.STATIC -> false
TopAppBarDividerStyle.ON_SCROLL -> true
},
)
.bottomDivider(
enabled = when (dividerStyle) {
TopAppBarDividerStyle.NONE -> false
TopAppBarDividerStyle.STATIC -> true
TopAppBarDividerStyle.ON_SCROLL -> false
},
)
if (titleTextHasOverflow) {
MediumTopAppBar(
windowInsets = windowInsets,
colors = bitwardenTopAppBarColors(),
scrollBehavior = scrollBehavior,
navigationIcon = navigationIconContent,
collapsedHeight = minimumHeight,
title = {
Text(
text = title,
style = BitwardenTheme.typography.titleLarge,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
modifier = Modifier.testTag(tag = "PageTitleLabel"),
)
},
modifier = customModifier,
actions = actions,
)
} else {
TopAppBar(
windowInsets = windowInsets,
colors = bitwardenTopAppBarColors(),
scrollBehavior = scrollBehavior,
navigationIcon = navigationIconContent,
expandedHeight = minimumHeight,
title = {
Text(
text = title,
style = BitwardenTheme.typography.titleLarge,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(tag = "PageTitleLabel"),
onTextLayout = {
titleTextHasOverflow = it.hasVisualOverflow
}, },
) )
}, .fastMap { it.measure(constraints = constraints) }
modifier = customModifier, } else {
actions = actions, this
.subcompose(
slotId = "defaultTopAppBarContent",
content = {
InternalDefaultTopAppBar(
title = title,
windowInsets = windowInsets,
scrollBehavior = scrollBehavior,
navigationIcon = navigationIcon,
minimumHeight = minimumHeight,
actions = actions,
dividerStyle = dividerStyle,
onTitleTextLayout = { titleTextHasOverflow = it.hasVisualOverflow },
)
},
)
.fastMap { it.measure(constraints = constraints) }
}
layout(
width = constraints.maxWidth,
height = placeables.maxOfOrNull { it.height } ?: 0,
) {
placeables.fastMap { it.place(x = 0, y = 0) }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InternalMediumTopAppBar(
title: String,
scrollBehavior: TopAppBarScrollBehavior,
navigationIcon: NavigationIcon?,
windowInsets: WindowInsets,
dividerStyle: TopAppBarDividerStyle,
actions: @Composable RowScope.() -> Unit,
minimumHeight: Dp,
modifier: Modifier = Modifier,
) {
MediumTopAppBar(
windowInsets = windowInsets,
colors = bitwardenTopAppBarColors(),
scrollBehavior = scrollBehavior,
navigationIcon = { NavigationIconButton(navigationIcon = navigationIcon) },
collapsedHeight = minimumHeight,
title = { TitleText(title = title) },
actions = actions,
modifier = modifier.topAppBarModifier(
scrollBehavior = scrollBehavior,
dividerStyle = dividerStyle,
),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InternalDefaultTopAppBar(
title: String,
scrollBehavior: TopAppBarScrollBehavior,
navigationIcon: NavigationIcon?,
windowInsets: WindowInsets,
dividerStyle: TopAppBarDividerStyle,
actions: @Composable RowScope.() -> Unit,
minimumHeight: Dp,
onTitleTextLayout: (TextLayoutResult) -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
windowInsets = windowInsets,
colors = bitwardenTopAppBarColors(),
scrollBehavior = scrollBehavior,
navigationIcon = { NavigationIconButton(navigationIcon = navigationIcon) },
expandedHeight = minimumHeight,
title = {
TitleText(
title = title,
maxLines = 1,
softWrap = false,
onTextLayout = onTitleTextLayout,
)
},
actions = actions,
modifier = modifier.topAppBarModifier(
scrollBehavior = scrollBehavior,
dividerStyle = dividerStyle,
),
)
}
@Composable
private fun NavigationIconButton(
navigationIcon: NavigationIcon?,
modifier: Modifier = Modifier,
) {
navigationIcon?.let {
BitwardenStandardIconButton(
painter = it.navigationIcon,
contentDescription = it.navigationIconContentDescription,
onClick = it.onNavigationIconClick,
modifier = modifier
.testTag(tag = "CloseButton")
.mirrorIfRtl(),
) )
} }
} }
@Composable
private fun TitleText(
title: String,
modifier: Modifier = Modifier,
maxLines: Int = 2,
softWrap: Boolean = true,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
) {
Text(
text = title,
style = BitwardenTheme.typography.titleLarge,
overflow = TextOverflow.Ellipsis,
maxLines = maxLines,
softWrap = softWrap,
onTextLayout = onTextLayout,
modifier = modifier.testTag(tag = "PageTitleLabel"),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Modifier.topAppBarModifier(
scrollBehavior: TopAppBarScrollBehavior,
dividerStyle: TopAppBarDividerStyle,
): Modifier = this
.testTag(tag = "HeaderBarComponent")
.scrolledContainerBottomDivider(
topAppBarScrollBehavior = scrollBehavior,
enabled = when (dividerStyle) {
TopAppBarDividerStyle.NONE -> false
TopAppBarDividerStyle.STATIC -> false
TopAppBarDividerStyle.ON_SCROLL -> true
},
)
.bottomDivider(
enabled = when (dividerStyle) {
TopAppBarDividerStyle.NONE -> false
TopAppBarDividerStyle.STATIC -> true
TopAppBarDividerStyle.ON_SCROLL -> false
},
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Preview @Preview
@Composable @Composable