Почти плавный скрол чата
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -200,7 +200,9 @@ private fun EmojiContent(
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(8),
|
||||
state = scrollState,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
emojiCategories.forEach { category ->
|
||||
// Заголовок категории
|
||||
@@ -214,7 +216,7 @@ private fun EmojiContent(
|
||||
)
|
||||
}
|
||||
|
||||
items(category.emojis) { emoji ->
|
||||
items(category.emojis, key = { emoji -> emoji }) { emoji ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
@@ -362,9 +364,11 @@ private fun GifContent(
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.weight(1f).padding(4.dp),
|
||||
contentPadding = PaddingValues(4.dp)
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(displayGifs) { gif ->
|
||||
items(displayGifs, key = { gif -> gif.id }, contentType = { "gif" }) { gif ->
|
||||
val url = gif.files?.get("hd")?.get("gif")?.url
|
||||
?: gif.files?.get("sd")?.get("gif")?.url
|
||||
?: gif.file?.get("hd")?.get("gif")?.url
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
@@ -30,10 +29,10 @@ fun SwipeableMessageItem(
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val offsetX = remember { Animatable(0f) }
|
||||
val replyThreshold = with(density) { 60.dp.toPx() }
|
||||
val maxDrag = with(density) { 90.dp.toPx() }
|
||||
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var isTriggered by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
@@ -42,27 +41,18 @@ fun SwipeableMessageItem(
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
scope.launch {
|
||||
// We only allow swiping to the left (negative delta)
|
||||
val newOffset = (offsetX.value + delta).coerceIn(-maxDrag, 0f)
|
||||
offsetX.snapTo(newOffset)
|
||||
|
||||
if (newOffset <= -replyThreshold && !isTriggered) {
|
||||
isTriggered = true
|
||||
// Trigger haptic feedback if available?
|
||||
} else if (newOffset > -replyThreshold) {
|
||||
isTriggered = false
|
||||
}
|
||||
}
|
||||
// We only allow swiping to the left (negative delta)
|
||||
val newOffset = (offsetX + delta).coerceIn(-maxDrag, 0f)
|
||||
offsetX = newOffset
|
||||
|
||||
isTriggered = newOffset <= -replyThreshold
|
||||
},
|
||||
onDragStopped = {
|
||||
if (offsetX.value <= -replyThreshold) {
|
||||
if (offsetX <= -replyThreshold) {
|
||||
onReply()
|
||||
}
|
||||
scope.launch {
|
||||
offsetX.animateTo(0f)
|
||||
isTriggered = false
|
||||
}
|
||||
offsetX = 0f
|
||||
isTriggered = false
|
||||
}
|
||||
)
|
||||
) {
|
||||
@@ -72,7 +62,7 @@ fun SwipeableMessageItem(
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 16.dp)
|
||||
.size(36.dp)
|
||||
.alpha(((-offsetX.value) / replyThreshold).coerceIn(0f, 1f))
|
||||
.alpha(((-offsetX) / replyThreshold).coerceIn(0f, 1f))
|
||||
.clip(CircleShape)
|
||||
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -88,7 +78,7 @@ fun SwipeableMessageItem(
|
||||
// Message Content
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
|
||||
.offset { IntOffset(offsetX.roundToInt(), 0) }
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
content()
|
||||
|
||||
Reference in New Issue
Block a user