mirror of
https://github.com/msfjarvis/compose-lobsters
synced 2025-08-15 02:57:04 +05:30
feat(comments): remember comment collapsed state
Remember the comment expanded/collapsed state when scrolling. Previously, the state was remembered inside the `Node` composable. When the users scroll, and `Node` re-composes, the state was being reset. In this commit, we add the collapsed state into the `CommentNode`. This makes sure that the state is retained even when the UI is re-composed.
This commit is contained in:
parent
00c75588cd
commit
86400c352b
4 changed files with 101 additions and 45 deletions
|
@ -6,15 +6,15 @@
|
||||||
*/
|
*/
|
||||||
package dev.msfjarvis.claw.common.comments
|
package dev.msfjarvis.claw.common.comments
|
||||||
|
|
||||||
import dev.msfjarvis.claw.database.local.PostComments
|
|
||||||
import dev.msfjarvis.claw.model.Comment
|
import dev.msfjarvis.claw.model.Comment
|
||||||
|
|
||||||
internal class CommentNode(
|
internal data class CommentNode(
|
||||||
val comment: Comment,
|
val comment: Comment,
|
||||||
private var parent: CommentNode? = null,
|
private var parent: CommentNode? = null,
|
||||||
val children: MutableList<CommentNode> = mutableListOf(),
|
val children: MutableList<CommentNode> = mutableListOf(),
|
||||||
val isUnread: Boolean = false,
|
val isUnread: Boolean = false,
|
||||||
val indentLevel: Int,
|
val indentLevel: Int,
|
||||||
|
val isExpanded: Boolean = true,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun addChild(child: CommentNode) {
|
fun addChild(child: CommentNode) {
|
||||||
|
@ -50,6 +50,7 @@ internal class CommentNode(
|
||||||
if (children != other.children) return false
|
if (children != other.children) return false
|
||||||
if (isUnread != other.isUnread) return false
|
if (isUnread != other.isUnread) return false
|
||||||
if (indentLevel != other.indentLevel) return false
|
if (indentLevel != other.indentLevel) return false
|
||||||
|
if (isExpanded != other.isExpanded) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -59,37 +60,7 @@ internal class CommentNode(
|
||||||
result = 31 * result + children.hashCode()
|
result = 31 * result + children.hashCode()
|
||||||
result = 31 * result + isUnread.hashCode()
|
result = 31 * result + isUnread.hashCode()
|
||||||
result = 31 * result + indentLevel
|
result = 31 * result + indentLevel
|
||||||
|
result = 31 * result + isExpanded.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun createListNode(
|
|
||||||
comments: List<Comment>,
|
|
||||||
commentState: PostComments?,
|
|
||||||
): MutableList<CommentNode> {
|
|
||||||
val commentNodes = mutableListOf<CommentNode>()
|
|
||||||
val isUnread = { id: String -> commentState?.commentIds?.contains(id) == false }
|
|
||||||
|
|
||||||
for (i in comments.indices) {
|
|
||||||
if (comments[i].parentComment == null) {
|
|
||||||
commentNodes.add(
|
|
||||||
CommentNode(
|
|
||||||
comment = comments[i],
|
|
||||||
isUnread = isUnread(comments[i].shortId),
|
|
||||||
indentLevel = 1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
commentNodes.lastOrNull()?.let { commentNode ->
|
|
||||||
commentNode.addChild(
|
|
||||||
CommentNode(
|
|
||||||
comment = comments[i],
|
|
||||||
isUnread = isUnread(comments[i].shortId),
|
|
||||||
indentLevel = commentNode.indentLevel + 1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return commentNodes
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2024 Harsh Shandilya.
|
||||||
|
* Use of this source code is governed by an MIT-style
|
||||||
|
* license that can be found in the LICENSE file or at
|
||||||
|
* https://opensource.org/licenses/MIT.
|
||||||
|
*/
|
||||||
|
package dev.msfjarvis.claw.common.comments
|
||||||
|
|
||||||
|
import dev.msfjarvis.claw.database.local.PostComments
|
||||||
|
import dev.msfjarvis.claw.model.Comment
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
internal class CommentsHandler {
|
||||||
|
|
||||||
|
private val _listItems: MutableStateFlow<List<CommentNode>> = MutableStateFlow(emptyList())
|
||||||
|
val listItems: StateFlow<List<CommentNode>> = _listItems.asStateFlow()
|
||||||
|
|
||||||
|
fun createListNode(comments: List<Comment>, commentState: PostComments?) {
|
||||||
|
val commentNodes = mutableListOf<CommentNode>()
|
||||||
|
val isUnread = { id: String -> commentState?.commentIds?.contains(id) == false }
|
||||||
|
|
||||||
|
for (i in comments.indices) {
|
||||||
|
if (comments[i].parentComment == null) {
|
||||||
|
commentNodes.add(
|
||||||
|
CommentNode(
|
||||||
|
comment = comments[i],
|
||||||
|
isUnread = isUnread(comments[i].shortId),
|
||||||
|
indentLevel = 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
commentNodes.lastOrNull()?.let { commentNode ->
|
||||||
|
commentNode.addChild(
|
||||||
|
CommentNode(
|
||||||
|
comment = comments[i],
|
||||||
|
isUnread = isUnread(comments[i].shortId),
|
||||||
|
indentLevel = commentNode.indentLevel + 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_listItems.value = commentNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateListNode(shortId: String, isExpanded: Boolean) {
|
||||||
|
val listNode = _listItems.value.toMutableList()
|
||||||
|
val index = listNode.indexOfFirst { it.comment.shortId == shortId }
|
||||||
|
|
||||||
|
if (index != -1) {
|
||||||
|
val commentNode = listNode[index].copy(isExpanded = isExpanded)
|
||||||
|
listNode[index] = commentNode
|
||||||
|
|
||||||
|
_listItems.value = listNode.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -60,6 +60,7 @@ fun CommentsPage(
|
||||||
markSeenComments = markSeenComments,
|
markSeenComments = markSeenComments,
|
||||||
openUserProfile = openUserProfile,
|
openUserProfile = openUserProfile,
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
|
commentsHandler = CommentsHandler(),
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,12 @@ package dev.msfjarvis.claw.common.comments
|
||||||
|
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
@ -21,6 +27,7 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.systemBars
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
@ -28,9 +35,6 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
@ -39,6 +43,7 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.msfjarvis.claw.common.posts.PostActions
|
import dev.msfjarvis.claw.common.posts.PostActions
|
||||||
import dev.msfjarvis.claw.common.posts.Submitter
|
import dev.msfjarvis.claw.common.posts.Submitter
|
||||||
import dev.msfjarvis.claw.common.ui.ThemedRichText
|
import dev.msfjarvis.claw.common.ui.ThemedRichText
|
||||||
|
@ -48,6 +53,8 @@ import dev.msfjarvis.claw.model.UIPost
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.temporal.TemporalAccessor
|
import java.time.temporal.TemporalAccessor
|
||||||
|
|
||||||
|
private const val AnimationDuration = 100
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CommentsPageInternal(
|
internal fun CommentsPageInternal(
|
||||||
details: UIPost,
|
details: UIPost,
|
||||||
|
@ -57,10 +64,19 @@ internal fun CommentsPageInternal(
|
||||||
markSeenComments: (String, List<Comment>) -> Unit,
|
markSeenComments: (String, List<Comment>) -> Unit,
|
||||||
openUserProfile: (String) -> Unit,
|
openUserProfile: (String) -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
|
commentsHandler: CommentsHandler,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { commentsHandler.createListNode(details.comments, commentState) }
|
||||||
|
|
||||||
|
val onToggleExpandedState = { shortId: String, isExpanded: Boolean ->
|
||||||
|
commentsHandler.updateListNode(shortId, isExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val commentNodes = createListNode(details.comments, commentState)
|
val commentNodes by commentsHandler.listItems.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(key1 = commentNodes) {
|
LaunchedEffect(key1 = commentNodes) {
|
||||||
if (details.comments.isNotEmpty() && !commentState?.commentIds.isNullOrEmpty()) {
|
if (details.comments.isNotEmpty() && !commentState?.commentIds.isNullOrEmpty()) {
|
||||||
val unreadCount = details.comments.size - (commentState?.commentIds?.size ?: 0)
|
val unreadCount = details.comments.size - (commentState?.commentIds?.size ?: 0)
|
||||||
|
@ -96,8 +112,8 @@ internal fun CommentsPageInternal(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
commentNodes.forEach { node ->
|
items(items = commentNodes, key = { node -> node.comment.shortId }) { node ->
|
||||||
item(key = node.comment.shortId) { Node(node, htmlConverter, openUserProfile) }
|
Node(node, htmlConverter, openUserProfile, onToggleExpandedState)
|
||||||
}
|
}
|
||||||
|
|
||||||
item(key = "bottom_spacer") {
|
item(key = "bottom_spacer") {
|
||||||
|
@ -127,22 +143,30 @@ private fun Node(
|
||||||
node: CommentNode,
|
node: CommentNode,
|
||||||
htmlConverter: HTMLConverter,
|
htmlConverter: HTMLConverter,
|
||||||
openUserProfile: (String) -> Unit,
|
openUserProfile: (String) -> Unit,
|
||||||
|
onToggleExpandedState: (String, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
var isChildrenShown by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
NodeBox(
|
NodeBox(
|
||||||
node = node,
|
node = node,
|
||||||
isExpanded = isChildrenShown,
|
isExpanded = node.isExpanded,
|
||||||
htmlConverter = htmlConverter,
|
htmlConverter = htmlConverter,
|
||||||
openUserProfile,
|
openUserProfile,
|
||||||
modifier = modifier.clickable(onClick = { isChildrenShown = !isChildrenShown }),
|
modifier =
|
||||||
|
modifier.clickable(
|
||||||
|
onClick = { onToggleExpandedState(node.comment.shortId, !node.isExpanded) }
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isChildrenShown) {
|
AnimatedVisibility(
|
||||||
|
visible = node.isExpanded,
|
||||||
|
enter = fadeIn(tween(AnimationDuration)) + expandVertically(tween(AnimationDuration)),
|
||||||
|
exit = fadeOut(tween(AnimationDuration)) + shrinkVertically(tween(AnimationDuration)),
|
||||||
|
) {
|
||||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
node.children.forEach { model -> Node(model, htmlConverter, openUserProfile) }
|
node.children.forEach { model ->
|
||||||
|
Node(model, htmlConverter, openUserProfile, onToggleExpandedState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue