You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android Paging Compose:查询条件变更时如何滚动到列表顶部?

问题描述

我有一个商品列表,在ProductsScreen界面中设置了筛选选项、搜索框以及筛选芯片:
商品列表界面

我使用Paging 3库实现分页功能,希望在搜索查询或选中的筛选芯片发生变更时,列表能够自动滚动到顶部。我已经在搜索查询或筛选芯片变更时将scrollToTop设为true,并在ProductsScreen中监听,但该逻辑并未生效。

下面是我的代码:

ProductsScreen代码

@Composable
fun ProductsScreen(
    modifier: Modifier = Modifier,
    navController: NavController,
    viewModel: ProductsScreenViewModel = hiltViewModel(),
    onProductItemClick: (String) -> Unit = {},
    onProductFilterButtonClick: (ProductsFilterPreferences) -> Unit = {},
) {
    //products
    val products = viewModel.products.collectAsLazyPagingItems()
    
    //load state
    val isLoading = products.loadState.refresh is LoadState.Loading

    val scrollToTop = viewModel.scrollToTop.observeAsState()

    val scaffoldState = rememberScaffoldState()

    val listState = rememberLazyListState()

    val scope = rememberCoroutineScope()

    val query by viewModel.query.collectAsState()

    val context = LocalContext.current

    //not working
    LaunchedEffect(key1 = Unit) {
        scrollToTop.value?.getContentIfNotHandled()?.let { scroll ->
            if (scroll) {
                listState.scrollToItem(0)
            }
        }
    }

RubiBrandsScaffold(
        modifier = modifier,
        scaffoldState = scaffoldState,
        topBar = {
            ProductsScreenToolbar(
                showFilterBadge = viewModel.showFilterBadge,
                onFilterButtonClick = { onProductFilterButtonClick(viewModel.productsFilterPreferences.value) },
                searchQuery = query,
                onTrailingIconClick = {
                    viewModel.setQuery("")
                },
                onQueryChange = { query ->
                    viewModel.setQuery(query)
                },
                onSearchButtonClick = { query ->
                    viewModel.onSearch(query)
                },
                onChipClick = { chip ->
                    viewModel.onChipSelected(chip)
                },
                selectedChip = viewModel.selectedChip,
            )
        },
        floatingActionButton = {
            AnimatedVisibility(
                visible = showFab,
                enter = scaleIn(),
                exit = scaleOut()
            ) {
                FloatingActionButton(
                    backgroundColor = MaterialTheme.colors.primary,
                    onClick = {
                        scope.launch {
                            //working
                            listState.scrollToItem(0)
                        }
                    },
                    modifier = Modifier
                        .padding(
                            dimensionResource(id = R.dimen.dimen_16)
                        )
                ) {
                    Icon(
                        painter = painterResource(id = R.drawable.ic_up),
                        contentDescription = null,
                        tint = MaterialTheme.colors.onPrimary
                    )
                }
            }
        }
    ) { paddingValues ->
        SwipeRefresh(
            indicator = { state, trigger ->
                RubiBrandsSwipeRefreshIndicator(state = state, trigger = trigger)
            }, state = swipeRefreshState, onRefresh = products::refresh
        ) {
            LazyColumn(
                state = listState,
                modifier = Modifier.fillMaxSize()
            ) {
                items(products) { product ->
                    product?.let {
                        ProductItem(
                            modifier = Modifier.clickable {
                                if (!isLoading) onProductItemClick(
                                    product.id
                                )
                            },
                            childModifier = Modifier.shimmerModifier(isLoading),
                            productImage = product.productImage?.get(0),
                            productTitle = product.productTitle,
                            productCount = product.productCount,
                            productStatus = product.productStatus?.asString(context)
                        )
                    }
                }
                products.apply {
                    when {
                        loadState.source.refresh is LoadState.Loading -> {
                            items(10) {
                                ProductItem(
                                    childModifier = Modifier.shimmerModifier(true),
                                    productImage = null,
                                    productTitle = "",
                                    productCount = "",
                                    productStatus = ""
                                )
                            }
                        }

                        loadState.source.append is LoadState.Loading -> {
                            item {
                                CircularProgressIndicator(
                                    modifier = Modifier
                                        .fillMaxWidth()
                                        .padding(16.dp)
                                        .wrapContentWidth(Alignment.CenterHorizontally)
                                )
                            }
                        }

                        loadState.source.refresh is LoadState.Error -> {
                            val e = products.loadState.refresh as LoadState.Error
                            item {
                                ErrorItem(
                                    modifier = Modifier.fillParentMaxSize(),
                                    message = e.error.localizedMessage
                                        ?: stringResource(id = R.string.something_went_wrong),
                                    onRetryClick = products::retry
                                )
                            }
                        }

                        loadState.source.append is LoadState.Error -> {
                            val e = products.loadState.append as LoadState.Error
                            item {
                                ErrorItem(
                                    modifier = Modifier
                                        .fillParentMaxWidth()
                                        .wrapContentHeight(),
                                    message = e.error.localizedMessage
                                        ?: stringResource(id = R.string.something_went_wrong),
                                    onRetryClick = products::retry
                                )
                            }
                        }

                        loadState.source.refresh is LoadState.NotLoading && loadState.source.refresh !is LoadState.Error && products.itemCount < 1 -> {
                            item {
                                RubiBrandsEmptyListView(modifier = Modifier.fillParentMaxSize())
                            }
                        }
                    }
                }
            }
        }
    }

}

