Scroll Position Changed in LazyColumn When Updating Item

255 Views Asked by At

I am currently working on a Jetpack Compose project where I use Paging 3 in conjunction with Room to display a list of items in a LazyColumn. Each item has download or delete button that trigger corresponding actions. However, when I click the button, it will update the item's status (Downloading, Downloaded, or Deleted) and change the associated icon of the button, at this point the LazyColumn automatically scrolls, losing the user's current scrolled position.

I am seeking guidance on maintaining the user's current scrolled position within the LazyColumn when updating an item's. The goal is to ensure a seamless user experience without unexpected scrolling interruptions. Please watch the attached video to get a clear understanding of the issue.

Any insights, code snippets, or suggested approaches to resolve this scrolling issue with Jetpack Compose Paging 3 and Room would be highly appreciated. Thank you in advance

Full source code: https://github.com/sendtodilanka/TranslatorExample.git

Code of the LanguageScreen.Kt

@Composable
fun languageViewModel(selectedType: String): LanguageViewModel {
    val factory = EntryPointAccessors.fromActivity(
        LocalContext.current as Activity,
        ViewModelFactoryProvider::class.java
    ).languageViewModelFactory()

    return viewModel(factory = LanguageViewModel.provideFactory(factory, selectedType))
}

@Composable
fun LanguageScreen(
    navController: NavHostController,
    selectedType: String
) {
    val context = LocalContext.current
    val lazyListState = rememberLazyListState()
    val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()

    val isFromSource = selectedType == SelectedType.SOURCE.name
    val viewModel: LanguageViewModel = languageViewModel(selectedType)
    val screenTitle = "Translate ${if (isFromSource) "from" else "to"}"

    val sourceLanguage by viewModel.run { sourceLanguage.collectAsState(defaultSource) }
    Timber.d("sourceLanguage: ${sourceLanguage.languageName}")

    val targetLanguage by viewModel.run { targetLanguage.collectAsState(defaultTarget) }
    Timber.d("targetLanguage: ${targetLanguage.languageName}")

    val languageList = viewModel.languageList.collectAsLazyPagingItems()
    Timber.d("languageList count: ${languageList.itemCount}")

    var openAutoDialog by rememberSaveable { mutableStateOf(false) }
    var openLDDialog by rememberSaveable { mutableStateOf(false) }
    var openLRDialog by rememberSaveable { mutableStateOf(false) }

    var languageId by rememberSaveable { mutableStateOf("") }
    var languageName by rememberSaveable { mutableStateOf("") }

    Scaffold(
        modifier = Modifier
            .imePadding()
            .nestedScroll(scrollBehavior.nestedScrollConnection),
        topBar = {
            SearchAppBar(
                title = screenTitle,
                navIcon = Icons.Filled.ArrowBack,
                scrollBehavior = scrollBehavior,
                onNavClick = { navController.navigateUp() },
                onQueryChanged = { viewModel.setSearchTerm(it) }
            )
        }
    ) { paddingValues ->
        LanguageItemList(
            modifier = Modifier.padding(paddingValues),
            lazyListState = lazyListState,
            languageList = languageList,
            sourceLanguageName = sourceLanguage.languageName,
            onItemClick = {
                onLanguageSelect(
                    languageId = it.languageId,
                    isFromSource = isFromSource,
                    sourceLanguageId = sourceLanguage.languageId,
                    targetLanguageId = targetLanguage.languageId,
                    onAutoWarning = { openAutoDialog = true },
                    onSwapSelected = {
                        viewModel.swapLanguage(
                            isFromSource = isFromSource,
                            sourceLanguageId = sourceLanguage.languageId,
                            targetLanguageId = targetLanguage.languageId
                        )
                    },
                    onSaveSelected = {
                        viewModel.saveLanguage(
                            isFromSource = isFromSource,
                            languageId = it.languageId
                        )
                    }
                )
            },
            onItemActionClick = {
                languageId = it.languageId
                languageName = it.languageName

                when (it.languageState) {
                    NONE, AUTO, DOWNLOADING -> {}
                    SUPPORTED -> openLDDialog = true
                    DOWNLOADED -> openLRDialog = true
                }
            }
        )
    }

    if (openAutoDialog) {
        AlertDialog(
            icon = Icons.Rounded.Info,
            dialogTitle = "Attention Please",
            dialogText = "This language is already selected as a target language. So please select another language.",
            onPositiveClick = { openAutoDialog = false },
            onDismissRequest = { openAutoDialog = false }
        )
    }

    if (openLDDialog) {
        AlertDialog(
            icon = Icons.Rounded.FileDownload,
            dialogTitle = "Download $languageName",
            dialogText = "Translate this language even when you are offline by downloading an offline translation file.",
            positiveBtnText = "Download",
            negativeBtnText = "Cancel",
            onPositiveClick = {
                Timber.d("$languageName language model download started.")
                openLDDialog = false

                viewModel.downloadLanguage(
                    languageId = languageId,
                    isWiFiRequired = false,
                    onSuccessListener = {
                        val message =
                            "$languageName language model download successfully."
                        Timber.d(message)
                        context.toast(message)
                    },
                    onFailureListener = {
                        val message =
                            "Failed to download the $languageName language model."
                        Timber.d(message)
                        context.toast(message)
                    }
                )
            },
            onDismissRequest = { openLDDialog = false }
        )
    }

    if (openLRDialog) {
        AlertDialog(
            icon = Icons.Rounded.DeleteOutline,
            dialogTitle = "Delete $languageName",
            dialogText = "If you remove this offline translation file, this language will be unavailable for offline translation.",
            positiveBtnText = "Delete",
            negativeBtnText = "Cancel",
            onPositiveClick = {
                Timber.d("$languageName language model removed started.")
                openLRDialog = false

                viewModel.deleteLanguage(
                    languageId = languageId,
                    onSuccessListener = {
                        val message =
                            "$languageName language model removed successfully."
                        Timber.d(message)
                        context.toast(message)
                    },
                    onFailureListener = {
                        val message =
                            "Failed to remove the $languageName language model."
                        Timber.d(message)
                        context.toast(message)
                    }
                )
            },
            onDismissRequest = { openLRDialog = false }
        )
    }
}

