Pass data to previous composable in Android Compose - android

I will take a simple sample.
I have 2 Screens: Screen A and Screen B. From Screen A, I open Screen B. And when I return Screen B to Screen A, I want to transfer data back to Screen A.
With Android Fragment, I can use Shared ViewModel or Fragment Result API to do this.
But with Android Compose, the Fragment Result Api is not in Compose. With using Shard ViewModel, what lifecycle do I have to attach Shared ViewModel so it can keep alive? Activity, ... or something else.
Or is there another way to do this?

If you use jetpack navigation, you can pass back data by adding it to the previous back stack entry's savedStateHandle. (Documentation)
Screen B passes data back:
composable("B") {
ComposableB(
popBackStack = { data ->
// Pass data back to A
navController.previousBackStackEntry
?.savedStateHandle
?.set("key", data)
navController.popBackStack()
}
)
}
Screen A Receives data:
composable("A") { backStackEntry ->
// get data passed back from B
val data: T by backStackEntry
.savedStateHandle
.getLiveData<T>("key")
.observeAsState()
ComposableA(
data = data,
navToB = {
// optional: clear data so LiveData emits
// even if same value is passed again
backStackEntry.savedStateHandle.remove("key")
// navigate ...
}
)
}
Replace "key" with a unique string, T with the type of your data and data with your data.

All of your compose composition operations happens within a single activity view hierarchy thus your ViewModel lifecycle will inevitably be bound to that root activity. It can actually be accessed from your composition through LocalLifecycleOwner.current.
Keep in mind that Compose is a totally different paradigm than activity/fragment, you can indeed share ViewModel across composables but for the sake of keeping those simple you can also just "share" data simply by passing states using mutable values and triggering recomposition.
class MySharedViewModel(...) : ViewModel() {
var sharedState by mutableStateOf<Boolean>(...)
}
#Composable
fun MySharedViewModel(viewModel: MySharedViewModel = viewModel()) {
// guessing you already have your own screen display logic
// This also works with compose-navigator
ComposableA(stateResult = viewModel.sharedState)
ComposableB(onUpdate = { viewModel.sharedState = false })
}
fun ComposableA(stateResult: Boolean) {
....
}
fun ComposableB(onUpdate: () -> Unit) {
Button(onClick = { onUpdate() }) {
Text("Update ComposableA result")
}
}
Here you'll find further documentation on managing states with compose

Let's say there are two screens.
1 - FirstScreen it will receive some data and residing on bottom in back stack user will land here from Second screen by press back button.
2 - SecondScreen it will send/attach some data to be received on previous first screen.
Lets start from second screen sending data, for that you can do something like this:
navController.previousBackStackEntry
?.savedStateHandle
?.set("key", viewModel.getFilterSelection().toString())
navController.popBackStack()
Now lets catch that data on first screen for that you can do some thing like this:
if (navController.currentBackStackEntry!!.savedStateHandle.contains("key")) {
val keyData =
navController.currentBackStackEntry!!.savedStateHandle.get<String>(
"key"
) ?: ""
}
Worked perfectly for me.

Related

Jetpack Compose do on compose, but not on recomposition - track ContentViewed

I'm trying to implement some kind of LaunchedEffectOnce as I want to track a ContentViewed event. So my requirement is that every time the user sees the content provided by the composable, an event should get tracked.
Here is some example code of my problem:
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
val items by viewModel.itemsToDisplay.collectAsState(initial = emptyList())
ItemList(items)
// when the UI is displayed, the VM should track an event (only once)
LaunchedEffectOnce { viewModel.trackContentViewed() }
}
#Composable
private fun LaunchedEffectOnce(doOnce: () -> Unit) {
var wasExecuted by rememberSaveable { mutableStateOf(false) }
if (!wasExecuted) {
LaunchedEffect(key1 = rememberUpdatedState(newValue = executed)) {
doOnce()
wasExecuted = true
}
}
}
This code is doing do the following:
Tracks event when MyScreen is composed
Does NOT track when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
But what I wan't to achieve is the following:
Tracks event when MyScreen is composed
Tracks when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
My ViewModel looks like that:
class MyViewModel() : ViewModel() {
val itemsToDisplay: Flow<List<Item>> = GetItemsUseCase()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
val contentTracking: Flow<Tracking?> = GetTrackingUseCase()
.distinctUntilChanged { old, new -> old === new }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
fun trackContentViewed(){
// track last element in contentTracking
}
}
I really hope someone can help me and can explain what I'm doing wrong here. Thanks in advance!
Assuming the following are true
your view model is scoped to the Fragment in which MyScreen enters composition
your composables leave the composition when you navigate to an item screen and re-enter composition when you navigate back
then you can simply track inside the view model itself whether specific content was already viewed in this view model's scope. Then when you navigate to any of the items screens you reset that "tracking state".
If you need to track only a single element of content then just a Boolean variable would be enough, but in case you need to track more than one element, you can use either a HashSet or a mutableSetOf (which returns a LinkedHashSet instead). Then when you navigate to any of the item screen you reset that variable or clear the Set.
Your VM code would then change to
class MyViewModel() : ViewModel() {
// ... you existing code remains unchanged
private var viewedContent = mutableSetOf<Any>()
fun trackContentViewed(key: Any){
if (viewedContent.add(key)) {
// track last element in contentTracking
Log.d("Example", "Key $key tracked for 'first time'")
} else {
// content already viewed for this key
Log.d("Example", "Key $key already tracked before")
}
}
fun clearTrackedContent() {
viewedContent.clear()
}
}
and the MyScreen composable would change to
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
// ... you existing code remains unchanged
// Every time this UI enters the composition (but not on recomposition)
// the VM will be notified
LaunchedEffect(Unit) {
viewModel.trackContentViewed(key = "MyScreen") // or some other key
}
}
Where you start the navigation to an item screen (probably in some onClick handler on items) you would call viewmodel.clearTrackedContent().
Since (1) is true when ViewModels are requested inside a Fragment/Activity and if (2) is also true in your case, then the VM instance will survive configuration changes (orientation change, language change...) and the Set will take care of tracking.
If (2) is not true in your case, then you have two options:
if at least recomposition happens when navigating back, replace LaunchedEffect with SideEffect { viewModel.trackContentViewed(key = "MyScreen") }
if your composables are not even recomposed then you will have to call viewModel.trackContentViewed also when navigating back.