ProductsScreenViewModel代码

@HiltViewModel
class ProductsScreenViewModel @Inject constructor(
    private val productsScreenUseCase: ProductsScreenUseCase
) : ViewModel() {


    var showFilterBadge by mutableStateOf(false)
        private set

    private val _query = MutableStateFlow<String>("")

    val query: StateFlow<String> = _query

    var selectedChip by mutableStateOf<ProductsChips>(ProductsChips.ALL)

    private val _productsFilterPreferences = MutableStateFlow(ProductsFilterPreferences())

    val productsFilterPreferences: StateFlow<ProductsFilterPreferences> = _productsFilterPreferences

    private val _scrollToTop = MutableLiveData<Event<Boolean>>()

    val scrollToTop: LiveData<Event<Boolean>> get() = _scrollToTop

    val products =
        _productsFilterPreferences.flatMapLatest { filterPreferences ->
            showFilterBadge = filterPreferences.sort.value != null
                    || filterPreferences.saleStatus.value != null
                    || filterPreferences.stockStatus.value != null
                    || filterPreferences.sku != null
                    || filterPreferences.priceOptions.value != R.string.all

            productsScreenUseCase.fetchProducts(params = filterPreferences)
        }.cachedIn(viewModelScope)


    fun setFilters(
        filters: ProductsFilterPreferences
    ) {
        _scrollToTop.value = Event(true)
        _productsFilterPreferences.update {
            it.copy(
                sort = filters.sort,
                state = filters.state,
                saleStatus = filters.saleStatus,
                stockStatus = filters.stockStatus,
                sku = filters.sku,
                priceOptions = filters.priceOptions
            )
        }
    }

    fun setQuery(query: String) {
        if (query.isEmpty()) {
            onSearch(query)
        }
        _query.value = query
    }

    fun onSearch(query: String) {
        //if I press the search button I'm setting scroll to top to true
        _scrollToTop.value = Event(true)
        _productsFilterPreferences.update {
            it.copy(query = query.trim().ifEmpty { null })
        }
    }

    fun onChipSelected(chip: ProductsChips) {
        selectedChip = chip
        //if I change the chip I'm setting scroll to top to true
        _scrollToTop.value = Event(true)
        _productsFilterPreferences.update {
            it.copy(state = chip.value)
        }
    }
}

解决方案

你的问题出在LaunchedEffect的触发时机和监听逻辑上,以下是修复方案:

1. 修正LaunchedEffect的监听逻辑

原来的LaunchedEffect(key1 = Unit)只会在Composable首次组合时执行一次,后续scrollToTop的变化不会触发它重新执行。你需要把scrollToTop作为LaunchedEffect的key,同时等待Paging数据加载完成后再执行滚动:

// 替换原来的LaunchedEffect代码
LaunchedEffect(scrollToTop) {
    viewModel.scrollToTop.asFlow().collect { event ->
        event.getContentIfNotHandled()?.let { shouldScroll ->
            if (shouldScroll) {
                // 监听Paging的刷新状态,等待数据加载完成后再滚动
                products.loadState.refresh.collect { loadState ->
                    if (loadState is LoadState.NotLoading) {
                        listState.scrollToItem(0)
                    }
                }
            }
        }
    }
}

2. 问题根源说明

  • LaunchedEffect(key1 = Unit)仅执行一次,后续scrollToTop更新时不会重新触发,导致无法响应新的滚动指令。
  • 直接调用scrollToItem(0)可能在Paging数据还没刷新完成时执行,此时列表无数据或未完成重组,滚动指令无效。

3. 可选优化:统一使用StateFlow

如果可以,建议把_scrollToTop改成MutableStateFlow,和其他状态流保持一致,Compose中使用Flow会更顺畅:

在ViewModel中修改:

private val _scrollToTop = MutableStateFlow<Event<Boolean>>(Event(false))
val scrollToTop: StateFlow<Event<Boolean>> get() = _scrollToTop

// 触发滚动时的调用逻辑不变,依然设置为Event(true)
_scrollToTop.value = Event(true)

在Composable中收集:

val scrollToTop by viewModel.scrollToTop.collectAsState()

LaunchedEffect(scrollToTop) {
    scrollToTop.getContentIfNotHandled()?.let { shouldScroll ->
        if (shouldScroll) {
            products.loadState.refresh.collect { loadState ->
                if (loadState is LoadState.NotLoading) {
                    listState.scrollToItem(0)
                }
            }
        }
    }
}

内容的提问来源于stack exchange,提问作者Jarnojr

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.25 05:45:35