How can I display an Icon to the right of a ScrollableTabRow so the ScrollableTabRow scrolls using the space to the left of the icon?
By placing them in a Row the ScrollableTabRow appears to take up the entire horizontal space and the Icon is not displayed.
Would like something like this:
You could place your ScrollableTabRow and your Icon in a Row and give your ScrollableTabRow 'weight(1f)'. That way the icon stays in place and TabRow can be scrolled on the left of the icon.
Row {
ScrollableTabRow(
selectedTabIndex = selectedTabIndex,
contentColor = Color.White,
edgePadding = 0.dp,
modifier = Modifier.weight(1f)
) {
tabs.forEachIndexed { tabIndex, tab ->
Tab(
selected = selectedTabIndex == tabIndex,
onClick = { onTabClick(tabIndex) },
text = { Text(text = tab) }
)
}
}
Icon(...)
}
Related
I want to make a ScrollableTabRow that goes past the width of the screen to make it clearer that there are more tabs to scroll through. It should look something like this:
When scrolled all the way to the left:
When scrolled somewhere in the middle:
When scrolled all the way to the right:
However, I cannot achieve it using the Material component ScrollableTabRow because the ScrollableTabRow is filling the remaining width of the screen, instead of fully wrapping its content.
When scrolled all the way to the left:
When scrolled all the way to the right:
Here is my code using the ScrollableTabRow composable:
Note: 1.unit is equal to 4.dp
Row {
HorizontalSpacer(width = 4.unit)
ScrollableTabRow(
modifier = Modifier.clip(CircleShape).wrapContentSize(),
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
SpecsheetTabIndicator(tabPositions = tabPositions, pagerState = pagerState)
},
edgePadding = 0.dp,
divider = {},
containerColor = MaterialTheme.colorScheme.primaryContainer,
) {
for ((index, tab) in tabs.withIndex()) {
val textColor by animateColorAsState(
targetValue = when (pagerState.currentPage) {
index -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onPrimaryContainer
},
)
Tab(
modifier = Modifier
.zIndex(6f)
.clip(CircleShape),
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = tab.text(),
color = textColor,
)
},
)
}
}
HorizontalSpacer(width = 4.unit)
}
Also, as you can see in my code above, the Row is not really scrolling because I am not sure how to implement nested scrolling. Adding a horizontalScroll modifier to the Row makes the app crash because of the unhandled nested scrolling.
I have achieved the behavior I wanted as demonstrated in the first three images using regular rows:
Row(
modifier = Modifier.horizontalScroll(scrollState),
) {
HorizontalSpacer(width = 4.unit)
Row(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
) {
for ((index, tab) in tabs.withIndex()) {
val containerColor by animateColorAsState(
targetValue = when (pagerState.currentPage) {
index -> MaterialTheme.colorScheme.primary
else -> Color.Transparent
},
)
val textColor by animateColorAsState(
targetValue = when (pagerState.currentPage) {
index -> MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onPrimaryContainer
},
)
Surface(
modifier = Modifier.clip(CircleShape),
color = containerColor,
) {
Tab(
modifier = Modifier
.zIndex(6f)
.clip(CircleShape)
.defaultMinSize(minWidth = 24.unit),
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = tab.text(),
color = textColor,
)
},
)
}
}
}
HorizontalSpacer(width = 4.unit)
}
However, I don't know how to reimplement a lot of the behavior that are built into the ScrollableTabRow like the proper rendering of the indicator and the proper scrolling of the tab row to the selected tab when the selected tab is not visible. Can anyone help me make this work for me?
I am experiencing flicker or overlapping when having a compose tabBar implementation with webviews as content. If I change the webviews with another view (ex. Box{Text}) it does not happen.
It seems as if the webview is filling more than it's border for a short while (See .gif below)
Update: I have been looking into if it was a recomposition issue (hence the simple test project) and I cannot identify any reason why it should recompose the tab bar.
When I add height to the tab bar, I can see the text is in the tab bar at all times.
A test project can be fetched here: https://github.com/msuhl/ComposeTabTest and is a very standard implementation
#Composable
private fun MyTabRow(
pagerState: PagerState,
coroutineScope: CoroutineScope,
) {
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.secondary
)
},
) {
tabRowItems.forEachIndexed { index, item ->
Tab(
selected = pagerState.currentPage == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
icon = {
Icon(imageVector = item.icon, contentDescription = "")
},
text = {
Text(
text = item.title,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
HorizontalPager(
count = tabRowItems.size,
state = pagerState,
) {
ShowWebView("http://google.com")
}
}
It was related to the lazy loading of webview and I was not able to make a direct fix.
Instead I ended up with a working, although kind og hackish, solution
If a LazyColumn is introduced around the webview, the issue does not occur.
In code:
HorizontalPager(
count = tabRowItems.size,
state = pagerState,
) {
LazyColumn {
item {
ShowWebView(url)
}
}
}
I'm currently working on a animated TopAppBar using Material3 LargeTopAppBar and Compose.
I'm using TopAppBarDefaults.exitUntilCollapsedScrollBehavior to make the AppBar collapse when scrolled.
When flung downwards it snaps to the expanded view, but when flung upwards it doesn't snap and more often than not just stays in between the two states(collapsed & expanded).
Here's some of the code:
val topappbarstate = rememberTopAppBarState()
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
decayAnimationSpec, topappbarstate)
Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = {
LargeHeader(
title = "Title",
subtitle = "Subtitle",
scrollBehavior
)
}, content = { innerPadding ->
LazyColumn(contentPadding = innerPadding,) {
items(count = 100) {
Box(Modifier.fillMaxWidth()) {
Text(text = "$it")
}
}
}
},
)
}
The LargeHeader is a function that contains a Motionlayout to animate the Title and Subtitle in the LargeTopAppBar to swing upwards when scrolled but has no impact on the flinging animation of the TopAppBar.
Is there a way to customize this flinging snap of the actual TopAppBar?
Sorry for my english and thanks in advance!
If i create like 1000 Tabs within the ScrollableTabRow will it only compose the Tabs which are visible to the screen or`will compose everytime non-visible Tabs if something changes?
Like same as a LazyColumn composes only visible items.
I've written a minimal example in which i'm just "repeat"ing the same composable a lot of times - with no item{}-like DSL available like the lazy-components have.
#Composable
#Preview
fun MinimalTabExample() {
var selectedTabIndex by remember { mutableStateOf(0) }
ScrollableTabRow(selectedTabIndex = selectedTabIndex) {
repeat(60) { tabNumber ->
Tab(
selected = selectedTabIndex == tabNumber,
onClick = { selectedTabIndex = tabNumber },
text = { Text(text = "Tab #$tabNumber") }
)
}
}
}
Also, if you look into the ScrollableTabRow component you'll find that all the tabs are measured right at the beginning: (tabs are the compose-tabs created above)
val tabPlaceables = subcompose(TabSlots.Tabs, tabs)
.map { it.measure(tabConstraints) }
I guess this is implemented this way due to the height-calculation: "Make the height as high as the highest component"
tabPlaceables.forEach {
layoutWidth += it.width
layoutHeight = maxOf(layoutHeight, it.height)
}
This code creates a ClickableText element in Jetpack Compose Composable:
ClickableText(
text = forgotPasswordAnnotatedString,
onClick = {
context.startActivity(intent)
},
modifier = Modifier
.padding(top = mediumPadding)
)
The annotated string is defined here to make the text look like a link:
val forgotPasswordAnnotatedString = buildAnnotatedString {
append(stringResource(R.string.forgot_password))
addStyle(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
),
start = 0,
end = 21,
)
}
When I encounter this text using the TalkBalk screen reader in Android, the screenreader does not make it clear that this is clickable text that will do something which tapped on. The reader just reads the text.
Is there a way to make it clear to the screen reader that this text is interactive? Otherwise should I just use a button and style it to look like a link?
It looks like your intention is for the whole text to be clickable? In which you best option is probably a TextButton as suggested by
Gabriele Mariotti.
But if you wan't only part of the link to be clickable, or to have multiple clickable sections, the best I've been able to land on is to draw an invisible box overtop of the Text. It means that I can control the touch target of the clickable area to be at least 48.dp and can use the semantics{} modifier to control how a screen reader interprets it.
Would welcome any suggestions.
// remember variables to hold the start and end position of the clickable text
val startX = remember { mutableStateOf(0f) }
val endX = remember { mutableStateOf(0f) }
// convert to Dp and work out width of button
val buttonPaddingX = with(LocalDensity.current) { startX.value.toDp() }
val buttonWidth = with(LocalDensity.current) { (endX.value - startX.value).toDp() }
Text(
text = forgotPasswordAnnotatedString,
onTextLayout = {
startX.value = it.getBoundingBox(0).left // where 0 is the start index of the range you want to be clickable
endX.value = it.getBoundingBox(21 - 1).right // where 21 is the end index of the range you want to be clickable
}
)
Note that buttonPaddingX is relative to the Text position not the Window, so you may have to surround both in a Box{} or use ConstraintLayout.
Then to draw the invisible box
Box(modifier = Modifier
.sizeIn(minWidth = 48.dp, minHeight = 48.dp) // minimum touch target size
.padding(start = buttonPaddingX)
.width(buttonWidth)
// .background(Color.Magenta.copy(alpha = 0.5f)) // uncomment this to debug where the box is drawn
.clickable(onClick = { context.startActivity(intent) })
.semantics {
// tell TalkBack whatever you need to here
role = Role.Button
contentDescription = "Insert button description here"
}
)
In my code I'm using pushStringAnnotation(TAG, annotation) rather than reference string indexes directly. That way I can get the start and end index of the clickable area with annotatedString.getStringAnnotations(TAG,0,annotatedString.length).first(). Useful if there a multiple links within the text.
It's disappointing that ClickableText doesn't have accessibility in mind from the get go, hopefully we'll be able to use it again in a future update.
Adding .semantics.contentDescription to the Modifier changes what is read by the screen reader. I had to word contentDescription to make it clear that this was a link to reset the your password.
The screen reader still doesn't recognize the element a clickable but hopefully the description will be useful to convey to the user that this element is interactive.
ClickableText(
text = forgotPasswordAnnotatedString,
onClick = {
context.startActivity(intent)
},
modifier = Modifier
.padding(top = mediumPadding)
// new code here:
.semantics {
contentDescription = "Forgot your password? link"
}
)