diff --git a/CollabTableAndroid/README.md b/CollabTableAndroid/README.md index 1f23e14..bb55c04 100644 --- a/CollabTableAndroid/README.md +++ b/CollabTableAndroid/README.md @@ -11,6 +11,7 @@ A collaborative table management Android application built with Jetpack Compose - Local Room database for offline support - Automatic synchronization with server (WebSocket-first with HTTP fallback) - Soft delete support (items can be recovered on server) +- Optimized large table scrolling (stable row ordering + debounced updates) ## Architecture @@ -92,6 +93,15 @@ app/src/main/java/com/collabtable/app/ 2. Fill in values for each field 3. Values are saved automatically as you type +### Performance Notes (Large Tables) +For very large tables the app applies several optimizations: +- Rows are kept in their original creation order rather than sorting by last update timestamp. This prevents the entire list from resorting after each edit and reduces scroll jank. +- Rapid successive database emissions are coalesced (debounced ~75ms) before updating the UI state, lowering unnecessary recompositions during bulk edits or sync bursts. +Further planned improvements: +- Incremental paging for extremely large datasets +- Horizontal virtualization for very wide column sets +If you encounter sluggishness with tens of thousands of rows, enable server-side filtering/sorting to reduce client load. + ### Column Content Alignment In the Edit Column dialog you can choose how each column's cell content is aligned (Left, Center, Right) via a single connected Material 3 segmented button group. The selection persists per list/field and updates immediately when you save changes. To adjust later, open the Manage Columns dialog or edit the field again. diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt index 5d27ea1..2fa25fa 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt @@ -11,11 +11,14 @@ import kotlinx.coroutines.flow.Flow @Dao interface ItemDao { - @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") + // Use stable creation order instead of updatedAt-based ordering to avoid resorting the entire list + // on every cell edit (which bumps updatedAt). This dramatically reduces scroll jank and recomposition + // churn for large tables while still presenting rows in a predictable order. + @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY createdAt ASC") fun getItemsForList(listId: String): Flow> @Transaction - @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") + @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY createdAt ASC") fun getItemsWithValuesForList(listId: String): Flow> @Query("SELECT * FROM items WHERE id = :itemId") diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt index c791837..7aef0c2 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt @@ -38,7 +38,7 @@ val migration2To3 = Item::class, ItemValue::class, ], - version = 3, + version = 4, exportSchema = false, ) abstract class CollabTableDatabase : RoomDatabase() { @@ -62,7 +62,7 @@ abstract class CollabTableDatabase : RoomDatabase() { context.applicationContext, CollabTableDatabase::class.java, "collab_table_database", - ).addMigrations(migration1To2, migration2To3) + ).addMigrations(migration1To2, migration2To3, migration3To4) .build() dbInstance = instance instance @@ -77,3 +77,11 @@ abstract class CollabTableDatabase : RoomDatabase() { } } } + +// Add index to accelerate item listing by listId ordered by creation time +val migration3To4 = + object : Migration(3, 4) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE INDEX IF NOT EXISTS index_items_listId_createdAt ON items(listId, createdAt)") + } + } diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt index 8950ac8..9a082a7 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt @@ -15,7 +15,10 @@ import androidx.room.PrimaryKey onDelete = ForeignKey.CASCADE, ), ], - indices = [Index("listId")], + indices = [ + Index("listId"), + Index(value = ["listId", "createdAt"]), + ], ) data class Item( @PrimaryKey val id: String, diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt index fd161ce..db2d475 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -81,6 +82,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Constraints import com.collabtable.app.R import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.model.Field @@ -956,19 +959,19 @@ fun ItemRow( remember(itemWithValues.values) { itemWithValues.values.associateBy { it.fieldId } } - Row( + val widths = remember(fields, fieldWidths) { fields.map { fieldWidths[it.id] ?: 150.dp } } + + EqualHeightRow( + widths = widths, modifier = Modifier .fillMaxWidth() - .height(androidx.compose.foundation.layout.IntrinsicSize.Min) - .clickable(onClick = onClick) - , - verticalAlignment = Alignment.CenterVertically, + .heightIn(min = 48.dp) + .clickable(onClick = onClick), ) { fields.forEach { field -> key(field.id) { val value = valuesByFieldId[field.id] - val fieldWidth = fieldWidths[field.id] ?: 150.dp val alignment = when (fieldAlignments[field.id]?.lowercase()) { "center" -> Alignment.Center "end", "right" -> Alignment.CenterEnd @@ -983,8 +986,6 @@ fun ItemRow( Box( modifier = Modifier - .width(fieldWidth) - .fillMaxHeight() .border( width = 1.dp, color = MaterialTheme.colorScheme.outline, @@ -1453,6 +1454,53 @@ fun ItemRow( } } +@Composable +private fun EqualHeightRow( + widths: List, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val widthsPx = remember(widths, density) { widths.map { with(density) { it.roundToPx() } } } + Layout( + modifier = modifier, + content = content, + ) { measurables, outerConstraints -> + val count = measurables.size + val n = widthsPx.size.coerceAtMost(count) + + // First pass: measure children with fixed widths and unconstrained height to find max height + var maxHeight = 0 + val placeablesFirst = Array(n) { i -> + val w = widthsPx[i].coerceAtLeast(0) + val c = Constraints.fixedWidth(w) + val p = measurables[i].measure(c) + if (p.height > maxHeight) maxHeight = p.height + p + } + + // Second pass: remeasure with fixed width and fixed row height so cells share same height + val placeables = Array(n) { i -> + val w = widthsPx[i].coerceAtLeast(0) + val c = Constraints.fixed(width = w, height = maxHeight) + measurables[i].measure(c) + } + + val totalWidth = widthsPx.take(n).sum() + val layoutWidth = totalWidth.coerceIn(outerConstraints.minWidth, outerConstraints.maxWidth) + val layoutHeight = maxHeight.coerceIn(outerConstraints.minHeight, outerConstraints.maxHeight) + + layout(layoutWidth, layoutHeight) { + var x = 0 + for (i in 0 until n) { + val p = placeables[i] + p.placeRelative(x, 0) + x += p.width + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddFieldDialog( diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt index bc2f2e6..1fa2bde 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt @@ -51,9 +51,12 @@ class ListDetailViewModel( } viewModelScope.launch { - database.itemDao().getItemsWithValuesForList(listId).collect { itemsData -> - _items.value = itemsData - } + database.itemDao() + .getItemsWithValuesForList(listId) + .debounce(75) + .collect { itemsData -> + _items.value = itemsData + } } viewModelScope.launch {