Redundant recomposition happenning in my layout. Why does it recompose even though inputs haven't changed?

I have a SnapshotStateMap that I use to track updates in my layout, this map is stored in a viewmodel.
This the site call:
val roundState = viewModel.roundState
for (index in 0 until attempts) {
val state = roundState[index] ?: WordState.Empty
Row {
RoundWord(state, letters)
}
}
In my program there are changes to only one item at the time, so basically my train of thought is:
I add a new state or update the old in map -> I pass it to RoundWord -> If there is no state for index I pass in empty state -> RoundWord Composable relies on state to display the needed UI.
Here is the body of RoundWord
#Composable
private fun RoundWord(
state: WordState,
letters: Int,
) {
when (state) {
is WordState.Progress -> ProgressWord(letters)
is WordState.Empty -> WordCells(letters)
is WordState.Resolved -> WordCells(letters) { WiPLetter(state.value.word[it]) }
}
}
From what I understand if there is no state in roundState map for a given index I provide Empty state that is defined as an object in a sealed interface hierarchy. Same object -> no recomposition. But for some reason it recomposes every time. I have been at this for a few days now and despite going though tons of documentation I can't see what I am missing here. Why does this recomposition happens for empty state?

StateFlow causing navigation loop in Jetpack Compose

There are two screens in the app. Screen A and Screen B. The UI and navigation logic of Screen A is based on the state class.
ScreenAState
data class ScreenAState(
val sourceName: String = "",
val navigateToScreenB: Boolean = false
)
If the user meets the requirements, the value of navigateToScreenB is changed to true and the user is navigated to Screen B using the following code.
if (uiState.navigateToScreenB) {
LaunchedEffect(uiState.navigateToScreenB) {
findNavController().navigate(actionToScreenB)
}
}
Now, the problem occurs when the user presses the back button on Screen B. As soon as the user comes back from Screen B to Screen A, the user is again navigated to Screen B and the loop continues if the back button is pressed again on Screen B.
I am not sure if I am using the LaunchedEffect properly. Any help will be appreciated. Thank You.
You should set navigateToScreenB to false after perform the navigation.
Declaring something like this in your view model.
class YourViewModel: ViewModel() {
private val _uiState = MutableStateFlow(ScreenAState())
val uiState = _uiState.asStateFlow()
fun onNavigateToScreenB() {
uiState.update {
it.copy(navigateToScreenB = false)
}
}
...
}
and in your screen:
val uiState by yourViewModel.uiState.collectAsState()
if (uiState.navigateToScreenB) {
LaunchedEffect(uiState.navigateToScreenB) {
viewModel.onNavigateToScreenB()
findNavController().navigate(actionToScreenB)
}
}

Reusing ViewModel between fragments

