Set Composable value parameter to result of suspend function - android

I am new to Compose and Kotlin. I have an application using a Room database. In the frontend, there is a Composable containing an Icon Composable. I want the Icon resource to be set depending on the result of a database operation that is executed within a suspend function.
My Composable looks like this:
#Composable
fun MoviePreview(movie : ApiMoviePreview, viewModel: ApiMovieViewModel) {
Card(
modifier = ...
) {
Row(
modifier = ...
) {
IconButton(
onClick = {
//...
}) {
Icon(
imageVector =
// This code does not work, as isMovieOnWatchList() is a suspend function and cannot be called directly
if (viewModel.isMovieOnWatchlist(movie.id)) {
Icons.Outlined.BookmarkAdded
} else {
Icons.Filled.Add
}
,
contentDescription = stringResource(id = R.string.addToWatchlist)
)
}
}
}
}
The function that I need to call is a suspend function, because Room requires its database operations to happen on a seperate thread. The function isMovieOnWatchlist() looks like this:
suspend fun isMovieOnWatchlist(id: Long) {
return movieRepository.isMovieOnWatchlist(id)
}
What would the appropriate way be to achieve the desired behaviour? I already stumbled across Coroutines, but the problem is that there seems to be no way to just return a value out of the coroutine function.

A better approach would be to prepare the data so everything you need is in the data/value class rather than performing live lookup per row/item which is not very efficient. I assume you have 2 tables and you'd probably want a LEFT JOIN however all these details are not included.
With Room it even includes implementations that use the Flow api, meaning it will observe the data when information in either table changes and re-runs the original query to provide you with the new changed dataset.
However this is out of scope of your original question but should you want to explore this then here is a good start : https://developer.android.com/codelabs/basic-android-kotlin-training-intro-room-flow#0
To your original question. This is likely achievable with a LaunchedEffect and some observed MutableState<ImageVector?> object within the composable, something like:
#Composable
fun MoviePreview(
movie: ApiMoviePreview,
viewModel: ApiMovieViewModel
) {
var icon by remember { mutableStateOf<ImageVector?>(value = null) } // null or default icon until update by result below
Card {
Row {
IconButton(onClick = {}) {
icon?.run {
Icon(
imageVector = this,
contentDescription = stringResource(id = R.string.addToWatchlist))
}
}
}
}
LaunchedEffect(Unit) {
// execute suspending function in provided scope closure and update icon state value once complete
icon = if (viewModel.isMovieOnWatchlist(movie.id)) {
Icons.Outlined.BookmarkAdded
} else Icons.Filled.Add
}
}

Related

Lambda function used as input argument causes recomposition

Consider snippet below
fun doSomething(){
}
#Composable
fun A() {
Column() {
val counter = remember { mutableStateOf(0) }
B {
doSomething()
}
Button(onClick = { counter.value += 1 }) {
Text("Click me 2")
}
Text(text = "count: ${counter.value}")
}
}
#Composable
fun B(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("click me")
}
}
Now when pressing "click me 2" button the B compose function will get recomposed although nothing inside it is got changed.
Clarification: doSomething is for demonstration purposes. If you insist on having a practical example you can consider below usage of B:
B{
coroutinScope.launch{
bottomSheetState.collapse()
doSomething()
}
}
My questions:
Why this lamda function causes recomposition
Best ways to fix it
My understanding of this problem
From compose compiler report I can see B is an skippable function and the input onClick is stable. At first I though its because lambda function is recreated on every recomposition of A and it is different to previous one. And this difference cause recomposition of B. But it's not true because if I use something else inside the lambda function, like changing a state, it won't cause recomposition.
My solutions
use delegates if possible. Like viewmode::doSomething or ::doSomething. Unfortunately its not always possible.
Use lambda function inside remember:
val action = remember{
{
doSomething()
}
}
B(action)
It seems ugly =)
3. Combinations of above.
When you click the Button "Click me 2" the A composable is recomposed because of Text(text = "count: ${counter.value}"). It happens because it recompose the scope that are reading the values that can change.
If you are using something like:
B {
Log.i("TAG","xxxx")
}
the B composable is NOT recomposed clicking the Button "Click me 2".
If you are using
B{
coroutinScope.launch{
Log.i("TAG","xxxx")
}
}
the B composable is recomposed.
When a State is read it triggers recomposition in nearest scope. And a scope is a function that is not marked with inline and returns Unit.
To use a coroutinScope you have to use rememberCoroutineScope that is a composable inline function. The the body of inline composable functions are simply copied into their call sites, such functions do not get their own recompose scopes.
To avoid it you can use:
B {
Log.i("TAG","xxxx")
}
and
#Composable
fun B(onClick: () -> Unit) {
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
onClick()
}
}
) {
Text(
"click me ",
)
}
}
Sources and credits:
Thracian's answer: Jetpack Compose Smart Recomposition
What is “donut-hole skipping” in Jetpack Compose? post: https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose
scoped recomposition: https://dev.to/zachklipp/scoped-recomposition-jetpack-compose-what-happens-when-state-changes-l78
You can use the LogCompositions composable described in the 2nd post to check the recomposition in your code.
Generally speaking, if you are using a property inside a lambda function that is unstable, it causes the child compose function unskippable and thus gets recomposed every time its parent gets recomposed. This is not something easily visible and you need to be careful with it. For example, the bellow code will cause B to get recomposed because coroutinScope is an unstable property and we are using it as an indirect input to our lambda function.
fun A(){
...
val coroutinScope = rememberCoroutineScope()
B{
coroutineScope.launch {
doSomething()
}
}
}
To bypass this you need to use remember around your lambda or delegation (:: operator). There is a note inside this video about it. around 40:05
There are many other parameters that are unstable like context. To figure them out you need to use compose compiler report.
Here is a good explanation about the why: https://multithreaded.stitchfix.com/blog/2022/08/05/jetpack-compose-recomposition/

