feat(chat-room-ui): ChatMessageAdapter 구현
This commit is contained in:
@@ -0,0 +1,390 @@
|
|||||||
|
/*
|
||||||
|
* 보이스온 - 채팅방 메시지 어댑터 (4.2 ViewHolder 구현)
|
||||||
|
* - 4가지 ViewType 지원
|
||||||
|
* - ViewHolder 바인딩 로직 구현: 시간 포맷팅, 상태 표시, 이미지 로딩, 그룹화 처리, 타이핑 애니메이션
|
||||||
|
*/
|
||||||
|
package kr.co.vividnext.sodalive.chat.talk.room
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.text.TextUtils
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.animation.Animation
|
||||||
|
import android.view.animation.AnimationUtils
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.LayoutRes
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import coil.load
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding
|
||||||
|
import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding
|
||||||
|
import kr.co.vividnext.sodalive.databinding.ItemChatUserMessageBinding
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채팅 리스트 아이템 타입 정의 (UI 렌더링을 위한 래퍼)
|
||||||
|
*/
|
||||||
|
sealed class ChatListItem {
|
||||||
|
data class UserMessage(val data: ChatMessage) : ChatListItem()
|
||||||
|
data class AiMessage(val data: ChatMessage, val displayName: String? = null) : ChatListItem()
|
||||||
|
data class Notice(val text: String) : ChatListItem()
|
||||||
|
object TypingIndicator : ChatListItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VIEW_TYPE_USER_MESSAGE = 1
|
||||||
|
const val VIEW_TYPE_AI_MESSAGE = 2
|
||||||
|
const val VIEW_TYPE_NOTICE = 3
|
||||||
|
const val VIEW_TYPE_TYPING_INDICATOR = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
private val items: MutableList<ChatListItem> = mutableListOf()
|
||||||
|
|
||||||
|
// Notice 접기 상태 (간단 전역 토글)
|
||||||
|
private var isNoticeCollapsed: Boolean = false
|
||||||
|
|
||||||
|
// 타이핑 인디케이터 표시 상태
|
||||||
|
private var isTypingVisible: Boolean = false
|
||||||
|
|
||||||
|
private fun findTypingIndicatorIndex(): Int {
|
||||||
|
return items.indexOfLast { it is ChatListItem.TypingIndicator }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타이핑 인디케이터를 목록 하단에 표시한다.
|
||||||
|
* 이미 표시 중이면 중복 삽입하지 않는다.
|
||||||
|
*/
|
||||||
|
fun showTypingIndicator() {
|
||||||
|
if (isTypingVisible) return
|
||||||
|
// 목록의 마지막에 추가
|
||||||
|
items.add(ChatListItem.TypingIndicator)
|
||||||
|
isTypingVisible = true
|
||||||
|
notifyItemInserted(items.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타이핑 인디케이터를 숨긴다(제거).
|
||||||
|
*/
|
||||||
|
fun hideTypingIndicator() {
|
||||||
|
val index = findTypingIndicatorIndex()
|
||||||
|
if (index >= 0) {
|
||||||
|
items.removeAt(index)
|
||||||
|
isTypingVisible = false
|
||||||
|
notifyItemRemoved(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
fun setItems(newItems: List<ChatListItem>) {
|
||||||
|
items.clear()
|
||||||
|
items.addAll(newItems)
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addItem(item: ChatListItem) {
|
||||||
|
items.add(item)
|
||||||
|
notifyItemInserted(items.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = items.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
return when (items[position]) {
|
||||||
|
is ChatListItem.UserMessage -> VIEW_TYPE_USER_MESSAGE
|
||||||
|
is ChatListItem.AiMessage -> VIEW_TYPE_AI_MESSAGE
|
||||||
|
is ChatListItem.Notice -> VIEW_TYPE_NOTICE
|
||||||
|
is ChatListItem.TypingIndicator -> VIEW_TYPE_TYPING_INDICATOR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return when (viewType) {
|
||||||
|
VIEW_TYPE_USER_MESSAGE ->
|
||||||
|
UserMessageViewHolder(
|
||||||
|
ItemChatUserMessageBinding.inflate(
|
||||||
|
inflater,
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VIEW_TYPE_AI_MESSAGE ->
|
||||||
|
AiMessageViewHolder(
|
||||||
|
ItemChatAiMessageBinding.inflate(
|
||||||
|
inflater,
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VIEW_TYPE_TYPING_INDICATOR ->
|
||||||
|
TypingIndicatorViewHolder(
|
||||||
|
ItemChatTypingIndicatorBinding.inflate(
|
||||||
|
inflater,
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VIEW_TYPE_NOTICE -> {
|
||||||
|
// 전용 레이아웃이 아직 없으므로 기본 텍스트 아이템으로 구현하고 접기 기능 지원
|
||||||
|
NoticeMessageViewHolder(inflateAndroidSimpleItem(parent))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Unknown viewType: $viewType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
// 그룹화 판정: 같은 발신자 연속 여부와 그룹의 마지막 여부 계산
|
||||||
|
fun isSameSender(prev: ChatListItem?, curr: ChatListItem?): Boolean {
|
||||||
|
if (prev == null || curr == null) return false
|
||||||
|
val p = when (prev) {
|
||||||
|
is ChatListItem.UserMessage -> prev.data.mine
|
||||||
|
is ChatListItem.AiMessage -> prev.data.mine
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
val c = when (curr) {
|
||||||
|
is ChatListItem.UserMessage -> curr.data.mine
|
||||||
|
is ChatListItem.AiMessage -> curr.data.mine
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
return p == c
|
||||||
|
}
|
||||||
|
|
||||||
|
val currItem = items[position]
|
||||||
|
val prevItem = if (position > 0) items[position - 1] else null
|
||||||
|
val nextItem = if (position < items.lastIndex) items[position + 1] else null
|
||||||
|
val grouped = isSameSender(prevItem, currItem)
|
||||||
|
val isLastInGroup = !isSameSender(currItem, nextItem)
|
||||||
|
|
||||||
|
when (holder) {
|
||||||
|
is UserMessageViewHolder -> {
|
||||||
|
val item = currItem as ChatListItem.UserMessage
|
||||||
|
holder.bind(item.data, showTime = isLastInGroup, isGrouped = grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AiMessageViewHolder -> {
|
||||||
|
val item = currItem as ChatListItem.AiMessage
|
||||||
|
holder.bind(
|
||||||
|
item.data,
|
||||||
|
item.displayName,
|
||||||
|
isGrouped = grouped,
|
||||||
|
showTime = isLastInGroup
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NoticeMessageViewHolder -> {
|
||||||
|
val item = currItem as ChatListItem.Notice
|
||||||
|
holder.bind(item.text, isNoticeCollapsed) {
|
||||||
|
isNoticeCollapsed = !isNoticeCollapsed
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is TypingIndicatorViewHolder -> {
|
||||||
|
holder.bind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// region ViewHolders
|
||||||
|
|
||||||
|
/** 사용자 메시지 뷰홀더: 시간 포맷팅, 상태(투명도) 표시 */
|
||||||
|
class UserMessageViewHolder(
|
||||||
|
private val binding: ItemChatUserMessageBinding
|
||||||
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
fun bind(data: ChatMessage, showTime: Boolean, isGrouped: Boolean) {
|
||||||
|
binding.tvMessage.text = data.message
|
||||||
|
binding.tvTime.text = formatMessageTime(data.createdAt)
|
||||||
|
binding.tvTime.visibility = if (showTime) View.VISIBLE else View.INVISIBLE
|
||||||
|
|
||||||
|
// 상태에 따른 시각적 피드백: 전송중은 약간 투명, 실패는 더 투명
|
||||||
|
val alpha = when (data.status) {
|
||||||
|
MessageStatus.SENDING -> 0.6f
|
||||||
|
MessageStatus.FAILED -> 0.4f
|
||||||
|
MessageStatus.SENT -> 1.0f
|
||||||
|
}
|
||||||
|
binding.messageContainer.alpha = alpha
|
||||||
|
|
||||||
|
// 그룹 내부 간격 최소화 (상단 패딩 축소)
|
||||||
|
adjustTopPadding(isGrouped)
|
||||||
|
|
||||||
|
// 접근성: 상태 포함 설명
|
||||||
|
val statusDesc = when (data.status) {
|
||||||
|
MessageStatus.SENDING -> "전송 중"
|
||||||
|
MessageStatus.FAILED -> "전송 실패"
|
||||||
|
MessageStatus.SENT -> "전송 완료"
|
||||||
|
}
|
||||||
|
|
||||||
|
val timeDesc = if (showTime) {
|
||||||
|
binding.tvTime.text
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.root.contentDescription =
|
||||||
|
"내 메시지 ${binding.tvMessage.text}, $statusDesc, $timeDesc"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adjustTopPadding(isGrouped: Boolean) {
|
||||||
|
val density = binding.root.resources.displayMetrics.density
|
||||||
|
val top = if (isGrouped) (2 * density).toInt() else (6 * density).toInt()
|
||||||
|
val bottom = (6 * density).toInt()
|
||||||
|
val start = binding.root.paddingStart
|
||||||
|
val end = binding.root.paddingEnd
|
||||||
|
binding.root.setPaddingRelative(start, top, end, bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 메시지 뷰홀더: 이미지 로딩, 그룹화 처리, 시간 포맷팅 */
|
||||||
|
class AiMessageViewHolder(
|
||||||
|
private val binding: ItemChatAiMessageBinding
|
||||||
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
fun bind(data: ChatMessage, displayName: String?, isGrouped: Boolean, showTime: Boolean) {
|
||||||
|
binding.tvMessage.text = data.message
|
||||||
|
binding.tvTime.text = formatMessageTime(data.createdAt)
|
||||||
|
binding.tvTime.visibility = if (showTime) View.VISIBLE else View.INVISIBLE
|
||||||
|
|
||||||
|
// 그룹화: isGrouped가 true면 프로필/이름 숨김
|
||||||
|
binding.ivProfile.visibility = if (isGrouped) View.INVISIBLE else View.VISIBLE
|
||||||
|
binding.tvName.visibility = if (isGrouped) View.GONE else View.VISIBLE
|
||||||
|
if (!isGrouped) {
|
||||||
|
binding.tvName.text = displayName ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로필 이미지 로딩 (Coil)
|
||||||
|
if (binding.ivProfile.isVisible) {
|
||||||
|
binding.ivProfile.load(data.profileImageUrl) {
|
||||||
|
placeholder(R.drawable.ic_placeholder_profile)
|
||||||
|
error(R.drawable.ic_placeholder_profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 내부 간격 최소화 (상단 패딩 축소)
|
||||||
|
adjustTopPadding(isGrouped)
|
||||||
|
|
||||||
|
// 접근성
|
||||||
|
itemView.contentDescription = buildString {
|
||||||
|
if (!isGrouped && !binding.tvName.text.isNullOrEmpty()) {
|
||||||
|
append(binding.tvName.text)
|
||||||
|
append(" ")
|
||||||
|
}
|
||||||
|
append("메시지 ")
|
||||||
|
append(binding.tvMessage.text)
|
||||||
|
if (showTime) {
|
||||||
|
append(", ")
|
||||||
|
append(binding.tvTime.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adjustTopPadding(isGrouped: Boolean) {
|
||||||
|
val density = itemView.resources.displayMetrics.density
|
||||||
|
val top = if (isGrouped) (2 * density).toInt() else (6 * density).toInt()
|
||||||
|
val bottom = (6 * density).toInt()
|
||||||
|
val start = itemView.paddingStart
|
||||||
|
val end = itemView.paddingEnd
|
||||||
|
itemView.setPaddingRelative(start, top, end, bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 안내 메시지 뷰홀더: 텍스트 표시 + 간단한 접기/펼치기 */
|
||||||
|
class NoticeMessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
private val textView: TextView = itemView.findViewById(android.R.id.text1)
|
||||||
|
fun bind(text: String, collapsed: Boolean, onToggle: () -> Unit) {
|
||||||
|
textView.text = text
|
||||||
|
// 접힘 상태: 한 줄 + 말줄임표, 펼침 상태: 여러 줄
|
||||||
|
if (collapsed) {
|
||||||
|
textView.maxLines = 1
|
||||||
|
textView.ellipsize = TextUtils.TruncateAt.END
|
||||||
|
} else {
|
||||||
|
textView.maxLines = 10_000
|
||||||
|
textView.ellipsize = null
|
||||||
|
}
|
||||||
|
itemView.setOnClickListener { onToggle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 타이핑 인디케이터 뷰홀더: 점 애니메이션 시작/정지 */
|
||||||
|
class TypingIndicatorViewHolder(
|
||||||
|
private val binding: ItemChatTypingIndicatorBinding
|
||||||
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
fun bind() {
|
||||||
|
startTypingAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startTypingAnimation() {
|
||||||
|
val context = binding.root.context
|
||||||
|
val anim1: Animation = AnimationUtils.loadAnimation(
|
||||||
|
context,
|
||||||
|
R.anim.typing_dots_animation
|
||||||
|
)
|
||||||
|
|
||||||
|
val anim2: Animation = AnimationUtils.loadAnimation(
|
||||||
|
context,
|
||||||
|
R.anim.typing_dots_animation
|
||||||
|
).apply { startOffset = 200 }
|
||||||
|
|
||||||
|
val anim3: Animation = AnimationUtils.loadAnimation(
|
||||||
|
context,
|
||||||
|
R.anim.typing_dots_animation
|
||||||
|
).apply { startOffset = 400 }
|
||||||
|
|
||||||
|
binding.dot1.startAnimation(anim1)
|
||||||
|
binding.dot2.startAnimation(anim2)
|
||||||
|
binding.dot3.startAnimation(anim3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopTypingAnimation() {
|
||||||
|
binding.dot1.clearAnimation()
|
||||||
|
binding.dot2.clearAnimation()
|
||||||
|
binding.dot3.clearAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region Util
|
||||||
|
private fun inflateAndroidSimpleItem(
|
||||||
|
parent: ViewGroup,
|
||||||
|
@LayoutRes layout: Int = android.R.layout.simple_list_item_1
|
||||||
|
): View {
|
||||||
|
return LayoutInflater.from(parent.context)
|
||||||
|
.inflate(layout, parent, false)
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
|
||||||
|
super.onViewAttachedToWindow(holder)
|
||||||
|
if (holder is TypingIndicatorViewHolder) {
|
||||||
|
holder.bind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
|
||||||
|
if (holder is TypingIndicatorViewHolder) {
|
||||||
|
holder.stopTypingAnimation()
|
||||||
|
}
|
||||||
|
super.onViewDetachedFromWindow(holder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// region file-level util
|
||||||
|
private fun formatMessageTime(timestamp: Long): String {
|
||||||
|
return try {
|
||||||
|
val df = SimpleDateFormat("a h:mm", Locale.getDefault())
|
||||||
|
df.format(Date(timestamp))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
Reference in New Issue
Block a user