fun onLanguageSelect(
    languageId: String,
    isFromSource: Boolean,
    sourceLanguageId: String,
    targetLanguageId: String,
    onAutoWarning: () -> Unit,
    onSwapSelected: () -> Unit,
    onSaveSelected: () -> Unit,
) {
    if (isFromSource && sourceLanguageId == "auto" && targetLanguageId == languageId) {
        onAutoWarning()
        return
    }

    if (languageId == targetLanguageId) {
        onSwapSelected()
    } else {
        onSaveSelected()
    }
}

@Composable
fun LanguageItemList(
    modifier: Modifier = Modifier,
    lazyListState: LazyListState,
    languageList: LazyPagingItems<ItemLanguageAdapter>,
    sourceLanguageName: String,
    onItemClick: (Language) -> Unit,
    onItemActionClick: (Language) -> Unit
) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .then(modifier),
        state = lazyListState,
        horizontalAlignment = Alignment.Start,
    ) {
        items(
            count = languageList.itemCount,
            key = languageList.itemKey(),
            contentType = languageList.itemContentType()
        ) { index ->
            when (val item = languageList[index]) {
                is ItemLanguageAdapter.Divider -> Divider(Modifier.padding(bottom = 16.dp))

                is ItemLanguageAdapter.Item -> LanguageItem(
                    title = item.language.languageName,
                    sourceLanguageName = sourceLanguageName,
                    languageState = item.language.languageState,
                    onItemClick = { onItemClick(item.language) },
                    onItemActionClick = { onItemActionClick(item.language) }
                )

                is ItemLanguageAdapter.SectionTitle -> ProvideTextStyle(
                    MaterialTheme.typography.labelLarge.copy(
                        color = MaterialTheme.colorScheme.primary,
                        fontWeight = FontWeight.SemiBold
                    )
                ) { Text(item.title, Modifier.padding(56.dp, 24.dp)) }

                null -> throw IllegalStateException("Encountered a null item at index $index.")
            }
        }
    }
}

@Preview
@Composable
fun LanguageItemPreview() {
    val english = Language(
        languageId = "en",
        languageName = "English",
        time = 0L,
        languageState = DOWNLOADED
    )

    TranslatorTheme {
        Surface {
            LanguageItem(
                title = english.languageName,
                sourceLanguageName = "English",
                languageState = DOWNLOADING,
                onItemClick = {},
                onItemActionClick = {}
            )
        }
    }
}

