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.
Related
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
}
)
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.
My jetpack compose app uses a LazyColumn to display data from history. When I am at the beginning of the LazyColumn and data is entered into the database, it will automatically be displayed on top with scrollState.scrollToItem(0, 0) each time. In this case, if I scroll down the list, exit the screen, and then return to it, then I will remain in the same place (at the bottom of the list). I need to fix this so that every time I enter the screen, I end up at the beginning of the list (LazyColumn).
#Composable
fun HistoryTableList(
viewModel: HistoryViewModel = viewModel()
) {
val scrollState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(
state = scrollState,
modifier = Modifier
.padding(
HistoryListHorizontalPadding,
0.dp,
HistoryListHorizontalPadding,
HistoryListPaddingBottom
)
.fillMaxWidth(),
) {
items(
items = historyItems,
key = { historyRecord ->
historyRecord.uid
}
) { historyRecord ->
historyRecord?.let {
viewModel.onListScrolled(scrollState.firstVisibleItemScrollOffset)
if (scrollState.firstVisibleItemIndex <= 1) {
coroutineScope.launch {
scrollState.scrollToItem(0, 0)
}
}
HistoryTableItem(history = historyRecord)
}
}
}
}
How to do this Scroll hide fab button in Jetpack Compose with transaction
Like this I need it:
You need to listen to the scroll state and apply AnimatedVisibiltiy. Here is an example using LazyColumn with LazyListState (you could also use Column with ScrollState)
#Composable
fun Screen() {
val listState = rememberLazyListState()
val fabVisibility by derivedStateOf {
listState.firstVisibleItemIndex == 0
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
Modifier.fillMaxSize(),
state = listState,
) {
items(count = 100, key = { it.toString() }) {
Text(modifier = Modifier.fillMaxWidth(),
text = "Hello $it!")
}
}
AddPaymentFab(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 40.dp),
isVisibleBecauseOfScrolling = fabVisibility
)
}
}
#Composable
private fun AddPaymentFab(
modifier: Modifier,
isVisibleBecauseOfScrolling: Boolean,
) {
val density = LocalDensity.current
AnimatedVisibility(
modifier = modifier,
visible = isVisibleBecauseOfScrolling,
enter = slideInVertically {
with(density) { 40.dp.roundToPx() }
} + fadeIn(),
exit = fadeOut(
animationSpec = keyframes {
this.durationMillis = 120
}
)
) {
ExtendedFloatingActionButton(
text = { Text(text = "Add Payment") },
onClick = { },
icon = { Icon(Icons.Filled.Add, "Add Payment") }
)
}
}
It may be late, but after struggling with this issue for a while, I was able to find the right solution from the Animation Codelab sourcecode.
The difference between this and the previous answer is that in this way, as soon as the page is scrolled up, the Fab is displayed and there is no need to reach the first item of the page to display the Fab.
step one: getting an instance of the lazyListState class inside LazyColumn
val lazyListState = rememberLazyListState()
Step two: Creating a top level variable to hold the scroll state so that recompositions do not change the state value unintentionally.
var isScrollingUp by mutableStateOf(false)
Step three: just copy this composable Extension Function inside the file
#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
}
Step four: Open an AnimatedVisibility block and pass the isScrollingUp variable as its first parameter. And finally, making the Fab and placing it inside the AnimatedVisibility
AnimatedVisibility(visible = isScrollingUp) {
FloatingActionButton(onClick = { /*TODO*/ }) {
// your code
}
}
have fun!
I want to check if the list is scrolled to end of the list. How ever the lazyListState does not provide this property
Why do I need this? I want to show a FAB for "scrolling to end" of the list, and hide it if last item is already visible
(Note: It does, but it's internal
/**
* Non-observable way of getting the last visible item index.
*/
internal var lastVisibleItemIndexNonObservable: DataIndex = DataIndex(0)
no idea why)
val state = rememberLazyListState()
LazyColumn(
state = state,
modifier = modifier.fillMaxSize()
) {
// if(state.lastVisibleItem == logs.length - 1) ...
items(logs) { log ->
if (log.level in viewModel.getShownLogs()) {
LogItemScreen(log = log)
}
}
}
So, how can I check if my LazyColumn is scrolled to end of the dataset?
Here is a way for you to implement it:
Extension function to check if it is scrolled to the end:
fun LazyListState.isScrolledToTheEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
Example usage:
val listState = rememberLazyListState()
val listItems = (0..25).map { "Item$it" }
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(listItems) { item ->
Text(text = item, modifier = Modifier.padding(16.dp))
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) {
if (!listState.isScrolledToTheEnd()) {
ExtendedFloatingActionButton(
modifier = Modifier.padding(16.dp),
text = { Text(text = "Go to Bottom") },
onClick = { /* Scroll to the end */}
)
}
}
I am sharing my solution in case it helps anyone.
It provides the info needed to implement the use case of the question and also avoids infinite recompositions by following the recommendation of https://developer.android.com/jetpack/compose/lists#control-scroll-position.
Create these extension functions to calculate the info needed from the list state:
val LazyListState.isLastItemVisible: Boolean
get() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
val LazyListState.isFirstItemVisible: Boolean
get() = firstVisibleItemIndex == 0
Create a simple data class to hold the information to collect:
data class ScrollContext(
val isTop: Boolean,
val isBottom: Boolean,
)
Create this remember composable to return the previous data class.
#Composable
fun rememberScrollContext(listState: LazyListState): ScrollContext {
val scrollContext by remember {
derivedStateOf {
ScrollContext(
isTop = listState.isFirstItemVisible,
isBottom = listState.isLastItemVisible
)
}
}
return scrollContext
}
Note that a derived state is used to avoid recompositions and improve performance.
The function needs the list state to make the calculations inside the derived state. Read the link I shared above.
Glue everything in your composable:
#Composable
fun CharactersList(
state: CharactersState,
loadNewPage: (offset: Int) -> Unit
) {
// Important to remember the state, we need it
val listState = rememberLazyListState()
Box {
LazyColumn(
state = listState,
) {
items(state.characters) { item ->
CharacterItem(item)
}
}
// We use our remember composable to get the scroll context
val scrollContext = rememberScrollContext(listState)
// We can do what we need, such as loading more items...
if (scrollContext.isBottom) {
loadNewPage(state.characters.size)
}
// ...or showing other elements like a text
AnimatedVisibility(scrollContext.isBottom) {
Text("You are in the bottom of the list")
}
// ...or a button to scroll up
AnimatedVisibility(!scrollContext.isTop) {
val coroutineScope = rememberCoroutineScope()
Button(
onClick = {
coroutineScope.launch {
// Animate scroll to the first item
listState.animateScrollToItem(index = 0)
}
},
) {
Icon(Icons.Rounded.ArrowUpward, contentDescription = "Go to top")
}
}
}
}
Cheers!
Starting from 1.4.0-alpha03 you can use LazyListState#canScrollForward to check if you can scroll forward or if you are at the end of the list.
val state = rememberLazyListState()
if (!state.canScrollForward){ /* ... */ }
Before you can use
the LazyListState#layoutInfo that contains information about the visible items. You can use it to retrieve information if the list is scrolled at the bottom.
Since you are reading the state you should use derivedStateOf to avoid redundant recompositions.
Something like:
val state = rememberLazyListState()
val isAtBottom by remember {
derivedStateOf {
val layoutInfo = state.layoutInfo
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (layoutInfo.totalItemsCount == 0) {
false
} else {
val lastVisibleItem = visibleItemsInfo.last()
val viewportHeight = layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset
(lastVisibleItem.index + 1 == layoutInfo.totalItemsCount &&
lastVisibleItem.offset + lastVisibleItem.size <= viewportHeight)
}
}
}
It's too late, but maybe it would be helpful to others.
seeing the above answers, The layoutInfo.visibleItemsInfo.lastIndex will cause recomposition many times, because it is composed of state.
So I recommend to use this statement like below with derivedState and itemKey in item(key = "lastIndexKey").
val isFirstItemFullyVisible = remember {
derivedStateOf {
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
}
}
val isLastItemFullyVisible by remember {
derivedStateOf {
listState.layoutInfo
.visibleItemsInfo
.any { it.key == lastIndexKey }.let { _isLastIndexVisible ->
if(_isLastIndexVisible){
val layoutInfo = listState.layoutInfo
val lastItemInfo = layoutInfo.visibleItemsInfo.lastOrNull() ?: return#let false
return#let lastItemInfo.size+lastItemInfo.offset == layoutInfo.viewportEndOffset
}else{
return#let false
}
}
}
}
if (isFirstItemFullyVisible.value || isLastItemFullyVisible) {
// TODO
}
Current solution that I have found is:
LazyColumn(
state = state,
modifier = modifier.fillMaxSize()
) {
if ((logs.size - 1) - state.firstVisibleItemIndex == state.layoutInfo.visibleItemsInfo.size - 1) {
println("Last visible item is actually the last item")
// do something
}
items(logs) { log ->
if (log.level in viewModel.getShownLogs()) {
LogItemScreen(log = log)
}
}
}
The statement
lastDataIndex - state.firstVisibleItemIndex == state.layoutInfo.visibleItemsInfo.size - 1
guesses the last item by subtracting last index of dataset from first visible item and checking if it's equal to visible item count
Just wanted to build upon some of the other answers posted here.
#Tuan Chau mentioned in a comment that this will cause infinite compositions, here is something I tried using his idea to avoid this, and it seems to work ok. Open to ideas on how to make it better!
#Composable
fun InfiniteLoadingList(
modifier: Modifier,
items: List<Any>,
loadMore: () -> Unit,
rowContent: #Composable (Int, Any) -> Unit
) {
val listState = rememberLazyListState()
val firstVisibleIndex = remember { mutableStateOf(listState.firstVisibleItemIndex) }
LazyColumn(state = listState, modifier = modifier) {
itemsIndexed(items) { index, item ->
rowContent(index, item)
}
}
if (listState.shouldLoadMore(firstVisibleIndex)) {
loadMore()
}
}
Extension function:
fun LazyListState.shouldLoadMore(rememberedIndex: MutableState<Int>): Boolean {
val firstVisibleIndex = this.firstVisibleItemIndex
if (rememberedIndex.value != firstVisibleIndex) {
rememberedIndex.value = firstVisibleIndex
return layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1
}
return false
}
Usage:
InfiniteLoadingList(
modifier = modifier,
items = listOfYourModel,
loadMore = { viewModel.populateMoreItems() },
) { index, item ->
val item = item as YourModel
// decorate your row
}
Try this:
val lazyColumnState = rememberLazyListState()
val lastVisibleItemIndex = state.layoutInfo.visibleItemsInfo.lastIndex + state.firstVisibleItemIndex