In my android project, I'm doing a simple Floating Action Button that can expand and show a list of buttons to perform different actions.
To track the current state of the FAB, I have the next enum class
enum class FabState {
Expanded,
Collapsed
}
For displaying the Floating Action Button, I have the following Composable function:
#Composable
fun MultiFloatingActionButton(
icon: ImageVector,
iconTint: Color = Color.White,
miniFabItems: List<MinFabItem>,
fabState: FabState, //The initial state of the FAB
onFabStateChanged: (FabState) -> Unit,
onItemClick: (MinFabItem) -> Unit
) {
val transition = updateTransition(targetState = fabState, label = "transition")
val rotate by transition.animateFloat(label = "rotate") {
when (it) {
FabState.Collapsed -> 0f
FabState.Expanded -> 315f
}
}
val fabScale by transition.animateFloat(label = "fabScale") {
when (it) {
FabState.Collapsed -> 0f
FabState.Expanded -> 1f
}
}
val alpha by transition.animateFloat(label = "alpha") {
when (it) {
FabState.Collapsed -> 0f
FabState.Expanded -> 1f
}
}
val shadow by transition.animateDp(label = "shadow", transitionSpec = { tween(50) }) { state ->
when (state) {
FabState.Expanded -> 2.dp
FabState.Collapsed -> 0.dp
}
}
Column(
horizontalAlignment = Alignment.End
) { // This is where I have my question, in the if condition
if (fabState == FabState.Expanded || transition.currentState == FabState.Expanded) {
miniFabItems.forEach { minFabItem ->
MinFab( //Composable for creating sub action buttons
fabItem = minFabItem,
alpha = alpha,
textShadow = shadow,
fabScale = fabScale,
onMinFabItemClick = {
onItemClick(minFabItem)
}
)
Spacer(modifier = Modifier.size(16.dp))
}
}
FloatingActionButton(
onClick = {
onFabStateChanged(
when (fabState) {
FabState.Expanded -> {
FabState.Collapsed
}
FabState.Collapsed -> {
FabState.Expanded
}
}
)
}
) {
Icon(
imageVector = icon,
tint = iconTint,
contentDescription = null,
modifier = Modifier.rotate(rotate)
)
}
}
}
The constants I defined are for animating the buttons that will show/hide depending on the FAB state.
When I first made the function, the original condition was giving me a different behavior, and playing around with all the posible conditions, I got 3 different results:
1st condition:
if (transition.currentState == FabState.Expanded) {...}
Result: animation not loading from collapsed to expanded, but it does from expanded to collapsed
2nd condition: if (fabState == FabState.Expanded) {...}
Result: animation loading from collapsed to expanded, but not from expanded to collapsed
3rd condition (the one I'm using right now):
if (fabState == FabState.Expanded || transition.currentState == FabState.Expanded) {...}
Result: animation loading in both ways
So my question is: how does every condition change the behavior of the animations?
Any help would be appreciated. Thanks in advance
fabState is updated as soon as onFabStateChanged is called and transition.currentState is updated when it ends the transition and transition.isRunning returns false
Animation only happens if the composable is present in the tree. When the condition is false in the if block, the elements are not available for animation.
condition 1 false during the enter perion which breaks the enter animation and condition 2 is false during the exit period which breaks the exit animation and both are false after exit. Therefore merging them solved your issue and also removes the composables from the tree when not wanted.
Better approach
AnimatedVisibility(
visible = fabState == FabState.Expanded,
enter = fadeIn()+ scaleIn(),
exit = fadeOut() + scaleOut(),
) {
miniFabItems.forEach { minFabItem ->
MinFab(
fabItem = minFabItem,
textShadow = 0.dp,
onMinFabItemClick = {
onItemClick(minFabItem)
}
)
Spacer(modifier = Modifier.size(16.dp))
}
}
And use graphicsLayer modifier to instead of rotate
Icon(
imageVector = Icons.Default.Add,
tint = Color.White,
contentDescription = null,
modifier = Modifier
.graphicsLayer {
this.rotationZ = rotate
}
)
Related
I have been experiencing very erratic jumping when using Jetpack Compose’s LazyColumns on Android TV.
D-Pad navigation is supposed to be supported in Compose for a while now, but it seems simple cases are not supported—or I am doing something terribly wrong when setting a custom focus overlay.
The follow code results in what is shown on this video. As you can see, I am simply navigating step by step from top to bottom but the focused item jumps very randomly in between. It feels like the number are reproducible, but I have not stopped to write them down to verify.
#Composable
fun Greeting(listItems: List<Int>) {
var currentItem by remember { mutableStateOf("None") }
val scrollState = rememberLazyListState()
val scope = rememberCoroutineScope()
Row {
Text(
text = "Current Focus = $currentItem",
modifier = Modifier.weight(1f)
)
Column(Modifier.weight(1f)) {
Text(text = "With focus changed")
LazyColumn(state = scrollState) {
itemsIndexed(listItems) { index, item ->
Item(
item,
{ currentItem = "Left $item" },
Modifier.onFocusChanged { focusState ->
scope.launch {
if (focusState.isFocused) {
val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo
val visibleSet = visibleItemsInfo.map { it.index }.toSet()
if (index == visibleItemsInfo.last().index) {
scrollState.scrollToItem(index)
} else if (visibleSet.contains(index) && index != 0) {
scrollState.scrollToItem(index - 1)
}
}
}
}
)
}
}
}
Column(Modifier.weight(1f)) {
Text(text = "Without focus changed")
LazyColumn {
items(listItems) { item ->
Item(
item,
{ currentItem = "Right $item" }
)
}
}
}
}
}
#Composable
fun Item(
item: Int,
onFocused: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
val focused by interactionSource.collectIsFocusedAsState()
Text("$item", modifier = modifier
.onFocusChanged { state ->
if (state.isFocused) {
onFocused()
}
}
.focusable(true, interactionSource)
.padding(8.dp)
.border(if (focused) 4.dp else 0.dp, MaterialTheme.colors.primary)
.padding(8.dp)
)
}
At first I thought I was doing something incorrectly and it is recomposing but different ways of checking the focus as well as just using plain buttons which already have a focus state (a very bad one for TV tbf) results in the exact same issue.
After reporting this to Google, it turns out that it actually was a bug in Jetpack Compose, which was fixed in the latest version 1.3.0-rc01.
I'm trying to implement a carousel component on Android TV with Compose, and I have a problem with fast scrolling using the dpad. NB: I want to keep the focused item as the first displayed item on the screen.
Here is a screen capture:
The first 5 items are scrolled by pressing and releasing the right key after each item. The next 15 items are scrolled by keeping the right key pressed to the end of the list.
The scrolling and focus management work well, but I would like to make it faster. On the screen capture you see that when pressing the right key, the list is scrolled then the next item gets the focus. It is really slow.
Here is the Composable function:
#Composable
private fun CustomLazyRow() {
val scrollState = rememberLazyListState()
LazyRow(
state = scrollState,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
itemsIndexed(
items = (1..20).toList()
) { index, item ->
var isFocused by remember { mutableStateOf(false) }
Text(
text = "Item $item",
modifier = Modifier
.dpadNavigation(scrollState, index)
.width(156.dp)
.aspectRatio(4 / 3F)
.onFocusChanged { isFocused = it.isFocused }
.focusable()
.border(if (isFocused) 4.dp else Dp.Hairline, Color.Black)
)
}
}
}
And the dpadNavigation Modifier function:
fun Modifier.dpadNavigation(
scrollState: LazyListState,
index: Int
) = composed {
val focusManager = LocalFocusManager.current
var focusDirectionToMove by remember { mutableStateOf<FocusDirection?>(null) }
val scope = rememberCoroutineScope()
onKeyEvent {
if (it.type == KeyEventType.KeyDown) {
when (it.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> focusDirectionToMove = FocusDirection.Left
KeyEvent.KEYCODE_DPAD_RIGHT -> focusDirectionToMove = FocusDirection.Right
}
if (focusDirectionToMove != null) {
scope.launch {
if (focusDirectionToMove == FocusDirection.Left && index > 0) {
// This does not work:
// scope.launch { scrollState.animateScrollToItem(index - 1) }
scrollState.animateScrollToItem(index - 1)
focusManager.moveFocus(FocusDirection.Left)
}
if (focusDirectionToMove == FocusDirection.Right) {
// scope.launch { scrollState.animateScrollToItem(index + 1) }
scrollState.animateScrollToItem(index + 1)
focusManager.moveFocus(FocusDirection.Right)
}
}
}
}
true
}
}
I thought it was caused by the animateScrollToItem function that had to complete before executing moveFocus.
So I tried to execute animateScrollToItem in its own launch block but it didn't work; in this case there is no scrolling at all.
You can see the complete source code in a repo at https://github.com/geekarist/perf-carousel.
I am able to do SwipeToDismiss but I want to restore the swiped LazyColumn item back to its original state.
I don't want to remove swiped item but want to restore it to its original state.
I am able to achieve this easily in RecyclerView by just calling notifyItemChanged() but can't figure out how to do this in LazyColumn.
Below is my code:
val dataList = remember{ mutableStateListOf<ListItem>()}
for(i in 0..100){
dataList.add(ListItem("$i", "'$i' is the item number."))
}
LazyColumn(Modifier.fillMaxSize()){
items(dataList, key = {it.id}){ item ->
val dismissState = rememberDismissState(
confirmStateChange = {
if(it == DismissedToEnd || it == DismissedToStart){
Handler(Looper.getMainLooper()).postDelayed({
//dataList.remove(item)
}, 1000)
}
true
}
)
SwipeToDismiss(
state = dismissState,
directions = setOf(StartToEnd, EndToStart),
dismissThresholds = { direction ->
FractionalThreshold(if (direction == StartToEnd || direction == EndToStart) 0.25f else 0.5f)
},
background = {
val direction = dismissState.dismissDirection ?: return#SwipeToDismiss
val color by animateColorAsState(
targetValue = when(dismissState.targetValue){
Default -> Color.LightGray
DismissedToEnd -> Color.Green
DismissedToStart -> Color.Red
}
)
val icon = when(direction){
StartToEnd -> Icons.Default.Done
EndToStart -> Icons.Default.Delete
}
val scale by animateFloatAsState(
if (dismissState.targetValue == Default) 0.8f else 1.2f
)
val alignment = when (direction) {
StartToEnd -> Alignment.CenterStart
EndToStart -> Alignment.CenterEnd
}
Box(modifier = Modifier
.fillMaxSize()
.background(color)
.padding(start = 12.dp, end = 12.dp),
contentAlignment = alignment
){
Icon(icon, contentDescription = "Icon", modifier = Modifier.scale(scale))
}
},
dismissContent = {ItemScreen(dismissState = dismissState, item = item)}
)
}
}
You can wait currentValue to become non Default and reset the state:
According to Thinking in Compose, composable function should be free of side effects - you shouldn't directly reset the state in the composable scope. For such situations you need to use one of special side effect functions, more info can be found in side-effects documentation.
Recomposition can happen many times, up to once a frame during animation, and not using side effect functions will lead to multiple calls, which can cause animation problems.
As DismissState.reset() is a suspend function, LaunchedEffect fits perfectly here: it's already running on a coroutine scope.
if (dismissState.currentValue != DismissValue.Default) {
LaunchedEffect(Unit) {
dismissState.reset()
}
}
Okay so I've been trying to implement swipe to delete function in my app. Whenever I swipe one of the items from the list I'm able to see a RedBackground behind and everything works fine. Also the swipe animation when I delete an item is triggered successfully. (Even though I'm not sure if it's a good idea to use delay for that? I can't think of any other way to do it).
But the enter animation when I add an item to the database/list is not working, and I'm not sure why. Here's the code of my Lazy Column
#Composable
fun DisplayTasks(
tasks: List<ToDoTask>,
onSwipeToDelete: (Action, ToDoTask) -> Unit,
navigateToTaskScreen: (Int) -> Unit
) {
LazyColumn {
items(
items = tasks,
key = { task ->
task.id
}
) { task ->
val dismissState = rememberDismissState()
val dismissDirection = dismissState.dismissDirection
val isDismissed = dismissState.isDismissed(DismissDirection.EndToStart)
if (isDismissed && dismissDirection == DismissDirection.EndToStart
) {
val scope = rememberCoroutineScope()
scope.launch {
delay(300)
onSwipeToDelete(Action.DELETE, task)
}
}
AnimatedVisibility(
visible = !isDismissed,
exit = shrinkVertically(
animationSpec = tween(
durationMillis = 300,
)
),
enter = expandVertically(
animationSpec = tween(
durationMillis = 300
)
)
) {
SwipeToDismiss(
state = dismissState,
directions = setOf(DismissDirection.EndToStart),
dismissThresholds = { FractionalThreshold(0.2f) },
background = { RedBackground() },
dismissContent = {
LazyColumnItem(
toDoTask = task,
navigateToTaskScreen = navigateToTaskScreen
)
}
)
}
}
}
}
First of all, you shouldn't perform any state changing actions inside composable. Instead use one of side effects, usually LaunchedEffect(key) { }: content of the block will be called on the first render and each time key is different from the last render. Also inside you're already in a coroutine scope, so no need to launch it. Check out more about side-effects in the documentation.
Item animation in the list is not yet supported. It's as simple as adding AnimatedVisibility to the items.
When compose firstly sees AnimatedVisibility in the compose tree, it draws(or not draws) it without animation.
And when on next recomposition visible is different from the last render time, it animates.
So to make it work as you wish you can do the following:
Add itemAppeared state value, which will make item in the list initially hidden, and using side effect make it visible right after render
Add columnAppeared which will prevent initial appearance animation - without it when screen renders all items will appear animatedly too
#Composable
fun DisplayTasks(
tasks: List<ToDoTask>,
onSwipeToDelete: (Action, ToDoTask) -> Unit,
) {
var columnAppeared by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
columnAppeared = true
}
LazyColumn {
items(
items = tasks,
key = { task ->
task.id
}
) { task ->
val dismissState = rememberDismissState()
val dismissDirection = dismissState.dismissDirection
val isDismissed = dismissState.isDismissed(DismissDirection.EndToStart)
if (isDismissed && dismissDirection == DismissDirection.EndToStart
) {
LaunchedEffect(Unit) {
delay(300)
onSwipeToDelete(Action.DELETE, task)
}
}
var itemAppeared by remember { mutableStateOf(!columnAppeared) }
LaunchedEffect(Unit) {
itemAppeared = true
}
AnimatedVisibility(
visible = itemAppeared && !isDismissed,
exit = shrinkVertically(
animationSpec = tween(
durationMillis = 300,
)
),
enter = expandVertically(
animationSpec = tween(
durationMillis = 300
)
)
) {
SwipeToDismiss(
state = dismissState,
directions = setOf(DismissDirection.EndToStart),
dismissThresholds = { FractionalThreshold(0.2f) },
background = {
Box(
Modifier
.background(Color.Red)
.fillMaxSize()
)
},
dismissContent = {
Text(task.id)
}
)
}
}
}
}
I have this Text:
Text(
text = stringResource(id = R.string.hello)
)
How can I show and hide this component?
I'm using Jetpack Compose version '1.0.0-alpha03'
As CommonsWare stated, compose being a declarative toolkit you tie your component to a state (for ex: isVisible), then compose will intelligently decide which composables depend on that state and recompose them. For ex:
#Composable
fun MyText(isVisible: Boolean){
if(isVisible){
Text(text = stringResource(id = R.string.hello))
}
}
Also you could use the AnimatedVisibility() composable for animations.
You can simply add a condition like:
if(isVisible){
Text("....")
}
Something like:
var visible by remember { mutableStateOf(true) }
Column() {
if (visible) {
Text("Text")
}
Button(onClick = { visible = !visible }) { Text("Toggle") }
}
If you want to animate the appearance and disappearance of its content you can use the AnimatedVisibility
var visible by remember { mutableStateOf(true) }
Column() {
AnimatedVisibility(
visible = visible,
enter = fadeIn(
// Overwrites the initial value of alpha to 0.4f for fade in, 0 by default
initialAlpha = 0.4f
),
exit = fadeOut(
// Overwrites the default animation with tween
animationSpec = tween(durationMillis = 250)
)
) {
// Content that needs to appear/disappear goes here:
Text("....")
}
Button(onClick = { visible = !visible }) { Text("Toggle") }
}
As stated above, you could use AnimatedVisibility like:
AnimatedVisibility(visible = yourCondition) { Text(text = getString(R.string.yourString)) }
/**
* #param visible if false content is invisible ie. space is still occupied
*/
#Composable
fun Visibility(
visible: Boolean,
content: #Composable () -> Unit
) {
val contentSize = remember { mutableStateOf(IntSize.Zero) }
Box(modifier = Modifier
.onSizeChanged { size -> contentSize.value = size }) {
if (visible || contentSize.value == IntSize.Zero) {
content()
} else {
Spacer(modifier = Modifier.size(contentSize.value.width.pxToDp(), contentSize.value.height.pxToDp()))
}
}
}
fun Int.pxToDp(): Dp {
return (this / getSystem().displayMetrics.density).dp
}
usage:
Visibility(text.value.isNotEmpty()) {
IconButton(
onClick = { text.value = "" },
modifier = Modifier
.padding(bottom = 8.dp)
.height(30.dp),
) {
Icon(Icons.Filled.Close, contentDescription = "Clear text")
}
}