@Composable
fun LanguageItem(
    modifier: Modifier = Modifier,
    title: String,
    sourceLanguageName: String,
    languageState: LanguageState,
    onItemClick: () -> Unit,
    onItemActionClick: () -> Unit
) {
    val isChecked = title == sourceLanguageName
    val color = MaterialTheme.colorScheme.run {
        if (isChecked) secondaryContainer else background
    }
    val iconColor = MaterialTheme.colorScheme.run {
        if (isChecked) onSecondaryContainer else primary
    }
    val textColor = MaterialTheme.colorScheme.run {
        if (isChecked) onSecondaryContainer else onBackground
    }

    ConstraintLayout(
        modifier = Modifier
            .height(48.dp)
            .fillMaxWidth()
            .padding(horizontal = 8.dp)
            .background(color, MaterialTheme.shapes.extraLarge)
            .clip(MaterialTheme.shapes.extraLarge)
            .clickable { onItemClick() }
            .then(modifier)
    ) {
        val (leading, text, trailing) = createRefs()

        val startGuideline = createGuidelineFromStart(48.dp)
        val endGuideline = createGuidelineFromEnd(48.dp)

        if (isChecked) {
            Icon(
                imageVector = Icons.Filled.Check,
                tint = iconColor,
                contentDescription = null,
                modifier = Modifier.constrainAs(leading) {
                    top.linkTo(parent.top)
                    bottom.linkTo(parent.bottom)
                    start.linkTo(parent.start)
                    end.linkTo(startGuideline)
                }
            )
        }

        Text(
            color = textColor,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
            style = MaterialTheme.typography.labelLarge,
            text = title,
            modifier = Modifier.constrainAs(text) {
                top.linkTo(parent.top)
                bottom.linkTo(parent.bottom)
                start.linkTo(startGuideline, margin = 8.dp)
                end.linkTo(endGuideline, margin = 8.dp)
                width = Dimension.fillToConstraints
            }
        )

        ItemTrailing(
            Modifier.constrainAs(trailing) {
                top.linkTo(parent.top)
                bottom.linkTo(parent.bottom)
                start.linkTo(endGuideline)
                end.linkTo(parent.end)
            }, languageState, iconColor, onItemActionClick
        )
    }
}

@Composable
fun ItemTrailing(
    modifier: Modifier = Modifier,
    languageState: LanguageState,
    iconColor: Color,
    onClick: () -> Unit
) {
    when (languageState) {
        NONE -> {}
        AUTO -> Icon(Icons.Outlined.AutoAwesome, null, modifier, iconColor)
        DOWNLOADING -> CircularProgressIndicator(modifier, iconColor)
        SUPPORTED, DOWNLOADED -> {
            IconButton(
                modifier = modifier,
                onClick = onClick,
                content = {
                    Icon(
                        Icons.Outlined.run {
                            if (languageState == DOWNLOADED) Delete else FileDownload
                        }, null, tint = iconColor
                    )
                }
            )
        }
    }
}

Code of the LanguageViewModel.Kt

