Changing scroll speed on LazyRow - android

Looking at the documentation for LazyRow I was wondering if it was possible to reduce the scroll speed, it looks like LazyRow inherits from ScrollState but I can't find anything useful on how to reduce the speed of the scroll

LazyRow doesn't have a parameter to customize scroll speed so you have to do it manually.
You could first capture the scroll gesture something like the below (from google example here ):
#Composable
fun ScrollableSample() {
// actual composable state
var offset by remember { mutableStateOf(0f) }
Box(
Modifier
.size(150.dp)
.scrollable(
orientation = Orientation.Vertical,
// Scrollable state: describes how to consume
// scrolling delta and update offset
state = rememberScrollableState { delta ->
offset += delta
delta
}
)
.background(Color.LightGray),
contentAlignment = Alignment.Center
) {
Text(offset.toString())
}
}
Then you have to implement the scroll state at your desired speed for the LazyRow by manually changing the LazyRow's state which is one of the LazyRow parameters.
you will also have to disable LazyRow userscrolling
something like this:
LazyRow(
...
state = yourCustomState,
userScrollEnabled = false,
...
){ ... }
below is a complete working solution:
var stateIsGood = rememberLazyListState()
var offset = 0f
LazyRow(
modifier = Modifier
.scrollable(
orientation = Orientation.Horizontal,
// Scrollable state: describes how to consume
// scrolling delta and update offset
state = rememberScrollableState { delta ->
offset += delta
vm.viewModelScope.launch {
//dividing by 8 the delta for 8 times slower horizontal scroll,
//you can change direction by making it a negative number
stateIsGood.scrollBy(-delta/8)
}
delta
}
),
state = stateIsGood,
userScrollEnabled = false,
) {
item {
Text(text = "Header")
}
// Add 3 items
items(3) { index ->
Text(text = "SCROLL ME First List items : $index")
}
// Add 2 items
items(2) { index ->
Text(text = "Second List Items: $index")
}
// Add another single item
item {
Text(text = "Footer")
}
}

As a shorter alternative to David's Code,
#Composable
fun LazyStack(ssd: Int = 1) { // Scroll-Speed-Divisor
val lazyStackState = rememberLazyListState()
val lazyStackScope = rememberCoroutineScope()
LazyColumn(
modifier = Modifier.pointerInput(Unit) {
detectVerticalDragGestures { _, dragAmount ->
lazyStackScope.launch {
lazyStackState.scrollBy(-dragAmount / ssd).log("checkpoint")
}
}
},
state = lazyStackState,
userScrollEnabled = false
) {...}
}
And yes, LazyStack is short for LazyStackOverflower

instead of prevent userScroll we can use flingBehavior parameter. When we update initialVelocity it should change fling effect.
#Composable
fun maxScrollFlingBehavior(): FlingBehavior {
val flingSpec = rememberSplineBasedDecay<Float>()
return remember(flingSpec) {
ScrollSpeedFlingBehavior(flingSpec)
}
}
private class ScrollSpeedFlingBehavior(
private val flingDecay: DecayAnimationSpec<Float>
) : FlingBehavior {
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
// Prevent very fast scroll
val newVelocity =
if (initialVelocity > 0F) minOf(initialVelocity, 15_000F)
else maxOf(initialVelocity, -15_000F)
return if (abs(newVelocity) > 1f) {
var velocityLeft = newVelocity
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = newVelocity,
).animateDecay(flingDecay) {
val delta = value - lastValue
val consumed = scrollBy(delta)
lastValue = value
velocityLeft = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
}
velocityLeft
} else newVelocity
}
}
and you can use like this
LazyColumn(
modifier = Modifier.fillMaxSize(),
flingBehavior = maxScrollFlingBehavior()
) {
// Content Here
}
If you want to implement your own logic, you can update initialVelocity param. Higher its 'absolute' value, the faster it moves.

Related

Implement CollapsingToolbar using Jetpack compose

