I have a simple Composable that shows some data to the user, and that data needs to be updated periodically. Now, I wish to add a smooth transition between data changes instead of just snapping the new data in, so that is the prime focus of this question.
Now, for a mere example, we could take a simple Image Composable. I have this
#Composable
fun ImageFrame(imagePainter: Painter){
Box{
Image(
imagePainter,
... //Modifications, etcetra
)
}
}
If I want the painter to change every three seconds, how should I go about animating this change in reference to the Box with a slide-in and slide-out motion?
Found a great experimental API, built into Compose for the exact same thing, it's called AnimatedContent. Here's an implementation for my use-case
#Composable
fun AnimatedImageFrame(image: Painter){
AnimatedContent(
targetState = image,
transitionSpec = {
(slideInHorizontally { -it } with slideOutHorizontally { it })
.using(
// Disable clipping since the faded slide-in/out should
// be displayed out of bounds.
SizeTransform(clip = false)
)
}
) {
Image(
painter = it,
contentDescription = ""
)
}
}
Every time you update the parameter passed to the AnimatedImageFrame, the image displayed would animate by doing a sliding motion.
To read more, refer to this doc.
I had a similar issue, and as far as I know, the only out-of-the-box animation in Compose for content changes is Crossfade.
I ended up implementing a custom animation based on the Crossfade implementation, as it effectively will take care of managing the state for you, you'll just need to swap out the alpha value animation for slide in/out animation. Inspect the Crossfade implementation, and you'll see how it works.
The bit where Crossfade composes the new content with the fading animation is this:
CrossfadeAnimationItem(key) {
val alpha by transition.animateFloat(
transitionSpec = { animationSpec }
) { if (it == key) 1f else 0f }
Box(Modifier.graphicsLayer { this.alpha = alpha }) {
content(key)
}
}
So for sliding in/out instead, you may end up with something like:
SlideContentInAnimationItem(key) {
transition.AnimatedVisibility(
visible = { it == key },
enter = slideInHorizontally( /* your animation config*/ ),
exit = slideOutHorizontally( /* your animation config*/ )
) {
content(key)
}
}
The targetState property and the key value are one and the same - in your example it would represent your ImagePainter - if that value changes due to a state change in the composable, the Crossfade or your new custom sliding animation will be triggered.
Related
I'm using a ScrollableTabRow to display some 60 Tabs.
At the very beginning, the indicator should start "in the middle".
However, this results in an unwanted scrolling animation when the composable is drawn - see video. Am i doing something wrong or is this component buggy?
#Composable
#Preview
fun MinimalTabExample() {
val tabCount = 60
var selectedTabIndex by remember { mutableStateOf(tabCount / 2) }
ScrollableTabRow(selectedTabIndex = selectedTabIndex) {
repeat(tabCount) { tabNumber ->
Tab(
selected = selectedTabIndex == tabNumber,
onClick = { selectedTabIndex = tabNumber },
text = { Text(text = "Tab #$tabNumber") }
)
}
}
}
But why would you like to do that?
I'm writing a calendar-like application and have a day-detail-view.
From there want a fast way to navigate to adjacent days. A Month into the future and a month into the past - relative to the selected month - is what i'm aiming for.
No, you are not doing it wrong. Also the component is not really buggy, rather the behaviour you are seeing is an implementation detail.
If we check the implementation of the ScrollableTabRow composable we see that the selectedTabIndex is used in two places inside the composable:
inside the default indicator implementation
as an input parameter for the scrollableTableData.onLaidOut call
The #1 is used for positioning the tabs inside the layout, so it is not interesting for this issue.
The #2 is used to scroll to the selected tab index.
The code below shows how the initial scroll state is set up, followed by the call to scrollableTabData.onLaidOut
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val scrollableTabData = remember(scrollState, coroutineScope) {
ScrollableTabData(
scrollState = scrollState,
coroutineScope = coroutineScope
)
}
// ...
scrollableTabData.onLaidOut(
density = this#SubcomposeLayout,
edgeOffset = padding,
tabPositions = tabPositions,
selectedTab = selectedTabIndex // <-- selectedTabIndex is passed here
)
And this is the implementation of the above call
fun onLaidOut(
density: Density,
edgeOffset: Int,
tabPositions: List<TabPosition>,
selectedTab: Int
) {
// Animate if the new tab is different from the old tab, or this is called for the first
// time (i.e selectedTab is `null`).
if (this.selectedTab != selectedTab) {
this.selectedTab = selectedTab
tabPositions.getOrNull(selectedTab)?.let {
// Scrolls to the tab with [tabPosition], trying to place it in the center of the
// screen or as close to the center as possible.
val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions)
if (scrollState.value != calculatedOffset) {
coroutineScope.launch {
scrollState.animateScrollTo( // <-- even the initial scroll is done using an animation
calculatedOffset,
animationSpec = ScrollableTabRowScrollSpec
)
}
}
}
}
}
As we can see already from the first comment
Animate if the new tab is different from the old tab, or this is called for the first time
but also in the implementation, even the first time the scroll offset is set using an animation.
coroutineScope.launch {
scrollState.animateScrollTo(
calculatedOffset,
animationSpec = ScrollableTabRowScrollSpec
)
}
And the ScrollableTabRow class does not expose a way to control this behaviour.
I'm trying to detect three scenarios :
1.- User scroll vertically (down) and notify to hide a button
2.- User stop scrolls and notify to hide button
3.- User scroll vertically (up) and notify to show the button
4.- User is in the bottom of the list and there are no more items and notify to show the button.
What I've tried is :
First approach is to use nestedScrollConnection as follows
val isVisible = remember { MutableTransitionState(false) }
.apply { targetState = true }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
isVisible.targetState = false
return super.onPostScroll(consumed, available, source)
}
}
}
LazyColumn(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.nestedScroll(nestedScrollConnection),
verticalArrangement = Arrangement.spacedBy(16.dp),
)
What I've tried is when y > 0 is going up, else is going down, but the others I don't know how to get them.
Another approach I followed is :
val scrollState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
state = scrollState,
But I don't know how to get if it's last item or not, I can get if the scroll is in progress.
Note
Answer from Skizo works but with this it's a bit weird because if you scroll up slowly the Y sometimes is not what I want and then hide it again, is there any way to leave some scroll to start reacting to this? For instance, scroll X pixels to start showing / hiding.
What I want is to hide/show is a Float Action Button depending on the scroll (the scenarios are the ones from above)
I've found this way, but it is using the offset and I'd like to animate the FloatActionButton instead of appearing from the bottom like a fade in/fade out I was using the Animation Visibility and I got this working with fade in/fade out but now, how can I adapt the code from github to use Animation Visibility? And also add this when the user ends scrolling that from now in the code is just while scrolling
Here's how you'd go about achieving these,
1.) Detect The Scrolling Direction (Vertically Up, or Vertically Down)
#Composable
private fun LazyListState.isScrollingUp(): Boolean {
var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) }
return remember(this) {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}
Now, just create a listState variable and use it with this Composable to retrieve the scrolling dierection.
val listState = rememberLazyListState()
val scrollingUp = listState.isScrollingUp()
Then, as you say, you'd like to get notified if the user stops scrolling, so for that you can just create a variable known as restingScrollPosition, I'll name it rsp for short. Now that you are familiar with the required helper methods, all that is required is devising a mechanism to trigger an event based on the value of the current scroll position that triggers the code in concern if the value has been the same for a particular amount of time (the definition of the scroll being "at rest").
So, here it is
var rsp by remember { mutableStateOf(listState.firstVisibleItemScrollOffset) }
var isScrolling by remember { mutableStateOf(false) } // to keep track of the scroll-state
LaunchedEffect(key = listState.firstVisibleItemScrollOffset){ //Recomposes every time the key changes
/*This block will also be executed
on the start of the program,
so you might want to handle
that with the help of another variable.*/
isScrolling = true // If control reaches here, we're scrolling
launch{
isScrolling = false
delay(100) //If there's no scroll after a hundred seconds, update rsp
if(!isScrolling){
rsp = listState.firstVisibleItemScrollOffset
/* Execute your trigger here,
this denotes the scrolling has stopped */
}
}
}
I don't think I would be explaining the workings of the last code here, please analyze it yourself to gain a better understanding, it's not difficult.
Ah yes to achieve the last objective, you can just use a little swashbuckling with the APIs, specifically methods like lazyListState.layoutInfo. It has all the info you'll require about the items currently visible on-screen, and so you can also use it to implement your subtle need where you wish to allow for a certain amount to be scrolled before triggering the codeblock. Just have a look at the availannle info in the object and you should be able to start it up.
UPDATE:
Based on the info provided in the comments added below as of yesterday, this should be the implementation,
You have a FAB somewhere in your heirarchy, and you wish to animate it's visibility based on the isScrollingUp, which is bound to the Lazy Scroller defined somewhere else in the heirarchy,
In that case, you can take a look at state-hoisting, which is a general best practice for declarative programming.
Just hoist the isScrollingUp() output up to the point where your FAB is declared, or please share the complete code if you need specific instructions based on your use-case. I would require the entire heirarchy to be able to help you out with this.
I've faced same problem some days ago and I did a mix of what you say.
To show or hide then scrolling up or down with the Y is enough.
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
val delta = available.y
isVisible.targetState = delta > 0
return Offset.Zero
}
}
}
And to detect there's no more items you can use
fun isLastItemVisible(lazyListState: LazyListState): Boolean {
val lastItem = layoutInfo.visibleItemsInfo.lastOrNull()
return lastItem == null || lastItem.size + lastItem.offset <= layoutInfo.viewportEndOffset
}
The use case is that you have 10s or 100s of items inside of a dropdown menu, the dropdown options have some ordering - as with number values or alphabetical listing of words - and selections are made in succession.
When the user reopens the menu, you'd like for it to open in the same region as their last selection, so that for instance you don't jump from "car" to "apple" but rather from "car" to "cat". Or if they just opted to view order number 358, they can quickly view order number 359.
Using views, you could create a Spinner and put all of your items in an ArrayAdapter and then call spinner.setSelection() to scroll directly to the index you want.
DropdownMenu doesn't have anything like HorizontalPager's scrollToPage(). So what solutions might exist to achieve this?
So far, I've tried adding verticalScroll() to the DropdownMenu's modifier and trying to do arithmetic with the scrollState. But it crashes at runtime with an error saying the component has infinite height, the same error you get if you try to nest scrollable components like a LazyColumn inside of a Column with verticalScroll.
It's a known issue.
DropdownMenu has its own vertical scroll modifier inside, and there is no API to work with it.
Until this problem is fixed by providing a suitable API, the only workaround I can think of is to create your own view - you can take the source code of DropdownMenu as reference.
I'll post a more detailed answer here because I don't want to mislead anyone with my comment above.
If you're in Android Studio, click the three dots on the mouse-hover quick documentation box and select "edit source" to open the source for DropdownMenu in AndroidMenu.android.kt. Then observe that it uses a composable called DropdownMenuItemContent. Edit source again and you're in Menu.kt.
You'll see this:
#Composable
internal fun DropdownMenuContent(
...
...
...
{
Column(
modifier = modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),//<-We want this
content = content
)
}
So in your custom composable just replace that rememberScrollState() with your favorite variable name for a ScrollState.
And then chain that reference all the way back up to your original view.
Getting Access to the ScrollState
#Composable
fun MyCustomDropdownMenu(
expanded:Boolean,
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
#Composable
fun MyCustomDropdownMenuContent(
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
//And now for your actual content
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded}))//<-More about this line in the sequel
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false })
{//your spinner content}
}
}
}
The above only specifies how to access the ScrollState of the DropdownMenu. But once you have the ScrollState, you'll have to do some arithmetic to get the scroll position right when it opens. Here's one way that seems alright.
Calculating the scroll distance
Even after setting the contents of the menu items explicitly, the distance was never quite right if I relied on those values. So I used an onTextLayout callback inside the Text of my menu items in order to get the true Text height at the time of rendering. Then I use that value for the arithmetic. It looks like this:
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
val chosenText:String by remember{mutableStateOf(myListOfSpinnerOptions[0])
val height by remember{mutableStateOf(0)}
val heightHasBeenChecked by remember{mutableStateOf(false)}
val coroutineScope=rememberCoroutineScope()
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded
coroutineScope.launch{scrollStateProvidedByTopParent.scrollTo(height*myListOfSpinnerOptions.indexOf[chosenText])}}))//<-This gets some arithmetic for scrolling distance
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false }) {
myListOfSpinnerOptions.forEach{option->
DropdownMenuItem(onClick={
chosenText=option
spinnerExpanded=false
}){
Text(option,onTextLayout={layoutResult->
if (!heightHasBeenChecked){
height=layoutResults.size.height
heightHasBeenChecked=true
}
}
)
}
}
}
}
}
My crossfade animations are no longer working since the release of Compose Alpha and I would really appreciate some help getting them working again. I am fairly new to Android/Compose. I understand that Crossfade is looking for a state change in its targetState to trigger the crossfade animation, but I am confused how to incorporate this. I am trying to wrap certain composables in the Crossfade animation.
Here are the official docs and helpful playground example, but I still cannot get it to work since the release of Alpha
https://developer.android.com/reference/kotlin/androidx/compose/animation/package-summary#crossfade
https://foso.github.io/Jetpack-Compose-Playground/animation/crossfade/
Here is my code, in this instance I was hoping to use the String current route itself as the targetState as a mutableStateOf object. I'm willing to use whatever will work though.
#Composable
fun ExampleComposable() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute: String? = navBackStackEntry?.arguments?.getString(KEY_ROUTE)
val exampleRouteTargetState = remember { mutableStateOf(currentRoute)}
Scaffold(
...
NavHost(navController, startDestination = "Courses") {
composable("Route") {
Crossfade(targetState = exampleRouteTargetState, animationSpec = tween(2000)) {
ExampleComposable1()
}
}
composable("Other Route")
ExampleComposable2()
}
)
...
}
Shouldn't navigation trigger a state change of the "exampleRouteTargetState" variable and then trigger crossfade? I could also wrap the composable elsewhere if you think wrapping it inside the NavHost may create an issue. Thanks so much for the help!!
Lately Google Accompanist has added a library which provides Compose Animation support for Jetpack Navigation Compose.. Do check it out. 👍🏻
https://github.com/google/accompanist/tree/main/navigation-animation
Still haven't gotten Crossfade working again, but I was able to implement some transitions inside NavHost. Hope this helps someone. Here are the docs if you want to fine tune these high level animations:
https://developer.android.com/jetpack/compose/animation#animatedvisibility
#ExperimentalAnimationApi
#Composable
fun ExampleAnimation(content: #Composable () -> Unit) {
AnimatedVisibility(
visible = true,
enter = fadeIn(initialAlpha = 0.3f),
exit = fadeOut(),
content = content,
initiallyVisible = false
)
}
And then simply wrap your NavHost composable declarations with your animation like so
NavHost(navController, startDestination = "A Route") {
composable(Screen.YourObject.Route) {
ExampleAnimation {
YourComposable()
}
}
I'm trying to port a rather complex Android View to Compose and I've managed to do a naive implementation by basically using a Canvas and moving the onDraw() code there. I've ran into issues when trying to optimize this to make it skip unneeded parts of the recomposition.
The view is a board for the game of GO (it would be the same for chess). I'm trying to get things such as the board's background to not redraw every time a move is made, as the background does not change. As my understanding of the docs is, if I pull the drawBackground() from the onDraw and just put it in an Image() composable, the Image() composable should not get recomposed unless its parameter (which is just the bitmap) changes. However, breakpoints show the method getting called every single time the position changes (e.g. the player makes a move). Am I doing something wrong? How could I take advantage of Compose here?
Code:
#Composable
fun Board(modifier: Modifier = Modifier, boardSize: Int, position: Position?, candidateMove: Point?, candidateMoveType: StoneType?, onTapMove: ((Point) -> Unit)? = null, onTapUp: ((Point) -> Unit)? = null) {
val background: ImageBitmap = imageResource(id = R.mipmap.texture)
Box(modifier = modifier
.fillMaxWidth()
.aspectRatio(1f)
) {
Image(bitmap = background) // Expecting this to run only once, but gets run every time Board() gets recomposed!!!
var width by remember { mutableStateOf(0) }
val measurements = remember(width, boardSize) { doMeasurements(width, boardSize, drawCoordinates) }
var lastHotTrackedPoint: Point? by remember { mutableStateOf(null) }
Canvas(modifier = Modifier.fillMaxSize()) {
if (measurements.width == 0) {
return#Canvas
}
//... lots of draw code here
}
}
}
Any Jetpack Compose guru can help me understand why is it not skipping that recomposition?
Try putting Image(bitmap = background) in a separate composable function. Or moving Canvas to another function.