Skip to content

Commit

Permalink
Merge pull request #891 from mannodermaus/fix/top-bar-text-break-in-l…
Browse files Browse the repository at this point in the history
…arge-font-size

Introduce auto sizing for all app bar title components
  • Loading branch information
takahirom authored Sep 5, 2024
2 parents 8818ef9 + d0ad0ff commit e2b3d10
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package io.github.droidkaigi.confsched.designsystem.component

import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.Paragraph
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import kotlin.math.ceil

/**
* A [Text] component that automatically downsizes its font size to fit its content,
* with the upper bound being defined by the font size of its [style].
*/
@Composable
fun AutoSizeText(
text: String,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Unspecified,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
color: Color = Color.Unspecified,
) {
BoxWithConstraints(
modifier = modifier.semantics(mergeDescendants = true) {},
contentAlignment = when (textAlign) {
TextAlign.Left, TextAlign.Start -> Alignment.CenterStart
TextAlign.Center -> Alignment.Center
TextAlign.Right, TextAlign.End -> Alignment.CenterEnd
TextAlign.Justify -> Alignment.Center
else -> Alignment.CenterStart
},
) {
Text(
text = text,
color = color,
fontSize = rememberFontSize(text, style, color, textAlign, maxLines),
style = style,
maxLines = maxLines,
textAlign = textAlign,
)
}
}

@Composable
private fun BoxWithConstraintsScope.rememberFontSize(
text: String,
style: TextStyle,
color: Color,
textAlign: TextAlign,
maxLines: Int,
): TextUnit {
val density = LocalDensity.current
val fontFamilyResolver = LocalFontFamilyResolver.current

return remember(text, style.fontSize, textAlign, maxLines, density, maxWidth, maxHeight) {
// Helper function to calculate if a given text size
// would cause an overflow when placed into this BoxWithConstraints
val hasOverflowWhenPlaced: TextUnit.() -> Boolean = {
val finalStyle = style.merge(
TextStyle(
color = color,
fontSize = this,
textAlign = textAlign,
),
)

with(density) {
Paragraph(
text = text,
style = finalStyle,
maxLines = maxLines,
constraints = Constraints(maxWidth = ceil(maxWidth.toPx()).toInt()),
density = this,
fontFamilyResolver = fontFamilyResolver,
).run {
didExceedMaxLines || maxHeight < height.toDp() || maxWidth < minIntrinsicWidth.toDp()
}
}
}

// If the original text size fits already without overflowing,
// then there is no need to do anything
if (!style.fontSize.hasOverflowWhenPlaced()) {
return@remember style.fontSize
}

// Otherwise, find the biggest font size that still fits using binary search
var lo = 1
var hi = style.fontSize.value.toInt()
val type = style.fontSize.type

while (lo <= hi) {
val mid = lo + (hi - lo) / 2
if (mid.asTextUnit(type).hasOverflowWhenPlaced()) {
hi = mid - 1
} else {
lo = mid + 1
}
}

// After the binary search, the right pointer is the largest size
// that still works without overflowing the box
hi.asTextUnit(type)
}
}

