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)
}
}
Related
This is a question about general navigation design in Jetpack compose which I find a bit confusing.
As I understand it, having multiple screens with each own Scaffold causes flickers when navigating (I definitely noticed this issue). Now in the app, I have a network observer that is tied to Scaffold (e.g. to show Snackbar when there is no internet connection) so that's another reason I'm going for a single Scaffold design.
I have a MainViewModel that holds the Scaffold state (e.g. top bar, bottom bar, fab, title) that each screen underneath can turn on and off.
#Composable
fun AppScaffold(
networkMgr: NetworkManager,
mainViewModel: MainViewModel,
navAction: NavigationAction = NavigationAction(mainViewModel.navHostController),
content: #Composable (PaddingValues) -> Unit
) {
LaunchedEffect(Unit) {
mainViewModel.navHostController.currentBackStackEntryFlow.collect { backStackEntry ->
Timber.d("Current screen " + backStackEntry.destination.route)
val route = requireNotNull(backStackEntry.destination.route)
var show = true
// if top level screen, do not show
topLevelScreens().forEach {
if (it.route.contains(route)) {
show = false
return#forEach
}
}
mainViewModel.showBackButton = show
mainViewModel.showFindButton = route == DrawerScreens.Home.route
}
}
Scaffold(
scaffoldState = mainViewModel.scaffoldState,
floatingActionButton = {
if (mainViewModel.showFloatingButton) {
FloatingActionButton(onClick = { }) {
Icon(Icons.Filled.Add, contentDescription = "Add")
}
}
},
floatingActionButtonPosition = FabPosition.End,
topBar = {
if (mainViewModel.showBackButton) {
BackTopBar(mainViewModel, navAction)
} else {
AppTopBar(mainViewModel, navAction)
}
},
bottomBar = {
if (mainViewModel.showBottomBar) {
// TODO
}
},
MainActivity looks like this
setContent {
AppCompatTheme {
var mainViewModel: MainViewModel = viewModel()
mainViewModel.coroutineScope = rememberCoroutineScope()
mainViewModel.navHostController = rememberNavController()
mainViewModel.scaffoldState = rememberScaffoldState()
AppScaffold(networkMgr, mainViewModel) {
NavigationGraph(mainViewModel)
}
}
}
Question 1) How do I make this design scalable? As one screen's FAB may have different actions from another screen's FAB. The bottom bar may be different between screens. The main problem is I need good a way for screens to talk to the parent Scaffold.
Question 2) Where is the best place to put the code under "LaunchedEffect" block whether it's ok here?
I found this StackOverflow answer that covers your question pretty well.
The key answers to your questions according to this answer are:
You define a data class that holds variables for each element that might change between the different screens that will be displayed inside the scaffold. This most probably will be at least the title:
data class ScaffoldViewState(
#StringRes val topAppBarTitle: Int? = null
)
Then, you store this data class using remember, so that a recomposition will be triggered whenever one value within the data class changes:
var scaffoldViewState by remember {
mutableStateOf(ScaffoldViewState())
}
Finally, you can assign the field within the data class to the title slot of the Scaffold.
Changing the variables of the data class should happen from the NavHost, as seen in the linked post.
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 using androidx.paging:paging-compose (v1.0.0-alpha-14), together with Jetpack Compose (v1.0.3), I have a custom PagingSource which is responsible for pulling items from backend.
I also use compose navigation component.
The problem is I don't know how to save a state of Pager flow between navigating to different screen via NavHostController and going back (scroll state and cached items).
I was trying to save state via rememberSaveable but it cannot be done as it is not something which can be putted to Bundle.
Is there a quick/easy step to do it?
My sample code:
#Composable
fun SampleScreen(
composeNavController: NavHostController? = null,
myPagingSource: PagingSource<Int, MyItem>,
) {
val pager = remember { // rememberSaveable doesn't seems to work here
Pager(
config = PagingConfig(
pageSize = 25,
),
initialKey = 0,
pagingSourceFactory = myPagingSource
)
}
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn() {
itemsIndexed(items = lazyPagingItems) { index, item ->
MyRowItem(item) {
composeNavController?.navigate(...)
}
}
}
}
I found a solution!
#Composable
fun Sample(data: Flow<PagingData<Something>>):
val listState: LazyListState = rememberLazyListState()
val items: LazyPagingItems<Something> = data.collectAsLazyPagingItems()
when {
items.itemCount == 0 -> LoadingScreen()
else -> {
LazyColumn(state = listState, ...) {
...
}
}
}
...
I just found out what the issue is when using Paging.
The reason the list scroll position is not remembered with Paging when navigating boils down to what happens below the hood.
It looks like this:
Composable with LazyColumn is created.
We asynchronously request our list data from the pager. Current pager list item count = 0.
The UI draws a lazyColumn with 0 items.
The pager responds with data, e.g. 10 items, and the UI is recomposed to show them.
User scrolls e.g. all the way down and clicks the bottom item, which navigates them elsewhere.
User navigates back using e.g. the back button.
Uh oh. Due to navigation, our composable with LazyColumn is recomposed. We start again with asynchronously requesting pager data. Note: pager item count = 0 again!
rememberLazyListState is evaluated, and it tells the UI that the user scrolled down all the way, so it now should go back to the same offset, e.g. to the fifth item.
This is the point where the UI screams in wild confusion, as the pager has 0 items, so the lazyColumn has 0 items.
The UI cannot handle the scroll offset to the fifth item. The scroll position is set to just show from item 0, as there are only 0 items.
What happens next:
The pager responds that there are e.g. 10 items again, causing another recomposition.
After recomposition, we see our list again, with scroll position starting on item 0.
To confirm this is the case with your code, add a simple log statement just above the LazyColumn call:
Log.w("TEST", "List state recompose. " +
"first_visible=${listState.firstVisibleItemIndex}, " +
"offset=${listState.firstVisibleItemScrollOffset}, " +
"amount items=${items.itemCount}")
You should see, upon navigating back, a log line stating the exact same first_visible and offset, but with amount items=0.
The line directly after that will show that first_visible and offset are reset to 0.
My solution works, because it skips using the listState until the pager has loaded the data.
Once loaded, the correct values still reside in the listState, and the scroll position is correctly restored.
Source: https://issuetracker.google.com/issues/177245496
Save the list state in your viewmodel and reload it when you navigate back to the screen containing the list. You can use LazyListState in your viewmodel to save the state and pass that into your composable as a parameter. Something like this:
class MyViewModel: ViewModel() {
var listState = LazyListState()
}
#Composable
fun MessageListHandler() {
MessageList(
messages: viewmodel.messages,
listState = viewmode.listState
)
}
#Composable
fun MessageList(
messages: List<Message>,
listState: LazyListState) {
LazyColumn(state = listState) {
}
}
If you don't like the limitations that Navigation Compose puts on you, you can try using Jetmagic. It allows you to pass any object between screens and even manages your viewmodels in a way that makes them easier to access from any composable:
https://github.com/JohannBlake/Jetmagic
The issue is that when you navigate forward and back your composable will recompose and collectAsLazyPagingItems() will be called again, triggering a new network request.
If you want to avoid this issue, you should call pager.flow.cacheIn(viewModelScope) on your ViewModel with activity scope (the ViewModel instance is kept across fragments) before calling collectAsLazyPagingItems().
LazyPagingItems is not intended as a persistent data store; it is just a simple wrapper for the UI layer. Pager data should be cached in the ViewModel.
please try using '.cachedIn(viewModelScope) '
simple example:
#Composable
fun Simple() {
val simpleViewModel:SimpleViewModel = viewModel()
val list = simpleViewModel.simpleList.collectAsLazyPagingItems()
when (list.loadState.refresh) {
is LoadState.Error -> {
//..
}
is LoadState.Loading -> {
BoxProgress()
}
is LoadState.NotLoading -> {
when (list.itemCount) {
0 -> {
//..
}
else -> {
LazyColumn(){
items(list) { b ->
//..
}
}
}
}
}
}
//..
}
class SimpleViewModel : ViewModel() {
val simpleList = Pager(
PagingConfig(PAGE_SIZE),
pagingSourceFactory = { SimpleSource() }).flow.cachedIn(viewModelScope)
}
In Jetpack Compose I'm using AndroidView to display an ad banner from a company called Smart.IO.
At the moment the banner shows when first initialised, but then fails to recompose when user comes back to the screen it's displayed on.
I'm aware of using the update function inside compose view, but I can't find any parameters I could use to essentially update on Banner to trigger the recomposition.
AndroidView(
modifier = Modifier
.fillMaxWidth(),
factory = { context ->
Banner(context as Activity?)
},
update = {
}
)
This could be a library error. You can check if this view behaves normally in normal Android XML.
Or maybe you need to use some API from this library, personally I haven't found any decent documentation or Android SDK source code.
Anyway, here is how you can make your view update.
You can keep track of life-cycle events, as shown in this answer, and only display your view during ON_RESUME. This will take it off the screen when it is paused, and make it create a new view when it resumes:
val lifeCycleState by LocalLifecycleOwner.current.lifecycle.observeAsSate()
if (lifeCycleState == Lifecycle.Event.ON_RESUME) {
AndroidView(
modifier = Modifier
.fillMaxWidth(),
factory = { context ->
Banner(context as Activity?)
},
update = {
}
)
}
Lifecycle.observeAsSate:
#Composable
fun Lifecycle.observeAsSate(): State<Lifecycle.Event> {
val state = remember { mutableStateOf(Lifecycle.Event.ON_ANY) }
DisposableEffect(this) {
val observer = LifecycleEventObserver { _, event ->
state.value = event
}
this#observeAsSate.addObserver(observer)
onDispose {
this#observeAsSate.removeObserver(observer)
}
}
return state
}
as you see this is how i implemented NavHost with MaterialBottomNavigation, i have many items on both Messages and Feeds screens, when i navigate between them both screens, they automatically recomposed but i don't wanna because of much data there it flickring and fps drops to under 10 when navigating, i tried to initialize data viewModels before NavHost but still same result, is there any way to compose screens once and update them just when viewModels data updated?
#Composable
private fun MainScreenNavigationConfigurations(
navController: NavHostController,
messagesViewModel: MessagesViewModel = viewModel(),
feedsViewModel: FeedsViewModel = viewModel(),
) {
val messages: List<Message> by messagesViewModel.messages.observeAsState(listOf())
val feeds: List<Feed> by feedsViewModel.messages.observeAsState(listOf())
NavHost(
navController = navController,
startDestination = "Messages"
) {
composable("Messages") {
Messages(navController, messages)
}
composable("Feeds") { Feeds(navController, feeds) }
}
}
I had a similar problem. In my case I needed to instantiate a boolean state "hasAlreadyNavigated".
The problem was:
-> Screen 1 should navigate to Screen 2;
-> Screen 1 has a conditional statement for navigating directly to screen 2 or show a content screen with an action button that navigates to Screen 2;
-> After it navigates to Screen 2, Screen 1 recomposes and it reaches the if statement again, causing a "navigation loop".
val hasAlreadyNavigated = remember { mutableStateOf(false) }
if (!hasAlreadyNavigated.value) {
if (!screen1ViewModel.canNavigate()) {
Screen1Content{
hasAlreadyNavigated.value = true
screen1ViewModel.allowNavigation()
navigateToScreen2()
}
} else {
hasAlreadyNavigated.value = true
navigateToScreen2()
}
}
With this solution, i could prevent recomposition and the "re-navigation".
I don't know if we need to be aware and build composables thinking of this recomposition after navigation or it should be library's responsibility.
Please use this code above your code. It will remember state of your current screen.
val navController = rememberNavController()
for more info check this out:
https://developer.android.com/jetpack/compose/navigation
Passing the navcontroller as a parameter causes recomposition. Use it as a lambda instead.
composable("Messages") {
Messages( onClick = {navController.navigate(route = "Click1")},
onClick2 = {navController.navigate(route = "Click2")},
messages)
}