I am trying to implement collapsing toolbar in my Detail screen using Jetpack compose : https://github.com/alirezaeiii/Compose-MultiModule-Cache/blob/master/feature_list/src/main/java/com/android/sample/app/feature/list/ui/detail/DetailsScreen.kt
val toolbarHeightPx = with(LocalDensity.current) {
278.dp.roundToPx().toFloat()
}
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset.Zero
}
}
}
Box(
Modifier
.fillMaxSize()
// attach as a parent to the nested scroll system
.nestedScroll(nestedScrollConnection)
) {
DetailsContent(
scrollState = scrollState,
onNamePosition = { newNamePosition ->
// Comparing to Float.MIN_VALUE as we are just interested on the original
// position of name on the screen
if (detailScroller.namePosition == Float.MIN_VALUE) {
detailScroller =
detailScroller.copy(namePosition = newNamePosition)
}
},
item = item,
boxHeight = with(LocalDensity.current) {
440.dp + toolbarOffsetHeightPx.value.toDp()
},
imageHeight = with(LocalDensity.current) {
420.dp + toolbarOffsetHeightPx.value.toDp()
},
sendNotification = sendNotification,
contentAlpha = { contentAlpha.value }
)
DetailsToolbar(
toolbarState, item.name, pressOnBack,
contentAlpha = { contentAlpha.value }
)
}
The idea is taken from sunflower Google Github project. When we scroll up it works as expected but when we scroll down, it will not sometimes fully scrolled. toolbarOffsetHeightPx should become 0 when we scroll down, but sometimes it is a negative value that cause image does not fully scrolled. It is not stable at all and 0 or any negative value may happen. it happens since we have :
boxHeight = with(LocalDensity.current) {
440.dp + toolbarOffsetHeightPx.value.toDp()
},
imageHeight = with(LocalDensity.current) {
420.dp + toolbarOffsetHeightPx.value.toDp()
}
Why is that and how to resolve it?
I reported it as a minor bug in issue tracker : https://issuetracker.google.com/issues/238177355

How can I fix toggle animation in Switch (Compose)?

I have a toggle issue with Switch that can look as following:
In the picture you can see only two of many possible states.
I have complex business logics in ViewModel that updates my whole screen state after clicking on switch.
However, in order to make it easier and demonstrate you the problem I found a simple example that is similar to my real-life scenario.
#Composable
fun MyCoolWidget() {
var isChecked by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope()
Switch(
checked = isChecked,
onCheckedChange = {
scope.launch {
delay(50) // to mimic the business logic and state update delay
// I use the 50 millis delay and then update the state
isChecked = it
}
},
)
}
Now you can test it by putting your finger to the one edge, holding the finger on the screen and moving it to the opposite edge. (Don't click on switch, SWIPE it!)
Observe the result.
How can I fix this problem?
Dependencies:
androidx.compose.material:material:1.1.1. Jetpack Compose version - 1.2.0-rc01. Kotlin version - 1.6.21
Thanks, best wishes!
It looks like a bug.
I would code my custom switch if i were you because you have to disable swipe feature.
Here is my custom ios like switch :
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun MySwitch(
modifier: Modifier = Modifier,
isChecked: Int,
onCheckedChange: (checked: Int) -> Unit
) {
var size by remember {
mutableStateOf(IntSize.Zero)
}
val marbleSize by remember(size) {
mutableStateOf(size.height.div(2))
}
val yOffset by remember(size, marbleSize) {
mutableStateOf((size.height.div(2) - marbleSize.div(2f)).toInt())
}
val marblePadding = 4.dp.value
val scope = rememberCoroutineScope()
val swipeableState = rememberSwipeableState(isChecked)
val backgroundColor = animateColorAsState(
targetValue = if (swipeableState.currentValue != 0) Color(0xFF34C759) else Color(0xD6787880)
)
val sizePx = size.width.minus(marbleSize + marblePadding.times(2))
val anchors = mapOf(0f to 0, sizePx - 1f to 1)
LaunchedEffect(key1 = swipeableState.currentValue, block = {
onCheckedChange.invoke(swipeableState.currentValue)
})
Box(
modifier = modifier
.aspectRatio(2f)
.clip(CircleShape)
.swipeable(
state = swipeableState,
anchors = anchors,
enabled = false, //because you need to disable swipe
orientation = Orientation.Horizontal
)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
if (it.x > size.width.div(2))
scope.launch {
swipeableState.animateTo(
1,
anim = tween(250, easing = LinearEasing)
)
}
else
scope.launch {
swipeableState.animateTo(
0,
anim = tween(250, easing = LinearEasing)
)
}
}
)
}
.background(backgroundColor.value)
.onSizeChanged {
size = it
}
) {
Box(
modifier = Modifier
.padding(horizontal = marblePadding.dp)
.offset {
IntOffset(
x = swipeableState.offset.value.roundToInt(),
y = yOffset
)
}
.size(with(LocalDensity.current) { marbleSize.toDp() })
.clip(CircleShape)
.background(Color.Red)
)
}
}
I hope it helps you.

