AnimatedVisibility & SwipeToDismiss Enter Animation does not trigger - Jetpack Compose - android

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

Related

What is the behavior of Jetpack Compose animations?

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

Scroll all screen with a lazy column and paging library inside

I'm new to Jetpack Compose and I'm not quite sure how to do what I need. In the screen below, I want to scroll the whole screen and not just the list at the bottom and when the scroll reaches the end of the list below, it still applies the paging library and goes to get more elements. I managed to get the Paging Library to work and the scroll in the list below too, but I can't make the rest of the page elements scroll as well - this is because only the list has scroll and not the rest of the page. Whenever I'm trying to do that, I get the following crash:
Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). If you want to add a header before the list of items please add a header as a separate item() before the main items() inside the LazyColumn scope. There are could be other reasons for this to happen: your ComposeView was added into a LinearLayout with some weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a custom layout. Please try to remove the source of infinite constraints in the hierarchy above the scrolling container.
and I don't really know why.
I leave you the code below and two screenshots: the first is the current state, where I can only scroll through the list. The second is what I intend, which is to scroll the entire page.
#Edit: I was able to implement all screen scroll with fixed height on the children lazy column, but that is not what I want.
#Composable
#ExperimentalFoundationApi
private fun MainActivityLayout(navController: NavHostController) {
LazyColumn(
modifier = Modifier
.paint(
painter = painterResource(id = R.drawable.main_background),
contentScale = ContentScale.FillBounds
)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
HeightSpacer(Dimen40)
Image(
painter = painterResource(id = R.drawable.ic_clearjobs_logo_2x),
contentDescription = null
)
HeightSpacer(Dimen47)
Navigation(navController = navController)
}
}
}
#Composable
#ExperimentalFoundationApi
fun JobOpeningsScreen(viewModel: JobOpeningsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
Column {
ClearJobsScreenTitle(
lightTitle = stringResource(id = R.string.job_openings_light_title),
boldTitle = stringResource(id = R.string.job_openings_bold_title)
)
HeightSpacer(Dimen60)
Row {
CategoryButton()
WidthSpacer(Dimen2)
OrderByButton()
}
HeightSpacer(Dimen30)
SearchTextField()
HeightSpacer(Dimen60)
when (uiState) {
is BaseViewState.Data -> JobOpeningsContent(
viewState = uiState.cast<BaseViewState.Data<JobOpeningsViewState>>().value
)
is BaseViewState.Loading -> {
LoadingView()
}
else -> {}
}
LaunchedEffect(key1 = viewModel, block = {
viewModel.onTriggerEvent(JobOpeningsEvent.LoadJobOffers)
})
}
}
#Composable
fun JobOpeningsContent(viewState: JobOpeningsViewState) {
val pagingItems = rememberFlowWithLifecycle(viewState.pagedData).collectAsLazyPagingItems()
SwipeRefresh(
state = rememberSwipeRefreshState(
isRefreshing = pagingItems.loadState.refresh == LoadState.Loading
),
onRefresh = { pagingItems.refresh() },
indicator = { state, trigger ->
SwipeRefreshIndicator(
state = state,
refreshTriggerDistance = trigger,
scale = true
)
},
content = {
LazyColumn(
modifier = Modifier.width(Dimen320),
verticalArrangement = Arrangement.spacedBy(Dimen30)
) {
items(pagingItems.itemCount) { index ->
pagingItems[index]?.let {
JobOpeningsRow(dto = it)
}
}
if (pagingItems.loadState.append == LoadState.Loading) {
item {
Box(
Modifier
.padding(24.dp)
) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
}
}
}
)
}
I found the solution to this problem, although it is not 100% and in terms of code it is not as good as I would like.
The error speaks for itself, we can't have infinite vertical scroll, Jetpack Compose doesn't allow it. I had the option of putting a fixed height on the Lazy Column of my list, but it wasn't what I wanted and it didn't work properly. The solution was to put everything inside a single LazyColumn and remove the Column from MainActivity, using a Box element and contentAlignment. I leave you below the final code that I used to solve the problem.
MainScreen function that before was MainActivityLayout function:
#Preview
#Composable
#ExperimentalFoundationApi
fun MainScreen() {
val navController = rememberNavController()
val topLevelDestinations = listOf(
NavigationItem.JobOpenings,
NavigationItem.Profile,
NavigationItem.About
)
val isTopLevelDestination =
navController
.currentBackStackEntryAsState()
.value
?.destination
?.route in topLevelDestinations.map { it.route }
val backStackEntryState = navController.currentBackStackEntryAsState()
Scaffold(
bottomBar = {
if (isTopLevelDestination) {
BottomNavBar(
navController = navController,
backStackEntryState = backStackEntryState,
bottomNavItems = topLevelDestinations
)
}
}
) {
Box(
modifier = Modifier
.paint(
painter = painterResource(id = R.drawable.main_background),
contentScale = ContentScale.FillBounds
)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Navigation(navController = navController)
}
}
}
New JobOpenings fun that is mixed with old JobOpeningsContent function:
#Composable
#ExperimentalFoundationApi
fun JobOpeningsScreen(viewModel: JobOpeningsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is BaseViewState.Data -> {
val pagedData = uiState.cast<BaseViewState.Data<JobOpeningsViewState>>().value.pagedData
val pagingItems = rememberFlowWithLifecycle(pagedData).collectAsLazyPagingItems()
SwipeRefresh(
state = rememberSwipeRefreshState(
isRefreshing = pagingItems.loadState.refresh == LoadState.Loading
),
onRefresh = { pagingItems.refresh() },
indicator = { state, trigger ->
SwipeRefreshIndicator(
state = state,
refreshTriggerDistance = trigger,
scale = true
)
},
content = {
LazyColumn(
modifier = Modifier
.width(Dimen320),
verticalArrangement = Arrangement.spacedBy(Dimen30)
) {
item {
ScreenHeader(
lightTitle = stringResource(id = R.string.job_openings_light_title),
boldTitle = stringResource(id = R.string.job_openings_bold_title)
)
HeightSpacer(Dimen60)
Row {
CategoryButton()
WidthSpacer(Dimen2)
OrderByButton()
}
HeightSpacer(Dimen30)
SearchTextField()
HeightSpacer(Dimen60)
}
items(pagingItems.itemCount) { index ->
pagingItems[index]?.let {
JobOpeningsRow(dto = it)
}
}
if (pagingItems.loadState.append == LoadState.Loading) {
item {
Box(Modifier.padding(Dimen24)) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
}
}
}
)
}
is BaseViewState.Loading -> LoadingView()
else -> {}
}
LaunchedEffect(key1 = viewModel, block = {
viewModel.onTriggerEvent(JobOpeningsEvent.LoadJobOffers)
})
}
#ExperimentalFoundationApi
#Preview
#Composable
fun JobOpenings() {
JobOpeningsScreen()
}
Problems that I found with this solution:
LoadingView appears at the top of the screen instead at the top of the list.
If anyone has any suggestion to improve this, I am open to it. This works perfectly with Paging Library + Swipe Refresh (Accompanist) and full page scroll.

How to scroll a LazyRow faster using the dpad?

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.

Restore SwipeToDismiss LazyColumn Item to it's original state?

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

Jetpack compose how to wait for animation ends

I have simple animation by AnimatedVisibility (slideInVertically/SlideOut).
When i press "nextScreenButton" i make new navigate by navController.
The transition is done instantly, so no time for exit animation.
How to make waiting until animations ends
I can enter some delay for animation time, but its not good way.
Code:
Scaffold() {
AnimatedVisibility(
//Boolean State for open animation
OpenChooseProfilePageAnim.value,
initiallyVisible= false,
enter = slideInVertically(
initialOffsetY = { fullHeight -> fullHeight },
animationSpec = tween(
durationMillis = 3000,
easing = LinearOutSlowInEasing
)
),
exit = slideOutVertically(
targetOffsetY = { fullHeight -> fullHeight },
animationSpec = tween(
durationMillis = 3000,
easing = LinearOutSlowInEasing
)
)
) {
ConstraintLayout() {
Card() {
Column() {
//Some other Composable items
//Composable button
NextScreenButton() {
//calling navigation here
}
}
}
}
}
}
Ty for help.
NextScreenButton code:
fun navigateToMainListPage(navController: NavController) {
//change State of visibility for "AnimatedVisibility"
AnimationsState.OpenChooseProfilePageAnim.value = false
//navigate to another route in NavHost
navController.navigate(ROUTE_MAIN_LIST)
}
NavHost:
#Composable
fun LoginGroupNavigation(startDestination: String) {
val navController = rememberNavController()
NavHost(navController, startDestination = startDestination) {
composable(LoginScreens.LoginScreen.route) {
LoginMainPage(navController)
}
composable(LoginScreens.EnteringPhoneNumScreen.route,
arguments = listOf(navArgument("title") { type = NavType.StringType },
)) {
val title = it.arguments?.getString("title") ?: ""
EnterPhoneNumberForSmsPage(
navController = navController,
title
)
}
//more composable screens
Here is the main idea to do this:
val animVisibleState = remember { MutableTransitionState(false) }
.apply { targetState = true }
//Note: Once the exit transition is finished,
//the content composable will be removed from the tree,
//and disposed.
//Both currentState and targetState will be false for
//visibleState.
if (!animVisibleState.targetState &&
!animVisibleState.currentState
) {
//navigate to another route in NavHost
navController.navigate(ROUTE_MAIN_LIST)
return
}
AnimatedVisibility(
visibleState = animVisibleState,
enter = fadeIn(
animationSpec = tween(durationMillis = 200)
),
exit = fadeOut(
animationSpec = tween(durationMillis = 200)
)
) {
NextButton() {
//start exit animation
animVisibleState.targetState = false
}
}
to answer your question: "how to wait for animation ends".
just check if the transition.targetState is the same as transition.currentState.
if it is the same, then the animation have ended.
AnimatedVisibility(
initiallyVisible= ...,
enter = ...,
exit = ...
) {
...<your layout>
//to detect if your animation have completed just check the following
if (this.transition.currentState == this.transition.targetState){
//Animation is completed when current state is the same as target state.
//you can call whatever you like here -> e.g. start music, show toasts, enable buttons, etc
callback.invoke() //we invoke a callback as an example.
}
}
this will also work for other animations like AnimatedContent, etc
Crossfade was introduced as the default transition for navigation in Navigation 2.4.0-alpha05. With the latest release of Navigation 2.4.0-alpha06, you can add custom transitions via Accompanist
val animVisibleState = remember { MutableTransitionState(false) }
val nextButtonPressState = remember { mutableStateOf(false) }
LaunchedEffect(key1 = true) {
animVisibleState.targetState = true
}
if ( !animVisibleState.targetState &&
!animVisibleState.currentState &&
nextButtonPressState.value
) {
navController.navigate(ROUTE_MAIN_LIST)
}
AnimatedVisibility(
visibleState = animVisibleState,
enter = fadeIn(),
exit = fadeOut()
) {
NextButton() {
nextButtonPressState.value = true
}
}
.apply {animVisibleState.target = true} at the declaration of the variable will apply this on every recompositions so LaunchedEffect can be a solution, there also much better way to handle the nextButtonPressionState

Categories

Resources