How to notify children of the layout they are being pressed? - android-jetpack-compose

I have a Row of Boxes, the main idea is I want to hightlight the currently pressed box, for example make it a different color, however I can't find a suitable out-of-the-box solution for this.
val interactionSource = remember { MutableInteractionSource() }
val pressed = interactionSource.collectIsPressedAsState()
This is the way I usually handle presses but I cant seem to find any way to make it work for multiple elements. And if I put the interaction source on the parent element I don't have any way to know what child is being pressed at the moment

I hope it can help
var currentClickIndex = remember{mutableStateOf(-1)}
val selectedColor = Color.Green
val unselectedColor = Color.Gray
Row{
for(i in 0..5){
Box(modifier = Modifier
.background(color = if(i == currentClickIndex) selectedColor else unselectedColor
.clickable{ currentIndex = i })
}
}

Related

Remember color used in parent, but calculated nested child

I am trying to figure out the right way to remember a color between re-compose, where the color is used in the parent, but calculated in a child.
var color by remember { mutableStateOf(Color.Transparent) }
Row(Modifier.height(100.dp)) {
Box(
Modifier
.width(6.dp)
.fillMaxHeight()
.background(color)
)
Column {
HorizontalPager(
count = pages.size,
state = pagerState
) { page ->
val page = pages[page]
color = page.color
}
}
}
However I am running into two issues
When the color changes when I page seem to get into an infinite re-compose.
The color seems to be a blend of the new page color and the previous page color.
Rather than having color as state, which is causing your recomposition loop. You could derive the same thing by hoisting your pagerState object and referencing the current page. Something like the following:
val pagerState = rememberPagerState()
var color = remember(pagerState.currentPage) { pages[pagerState.currentPage].color }
Row(Modifier.height(100.dp)) {
Box(
Modifier
.width(6.dp)
.fillMaxHeight()
.background(color)
)
Column {
HorizontalPager(
count = pages.size,
state = pagerState
) { page ->
}
}
}

how to animate button click in jetpack compose without consuming the click event

I saw similar questions with no great solutions so I thought this might be useful.
I had a problem when I wanted to animate a button the user clicked to make it larger when pressed; not as easy as it sounds because when you get the animation working the onClick event never fires. [Because it depends on the up event I guess]
I found a way to make both the animation and the click work for a icon button and I thought it might work for other cases.
#OptIn(ExperimentalComposeUiApi::class)
#Composable
fun RoundIconButton(
modifier: Modifier = Modifier,
imageVector: ImageVector,
onClick: () -> Unit,
tint: Color = Color.Black.copy(alpha = 0.8f),
backgroundColor: Color =
MaterialTheme.colors.background,
elevation: Dp = 4.dp,
contentDescription: String
) {
val interactionSource = remember {
MutableInteractionSource() }
val isPressed by
interactionSource.collectIsPressedAsState()
val transition = updateTransition(targetState
= isPressed, label = "")
val size by transition.animateDp(label = "")
{ state ->
when(state) {
false -> 40.dp
true -> 50.dp
}
}
Card(
modifier = modifier
.padding(all = 4.dp)
.clickable(interactionSource =
interactionSource,indication =
LocalIndication.current,onClick= onClick)
.then(Modifier.size(size)),
shape = CircleShape,
backgroundColor = backgroundColor,
elevation = elevation,
) {
Icon( imageVector = imageVector,
contentDescription = contentDescription,tint
= tint)
}
}

LazyColumn animateScrollBy does nothing?

Update: original question followed by updated sample code that triggers it as requested.
I have a list of times broken down by half hour in a lazy list
E.g 12:00, 12:30
The list is attached to a horizontal pager from the accompanist-pager library
When a user is swiping between pages, I need to update the selected time, and I want the selected time to always be in the center of the screen, if it's not near the top/bottom where that can't be the case.
The only way I've figured out how to accurately do this with the methods provided to us is as below.
Note: i have previously captured the button / list height.
val listState = rememberLazyListState()
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (page != selectedPage) {
listState.scrollToItem(0) //Need to be at the top for calc below to work
listState.scrollBy(buttonHeight.toFloat() * page - (listVisibleHeight / 2) + buttonHeight)
}
}
}
Now this works perfectly. When I start at 00:00 and move down the list no scrolling occurs until I reach the middle, and once I reach the middle, the selected time, stays in the middle as the times scroll accordingly, and remains in the middle until I approach the end of the list where it starts moving downwards.
Now there's also a method listState.animateScrollBy(float, animation) which would be ideal so there's an animation if you have clicked on something not in the middle, and it has to animate it back to the middle, but when I use this one absolutely nothing happens?
listState.animateScrollBy(buttonHeight.toFloat() * page - (listVisibleHeight / 2) + buttonHeight)
It just seems flat out broken to me? As if I never called the function. 0 scrolling, even when I try adding a non default animation. Am I doing something wrong here?
It's doing this on compose 1.2.0-alpha04 and 1.2.0-alpha06
I can continue to use scrollBy but without the animation, it will be a little jumpy if the user clicks a time vs swipes to a time.
Example now that I know why it's happening as requested
Special Imports for the pager
implementation "com.google.accompanist:accompanist-pager:0.24.5-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators:0.24.5-alpha"
Note: The pager itself has been wonky since I updated it to fix a crash in an earlier version... it doesn't quite swipe right, it gets stuck at times. I have not looked into it yet at all though.
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
#ExperimentalPagerApi
#Composable
fun TestScreen() {
val numberOfRows = 48
val listState = rememberLazyListState()
var selectedRow by remember { mutableStateOf(0) }
var buttonHeight by remember { mutableStateOf(0) }
var listVisibleHeight by remember { mutableStateOf(0) }
val pagerState = PagerState(currentPage = selectedRow)
Row(
modifier = Modifier
.onGloballyPositioned {
listVisibleHeight = it.size.height
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.wrapContentSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(numberOfRows) { index ->
Button(
modifier = Modifier
.wrapContentWidth()
.onGloballyPositioned {
buttonHeight = it.size.height
},
onClick = {}
) {
if (index == selectedRow) {
Text(text = index.toString(), color = Color.White)
} else {
Text(text = index.toString(), color = Color.Red)
}
}
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (page != selectedRow) {
//This doesn't work if selectedRow = page happens before the other code due to recomposition
//If it's after, it executes but it doesn't look right as it moves then highlights
selectedRow = page
listState.scrollToItem(0)
try {
listState.animateScrollBy(distanceToMiddleOfScreen(buttonHeight, listVisibleHeight, page))
} catch (exception: Exception) {
Log.e("scrollBug", "animateScrollBy Exception", exception)
}
}
}
}
HorizontalPager(
count = 48,
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(Color.White),
) { page ->
Text(text = page.toString())
}
}
}
private fun distanceToMiddleOfScreen(itemHeight: Int, totalListHeight: Int, offset: Int): Float {
return itemHeight.toFloat() * offset - (totalListHeight / 2) + itemHeight
}
Your pager behaves strangely because you're creating its state on reach recomposition:
val pagerState = PagerState(currentPage = selectedRow)
This initializer made for you to use in your own state, which you're responsible to remember between recompositions.
In case when you use it in the view, to save its state between recompositions you need to use remember version:
val pagerState = rememberPagerState()
After this your LaunchedEffect won't be restarted and won't stop your animateScrollBy.
I can't think of a general solution of scrolling problem until this feature request is closed. But in case when all your items have equal size, you can calculate current scroll position, final scroll position, and so you can calculate the difference to scroll:
LaunchedEffect(Unit) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (page != selectedRow) {
selectedRow = page
val currentOffset = listState.firstVisibleItemIndex * buttonHeight + listState.firstVisibleItemScrollOffset
val finalOffset = distanceToMiddleOfScreen(buttonHeight, listVisibleHeight, page)
listState.animateScrollBy(finalOffset - currentOffset)
}
}
}
Also your distanceToMiddleOfScreen is not accurate, it can be fixed like this:
return itemHeight * (offset + 0.5f) - totalListHeight / 2
Result:

Change highlight on clickable Text possible?

I have a #Composable Text("Dropbox") whose Modifier also implements a
clickable {
//..
}
But when I tapping on it
, it gets a grey highlight!?
I do not want this grey hightlight. Can I get rid of this?
Here indication is responsible for showing the highlight.
So making indication = null should do the job
.clickable(
onClick = {
//..
},
indication = null,
interactionSource = remember { MutableInteractionSource() }
)

How to make ClickableText accessible to screen readers

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"
}
)

Resources