How to detect up/down scroll for a Column with vertical scroll?

I have a column which has many items; based on the scroll, I want to show/hide the Floating action button, in case the scroll is down, hide it and in case the scroll is up, show it.
My code is working partially, but the scrolling is buggy. Below is the code. Need help.
Column(
Modifier
.background(color = colorResource(id = R.color.background_color))
.fillMaxWidth(1f)
.verticalScroll(scrollState)
.scrollable(
orientation = Orientation.Vertical,
state = rememberScrollableState {
offset.value = it
coroutineScope.launch {
scrollState.scrollBy(-it)
}
it
},
)
) { // 10-20 items }
Based on the offset value (whether positive/negative), I am maintaining the visibility of FAB.
You can use the nestedScroll modifier.
Something like:
val fabHeight = 72.dp //FabSize+Padding
val fabHeightPx = with(LocalDensity.current) { fabHeight.roundToPx().toFloat() }
val fabOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = fabOffsetHeightPx.value + delta
fabOffsetHeightPx.value = newOffset.coerceIn(-fabHeightPx, 0f)
return Offset.Zero
}
}
}
Since composable supports nested scrolling just apply it to the Scaffold:
Scaffold(
Modifier.nestedScroll(nestedScrollConnection),
scaffoldState = scaffoldState,
//..
floatingActionButton = {
FloatingActionButton(
modifier = Modifier
.offset { IntOffset(x = 0, y = -fabOffsetHeightPx.value.roundToInt()) },
onClick = {}
) {
Icon(Icons.Filled.Add,"")
}
},
content = { innerPadding ->
Column(
Modifier
.fillMaxWidth(1f)
.verticalScroll(rememberScrollState())
) {
//....your code
}
}
)
It can work with a Column with verticalScroll and also with a LazyColumn.

Get last visible item index in jetpack compose LazyColumn

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

How to create a drag gesture in any direction in Android Jetpack Compose

I want to detect a gesture in #Composable, which will allow me to drag an element across the screen in any direction.
I tried using LongPressDragObserver but after dragging for a bit, it snaps to a single Orientation (either Horizontally or Vertically) and Offset doesn't change for the other Orientation at all (it will equal to 0 all the time)
Example functionality I want to achieve:
Long press on the FAB and drag it around the screen so that it's position is constantly under user's finger.
I'm using Compose 1.0.0-alpha04
Example code which drags in only one direction (thanks to Rafsanjani)
.dragGestureFilter(dragObserver = object : DragObserver {
override fun onDrag(dragDistance: Offset): Offset {
val newX = dragDistance.x + verticalOffset.value
val newY = dragDistance.y + horizontalOffset.value
verticalOffset.value = newX
horizontalOffset.value = newY
return dragDistance
}
})
You can use Modifier.pointerInput with detectDragGestures to do exactly the same as you want.
Example:
#Composable
fun Drag2DGestures() {
var size by remember { mutableStateOf(400.dp) }
val offsetX = remember { mutableStateOf(0f) }
val offsetY = remember { mutableStateOf(0f) }
Box(modifier = Modifier.size(size)){
Box(
Modifier
.offset { IntOffset(offsetX.value.roundToInt(), offsetY.value.roundToInt()) }
.background(Color.Blue)
.size(50.dp)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
offsetX.value = (offsetX.value + dragAmount.x)
.coerceIn(0f, size.width.toFloat() - 50.dp.toPx())
offsetY.value = (offsetY.value + dragAmount.y)
.coerceIn(0f, size.height.toFloat() - 50.dp.toPx())
}
}
)
Text("Drag the box around", Modifier.align(Alignment.Center))
}
}
will produce this result:
ٍٍٍSorry for the jank/drop in frames, the built-in emulator recorder cannot record 60fps smoothly
Compose version: alpha-11
Here's how to make any component draggable:
#Composable
fun DraggableComponent(content: #Composable () -> Unit) {
val offset = remember { mutableStateOf(IntOffset.Zero) }
Box(
content = { content() },
modifier = Modifier
.offset { offset.value }
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
val offsetChange = IntOffset(dragAmount.x.roundToInt(), dragAmount.y.roundToInt())
offset.value = offset.value.plus(offsetChange)
}
}
)
}
Use it like this:
DraggableComponent {
FloatingActionButton(
...
)
}
More info: https://developer.android.com/jetpack/compose/gestures#dragging

Categories

Resources