How to start new activity in jetpack compose

I want to start new activity in jetpack compose. So I want to know what is the idiomatic way of doing in jetpack compose. Is any side effect api need to be use or not when opening.
ClickableItemContainer.kt
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun ClickableItemContainer(
rippleColor: Color = TealLight,
content: #Composable (MutableInteractionSource) -> Unit,
clickAction: () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
CompositionLocalProvider(
LocalRippleTheme provides RippleTheme(rippleColor),
content = {
Surface(
onClick = { clickAction() },
interactionSource = interactionSource,
indication = rememberRipple(true),
color = White
) {
content(interactionSource)
}
}
)
}
MaterialButton.kt
#Composable
fun MaterialButton(
text: String,
spacerHeight: Dp,
onActionClick: () -> Unit
) {
Spacer(modifier = Modifier.height(spacerHeight))
ClickableItemContainer(rippleColor = AquaDarker, content = {
Box(
modifier = Modifier
.background(Aqua)
.fillMaxWidth(),
) {
Text(
text = text,
modifier = Modifier
.align(Alignment.Center),
style = WhiteTypography.h5
)
}
}) {
onActionClick()
}
}
OpenPermissionSetting.kt
#Composable
fun OpenPermissionSetting(router: Router = get()) {
val activity = LocalContext.current as Activity
MaterialButton(
text = "Open Setting",
spacerHeight = 10.dp
) {
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
}
So my question is this intent should be use in any Side-effect i.e. LaunchEffect?
LaunchedEffect(key1 = true){
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
Thanks
#Composable
fun OpenPermissionSetting(router: Router = get()) {
val activity = LocalContext.current as Activity
MaterialButton(
text = "Open Setting",
spacerHeight = 10.dp
) {
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
}
And this one opens new Activity when Button is clicked
var startNewActivity by remember {mutabelStateOf(false)}
#Composable
fun OpenPermissionSetting(router: Router = get()) {
MaterialButton(
text = "Open Setting",
spacerHeight = 10.dp
) {
startActivity = true
}
}
LaunchedEffect(key1 = startActivity){
if(startActivity) {
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
}
This one opens activity as soon as your Composable enters composition .Setting a true, false, Unit key or any key doesn't change the fact that code inside LaunchedEffect will be invoked in when it enters composition. You can however change when that code will be run again using key or keys and a conditional statement inside LaunchedEffect.
LaunchedEffect(key1 = true){
activity.startActivity(Intent(this#CurrentClassName,RequiredClassName::class.java)
}
You should understand use cases SideEffect api how they work and ask yourself if this applies to my situation.
SideEffect is good for situations when you only want an action to happen if composition happens successfully. If, even if small chance your state changes fast and current composition is ignored then you shouldn't invoke that action for instance logging composition count is a very good use case for SideEffect function.
Recomposition starts whenever Compose thinks that the parameters of a
composable might have changed. Recomposition is optimistic, which
means Compose expects to finish recomposition before the parameters
change again. If a parameter does change before recomposition
finishes, Compose might cancel the recomposition and restart it with
the new parameter.
When recomposition is canceled, Compose discards the UI tree from the
recomposition. If you have any side-effects that depend on the UI
being displayed, the side-effect will be applied even if composition
is canceled. This can lead to inconsistent app state.
Ensure that all composable functions and lambdas are idempotent and
side-effect free to handle optimistic recomposition.
LaunchedEffect is good for when you wish to have a coroutineScope for animations, scrolling or calling other suspending functions. Another use case for LaunchedEffect is triggering one time actions when the key or keys you set changes.
As in sample above if you set key for LaunchedEffect and check if it's true in a code block you can trigger action only condition is true. LaunchedEffect is also useful when actions that don't require user interactions but a state change happens and only needs to be triggered once.
Executing a callback only when reaching a certain state without user interactions
DisposableEffect is required when you wish to check when your Composable enters and exits composition. onDispose function is also useful for clearing resources or callbacks, sensor register, or anything that needs to be cleared when your Composable exits recomposition.
I would use just LaunchedEffect with the flag let's say val showActivity: Boolean, and the LaunchedEffect function would look like:
#Composable
fun OpenPermissionSetting(viewModel: ViewModel) {
val uiState = viewModel.uiState
MaterialButton(
text = "Open Setting",
spacerHeight = 10.dp
) {
viewModel.onShowActivity()
}
}
LaunchedEffect(showActivity){
if (uiState.showActivity) {
activity.startActivity(...)
viewModel.onShowActivityDone()
}
}
Remember to avoid leaving the flag on true, because its may cause some problems if you have more recompositions :)
Composables are designed to only propagate states down the hierarchy, and propagate actions up the hierarchy. So, no, you shouldn't be launching activities from within the composable. You need to trigger a callback, like your onActionClick: () -> Unit, to the original ComponentActivity where your composables reside (and if this has to go through several nested composables, you'll need to propagste that action all the way up). Then, in your activity, you can direct it to process the actions that were selected. Something like this:
in ComponentActivity:
ClickableItemContainer(
rippleColor = ...,
content = ...,
clickAction = {
startActivity(...)
}
)

Android Jetpack Compose: VM not updating data structure when modified

I’ve got a problem with a LazyColumn of elements that have a favourite button: basically when I tap the favourite button, the item that is being favourited (a document in my case) is changed in the underlying data structure in the VM, but the view isn’t updated, so I never see any change in the button state.
class MainViewModel(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) : ViewModel() {
var documentList = emptyList<PDFDocument>().toMutableStateList()
....
fun toggleFavoriteDocument(pdfDocument: PDFDocument) {
documentList.find {
it == pdfDocument
}?.let {
it.favorite = !it.favorite
}
}
}
The composables are:
#Composable
fun DocumentRow(
document: PDFDocument,
onDocumentClicked: (String, Boolean) -> Unit,
onFavoriteValueChange: (Uri) -> Unit
) {
HeartIcon(
isFavorite = document.favorite,
onValueChanged = { onFavoriteValueChange(document.uri) }
)
}
#Composable
fun HeartIcon(
isFavorite: Boolean,
color: Color = Color(0xffE91E63),
onValueChanged: (Boolean) -> Unit
) {
IconToggleButton(
checked = isFavorite,
onCheckedChange = {
onValueChanged()
}
) {
Icon(
tint = color,
imageVector = if (isFavorite) {
Icons.Filled.Favorite
} else {
Icons.Default.FavoriteBorder
},
contentDescription = null
)
}
}
Am I doing something wrong? because when I call the toggleFavouriteDocument in the ViewModel, I see it’s marked or unmarked as favorite but there is no recomposition at all anywhere.
I might be missing it because you didn't post the rest of your code, but your documentList in the VM isn't observable, so how would the Composable know that it got changed? It needs to be something like Flow or LiveData, and it needs to be observed in the Composable. Something like this:
in ViewModel:
val documentList = MutableLiveData<List<PDFDocument>>()
in Composable:
val documentList by viewModel.documentList.observeAsState(List<PDFDocument>())
And you'll probably have to change the way you modify items in documentList. LiveData is weird about mutable collections inside MutableLiveData, and modifying individual items doesn't trigger a state change. You have to create a copy of the list with the modified items, and then re-port the whole list to the LiveData variable:
fun toggleFavoriteDocument(pdfDocument: PDFDocument) {
documentList.value?.let { oldList ->
// create a copy of existing list
val newList = mutableListOf<PDFDocument>()
newList.addAll(oldList)
// modify the item in the new list
newList.find {
it == pdfDocument
}?.let {
it.favorite = !it.favorite
}
// update the observable
documentList.postValue(newList)
}
}
Edit: There's also a potential problem with the way that you're trying to update the favorite value in the existing list. Without knowing how PDFDocument is implemented, I don't know if you can use the = operator. You should test that to make sure that newList.find { it == pdfDocument } actually finds the document

Why need the author to add the keyword remember in this #Composable?

The Code A is from the project ThemingCodelab, you can see full code here.
I think that the keyword remember is not necessary in Code A.
I have tested the Code B, it seems that I can get the same result just like Code A.
Why need the author to add the keyword remember in this #Composable ?
Code A
#Composable
fun Home() {
val featured = remember { PostRepo.getFeaturedPost() }
val posts = remember { PostRepo.getPosts() }
MaterialTheme {
Scaffold(
topBar = { AppBar() }
) { innerPadding ->
LazyColumn(contentPadding = innerPadding) {
item {
Header(stringResource(R.string.top))
}
item {
FeaturedPost(
post = featured,
modifier = Modifier.padding(16.dp)
)
}
item {
Header(stringResource(R.string.popular))
}
items(posts) { post ->
PostItem(post = post)
Divider(startIndent = 72.dp)
}
}
}
}
}
Code B
#Composable
fun Home() {
val featured =PostRepo.getFeaturedPost()
val posts = PostRepo.getPosts()
...//It's the same with the above code
}
You need to use remember to prevent recomputation during recomposition.
Your example works without remember because this view will not recompose while you scroll through it.
But if you use animations, add state variables or use a view model, your view can be recomposed many times(when animating up to once a frame), in which case getting data from the repository will be repeated many times, so you need to use remember to save the result of the computation between recompositions.
So always use remember inside a view builder if the calculations are at least a little heavy, even if right now it looks like the view is not gonna be recomposed.
You can read more about the state in compose in documentation, including this youtube video, which explains the basic principles.

Composable reparenting in Jetpack Compose

Is there a way to reparent a Composable without it losing the state? The androidx.compose.runtime.key seems to not support this use case.
For example, after transitioning from:
// This function is in the external library, you can not
// modify it!
#Composable
fun FooBar() {
val uid = remember { UUID.randomUUID().toString() }
Text(uid)
}
Box {
Box {
FooBar()
}
}
to
Box {
Row {
FooBar()
}
}
the Text will show a different message.
I'm not asking for ways to actually remember the randomly generated ID, as I could obviously just move it up the hierarchy. What I want to archive is the composable keeping its internal state.
Is this possible to do without modifying the FooBar function?
The Flutter has GlobalKey specifically for this purpose. Speaking Compose that might look something like this:
val key = GlobalKey.create()
Box {
Box {
globalKey(key) {
FooBar()
}
}
}
Box {
Row {
globalKey(key) {
FooBar()
}
}
}
This is now possible with
movableContentOf
See this example:
val boxes = remember {
movableContentOf {
LetterBox(letter = 'A')
LetterBox(letter = 'B')
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = { isRow = !isRow }) {
Text(text = "Switch")
}
if (isRow) {
Row(
Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
boxes()
}
} else {
Column(
Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
boxes()
}
}
}
remember will store only one value in the same view. The key in Compose has a very different purpose: if the key passed to remember has a different value from the last recomposition, it means that the old value is no longer relevant and must be recomputed.
There is no direct equivalent of Flutter keys in Compose.
You can simply declare a global variable. In case you need to change it, wrap it with a mutable state, so changes will update your view.
var state by mutableStateOf(UUID.randomUUID().toString())
I'm not sure if that the same what GlobalKey does, in any case it's not the best practice, just like any other global variable.
If you need to share some data between views, it is much cleaner to use view models.
#Composable
fun TestScreen() {
val viewModel = viewModel<SomeViewModel>()
Column {
Text("TestScreen text: ${viewModel.state}")
OtherView()
}
}
#Composable
fun OtherView() {
val viewModel = viewModel<SomeViewModel>()
Text("OtherScreen text: ${viewModel.state}")
}
class SomeViewModel: ViewModel() {
var state by mutableStateOf(UUID.randomUUID().toString())
}
The hierarchy topmost viewModel call creates a view model - in my case inside TestScreen. All children that call viewModel of the same class will get the same object. The exception to this is different destinations of Compose Navigation, see how to handle this case in this answer.
You can update the mutable state value, and it will be reflected on all views using that model. Check out more about state in Compose.
When the view that created the view model is removed from the view hierarchy, the view model is also freed, so a new one will be created next time.

Categories

Resources