feat(chat-room-ui): ChatMessageAdapter 구현

This commit is contained in:
2025-08-13 21:08:01 +09:00
parent 9bb8dcd881
commit 45b76da1e8

View File

@@ -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