Came across a curious situation with AndroidView this morning.
I have a ProductCard interface that looks like this
interface ProductCard {
val view: View
fun setup(
productState: ProductState,
interactionListener: ProductCardView.InteractionListener
)
}
This interface can be implemented by a number of views.
A composable that renders a list of AndroidView uses ProductCard to get a view and pass in state updates when recomposition happens.
#Composable
fun BasketItemsList(
modifier: Modifier,
basketItems: List<ProductState>,
provider: ProductCard,
interactionListener: ProductCardView.InteractionListener
) {
LazyColumn(modifier = modifier) {
items(basketItems) { product ->
AndroidView(factory = { provider.view }) {
Timber.tag("BasketItemsList").v(product.toString())
provider.setup(product, interactionListener)
}
}
}
}
With this sample, any interaction with the ProductCard view’s (calling ProductCard.setup()) doesn’t update the screen. Logging shows that the state gets updated but the catch is that it’s only updated once per button. For example, I have a favourites button. Clicking it once pushes a state update only once, any subsequent clicks doesn’t propagate. Also the view itself doesn’t update. It’s as if it was never clicked.
Now changing the block of AndroidView.update to use it and casting it as a concrete view type works as expected. All clicks propagate correctly and the card view gets updated to reflect the state.
#Composable
fun BasketItemsList(
modifier: Modifier,
basketItems: List<ProductState>,
provider: ProductCard,
interactionListener: ProductCardView.InteractionListener
) {
LazyColumn(modifier = modifier) {
items(basketItems) { product ->
AndroidView(factory = { provider.view }) {
Timber.tag("BasketItemsList").v(product.toString())
// provider.setup(product, interactionListener)
(it as ProductCardView).setup(product, interactionListener)
}
}
}
}
What am I missing here? why does using ProductCard not work while casting the view to its type works as expected?
Update 1
Seems like casting to ProductCard also works
#Composable
fun BasketItemsList(
modifier: Modifier,
basketItems: List<ProductState>,
provider: ProductCard,
interactionListener: ProductCardView.InteractionListener
) {
LazyColumn(modifier = modifier) {
items(basketItems) { product ->
AndroidView(factory = { provider.view }) {
Timber.tag("BasketItemsList").v(product.toString())
// provider.setup(product, interactionListener)
(it as ProductCard).setup(product, interactionListener)
}
}
}
}
So the question is why do we have to use it inside AndroidView.update instead of any other references to the view?
The answer here is I was missing the key value which is needed for LazyColumn items in order for compose to know which item has changed and call update on it.
Related
I'm migrating from standard Android to Jetpack Compose but I'm having a problem. I don't know how to "convert" the following implementation:
Standard Android:
I call a function and receive a View.
I use the FrameLayout addView() method call to fill my FrameLayout with the received view.
How can I do it in composing?
In composing using a FrameLayout means to use a Box but I don't know how I can fill it with my received view.
Could anyone suggest to me the correct way?
here the code:
#Composable
fun MyCard(testActivity: Activity? = null) {
AdvertView(testActivity) { view ->
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(factory = { view })
}
}
}
following where it's displayed the error:
Just use a List<View> and loop over it. Adding elements into the list will trigger recomposition.
#Composable
fun MyOldFrameLayout(modifier: Modifier = Modifier, dynamicViews: List<View>) {
Box(modifier.fillMaxSize()) {
dynamicViews.forEach { view -> AndroidView(factory= { view }) }
}
}
Based on what you provided on your screenshot
I suggest you refactor your screenshot into:
#Composable fun MyCard(onClick: () -> Unit) {
Box(modifier.fillMaxSize()) {
AndroidView(factory= { context -> AdvertView(context) { onClick() } })
}
}
You can do something like:
Box(
modifier = Modifier.fillMaxSize()
)
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
}
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.
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.
I have a component with some mutable state list. I pass an item of that, and a callback to delete the item, to another component.
#Composable
fun MyApp() {
val myItems = mutableStateListOf("1", "2", "3")
LazyColumn {
items(myItems) { item ->
MyComponent(item) { toDel -> myItems.remove(toDel) }
}
}
}
The component calls the delete callback in a clickable Modifier.
#Composable
fun MyComponent(item: String, delete: (String) -> Unit = {}) {
Column {
Box(
Modifier
.size(200.dp)
.background(MaterialTheme.colors.primary)
.clickable { delete(item) }
) {
Text(item, fontSize = 40.sp)
}
}
}
This works fine. But when I change the clickable for my own Modifier with pointerInput() then there's a problem.
fun Modifier.myClickable(delete: () -> Unit) =
pointerInput(Unit) {
awaitPointerEventScope { awaitFirstDown() }
delete()
}
#Composable
fun MyComponent(item: String, delete: (String) -> Unit = {}) {
Column {
Box(
Modifier
.size(200.dp)
.background(MaterialTheme.colors.primary)
.myClickable { delete(item) } // NEW
) {
Text(item, fontSize = 40.sp)
}
}
}
If I click on the first item, it removes it. Next, if I click on the newest top item, the old callback for the now deleted first item is called, despite the fact that the old component has been deleted.
I have no idea why this happens. But I can fix it. I use key():
#Composable
fun MyApp() {
val myItems = mutableStateListOf("1", "2", "3")
LazyColumn {
items(myItems) { item ->
key(item) { // NEW
MyComponent(item) { toDel -> myItems.remove(toDel) }
}
}
}
}
So why do I need key() when I use my own modifier? This is also the case in this code from jetpack, and I don't know why.
As the accepted answer says, Compose won't recalculate my custom Modifier because pointerEvent() doesn't have a unique key.
fun Modifier.myClickable(key:Any? = null, delete: () -> Unit) =
pointerInput(key) {
awaitPointerEventScope { awaitFirstDown() }
delete()
}
and
Box(
Modifier
.size(200.dp)
.background(MaterialTheme.colors.primary)
.myClickable(key = item) { delete(item) } // NEW
) {
Text(item, fontSize = 40.sp)
}
fixes it and I don't need to use key() in the outer component. I'm still unsure why I don't need to send a unique key to clickable {}, however.
Compose is trying to cache as many work as it can by localizing scopes with keys: when they haven't changes since last run - we're using cached value, otherwise we need to recalculate it.
By setting key for lazy item you're defining a scope for all remember calculations inside, and many of system functions are implemented using remember so it changes much. Item index is the default key in lazy item
So after you're removing first item, first lazy item gets reused with same context as before
And now we're coming to your myClickable. You're passing Unit as a key into pointerInput(It has a remember inside too). By doing this you're saying to recomposer: never recalculate this value until context changes. And the context of first lazy item hasn't changed, e.g. key is still same index, that's why lambda with removed item remains cached inside that function
When you're specifying lazy item key equal to item, you're changing context of all lazy items too and so pointerInput gets recalculated. If you pass your item instead of Unit you'll have the same effect
So you need to use key when you need to make use your calculations are not gonna be cached between lazy items in a bad way
Check out more about lazy column keys in the documentation
Jetpack compose optimizes the re-compose by only recomposing Widget which value has been changed.
In your Custom implementation of Modifier.myClickable when item list is changing due to deletion, only the inner Text(item, fontSize = 40.sp) will be recomposed since item has changed and it is the only one which is reading item. The outer Box() is not recomposed, hence it is holding the previous callback. But When you add key(item), the outer box will also be re-composed as the key value has changed. Hence it is working after adding the key.
So why is was working with Modifier.clickable { delete(item) }?
I think Compose kept track of change in the callback clickable { delete(item) }. So when the callback changed due to item deletion, it recomposed MyComponent, Hence is was working with clickable