androidandroid-jetpack-compose

LazyColumn not scrolling smoothly with keyboard


I'm trying to develope an AI chat interface in Jetpack Compose, similar in functionality to DeepSeek. I'm using a LazyColumn to display the conversation history. When the soft keyboard appears, I want the LazyColumn to scroll upwards smoothly and synchronously with the keyboard's animation, keeping the latest messages visible above the keyboard.

Currently, the keyboard is obscuring the bottom part of the LazyColumn, including the most recent messages and the input field itself. The content only adjusts after the keyboard is fully visible, which creates a poor user experience. I'm looking for a smooth, animated transition.

What I've tried

I try to listen the change of liststate, but debounce(50) makes LazyColumn scroll not smoothly. The following is some of my code

@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, FlowPreview::class)
    @Composable
    fun Chat(chatModel: AIChatModel = AIChatModel(), modifier: Modifier = Modifier) {

        val listState = rememberLazyListState()
        var lastKnownLastVisibleItemIndex by remember { mutableIntStateOf(aiChatModel.chatHistory.size-1) }
        var lastKnownLastVisibleItemOffset by remember { mutableIntStateOf(0) }
        var lastKnownHeight by remember { mutableIntStateOf(0) }


        Scaffold(
            modifier = modifier,
            topBar = {
                TopAppBar(
                    title = {
                        Column(modifier= Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
                            Text("DeepSeek")
                        }
                    }
                )
            },
        ) { padding ->
            Column(modifier= Modifier
                .padding(padding)
                .fillMaxSize()) {
                // listening listState
                LaunchedEffect(key1 = listState) {
                    println("listState changed")
                    snapshotFlow {
                        val visibleItemsInfo = listState.layoutInfo.visibleItemsInfo
                        if (visibleItemsInfo.isNotEmpty()) {
                            val lastVisibleItem = visibleItemsInfo.last()
                            val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
                            if (viewportHeight != lastKnownHeight) {
                                println("viewportHeight: $viewportHeight")
                                Triple(lastKnownLastVisibleItemIndex, lastKnownLastVisibleItemOffset, viewportHeight)
                            }else {
                                println("lastVisibleItemIndex: ${lastVisibleItem.index}, lastVisibleItemOffset: ${lastVisibleItem.offset}")
                                Triple(lastVisibleItem.index, lastVisibleItem.offset, lastKnownHeight)
                            }
                        } else {
                            Triple(lastKnownLastVisibleItemIndex, lastKnownLastVisibleItemOffset, lastKnownHeight)
                        }
                    }
                        .filter {
                            it.first != lastKnownLastVisibleItemIndex || it.second != lastKnownLastVisibleItemOffset || it.third != lastKnownHeight
                        }
                        .debounce(50)
                        .collect { (index, offset, height) ->

                            if(height != lastKnownHeight && index > 0){
                                listState.scrollToItem(lastKnownLastVisibleItemIndex, -lastKnownLastVisibleItemOffset+lastKnownHeight-height)
                                println("scrollToItem: $lastKnownLastVisibleItemIndex, ${lastKnownLastVisibleItemOffset+lastKnownHeight-height}")
                            } 

                            lastKnownLastVisibleItemIndex = index
                            lastKnownLastVisibleItemOffset = offset
                            lastKnownHeight = height
                        }
                }

                LazyColumn(
                    modifier = Modifier
                        .fillMaxWidth()
                        .weight(1f),
                    state = listState
                ) {
                    items(chatModel.chatHistory) { message ->
                        MessageBubble(message = message)
                    }
                    if (chatModel.output.isNotEmpty()) {
                        item {
                            MessageBubble(role = "assistant", content = chatModel.output)
                        }
                    }

                }
                OutlinedTextField(
                    value = chatModel.input,
                    onValueChange = {
                        chatModel.input = it
                    },
                    placeholder = {
                        Text("Enter text")
                    },
                    maxLines = 6,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp)
                        .padding(end = 0.dp)
                )
                Row(modifier = Modifier
                    .imePadding()
                    .fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically,
                    horizontalArrangement = Arrangement.End) {
                    IconButton(
                        modifier = Modifier.padding(bottom = 8.dp, start = 8.dp),
                        onClick = {
                            chatModel.clearHistory()
                        },
                        enabled = chatModel.chatHistory.isNotEmpty()
                    ) {
                        Icon(
                            imageVector = Icons.Outlined.Delete,
                            contentDescription = "Clear",
                        )
                    }
                    if (chatModel.isEnding) {
                        IconButton(
                            modifier = Modifier.padding(bottom = 8.dp, end = 16.dp, start = 8.dp),
                            onClick = {
                                chatModel.sendMessage()
                            },
                            enabled = chatModel.input.isNotEmpty() && chatModel.input.isNotBlank(),
                        ) {
                            Icon(
                                imageVector = Icons.AutoMirrored.Filled.Send,
                                contentDescription = "Send",
                            )
                        }
                    }else{
                        IconButton(
                            modifier = Modifier.padding(bottom = 8.dp, end = 16.dp, start = 8.dp),
                            onClick = {
                                chatModel.endResponse()
                            },
                        ) {
                            Icon(
                                imageVector = Icons.Outlined.Add,
                                contentDescription = "Send",
                            )
                        }
                    }

                }
            }
        }
    }

Solution

  • I never managed to get a clean programmatic scroll when the keyboard was expanded, so a common approach is to set reverseLayout = true on the LazyColumn. That way, the LazyColumn does not need to be scrolled after the keyboard opens.

    Please try the following sample code:

    @Composable
    fun Chat() {
    
        val lazyColumnState = rememberLazyListState()
        var textFieldText by remember { mutableStateOf("") }
        val chatList = remember {
            mutableStateListOf(
                "I appreciate you seeing my whole potential.\nBe assured that my gratitude is on the same level as your arrogance.",
                "Aw you're such a nice guy... NOT",
                "I did not mean it in a mean way :)",
                "Bro that's so mean!",
                "The only thing that's boring is having a conversation with you...",
                "Bro why do you do this, it's so boring!",
                "I am learning Jetpack Compose, and you?",
                "I am fine, thanks.",
                "Hello, how are you?"
            )
        }
    
        LaunchedEffect(chatList.size) {
            lazyColumnState.animateScrollToItem(0)
        }
    
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Bottom
        ) {
            LazyColumn(
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(1f),
                state = lazyColumnState,
                reverseLayout = true,
                contentPadding = PaddingValues(16.dp),
            ) {
                itemsIndexed(
                    items = chatList
                ) { chatIndex, chatMessage ->
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = if (chatIndex % 2 == chatList.size % 2) Arrangement.Start else Arrangement.End  // simulate chat conversation
                    ) {
                        Text(
                            text = chatMessage,
                            textAlign = if (chatIndex % 2 == chatList.size % 2) TextAlign.Start else TextAlign.End,
                            modifier = Modifier.padding(10.dp)
                        )
                    }
                }
            }
            Row {
                TextField(
                    modifier = Modifier.weight(1f),
                    value = textFieldText,
                    onValueChange = { inputTextField ->
                        textFieldText = inputTextField
                    }
                )
                IconButton(
                    onClick = {
                        chatList.add(0, textFieldText)  // insert new Messages at the beginning of the List
                        textFieldText = ""
                    }
                ) {
                    Icon(Icons.AutoMirrored.Filled.Send, "")
                }
            }
        }
    }
    

    Output:

    Screen Recording