private fun Int.asTextUnit(type: TextUnitType) = when (type) {
TextUnitType.Sp -> this.sp
TextUnitType.Em -> this.em
TextUnitType.Unspecified -> TextUnit.Unspecified
else -> error("Invalid TextUnitType: $type")
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
Expand All @@ -29,6 +28,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.github.droidkaigi.confsched.designsystem.component.AutoSizeText

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand Down Expand Up @@ -68,7 +68,7 @@ fun AnimatedLargeTopAppBar(
// No animation required as it is erased with alpha
exit = ExitTransition.None,
) {
Text(
AutoSizeText(
text = title,
modifier = Modifier.then(
when (isCenterTitle) {
Expand All @@ -82,6 +82,7 @@ fun AnimatedLargeTopAppBar(
},
),
textAlign = TextAlign.Center,
maxLines = 1,
)
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
Expand All @@ -29,6 +28,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.github.droidkaigi.confsched.designsystem.component.AutoSizeText

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand Down Expand Up @@ -68,7 +68,7 @@ fun AnimatedMediumTopAppBar(
// No animation required as it is erased with alpha
exit = ExitTransition.None,
) {
Text(
AutoSizeText(
text = title,
modifier = Modifier.then(
when (isCenterTitle) {
Expand All @@ -77,11 +77,13 @@ fun AnimatedMediumTopAppBar(
.padding(end = navigationIconWidthDp.dp)
.fillMaxWidth()
}

false -> Modifier
null -> Modifier.alpha(0f)
},
),
textAlign = TextAlign.Center,
maxLines = 1,
)
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
Expand All @@ -17,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign
import io.github.droidkaigi.confsched.designsystem.component.AutoSizeText

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -38,7 +38,7 @@ fun AnimatedTextTopAppBar(
TopAppBar(
title = {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
AutoSizeText(
text = title,
color = textColor,
modifier = Modifier
Expand All @@ -47,10 +47,11 @@ fun AnimatedTextTopAppBar(
alpha = 1f - transitionFraction
},
textAlign = TextAlign.Start,
maxLines = 1,
style = MaterialTheme.typography.headlineSmall,
)

Text(
AutoSizeText(
text = title,
color = textColor,
modifier = Modifier
Expand All @@ -60,6 +61,7 @@ fun AnimatedTextTopAppBar(
alpha = transitionFraction
},
textAlign = TextAlign.Center,
maxLines = 1,
style = MaterialTheme.typography.titleMedium,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ import io.github.droidkaigi.confsched.model.DroidKaigi2024Day
import io.github.droidkaigi.confsched.model.TimetableItem
import io.github.droidkaigi.confsched.sessions.TimetableScreen
import io.github.droidkaigi.confsched.sessions.TimetableScreenTestTag
import io.github.droidkaigi.confsched.sessions.TimetableTitleTestTag
import io.github.droidkaigi.confsched.sessions.TimetableUiTypeChangeButtonTestTag
import io.github.droidkaigi.confsched.sessions.component.TimetableGridItemTestTag
import io.github.droidkaigi.confsched.sessions.section.TimetableGridTestTag
import io.github.droidkaigi.confsched.sessions.section.TimetableListTestTag
import io.github.droidkaigi.confsched.sessions.section.TimetableTabTestTag
import io.github.droidkaigi.confsched.testing.utils.assertLineCount
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
Expand All @@ -41,9 +43,11 @@ class TimetableScreenRobot @Inject constructor(
private val screenRobot: DefaultScreenRobot,
private val timetableServerRobot: DefaultTimetableServerRobot,
private val deviceSetupRobot: DefaultDeviceSetupRobot,
fontScaleRobot: DefaultFontScaleRobot,
) : ScreenRobot by screenRobot,
TimetableServerRobot by timetableServerRobot,
DeviceSetupRobot by deviceSetupRobot {
DeviceSetupRobot by deviceSetupRobot,
FontScaleRobot by fontScaleRobot {
val clickedItems = mutableSetOf<TimetableItem>()

fun setupTimetableScreenContent(customTime: LocalDateTime? = null) {
Expand All @@ -68,6 +72,12 @@ class TimetableScreenRobot @Inject constructor(
waitUntilIdle()
}

fun checkTitleDisplayedInSingleLine() {
composeTestRule
.onNode(hasTestTag(TimetableTitleTestTag))
.assertLineCount(1)
}

fun clickFirstSession() {
composeTestRule
.onAllNodes(hasTestTag(TimetableItemCardTestTag))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package io.github.droidkaigi.confsched.testing.utils

import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.SemanticsNodeInteractionCollection
import androidx.compose.ui.text.TextLayoutResult
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue

fun hasTestTag(
testTag: String,
Expand Down Expand Up @@ -66,6 +69,22 @@ fun SemanticsNodeInteraction.assertTextDoesNotContain(
}
}

fun SemanticsNodeInteraction.assertLineCount(expectedCount: Int) {
fetchSemanticsNode()
.let { node ->
val results = mutableListOf<TextLayoutResult>()
node.config.getOrNull(SemanticsActions.GetTextLayoutResult)
?.action
?.invoke(results)
val result = results.firstOrNull()

assertTrue(
"Node has unexpected line count (expected $expectedCount, but was ${result?.lineCount})",
result?.lineCount == expectedCount,
)
}
}

private fun buildErrorMessageForMinimumCountMismatch(
errorMessage: String,
foundNodes: List<SemanticsNode>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ class TimetableScreenTest(private val testCase: DescribedBehavior<TimetableScree
}
}
}
describe("when font scale is large") {
doIt {
setFontScale(3f)
setupTimetableServer(ServerStatus.Operational)
setupTimetableScreenContent()
}
itShould("show title in a single line") {
captureScreenWithChecks(checks = {
checkTitleDisplayedInSingleLine()
})
}
}
listOf(
InitialTabTestSpec(
date = LocalDate(2024, 9, 11),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
Expand All @@ -31,9 +31,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
Expand All @@ -47,6 +45,7 @@ import conference_app_2024.feature.sessions.generated.resources.timeline_view
import conference_app_2024.feature.sessions.generated.resources.timetable
import io.github.droidkaigi.confsched.compose.EventFlow
import io.github.droidkaigi.confsched.compose.rememberEventFlow
import io.github.droidkaigi.confsched.designsystem.component.AutoSizeText
import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme
import io.github.droidkaigi.confsched.droidkaigiui.SnackbarMessageEffect
import io.github.droidkaigi.confsched.droidkaigiui.UserMessageStateHolder
Expand All @@ -66,6 +65,7 @@ import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview

const val timetableScreenRoute = "timetable"
const val TimetableTitleTestTag = "TimetableTitle"
const val TimetableUiTypeChangeButtonTestTag = "TimetableUiTypeChangeButton"
fun NavGraphBuilder.nestedSessionScreens(
onSearchClick: () -> Unit,
Expand Down Expand Up @@ -158,12 +158,11 @@ private fun TimetableScreen(
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
AutoSizeText(
modifier = Modifier.testTag(TimetableTitleTestTag).weight(1f),
text = stringResource(SessionsRes.string.timetable),
fontSize = 24.sp,
lineHeight = 32.sp,
fontWeight = FontWeight.W400,
modifier = Modifier.weight(1F),
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
)
IconButton(
onClick = dropUnlessResumed(block = onSearchClick),
Expand Down

0 comments on commit e2b3d10

Please sign in to comment.