Почти плавный скрол чата

This commit is contained in:
Халимов Рустам
2026-04-28 00:16:52 +03:00
parent d9a20e40b0
commit 46c22300e9
15 changed files with 145 additions and 110 deletions
@@ -152,39 +152,46 @@ fun ChatDetailScreen(
val items = mutableListOf<MessageListItem>()
if (state.messages.isEmpty()) return@remember items
// Группируем от новых к старым (Index 0 = bottom)
// Группируем от старых к новым (для normal layout)
val reversedMessages = state.messages.reversed()
var i = 0
while (i < state.messages.size) {
val isoDate = state.messages[i].createdAt
// Получаем дату в локальном часовом поясе (например "2024-04-15")
while (i < reversedMessages.size) {
val isoDate = reversedMessages[i].createdAt
val localDate = viewModel.getLocalDateString(isoDate)
val dayMessages = mutableListOf<chats.domain.model.Message>()
// Собираем все сообщения за этот локальный день
while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) {
dayMessages.add(state.messages[i])
while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) {
dayMessages.add(reversedMessages[i])
i++
}
// Добавляем сообщения в инвертированном порядке
// Добавляем заголовок даты
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
// Добавляем сообщения
dayMessages.forEach { msg ->
items.add(MessageListItem.MessageItem(msg))
}
// Добавляем заголовок (над группой сообщений)
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
}
items
}
// Выносим allChatMedia наружу - до LazyColumn
val allChatMedia = remember(state.messages) {
state.messages.flatMap { msg -> msg.media.map { it to msg.id } }
}
// Отслеживаем текущую дату для плавающего заголовка
val floatingDate by remember {
derivedStateOf {
val firstIndex = listState.firstVisibleItemIndex
// Не показываем плашку на самом первом элементе (там и так дата)
// И показываем только если мы прокрутили хотя бы немного
if (firstIndex > 0 && firstIndex < listItems.size) {
val item = listItems[firstIndex]
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) return@derivedStateOf null
// Находим первый видимый элемент
val firstVisibleItemIndex = visibleItems.first().index
if (firstVisibleItemIndex > 0 && firstVisibleItemIndex < listItems.size) {
val item = listItems[firstVisibleItemIndex]
if (item is MessageListItem.MessageItem) {
viewModel.formatDateHeader(item.message.createdAt)
} else if (item is MessageListItem.DateHeader) {
@@ -195,67 +202,77 @@ fun ChatDetailScreen(
}
// Показывать ли кнопку "вниз"
// Она должна появляться если мы не в самом низу
// Проверка видимости кнопки скролла вниз
val showScrollToBottom by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 5
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val totalCount = layoutInfo.totalItemsCount
if (totalCount == 0) return@derivedStateOf false
// Проверяем, видим ли мы последний элемент
val lastVisibleIndex = visibleItems.lastOrNull()?.index ?: 0
lastVisibleIndex < totalCount - 1
}
}
// 4. Авто-скролл к новым сообщениям
val firstMessageId = state.messages.firstOrNull()?.id
LaunchedEffect(firstMessageId) {
if (firstMessageId != null) {
// Если мы уже внизу (индекс 0) или это наше сообщение - скроллим вниз
val isAtBottom = listState.firstVisibleItemIndex <= 1
val isMyMessage = state.messages.firstOrNull()?.senderId == viewModel.getCurrentUserId()
// Авто-скролл к новым сообщениям - только если мы уже внизу
val lastMessageId = state.messages.lastOrNull()?.id
LaunchedEffect(lastMessageId) {
if (lastMessageId != null && listItems.isNotEmpty()) {
val isAtBottom = listState.layoutInfo.run {
val visibleItems = visibleItemsInfo
val totalCount = totalItemsCount
if (totalCount == 0 || visibleItems.isEmpty()) return@run true
visibleItems.last().index >= totalCount - 2
}
if (isAtBottom || isMyMessage) {
listState.animateScrollToItem(0)
// Если мы внизу - помечаем сразу как прочитанное
if (isAtBottom && !isMyMessage) {
viewModel.markAsRead()
}
if (isAtBottom) {
listState.animateScrollToItem(listItems.size - 1)
}
}
}
// Автоматическая прокрутка и прочтение при инициализации и новых сообщениях
// Автоматическая прокрутка при инициализации
LaunchedEffect(listItems.size) {
if (listItems.isNotEmpty()) {
if (state.initialScrollIndex != null && state.initialScrollIndex != -1) {
// Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0
listState.scrollToItem(0)
viewModel.onInitialScrollDone()
viewModel.markAsRead()
}
listState.scrollToItem(listItems.size - 1)
viewModel.onInitialScrollDone()
viewModel.markAsRead()
}
}
// Подгрузка истории при прокрутке вверх
LaunchedEffect(listState, state.messages.size) {
snapshotFlow {
val lastIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalCount = listState.layoutInfo.totalItemsCount
lastIndex to totalCount
}
.collect { (lastVisibleIndex, totalCount) ->
if (totalCount > 0 && lastVisibleIndex >= totalCount - 20 && !state.isLoadingMore) {
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, total $totalCount")
viewModel.loadMoreMessages()
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo }
.collect { layoutInfo ->
val visibleItems = layoutInfo.visibleItemsInfo
val totalCount = layoutInfo.totalItemsCount
if (totalCount == 0 || visibleItems.isEmpty()) return@collect
val firstVisibleIndex = visibleItems.first().index
if (firstVisibleIndex < 5 && !state.isLoadingMore) {
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $firstVisibleIndex")
viewModel.loadMoreMessages()
}
}
}
}
val unreadCount = remember(state.messages) {
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
}
// Авто-прочитка при нахождении внизу списка (в reverseLayout это индекс 0)
LaunchedEffect(listState.isScrollInProgress, listState.firstVisibleItemIndex) {
if (!listState.isScrollInProgress && listState.firstVisibleItemIndex <= 1) {
viewModel.markAsRead()
// Авто-прочитка при нахождении внизу списка
LaunchedEffect(listState.isScrollInProgress) {
if (!listState.isScrollInProgress) {
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val totalCount = layoutInfo.totalItemsCount
if (totalCount > 0 && visibleItems.isNotEmpty()) {
val lastVisibleIndex = visibleItems.last().index
if (lastVisibleIndex >= totalCount - 2) {
viewModel.markAsRead()
}
}
}
}
@@ -338,21 +355,26 @@ fun ChatDetailScreen(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
reverseLayout = true,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val allChatMedia = state.messages.flatMap { msg ->
msg.media.map { it to msg.id }
}.reversed()
items(listItems, key = {
when(it) {
is MessageListItem.MessageItem -> it.message.id
is MessageListItem.DateHeader -> "date_${it.date}"
MessageListItem.UnreadSeparator -> "unread_separator"
items(
items = listItems,
key = { item ->
when(item) {
is MessageListItem.MessageItem -> "msg_${item.message.id}"
is MessageListItem.DateHeader -> "date_${item.date}"
MessageListItem.UnreadSeparator -> "unread_separator"
}
},
contentType = { item ->
when(item) {
is MessageListItem.MessageItem -> "message"
is MessageListItem.DateHeader -> "date_header"
MessageListItem.UnreadSeparator -> "unread_separator"
}
}
}) { item ->
) { item ->
when(item) {
is MessageListItem.MessageItem -> {
SwipeableMessageItem(
@@ -487,7 +509,7 @@ fun ChatDetailScreen(
.border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape)
.clickable {
scope.launch {
listState.animateScrollToItem(0)
listState.animateScrollToItem(listItems.size - 1)
}
},
contentAlignment = Alignment.Center
@@ -1032,6 +1054,7 @@ fun ForwardChatSelectionDialog(
)
}
@Immutable
sealed class MessageListItem {
data class MessageItem(val message: chats.domain.model.Message) : MessageListItem()
data class DateHeader(val date: String) : MessageListItem()