We added a `AndroidView` inside `LazyList`. And th...
# compose
r
We added a
AndroidView
inside
LazyList
. And the list started janking when scrolled fast. Profiled and noticed that android view is inflating the view every-time a new item comes becomes visible. Is there a way to make them recycle views? have added key and content type to list items but that doesn’t help.
f
Testing it in release or debug?
c
cc @Ben Trengrove [G]
b
This isn't currently possible. Here is the bug tracking this issue, https://issuetracker.google.com/230099236
r
Thanks.
What do you guys think? any cons of doing this?
m
LazyList is designed to be efficient from a compose standpoint. It generally will try to re-use nodes when it can. The issue here is that it doesn't know when to re-use things because AndroidView is a black box to it and it can't check the node tree to figure out what's compatible.. Try setting a "contentType" when you create the item. This would be akin to a view type in a recycler view from what i read in the docs:
contentType: Any? = null
the type of the content of this item. The item compositions of the same type could be reused more efficiently. Note that null is a valid type and items of such type will be considered compatible.
Copy code
object MyViewType

item(contentType=MyViewType) {
    AndroidView(...) { ... }
}
I think this will solve your issue in a much more simplistic way.
r
Using contentType doesn’t help. Have mentioned in the original post. It reuses composable but the androidview’s view inflation runs again any time a new view appears.
m
sorry. i missed that. It seems even a "remember" with a view doesn't solve the issue. Given all that, i see no issue with your view pool approach. You just have to make sure that you can dispose of the pool when the composable with the LazyList goes out of scope
r
We were thinking of adding a box over the lazyParent which can do
remember{ViewPool()}
And ideally when the box is disposed view pool should just get GC collected.
m
You don't even need a box really. You just need to remember the view pool, and put a DisposableEffect block to de-allocate anything that needs it. In additional, for each view you are using from your view pool, you need a Disposable effect to return it to the pool.
Copy code
class ViewPool<T : View>(
    val factory: (Context) -> T,
    val viewBlockCreateSize: Int = 1,
) {
    private val allocatedViews = mutableListOf<T>()
    private val freeViews = mutableListOf<T>()
    private var createCounter = 0

    fun checkoutView(context: Context): T {
        Log.d("VIEWPOOL", "checkout")
        return synchronized(this) {
            if (freeViews.isEmpty()) {
                (0 until viewBlockCreateSize).forEach {
                    createCounter += 1
                    Log.d("VIEWPOOL", "create ${createCounter}")
                    freeViews.add(factory(context))
                }
            }

            freeViews.removeAt(0).apply {
                allocatedViews.add(this)
            }
        }
    }

    fun returnView(view: T) {
        Log.d("VIEWPOOL", "return")
        synchronized(this) {
            (view.parent as? ViewGroup)?.removeView(view)
            if (allocatedViews.remove(view)) {
                freeViews.add(view)
            }
        }
    }

    fun dispose() {
        synchronized(this) {
            freeViews.clear()
            allocatedViews.forEach {
                (it.parent as ViewGroup)?.removeView(it)
            }
            allocatedViews.clear()
        }
    }
}


@Composable
fun RecycledIcon(
    viewPool: ViewPool<AppCompatImageView>,
    @DrawableRes drawableRes: Int,
) {

    val contentColor = LocalContentColor.current
    val tintColor = Color.argb(
        contentColor.toArgb().alpha,
        contentColor.toArgb().red,
        contentColor.toArgb().green,
        contentColor.toArgb().blue,
    )

    RecycledView(
        viewPool = viewPool,
        update = { view ->
            val drawable = view.context.getDrawable(drawableRes)
            drawable?.let {
                view.setImageDrawable(
                    it.mutate().apply {
                        setTint(this, tintColor)
                    }
                )
                view.layoutParams?.width = it.intrinsicWidth ?: 0
                view.layoutParams?.width = it.intrinsicHeight ?: 0
            }
        }
    )
}

@Composable
fun <T : View> RecycledView(
    viewPool: ViewPool<T>,
    update: (T) -> Unit,
) {
    lateinit var view: T

    AndroidView(
        factory = { context ->
            view = viewPool.checkoutView(context)
            view
        },
        update = update
    )

    DisposableEffect(key1 = true) {
        onDispose {
            viewPool.returnView(view)
        }
    }
}

@Composable
@Preview
fun RecycledIconPreview() {
    MaterialTheme {
        Surface(modifier = Modifier.fillMaxSize()) {
            val viewPool = remember {
                ViewPool<AppCompatImageView>(
                    factory = { context ->
                        AppCompatImageView(context)
                    },
                    viewBlockCreateSize = 1,
                )
            }

            LazyColumn {
                (0..60).forEach {
                    item {
                        RecycledIcon(viewPool, androidx.appcompat.R.drawable.abc_ic_menu_cut_mtrl_alpha)
                    }
                }
            }

            DisposableEffect(key1 = true) {
                onDispose {
                    viewPool.dispose()
                }
            }
        }
    }
}
284 Views