From ce294dbc0b99782e9588d5f443d36561b5a62ce4 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 6 May 2026 12:14:17 -0300 Subject: [PATCH] Add mapping-based lazycolumn / lazyrow. --- .../mapping/compose/MappingLazyList.kt | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/compose/MappingLazyList.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/compose/MappingLazyList.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/compose/MappingLazyList.kt new file mode 100644 index 0000000000..291b74852b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/compose/MappingLazyList.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.util.adapter.mapping.compose + +import android.content.Context +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.toPersistentHashMap +import kotlinx.coroutines.flow.distinctUntilChanged +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.paging.PagingController +import org.signal.paging.ProxyPagingController +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.SettingHeader +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder + +data class MappingEntry( + val key: ((T) -> Any)? = null, + val content: @Composable LazyItemScope.(T) -> Unit +) + +typealias MappingEntryProvider = PersistentMap, MappingEntry> + +@Composable +fun MappingLazyColumn( + controller: MappingLazyListController, + modifier: Modifier = Modifier +) { + val lazyListState = rememberLazyListState() + val items = controller.items + + LazyColumn( + state = lazyListState, + modifier = modifier + ) { + insertProvidedItems(items, controller.entryProvider, controller.placeholder) + } + + PagerEffect(controller, lazyListState) +} + +@Composable +fun MappingLazyRow( + controller: MappingLazyListController, + modifier: Modifier = Modifier +) { + val lazyListState = rememberLazyListState() + val items = controller.items + + LazyRow( + state = lazyListState, + modifier = modifier + ) { + insertProvidedItems(items, controller.entryProvider, controller.placeholder) + } + + PagerEffect(controller, lazyListState) +} + +private fun LazyListScope.insertProvidedItems( + items: List, + provider: MappingEntryProvider, + placeholder: @Composable () -> Unit +) { + itemsIndexed( + items = items, + contentType = { _, model -> model?.javaClass }, + key = { index, model -> + if (model == null) { + index + } else { + @Suppress("UNCHECKED_CAST") + val entry = provider[model.javaClass] as MappingEntry + entry.key?.invoke(model) ?: index + } + } + ) { _, model -> + if (model == null) { + placeholder() + } else { + @Suppress("UNCHECKED_CAST") + val entry = provider[model.javaClass] as MappingEntry + with(entry) { + content(model) + } + } + } +} + +@Composable +private fun PagerEffect(controller: MappingLazyListController, lazyListState: LazyListState) { + LaunchedEffect(controller, lazyListState) { + snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.map { it.index } } + .distinctUntilChanged() + .collect { indices -> + indices.forEach { index -> + controller.pagingController.onDataNeededAroundIndex(index) + } + } + } +} + +@Composable +fun rememberMappingEntryProvider( + builderFn: MappingEntryProviderBuilder.() -> Unit +): MappingEntryProvider { + return remember { + MappingEntryProviderBuilder().apply { + builderFn() + }.build() + } +} + +@DayNightPreviews +@Composable +private fun MappingLazyColumnPreview() { + Previews.Preview { + val provider = rememberMappingEntryProvider { + entry { + Text(text = "String $it") + } + + entry { + Text(text = "Int $it") + } + + viewHolder { + LayoutFactory( + { view -> SettingHeader.ViewHolder(view) }, + R.layout.base_settings_header_item + ).createViewHolder(FrameLayout(it)) + } + } + + val controller = remember(provider) { + MappingLazyListController(provider).apply { + items = listOf("A", "B", "C", 1, 2, 3, SettingHeader.Item("SettingHeader.Item")) + } + } + + MappingLazyColumn( + controller = controller, + modifier = Modifier.fillMaxSize() + ) + } +} + +class MappingEntryProviderBuilder { + val map: MutableMap, MappingEntry> = hashMapOf() + + inline fun entry(noinline key: ((R) -> Any)? = null, noinline content: @Composable (R) -> Unit) { + map[R::class.java] = MappingEntry(key = key) { model -> content(model) } + } + + inline fun viewHolder(noinline key: ((R) -> Any)? = null, crossinline createViewHolder: (Context) -> MappingViewHolder) { + entry(key = key, content = { model -> + var viewHolder: MappingViewHolder? by remember { mutableStateOf(null) } + + AndroidView( + factory = { + val holder = createViewHolder(it) + viewHolder = holder + holder.itemView + }, + update = { + viewHolder?.bind(model) + } + ) + }) + } + + fun build(): MappingEntryProvider { + return map.toPersistentHashMap() + } +} + +@Stable +class MappingLazyListController( + val entryProvider: MappingEntryProvider, + val placeholder: @Composable () -> Unit = { Spacer(Modifier.height(100.dp)) } +) { + + private var proxyController = ProxyPagingController() + + var pagingController: PagingController + get() = proxyController + set(value) { + proxyController.set(value) + } + + var items: List by mutableStateOf(emptyList()) +}