When I use AsyncImage from `coil` in a `lazyList` I see lags during scrolling because image needs so...
e
When I use AsyncImage from
coil
in a
lazyList
I see lags during scrolling because image needs some time to load. Is it possible to somehow preload a couple of items in direction of scrolling to avoid that lags? I was not able to find any API for that.
u
coil wont load images on main thread, the lag must be something else
e
@ursus by the lag I don’t mean scroll lag, scrolling itself is smooth enough. But I see the item without an image for sometime (~100ms) even if I scroll back. This doesn’t look nice and smooth.
Since in my case only one item fits the screen it looks even worse.
u
what dp you mean dont see.. is it simply still loading asyncly, or is main thread frozen
e
main thread is not frozen. it is loading fine. but because it takes long the whole UX looks weird. Usually in such cases the items are preloaded so when user scrolls they are already ready to be drawn. But in this case it starts loading only when becomes visible and since loading from the video takes longer it looks bad.
u
doesnt happen to me, so I'd assume your cache is small and stuff gets evicted or sth like that
e
I am loading from the local mp4 file. The first time there is no cache and it is fine, but it looks, like it doesn’t use cache in this case.
u
i'd assume so
look into if `File`s are even cached, I would not be surprised if not
e
so the issue could be fixed by prefetching, but it looks like LazyColumn doesn’t have such API
files are local, they are not downloaded
f
Yeah, it doesn't. There is an open issue for this feature https://issuetracker.google.com/issues/172029355
You can add a placeholder or shimmer, it may fix the UX
u
oO
e
@Fyodor Danilov it is so weird..
s
Technically what you want is for coil to preload images and not for Compose to prefetch items. The prefetcher API addresses the latter, but you can also request the image preload by observing changes to
layoutInfo
. Prefetching items is somewhat wasteful in this scenario, as you end up doing the work that
AsyncImage
does ahead of time
e
@shikasd Actually in my case the loading time is not the only issue. Also the size of the item (it’s height) depends on the actual aspect ratio of the media, which is loaded. I am still experimenting with that, but it is really tricky to avoid layout changes in this case. Even if I figure out how to most correctly force the item to remeasure it will produce visible effects like jumping items on the screen. Things will become even more challenging if I replace AsyncImage with Media3 (Exoplayer) in the list. Prefetching API would make both of this cases much more simple, since the item will be loaded and remeasured before it appears on the screen.
s
True, but also it won't help if you scroll fast enough, as prefetch requires some idle time to do the prefetching work.
e
@shikasd I agree it is not a silver bullet in this case. Could you please advise the best approach for resizing issue? (I also asked it here: https://kotlinlang.slack.com/archives/CJLTWPH7S/p1707988936171319) For example I want to put a placeholder with 3/4 aspect ratio and after loaded change the size of the element to reflect the actual aspect ratio. Also it would be nice to somehow remember the final size of already loaded in the past items to avoid it’s resizing when scrolling back.
s
You can use the width/height of existing items or
LazyColumn
layout to measure the future items, I think That's what prefetch in
LazyColumn
does.
e
@shikasd each item can have it’s own aspect ratio.
s
Right, but the width would be constant for all items in the column
e
Now, got your idea, thanks. In worst case when the item is already on the screen and “prefetch” didn’t work, is calling
forceRemeasure
the correct way to change the size? Or should I have some state value
measuredHeight
in the scope and change the modifier via something like
Copy code
.run {
   if (measuredHeight != null) {
      height(measuredHeight)
   } else {
      aspectRatio(3f/4)
   }
}
?
s
I don't think I follow,
aspectRatio
does this thing for you already, iirc
e
@shikasd my case is the following. the item in a LazyColumn is loading video. All videos have different aspect ratio and I don’t know in advance what it will be for each item. So when the item is measured it doesn’t know the final aspect ratio yet. Then the video is loaded and item should resize according to the size of the video.
s
How do you measure an item?
e
I am using
Media
composable provided by
com.github.fengdai.compose:media
which is a compose wrapper around androidx.media3. If I don’t specify any size constrain on height (only
fillMaxWidth
), the
Media
height in the
LazyColumn
is 0 even after loading. But if used outside of the LazyColumn, In a Box, for example it loads and shows video with same settings. it is based on AndroidView around Exoplayer. It has pretty complex logic around video loading. As I understood the main point is here (image below). Probably it is not the best approach.
s
Right, but here it is measured by Compose, and you don't really prefetch it ahead of time iiuc
e
yes, correct. right now I am not doing anything special. and I am talking about the most correct approach to resize the video view after it is loaded (and knows it’s aspect ratio). does the approach from the screenshot look fine for such case?
s
Ideally, you would preload the aspect ratio though, as resizing causes scroll jumps
It looks fine, but it is hard to judge based on just this one snippet
e
Thank you, @shikasd! I will try to use LazyListLayoutInfo to implement something like prefetch. Probably it even makes sense to use image fetcher from Coil to load a frame of the video in advance and cache it. This should solve both loading delay issue and the unknown aspect ratio, which could be taken from that image. It still looks pretty overcomplicated for me for this relatively easy task, but looks like I don’t have better choice. Preload API would make this much easier if all this will just happen automatically because the item itself will be preinitialized..
t
I do preloading in one of my productive projects with Coil. Why do you say it is not supported? But of course it only loads the jpg images. The decoding will still happen on the fly.
The code looks like this:
Copy code
ctx.imageLoader.enqueue(
     ImageRequest.Builder(ctx)
         .data(imageUrl)
         .apply { metrics?.let { size(metrics.widthPixels, metrics.heightPixels) } }
         .build()
 )
As you can see you can also provide a size for the decoded image i think. Not sure maybe it will decode the image also. But than it is decompressed in the memory and there is not much room for cached imags.
e
@Timo Drick thank you for the feedback. I meant that the LazyColumn doesn’t have API to make some preloading. One has to implement it manually. The best way to solve this would be to specify a number of offscreen elements which should be allocated and initialised during the scroll process. In this case the preloading will be done transparently without any special code. And it would include image caching, size measurement and any other operations which can be done in the background before these items will be actually added to the Column and drawn.
t
Ah ok yes sorry i missunderstood this. Yes i do worked on this thing in some of my projects. But at the end i think it would be good to have a flow which provides the list data. But there is no inbuild function yet which makes a flow usable by a LazyList. I developed some in the past but it gets very complicated. Using a flow you can exactly define how many items get preloaded. My code is old and maybe it is now easier to do this. But if you want i could search for a snipped.
Ok found it:
Copy code
inline fun <T> LazyListScope.itemsFlow(
    flow2StateList: FlowListCollector<T>,
    crossinline itemContent: @Composable LazyItemScope.(item: FlowState<T>) -> Unit
) = itemsIndexed(flow2StateList.list) { index, item ->
    flow2StateList.requestListSize(index + 1 + 10)
    itemContent(this, item)
}

fun <T> Flow<T>.stateList() = FlowListCollector(this)

sealed class FlowState<out T> {
    object Loading : FlowState<Nothing>()
    object Empty : FlowState<Nothing>()
    class Error(val error: Throwable): FlowState<Nothing>()
    class Item<T>(val value: T): FlowState<T>()
}

class FlowListCollector<T>(
    private val flow: Flow<T>
): CoroutineScope {
    private val job = Job()
    override val coroutineContext = Dispatchers.Main + job

    val list: SnapshotStateList<FlowState<T>> = mutableStateListOf()

    private val listItemsChannel = Channel<Int>(Channel.CONFLATED)
    private val requestItemsChannel = Channel<Int>(Channel.CONFLATED)
    private val retryChannel = Channel<Boolean>(Channel.CONFLATED)
    private var requestedListSize = 1
    private var listSize = 0

    init {
        list.add(FlowState.Loading) // set last list item to loading
        launch {
            var inProgress = true
            log("$this - Start collecting for $flow")
            if (requestedListSize <= 0) {
                requestedListSize = requestItemsChannel.receive()
            }

            flow.buffer(5)
                .retryWhen { cause, _ ->
                    log("Retry", cause)
                    if (inProgress) {
                        list.removeLast()
                    }
                    list.add(FlowState.Error(cause)) // Add error item to end of list
                    listItemsChannel.send(list.size)
                    inProgress = true
                    return@retryWhen retryChannel.receive()
                }.collect {
                    if (inProgress) {
                        list.removeLast()
                        inProgress = false
                    }
                    list.add(FlowState.Item(it))
                    listSize++
                    listItemsChannel.send(list.size)
                    if (listSize >= requestedListSize) {
                        log("Wait until new items needed. List size: $listSize requested: $requestedListSize")
                        requestedListSize = requestItemsChannel.receive()
                    }
                }
            if (inProgress) {
                inProgress = false
                list.removeLast()
                if (list.isEmpty()) {
                    list.add(FlowState.Empty)
                }
            }
            log("$this - collection ready")
        }
    }

    suspend fun preload(requestSize: Int) {
        if (job.isActive) {
            requestItemsChannel.send(requestSize)
            //Wait until preload is ready
            while (listSize < requestSize) {
                val newSize = listItemsChannel.receive()
                log("new size: $newSize list size: ${listSize}")
            }
        }
    }

    fun requestListSize(requestSize: Int) {
        if (job.isActive) {
            launch {
                log("Request items. List size: $listSize requested: $requestSize")
                if (listSize < requestSize) {
                    log("Send request")
                    requestItemsChannel.send(requestSize)
                }
            }
        }
    }

    fun retry() {
        launch {
            retryChannel.send(true)
        }
    }

    protected fun finalize() {
        job.cancel()
        requestItemsChannel.cancel()
        retryChannel.cancel()
        log("$this - Channel and job canceled")
    }
}
998 Views