fix(ui): keep previous search results while reloading

Keep showing the last results while a new search generation loads,
and guard list access: the paging snapshot can shrink mid-layout,
which threw and crashed on an empty result set.
This commit is contained in:
OneWay committed 2026-09-22 00:37:48 +08:00
1 parent 447d086cdf
commit b5fc7e7246
2 files changed
+37 -5

No files matched your search

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -50,6 +51,7 @@ internal fun SearchResultsPane(
var restoredScrollPosition by remember { mutableStateOf(false) } var restoredScrollPosition by remember { mutableStateOf(false) }
var pendingScrollToTop by remember { mutableStateOf(false) } var pendingScrollToTop by remember { mutableStateOf(false) }
var sawRefreshForPendingScroll by remember { mutableStateOf(false) } var sawRefreshForPendingScroll by remember { mutableStateOf(false) }
var staleEntries by remember { mutableStateOf(emptyList<IndexEntry>()) }
val imeBottomPadding = with(LocalDensity.current) { val imeBottomPadding = with(LocalDensity.current) {
WindowInsets.ime.getBottom(this).toDp() WindowInsets.ime.getBottom(this).toDp()
} }
@@ -72,9 +74,28 @@ internal fun SearchResultsPane(
restoredScrollPosition = true restoredScrollPosition = true
pendingScrollToTop = query.isNotBlank() pendingScrollToTop = query.isNotBlank()
sawRefreshForPendingScroll = false sawRefreshForPendingScroll = false
if (query.isBlank()) {
staleEntries = emptyList()
}
} }
} }
val loaded = ArrayList<IndexEntry>(results.itemCount)
for (index in 0 until results.itemCount) {
val entry = results.peek(index)
if (entry != null) {
loaded.add(entry)
}
}
SideEffect {
if (results.loadState.refresh is LoadState.NotLoading && loaded.isNotEmpty()) {
staleEntries = loaded
}
}
val pendingReload = results.itemCount == 0 &&
results.loadState.refresh is LoadState.Loading
val shownStale = if (pendingReload && searchState.text.isNotBlank()) staleEntries else emptyList()
LaunchedEffect(results.loadState.refresh, pendingScrollToTop) { LaunchedEffect(results.loadState.refresh, pendingScrollToTop) {
if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) { if (pendingScrollToTop && results.loadState.refresh is LoadState.Loading) {
sawRefreshForPendingScroll = true sawRefreshForPendingScroll = true
@@ -125,6 +146,7 @@ internal fun SearchResultsPane(
listState = listState, listState = listState,
showDictionaryName = showDictionaryName, showDictionaryName = showDictionaryName,
contentPadding = PaddingValues(bottom = imeBottomPadding), contentPadding = PaddingValues(bottom = imeBottomPadding),
staleEntries = shownStale,
) )
} }
} }
@@ -30,8 +30,11 @@ fun SearchResultsList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
showDictionaryName: Boolean = true, showDictionaryName: Boolean = true,
contentPadding: PaddingValues = PaddingValues() contentPadding: PaddingValues = PaddingValues(),
staleEntries: List<IndexEntry> = emptyList(),
) { ) {
val showStale = staleEntries.isNotEmpty() && results.itemCount == 0
val rowCount = if (showStale) staleEntries.size else results.itemCount
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.testTag("search_results") .testTag("search_results")
@@ -41,11 +44,18 @@ fun SearchResultsList(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items( items(
count = results.itemCount, count = rowCount,
key = results.itemKey { "${it.dictionaryPath}:${it.offset.value}:${it.word}" }, key = { index ->
contentType = results.itemContentType { "search_result" } val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results.peek(index) else null
entry?.let { "${it.dictionaryPath}:${it.offset.value}:${it.word}" }
?: "search_result_$index"
},
contentType = { "search_result" }
) { index -> ) { index ->
results[index]?.let { entry -> val entry = if (showStale) staleEntries.getOrNull(index)
else if (index in 0 until results.itemCount) results.peek(index) else null
if (entry != null) {
Box( Box(
modifier = Modifier.animateItem( modifier = Modifier.animateItem(
fadeInSpec = if (index < 8) tween(150) else null, fadeInSpec = if (index < 8) tween(150) else null,