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