I am using Navigation Component to write an app that is one activity multiple fragment architecture and passing the data(class object) by using ShareViewModel. My issue is passing data between fragment works fine, however when I back to the previous fragment the data change to the latest object store in ShareViewModel.
The flow would be like:
characterListFragment -> characterDetailFragmentA -> episodeFragment -> characterDetailFragmentB
Start to back navigation:
characterDetailFragmentB -> episodeFragment -> characterDetailFragmentA (The shown data is same as the characterDetailFragmentB one) -> characterListFragment
Here is my navGraph: ​
characterListFragmentwhich show a list of data.
characterDetailFragmentwhich show the selected data from characterListFragment.
episodeFragment which show the all the data in selected episode.
I am using a shareViewModel to pass data between characterListFragment and characterDetailFragment. Also I reuse the shareViewModel to pass data between episodeFragment and characterDetailFragment
Here is the shareViewModel code
class ShareSelectedCharacterViewModel : ViewModel() {
val selected = MutableLiveData<Character>()
fun select(item: Character) {
selected.value = item
}
}
And I hook the shareViewModel to the activity lifecycle like so
private val shareViewModel: ShareSelectedCharacterViewModel by activityViewModels()
When I selected a data in characterDetailFragment or episodeFragment I will set the data to shareViewModel and get the data in characterDetailFragment by using the same lifecycle scope shareViewModel.
// Set selected data
shareViewModel.select(character)
sharedModel.selected.observe(viewLifecycleOwner, Observer {
// Observe the data
})
My issue is passing data between fragment works fine, however when I back to the previous fragment the data change to the latest object store in ShareViewModel. I know the data will be change due to the using the same viewModel. So I am wonder is there any way to handle this kind of scenario?
I find a way to solve this problem. Adding a stack structure in ShareViewModel and detect the back navigation in characterDetailFragment .
/**
* Using stack to store the sharedData and pop the stored data
* when user pressBack.
*/
class ShareSelectedCharacterViewModel : ViewModel() {
private val stack = Stack<Character>()
val selected = MutableLiveData<Character>()
fun select(item: Character) {
selected.value = item
stack.add(item)
}
fun pop() {
stack.pop()
if(stack.isNotEmpty()) {
selected.value = stack.peek()
}
}
}
When I press back button in characterDetailFragment I call the viewModel.pop method to set the previous data into selected.
/**
* Implement Custom Back Navigation:
* when user pressBack in CharacterDetailFragment, we also need to pop out the data
* that stored in the ShareSelectedCharacterViewModel to retrieve previous one.
*/
requireActivity().onBackPressedDispatcher.addCallback(this) {
findNavController().popBackStack()
sharedModel.pop()
}
Is there a better way to handle this situation?

Android navigation component: How to get the current navigation graph's id?

I am using nested navigation graphs in order to scope and share my viewmodels across a set of fragments.
I also have a BaseFragment class which obtains the reference to the required viewmodel:
fun provideViewModel() : VM {
return if(viewModelScopeGraphId != null) {
ViewModelProvider(findNavController().getViewModelStoreOwner(viewModelScopeGraphId!!)).get(viewModelClass)
} else {
ViewModelProvider(this).get(viewModelClass)
}
}
I can override a property, viewModelScopeGraphId, if I need the viewmodel to be scoped to the navigation graph (or nested navigation graph) with that specific id.
Ideally I would just want to set a boolean flag like useScopedViewModel and obtain the id of the current navigation graph, for example:
fun provideViewModel() : VM {
return if(useScopedViewModel) {
ViewModelProvider(findNavController().getViewModelStoreOwner(getCurrentNavGraphId())).get(viewModelClass)
} else {
ViewModelProvider(this).get(viewModelClass)
}
}
I have tried using navController.graph.id to get the current graph id, but it seems the id I get from there does not match up with my resource id's (eg. R.id.nav_graph). Is there something I am missing?
I have a similar issue (more context of my case at the end), tried a few solutions, none successful.
In the end, I give up and just give the graph id as a parameter for the Fragment.
As it could help you to achieve your own solution, so my solution looks like the following:
Have to create this extension, as the original navGraphViewModels only accepts a #IdRes navGraphId: Int and I want to lazy load the arguments, etc. (Also I've simplified removing the factoryProducer from the arguments, as so far I will not use it for our solution.
inline fun <reified VM : ViewModel> Fragment.navGraphViewModels(
noinline graphIdProducer: () -> Int
): Lazy<VM> {
val backStackEntry by lazy {
findNavController().getBackStackEntry(graphIdProducer())
}
val storeProducer: () -> ViewModelStore = {
backStackEntry.viewModelStore
}
return createViewModelLazy(VM::class, storeProducer, {
backStackEntry.defaultViewModelProviderFactory
})
}
In my fragment where I want to use it, I recover the Arguments using the navArgs, and recover the ViewModel using the extension above:
private val listenerViewModel: ListenerViewModel by navGraphViewModels {
navArgs.graphId
}
private val navArgs: MyFragmentArgs by navArgs()
And to whoever needs to "listen" for that ViewModel, can simply load using the Navigation navGraphViewModels:
private val listenerViewModel: ListenerViewModel by navGraphViewModels(
R.id.my_graph_a
)
And from another graph/fragment I simple do:
private val listenerViewModel: ListenerViewModel by navGraphViewModels(
R.id.my_graph_b
)
To explain a little the context of my case:
I have two distinct Fragments, where the user can click in a Country selection.
Each of these Fragments is inside a different Graph, as they are different flows.
The Country selection is a Fragment where load the supported countries list from an API, displays it, pre-select any previous user selection (also given as a FragmentArgs), and the user can change the selection, which implies coming back to the previous screen, with the newly selected value or just come back not triggering anything here.
I'm aware and expecting the release of https://issuetracker.google.com/issues/79672220, but as it is today (March-2020) it is only available in alpha.
You may try to check it with graph's start destination.
when (navController.graph.startDestinationId) {
R.id.firstFragmentOfFirstGraph -> { /* First graph */ }
R.id.firstFragmentOfSecondGraph -> { /* Second graph */}
}

Categories

Resources