class LanguageViewModel @AssistedInject constructor(
    @Assisted val selectedTypeArgs: String,
    private val repository: Repository,
    private val dataStoreManager: DataStoreManager
) : ViewModel() {

    private val defaultAuto = Language(
        languageId = "auto",
        languageName = "Detect language",
        time = 0L,
        languageState = LanguageState.AUTO
    )

    val defaultSource = Language(
        languageId = "en",
        languageName = "English",
        time = 0L,
        languageState = LanguageState.DOWNLOADED
    )

    val defaultTarget = Language(
        languageId = "fr",
        languageName = "French",
        time = 0L,
        languageState = LanguageState.SUPPORTED
    )

    // Find whether the request came from source or target
    private val isFromSource = selectedTypeArgs == SelectedType.SOURCE.name

    // Get search results. if it is empty then get full list
    private val searchTerm: MutableStateFlow<String> = MutableStateFlow("")
    fun setSearchTerm(string: String) {
        searchTerm.value = string
    }

    /** Find the selected languages */
    val sourceLanguage = dataStoreManager.readValue(if (isFromSource) sourceKey else targetKey)
        .mapNotNull { getLanguageById(it) }
        .flowOn(Dispatchers.IO)

    val targetLanguage = dataStoreManager.readValue(if (isFromSource) targetKey else sourceKey)
        .mapNotNull { getLanguageById(it) }
        .flowOn(Dispatchers.IO)

    private suspend fun getLanguageById(languageId: String?): Language? {
        return languageId?.let {
            if (it == "auto") defaultAuto else repository.findLanguageById(it)
        }
    }

    val languageList = searchTerm.flatMapLatest {
        if (it.isNotBlank()) findLanguageByName(it) else findLanguagesWithRecent()
    }.flowOn(Dispatchers.IO).cachedIn(viewModelScope)

    private fun findLanguagesWithRecent(): Flow<PagingData<ItemLanguageAdapter>> {
        return repository.findLanguagesWithRecent().mapLatest { pagingData ->
            pagingData
                .let { if (isFromSource) it.insertHeaderItem(item = Item(defaultAuto)) else it }
                .insertHeaderItem(item = Divider())
                //.addBannerAds(totalAdCount = 2, isPremium = false) { count -> BannerAd(count) }
        }.flowOn(Dispatchers.IO)
    }

    private fun findLanguageByName(languageName: String): Flow<PagingData<ItemLanguageAdapter>> {
        return repository.findLanguageByName(languageName).mapLatest {
            it.insertHeaderItem(item = Divider())
        }.flowOn(Dispatchers.IO)
    }

    fun swapLanguage(isFromSource: Boolean, sourceLanguageId: String, targetLanguageId: String) {
        viewModelScope.launch(Dispatchers.IO) {
            dataStoreManager.dataStore.edit {
                it[if (isFromSource) targetKey else sourceKey] = sourceLanguageId
                it[if (isFromSource) sourceKey else targetKey] = targetLanguageId
            }
        }
    }

    fun saveLanguage(isFromSource: Boolean, languageId: String) {
        viewModelScope.launch(Dispatchers.IO) {
            dataStoreManager.storeValue(
                key = if (isFromSource) sourceKey else targetKey,
                value = languageId
            )
        }
    }

    fun downloadLanguage(
        languageId: String,
        isWiFiRequired: Boolean,
        onSuccessListener: OnSuccessListener<in Void>,
        onFailureListener: OnFailureListener,
    ) {
        updateLanguageState(
            languageId = languageId,
            languageState = DOWNLOADING
        )

        repository.downloadModel(
            languageId = languageId,
            isWiFiRequired = isWiFiRequired,
            onSuccessListener = {
                updateLanguageState(
                    languageId = languageId,
                    languageState = DOWNLOADED
                )
                onSuccessListener.onSuccess(it)
            },
            onFailureListener = {
                updateLanguageState(
                    languageId = languageId,
                    languageState = SUPPORTED
                )
                onFailureListener.onFailure(it)
            }
        )
    }

    fun deleteLanguage(
        languageId: String,
        onSuccessListener: OnSuccessListener<in Void>,
        onFailureListener: OnFailureListener,
    ) {
        repository.removeLanguageModel(
            languageId = languageId,
            onSuccessListener = {
                updateLanguageState(
                    languageId = languageId,
                    languageState = SUPPORTED
                )
                onSuccessListener.onSuccess(it)
            },
            onFailureListener = {
                updateLanguageState(
                    languageId = languageId,
                    languageState = DOWNLOADED
                )
                onFailureListener.onFailure(it)
            }
        )
    }

    private fun updateLanguageState(languageId: String, languageState: LanguageState) {
        viewModelScope.launch(Dispatchers.IO) {
            repository.updateLanguageState(languageId, languageState)
        }
    }

    @AssistedFactory
    interface Factory {
        fun create(selectedType: String): LanguageViewModel
    }

    companion object {
        fun provideFactory(
            factory: Factory,
            selectedType: String,
        ): ViewModelProvider.Factory {

            return object : ViewModelProvider.Factory {

                @Suppress("UNCHECKED_CAST")
                override fun <T : ViewModel> create(modelClass: Class<T>): T {
                    return factory.create(selectedType) as T
                }
            }
        }
    }
}

@EntryPoint
@InstallIn(ActivityComponent::class)
interface ViewModelFactoryProvider {

    fun languageViewModelFactory(): LanguageViewModel.Factory
}
0

There are 0 best solutions below