Avoid initial scrolling when using Jetpack Compose ScrollableTabRow - android

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.

Related

Jetpack Compose Wear OS Scroll to top onResume

I have a Wear OS Overlay built with Compose, that uses a ScalingLazyColumn and rememberScalingLazyListState() for scrolling. When the user leaves the overlay and returns I want the column to scroll to the top instead of saving their location. Is there a way to do this?
The screen uses LiveData/State so elements recompose while the user is on the screen, and I do not want to lose their scroll position in this case.
#Composable
fun WearApp(weatherVM: WeatherViewModel, application: Application) {
WearAppTheme {
val weather = weatherVM.weather.observeAsState(initial = null)
val listState = rememberScalingLazyListState()
Scaffold(
...
positionIndicator = {
PositionIndicator(
scalingLazyListState = listState
)
}
) {
ScalingLazyColumn(
modifier = Modifier.fillMaxSize(),
autoCentering = AutoCenteringParams(itemIndex = 0),
state = listState
) {
item {
...
}
item {
...
}
...
If you don't have navigation, then you should just be able to use lifecycle events to change the list state on resume.
See https://stackoverflow.com/a/66807899/1542667
If you do use navigation, then you'll need to get the Lifecycle above the SwipeDismissableNavHost, as each navigation destination has it's own lifecycle, resume could be called when you swipe back to the top level.
LaunchedEffect(Unit) {
lifecycleOwner.repeatOnLifecycle(state = Lifecycle.State.RESUMED) {
scrollState.scrollToItem(index = 0)
}
}

How to detect scroll behaviour in LazyColumn?

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
}

Scroll to top when adding new items

I have a usecase where I would like a LazyColumn to scroll to the top if a new item is added to the start of the list - but only if the list was scrolled to top before. I'm currently using keys for bookkeeping the scroll position automatically, which is great for all other cases than when the scroll state is at the top of the list.
This is similar to the actual logic I have in the app (in my case there is however no button, showFirstItem is a parameter to the composable function, controlled by some other logic):
var showFirstItem by remember { mutableStateOf(true) }
Column {
Button(onClick = { showFirstItem = !showFirstItem }) {
Text("${if (showFirstItem) "Hide" else "Show"} first item")
}
LazyColumn {
if (showFirstItem) {
item(key = "first") {
Text("First item")
}
}
items(count = 100,
key = { index ->
"item-$index"
}
) { index ->
Text("Item $index")
}
}
}
As an example, I would expect "First item" to be visible if I scroll to top, hide the item and them show it again. Or hide the item, scroll to top and then show it again.
I think the solution could be something with LaunchedEffect, but I'm not sure at all.
If a new item has been added and user is at top, the new item would not appear unless the list is scrolled to the top. I have tried this:
if (lazyListState.firstVisibleItemIndex <= 1) {
//scroll to top to ensure latest added book gets visible
LaunchedEffect(key1 = key) {
lazyListState.scrollToItem(0)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState
) {...
And it seems to work. But it breaks the pull to refresh component that I am using. So not sure how to solve that. I am still trying to force myself to like Compose. Hopefully that day will come =)
You can scroll to the top of the list on a LazyColumn like this:
val coroutineScope = rememberCoroutineScope()
...
Button(onClick = {
coroutineScope.launch {
// 0 is the first item index
scrollState.animateScrollToItem(0)
}
}) {
Text("Scroll to the top")
}
So call scrollState.animateScrollToItem(0) where ever you need from a coroutine, e.g. after adding a new item.
In your item adding logic,
scrollState.animateScrollToItem(0)
//Add an optional delay here
showFirst = ... //Handle here whatever you want
Here, scrollState is to be passed in the LazyColumn
val scrollState = rememberScrollState() LazyColumn(state = scrollState) { ... }

Jetpack Compose: Make full-screen (absolutely positioned) component

How can I go about making a composable deep down within the render tree full screen, similar to how the Dialog composable works?
Say, for example, when a use clicks an image it shows a full-screen preview of the image without changing the current route.
I could do this in CSS with position: absolute or position: fixed but how would I go about doing this in Jetpack Compose? Is it even possible?
One solution would be to have a composable at the top of the tree that can be passed another composable as an argument from somewhere else in the tree, but this sounds kind of messy. Surely there is a better way.
From what I can tell you want to be able to draw from a nested hierarchy without being limited by the parent constraints.
We faced similar issues and looked at the implementation how Composables such as Popup, DropDown and Dialog function.
What they do is add an entirely new ComposeView to the Window.
Because of this they are basically starting from a blank canvas.
By making it transparent it looks like the Dialog/Popup/DropDown appears on top.
Unfortunately we could not find a Composable that provides us the functionality to just add a new ComposeView to the Window so we copied the relevant parts and made following.
#Composable
fun FullScreen(content: #Composable () -> Unit) {
val view = LocalView.current
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val id = rememberSaveable { UUID.randomUUID() }
val fullScreenLayout = remember {
FullScreenLayout(
view,
id
).apply {
setContent(parentComposition) {
currentContent()
}
}
}
DisposableEffect(fullScreenLayout) {
fullScreenLayout.show()
onDispose { fullScreenLayout.dismiss() }
}
}
#SuppressLint("ViewConstructor")
private class FullScreenLayout(
private val composeView: View,
uniqueId: UUID
) : AbstractComposeView(composeView.context) {
private val windowManager =
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val params = createLayoutParams()
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
init {
id = android.R.id.content
ViewTreeLifecycleOwner.set(this, ViewTreeLifecycleOwner.get(composeView))
ViewTreeViewModelStoreOwner.set(this, ViewTreeViewModelStoreOwner.get(composeView))
ViewTreeSavedStateRegistryOwner.set(this, ViewTreeSavedStateRegistryOwner.get(composeView))
setTag(R.id.compose_view_saveable_id_tag, "CustomLayout:$uniqueId")
}
private var content: #Composable () -> Unit by mutableStateOf({})
#Composable
override fun Content() {
content()
}
fun setContent(parent: CompositionContext, content: #Composable () -> Unit) {
setParentCompositionContext(parent)
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
}
private fun createLayoutParams(): WindowManager.LayoutParams =
WindowManager.LayoutParams().apply {
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
token = composeView.applicationWindowToken
width = WindowManager.LayoutParams.MATCH_PARENT
height = WindowManager.LayoutParams.MATCH_PARENT
format = PixelFormat.TRANSLUCENT
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
}
fun show() {
windowManager.addView(this, params)
}
fun dismiss() {
disposeComposition()
ViewTreeLifecycleOwner.set(this, null)
windowManager.removeViewImmediate(this)
}
}
Here is an example how you can use it
#Composable
internal fun Screen() {
Column(
Modifier
.fillMaxSize()
.background(Color.Red)
) {
Text("Hello World")
Box(Modifier.size(100.dp).background(Color.Yellow)) {
DeeplyNestedComposable()
}
}
}
#Composable
fun DeeplyNestedComposable() {
var showFullScreenSomething by remember { mutableStateOf(false) }
TextButton(onClick = { showFullScreenSomething = true }) {
Text("Show full screen content")
}
if (showFullScreenSomething) {
FullScreen {
Box(
Modifier
.fillMaxSize()
.background(Color.Green)
) {
Text("Full screen text", Modifier.align(Alignment.Center))
TextButton(onClick = { showFullScreenSomething = false }) {
Text("Close")
}
}
}
}
}
The yellow box has set some constraints, which would prevent the Composables from inside to draw outside its bounds.
Using the Dialog composable, I have been able to get a proper fullscreen Composable in any nested one. It's quicker and easier than some of other answers.
Dialog(
onDismissRequest = { /* Do something when back button pressed */ },
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false, usePlatformDefaultWidth = false)
){
/* Your full screen content */
}
If I understand correctly you just don't want to navigate anywhere. Id something like this.
when (val viewType = viewModel.viewTypeGallery.get()) {
is GalleryViewModel.GalleryViewType.Gallery -> {
Gallery(viewModel, scope, installId, filePathModifier, fragment, setImageUploadType)
}
is GalleryViewModel.GalleryViewType.ImageViewer -> {
Row(Modifier.fillMaxWidth()) {
Image(
modifier = Modifier
.fillMaxSize(),
painter = rememberCoilPainter(viewType.imgUrl),
contentScale = ContentScale.Crop,
contentDescription = null
)
}
}
}
I just keep track of what type the view is meant to be. In my case I'm not displaying a dialog I'm removing my entire gallery and showing an image instead.
Alternatively you could just have an if(viewImage) condition below your call your and layer the 'dialog' on top of it.
After notice that, at least for now, we don't have any Composable to do "easy" fullscreen, I decided to implement mine one, mostly based on ideas from #foxtrotuniform6969 and #ntoskrnl. Also, I tried to do it most possible without to use platform dependent functions then I think this is very suiteable to Desktop/Android.
You can check the basic implementation in this GitHub repository.
By the way, the implementation idea was just:
Create a composable to wrap the target composables tree that can call an FullScreen composable;
Retrieve the full screen dimensions/size from a auxiliary Box matched to the root screen size using the .onGloballyPositioned() modifier;
Store the full screen size and all FullScreen composables created in the tree onto appropriated compositionLocalOf instances (see documentation).
I tried to use this in a Desktop project and seems to be working, however I didn't tested in Android yet. The repository also contains a example.
Feel free to navigate in the repository and sent a pull request if you can. :)

Jetpack Compose LazyColumn Scroll Listener

I want to programmatically change which tab is selected as the user scrolls past each "see more" item in the list below. How would I best accomplish this?
As Ryan M writes, you can use LazyListState.firstVisibleItemIndex. The magic of Compose is that you can just use it in an if statement and Compose will do the work. Look at the following example, which displays a different text based on the first visible item. Similarly, you can select a different tab based on the first visible item.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val listState = rememberLazyListState()
Column {
Text(if (listState.firstVisibleItemIndex < 100) "< 100" else ">= 100")
LazyColumn(state = listState) {
items(1000) {
Text(
text = "$it",
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
Just create a NestedScrollConnection and assign it to parent view nestedScroll modifier:
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
// called when you scroll the content
return Offset.Zero
}
}
}
Assign it to LazyColumn or to its parent composed view:
Modifier.nestedScroll(nestedScrollConnection)
Exemple:
LazyColumn(
...
modifier = Modifier.nestedScroll(nestedScrollConnection)
) {
items(...) {
Text("Your text...")
}
}
You can use the LazyListState.firstVisibleItemIndex property (obtained via rememberLazyListState and set as the state parameter to LazyColumn) and set the current tab based on that.
Reading that property is considered a model read in Compose, so it will trigger a recomposition whenever the first visible item changes.
If you instead want to do something more complex based on more than just the first visible item, you can use LazyListState.layoutInfo to get information about all visible items and their locations, rather than just the first.

Categories

Resources