[Background]
저번글에는 채팅 리스트를 어떻게 커스텀을 진행할지에 대해서 한번 알아봤습니다.
이번 글에는 이어서 한번 채팅방을 어떻게 커스텀을 진행할 수 있는지에 대해서 자세하게 한번 알아보겠습니다.
그리고 이번에 샌드버드 uikit 3.9 버전이 나오면서 기존에 쓰던 방식에서 조금 바뀐 점이 있어서 한번 다뤄보겠습니다.
내용들이 어느 정도 처음 정리 글과 연과 되어 있으니 모르는 용어가 있다면 한번 확인해 보시면 좋을 것 같습니다.
https://jcodingcraft.tistory.com/2
이 글에 사용된 버전들
- Java 17
- Gradle 8.0.2
- kotlin 1.8.22
- SendBird UIKIt 3.9
[3.9에 바뀐 점]
UIKit 3.9 버전으로 들어오면서 바뀐 가장 큰 점은 UIKitFragmentFactory이 Deprecated 되어서 이제 Custom Fragment을 UIKit에 넘겨주는 방식이 달라졌다.
원래는 이렇게 Custom Fragment을 지정해 줬다
class CustomFragmentFactory : UIKitFragmentFactory() {
override fun newChannelFragment(channelUrl: String, args: Bundle): Fragment {
return ChannelFragment.Builder(channelUrl)
.setCustomFragment(CustomChannel())
.withArguments(args)
.build()
}
}
class BaseApplication : Application() {
...
SendbirdUIKit.setUIKitFragmentFactory(CustomFragmentFactory())
}
이제는 따로 FragmentProvider을 이용해서 클래스를 만들 필요 없이 바로 Application 클래스에서 지정해 줄 수 있다
class BaseApplication : Application() {
...
FragmentProviders.channelList = ChannelListFragmentProvider {
ChannelListFragment.Builder().withArguments(it).setUseHeader(true)
.setCustomFragment(CustomChannelList()).build()
}
FragmentProviders.channel = ChannelFragmentProvider { url, args ->
ChannelFragment.Builder(url).withArguments(args).setUseHeader(true)
.setCustomFragment(CustomChannelFragment()).build()
}
}
FragmentProvider는 싱글턴 클래스로 내부는 이렇게 되어 있다
object FragmentProvider {
var channelList = ChannelListFragmentProvider { args ->
ChannelListFragment.Builder().withArguments(args).setUseHeader(true).build()
}
}
channelList을 다시 선언을 해줄 때 setCustomFragment 함수를 이용하면 커스텀이 진행된 Fragment을 넣어줄 수 있다.
3.9로 업데이트된 지 얼마 안 돼서 그런지 아직 공식문서는 업데이트가 안 됐네요 ㅠ
이제 다시 본론으로 돌아가겠습니다
[Channel 구성]
ChannelActivity -> ChannelFragment -> ChannelModule 이렇게 이어지고
Module안에 Component가 총 4개로 나눠집니다.
ChannelHeaderComponent -> 채팅의 toolbar 부분
MessageListComponent -> 채팅의 메시지 부분
MessageInputComponent -> 채팅 메시지 넣는 부분
그리고 마지막으로 StatusComponent가 있습니다.
이 Component은 채팅을 첨 시작할 때 먼저 보이게 됩니다.
로딩중일 때 아니면 보여줄 메시지가 없을 때 보여주게 되는 화면입니다.
채팅 리스트보다 수정해야 할 부분이 더 많은데 한번 하나씩 살펴보겠습니다.
[커스터마이즈]
원래 제공되는 화면은 왼쪽인데 한번 온라인 상태까지 표시해 보고 메뉴도 추가하고
자체적인 기능도 한번 넣어보겠습니다.
자체적인 기능은 그냥 보여주기 위해서 숫자를 메시지로 보낼 수 있는 기능으로
잘 보이기 위해서 색도 다르게 설정했습니다.
시작하기 전에 미리 설정한 내용에 대해서 알려드리겠습니다
UIKitConfig.groupChannelConfig.enableTypingIndicator = true
UIKitConfig.groupChannelConfig.enableReactions = false
지금 Application 클래스에 설정된 내용은 두 가지가 있는데요
첫 번째 enableTypingIndicator 같은 경우에는 상대방이 타이핑 중인지 채팅 리스트에서 볼 수 있게 하는 설정으로 이렇게 보입니다.
is typing이라고 떠서 상대방이 지금 타이핑 중인지 확인할 수 있게 해 줍니다.
두 번째 설정인 enableReactions 같은 경우에는 카카오톡에서 상대 메시지에 좋아요, 체크 아이콘을 다는 거처럼 채팅을 할 수 있게 합니다.
이 기능을 뺀 이유는 넣기 복잡하고 크게 중요한 기능은 아닌 거 같아서 뺐습니다.
간단하게 시연을 보여드리면 평범한 채팅방 느낌으로
가장 먼저 채팅방을 커스텀을 하기 위해서는 채팅 리스트에서 올바른 채팅방이 만들어질 수 있도록 직접 정의해줘야 합니다.
class CustomChannelList : ChannelListFragment() {
override fun onBeforeReady(
status: ReadyStatus,
module: ChannelListModule,
viewModel: ChannelListViewModel,
) {
super.onBeforeReady(status, module, viewModel)
module.channelListComponent.setOnItemClickListener { _, _, channel ->
GroupChannel.getChannel(channel.url) { _, e ->
if (e != null) {
Toast.makeText(context, e.toString(), Toast.LENGTH_SHORT).show()
} else {
val intent = ChannelActivity.newIntentFromCustomActivity(
requireContext(),
CustomChannelActivity::class.java,
channel.url
)
startActivity(intent)
}
}
}
}
}
채팅 리스트를 커스텀을 할 때 만든 CustomChannelList에서 채팅을 클릭했을 때 이동을 정의해야 합니다.
newIntentFromCustomActivity 이름에 있는 것처럼 커스텀 ChannelActivity에 이동하는 것을 도와줍니다.
channel.url을 넘기면 올바른 채팅방이 만들어지는 것을 도와줍니다.
CustomChannelActivity을 만들어야 하는데 채팅 리스트처럼 얘가 할 역할은 없습니다.
class CustomChannelActivity : ChannelActivity()
따로 정의할 것이 없기 때문에 xml 파일을 만들 필요도 없어서 class을 만들고 Manifest에 직접 activity을 등록해 주면 끝입니다.
CustomChannelActivity을 만들지 않고 그냥 이미 선언되어 있는 ChannelActivity로 newIntent 써서 이동을 할 수도 있지만
이렇게 하면 Hilt-dagger을 이용한 의존성 주입이 불가능하며 viewModel 생명주기에 대한 컨트롤이 더욱 힘들어집니다.
Hilt을 이용해 의존성을 주입하려면 @AndroidEntryPoint을 Acitivity 위에 달아줘야 하는데 ChannelActivity을 그대로 사용하면 이걸 못 달아준다.
이제부터 Component 하나씩 어떻게 구현할지 보여드리겠습니다.
가장 먼저 header 부분부터
class CustomChannelHeader : ChannelHeaderComponent() {
private lateinit var binding: CustomChannelHeaderBinding
override fun onCreateView(
context: Context,
inflater: LayoutInflater,
parent: ViewGroup,
args: Bundle?
): View {
binding = CustomChannelHeaderBinding.inflate(LayoutInflater.from(context), parent, false)
binding.btnBack.setOnClickListener(this::onLeftButtonClicked)
binding.tbProfile.setOnMenuItemClickListener {
when (it.itemId) {
R.id.item_info -> {
onRightButtonClicked(binding.root)
true
}
R.id.item_alarm -> {
Toast.makeText(context, "alarm clicked", Toast.LENGTH_SHORT).show()
true
}
else -> {
true
}
}
}
return binding.root
}
override fun notifyChannelChanged(channel: GroupChannel) {
val members = channel.members
val opponent: Member
if (members.size > 1) {
// 현재 사용자의 닉네임을 가져온다
val currentUser = SendbirdChat.currentUser?.nickname ?: " "
opponent =
if (members[0].nickname != currentUser) members[0] else members[1]
// 상대방의 닉네임을 채팅방 이름으로 한다
binding.tvOpponentName.text = opponent.nickname
// 온라인 여부를 설정한다
binding.tvOnline.text = opponent.connectionStatus.toString()
} else {
binding.tvOpponentName.text = channel.name
}
}
}
대부분의 Component은 Left, right button Click이 있는데 이 Component에서는 left가 뒤로 가기, right이 이 채팅방의 정보를 볼 수 있게 합니다.
onCreateView은 단지 xml 파일 연결하고 클릭 리스너 붙이는 것만 하는데
밑에 notifyChannelChanged가 하는 일이 많아 보일 수 있습니다.
샌드버드 같은 경우에는 1대 1 채팅이 따로 없으며 GroupChannel, OpenChannel으로 나눠집니다.
그래서 채팅방의 이름이 그룹 채팅방의 이름으로 되어 있어서 직접 상대방의 닉네임을 채팅방 이름으로 지정했습니다.
그리고 connectionStatus을 이용해서 상대방이 온라인인지 아닌지 알 수 있어서 이걸 채팅방 이름 밑에 넣었습니다.
다음 Component은 메시지를 보여주는 MessageListComponent입니다.
MessageListComponent은 그냥 RecyclerView로 이루어져 있어서 어댑터만 수정해도 충분합니다.
class CustomListAdapter(
channel: GroupChannel,
) : MessageListAdapter(channel) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
val inflater = LayoutInflater.from(parent.context)
when (viewType) {
// 내 숫자 메시지
COUNT_ME -> {
val myCount = MyCountBinding.inflate(inflater, parent, false)
val viewHolder = MyCountViewHolder(myCount)
return applyClickListener(viewHolder)
}
// 상대 숫자 메시지
COUNT_OTHER -> {
val otherCount = OtherCountBinding.inflate(inflater, parent, false)
val viewHolder = OtherCountViewHolder(otherCount)
return applyClickListener(viewHolder)
}
// 내 메시지
MessageType.VIEW_TYPE_USER_MESSAGE_ME.value -> {
val myMessage = MyMessageBinding.inflate(inflater, parent, false)
val viewHolder = MyMessageViewHolder(myMessage)
return applyClickListener(viewHolder)
}
// 상대 메시지
MessageType.VIEW_TYPE_USER_MESSAGE_OTHER.value -> {
val otherMessage = OtherMessageBinding.inflate(inflater, parent, false)
val viewHolder = OtherMessageViewHolder(otherMessage)
return applyClickListener(viewHolder)
}
}
return super.onCreateViewHolder(parent, viewType)
}
override fun getItemViewType(position: Int): Int {
val message = getItem(position)
val customType = message.customType
// 메세지가 숫자 메시지면 다른 viewType을 돌려준다
if (!TextUtils.isEmpty(customType)
&& customType == StringSet.count_type
&& message is UserMessage
) {
return when {
MessageUtils.isMine(message) -> COUNT_ME
else -> COUNT_OTHER
}
}
return super.getItemViewType(position)
}
// 메시지를 클릭해서 답장, 수정, 삭제 같은 기능을 추가할 수 있게 한다.
private fun applyClickListener(viewHolder: MessageViewHolder): MessageViewHolder {
viewHolder.setMessageUIConfig(messageUIConfig)
val views: Map<String, View> = viewHolder.clickableViewMap
for (entry in views.entries) {
val identifier = entry.key
entry.value.setOnClickListener {
val msgPosition = viewHolder.absoluteAdapterPosition
if (msgPosition != -1) {
onListItemClickListener?.onIdentifiableItemClick(
it,
identifier,
msgPosition,
getItem(msgPosition)
)
}
}
entry.value.setOnLongClickListener {
val msgPosition = viewHolder.absoluteAdapterPosition
if (msgPosition != -1) {
onListItemLongClickListener?.onIdentifiableItemLongClick(
it,
identifier,
msgPosition,
getItem(msgPosition)
)
}
true
}
}
return viewHolder
}
companion object {
private const val COUNT_ME = 10001
private const val COUNT_OTHER = 10002
}
}
RecyclerView 어댑터랑 크게 하는 게 다르지가 않아서 따로 설명은 안 하겠습니다
여기서 중요한 내용은 applyClickListener인데요 viewholder마다 어떤 부분을 클릭할 수 있는지 clickableViewMap을 지정해 주는데 이것을 가져와 내가 만든 어댑터에다가 적용하고 있습니다.
ViewHolder에 대해서도 보여드리자면
class MyMessageViewHolder(
private val binding: MyMessageBinding
) : GroupChannelMessageViewHolder(binding.root) {
// 어덥터에 있는 applyClickListener에 어떤 부분을 클릭할 수 있는지 지정해주는 부분
override fun getClickableViewMap(): MutableMap<String, View> {
val viewMap = ConcurrentHashMap<String, View>()
viewMap[ClickableViewIdentifier.Chat.name] = binding.tvMessage
return viewMap
}
override fun setEmojiReaction(
reactionList: MutableList<Reaction>,
emojiReactionClickListener: OnItemClickListener<String>?,
emojiReactionLongClickListener: OnItemLongClickListener<String>?,
moreButtonClickListener: View.OnClickListener?
) {
// reaction 설정을 꺼놔서 따로 설정할 필요가 없습니다.
}
override fun bind(channel: BaseChannel, message: BaseMessage, params: MessageListUIParams) {
val context = binding.root.context
val sendingStatus = message.sendingStatus == SendingStatus.SUCCEEDED
binding.tvSentAt.visibility = if (sendingStatus) View.VISIBLE else View.GONE
val sentAt =
DateUtils.formatDateTime(context, message.createdAt, DateUtils.FORMAT_SHOW_TIME)
binding.tvSentAt.text = sentAt
binding.tvMessage.text = message.message
binding.ivStatus.visibility = drawStatus(binding.ivStatus, message, channel)
val padding = context.resources.getDimensionPixelSize(com.sendbird.uikit.R.dimen.sb_size_8)
binding.root.setPadding(
binding.root.paddingLeft,
padding,
binding.root.paddingRight,
padding
)
}
}
fun drawStatus(view: ImageView, message: BaseMessage, channel: BaseChannel): Int {
val context: Context = view.context
return when (message.sendingStatus) {
SendingStatus.SUCCEEDED -> {
if (channel is GroupChannel) {
val unreadMemberCount = channel.getUnreadMemberCount(message)
val unDeliveredMemberCount = channel.getUndeliveredMemberCount(message)
if (unreadMemberCount == 0) {
drawRead(view, context)
} else if (unDeliveredMemberCount == 0) {
drawDelivered(view, context)
} else {
drawSent(view, context)
}
}
View.VISIBLE
}
else -> View.GONE
}
}
ViewHolder의 형태는 똑같으며 어떤 xml에 링크하고 어떤 데이터가 어떤 view에 들어갈지 지정을 해줍니다.
조금 특이한 게 drawStatus인데, 읽음 표시를 위해 내 메시지를 읽었는지 파악해서 회색 체크에서 초록색 체크로 바꿔줍니다.
실시간으로 바꿔집니다.
마지막 Component은 message을 입력하는 부분인 MessageInputComponent입니다.
class CustomChannelInputComponent : MessageInputComponent() {
private lateinit var binding: CustomChannelInputBinding
private var mode = MessageInputView.Mode.DEFAULT
var voiceInput: View.OnClickListener? = null
override fun onCreateView(
context: Context,
inflater: LayoutInflater,
parent: ViewGroup,
args: Bundle?
): View {
binding = CustomChannelInputBinding.inflate(inflater, parent, false)
binding.ivSend.setOnClickListener {
onInputRightButtonClicked(it)
}
binding.ivCamera.setOnClickListener {
onInputLeftButtonClicked(it)
}
binding.ivMick.setOnClickListener {
voiceInput?.onClick(it)
}
binding.ivCross.setOnClickListener {
requestInputMode(MessageInputView.Mode.DEFAULT)
}
// 숫자 메시지 버튼을 클릭하면 따로 창이 나오고 버튼 색을 변화 시킨다.
binding.ivCount.setOnClickListener {
binding.countPanel.apply {
if (visibility == View.VISIBLE) {
visibility = View.GONE
binding.ivCount.setColorFilter(
ContextCompat.getColor(
getContext(), R.color.grey
)
)
} else {
visibility = View.VISIBLE
binding.ivCount.setColorFilter(
ContextCompat.getColor(
getContext(), R.color.black
)
)
}
}
}
// 메시지를 입력해야 전송 버튼을 보여준다
binding.etMessageInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// TODO("Not yet implemented")
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
onInputTextChanged(s ?: " ", start, before, count)
}
override fun afterTextChanged(s: Editable?) {
binding.etMessageInput.apply {
val lineCount = lineCount + 2
val maxLines = 5
val layoutParams = layoutParams
layoutParams.height = lineHeight * lineCount.coerceAtMost(maxLines) - 5
this.layoutParams = layoutParams
}
binding.ivSend.visibility = if (s?.isNotEmpty() != true) View.GONE else View.VISIBLE
}
})
return binding.root
}
// 어떤 EditText에 있는 텍스트를 보낼지 지정해준다.
override fun getEditTextView(): EditText {
return binding.etMessageInput
}
override fun getRootView(): View {
return binding.root
}
// 수정 모드인지, 답장 모드인지에 따라 보여줄 창을 다르게 한다
override fun requestInputMode(mode: MessageInputView.Mode) {
val before = this.mode
this.mode = mode
if (mode == MessageInputView.Mode.QUOTE_REPLY) {
binding.ivSend.setOnClickListener(this::onInputRightButtonClicked)
} else if (mode == MessageInputView.Mode.EDIT) {
binding.ivSend.setOnClickListener(this::onEditModeSaveButtonClicked)
binding.replyPanel.visibility = View.GONE
binding.ivCross.visibility = View.GONE
} else {
binding.ivSend.setOnClickListener(this::onInputRightButtonClicked)
binding.replyPanel.visibility = View.GONE
binding.replyPanel.visibility = View.GONE
}
onInputModeChanged(before, mode)
}
// 답장, 수정을 하면 메시지 형태를 바꿔준다
override fun notifyDataChanged(
message: BaseMessage?,
channel: GroupChannel,
defaultText: String
) {
super.notifyDataChanged(message, channel, defaultText)
if (mode == MessageInputView.Mode.QUOTE_REPLY) {
if (message != null) {
binding.replyPanel.text = applyColor(message)
binding.replyPanel.visibility = View.VISIBLE
binding.ivCross.visibility = View.VISIBLE
}
} else if (mode == MessageInputView.Mode.EDIT) {
if (message != null) {
binding.etMessageInput.setText(message.message)
}
} else {
binding.etMessageInput.setText(defaultText)
}
}
// 단지 텍스트에 색을 입히기 위한 함수
private fun applyColor(message: BaseMessage): SpannableStringBuilder {
val m = SpannableStringBuilder()
val first = SpannableString("reply to ${message.sender?.nickname ?: ""}\n")
first.setSpan(
ForegroundColorSpan(Color.BLACK),
0,
first.length,
SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE
)
m.append(first)
val second = SpannableString(message.message)
second.setSpan(
ForegroundColorSpan(Color.parseColor("#939393")),
0,
second.length,
SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE
)
m.append(second)
return m
}
}
여기서 중요한 부분은 getEditTextView()로 이것을 설정 안 해주면 어떤 EditText의 텍스트를 보낼지 모르기 때문에 메시지 전송이 안된다.
이 Component에서 left, right click listener는 각각 파일 전송, 메시지 보내기로 정의되어 있습니다.
나머지는 전부다 기능을 추가하기 위한 것으로 전송 버튼 보여주기, 수정, 답장 다르게 보여주기 등등을 위해 있습니다.
이제 모든 Component을 커스텀했다면 이제 적용을 해줘야 합니다.
채팅 리스트처럼 CustomChannelFragment에다가 다 적용을 해줘야 합니다.
class CustomChannelFragment : ChannelFragment() {
private val viewModels: ChatViewModel by activityViewModels()
모듈에 Component 적용
override fun onCreateModule(args: Bundle): ChannelModule {
val module = super.onCreateModule(args)
module.setHeaderComponent(CustomChannelHeader())
module.setInputComponent(CustomChannelInputComponent())
return module
}
// MessageListComponent에 커스텀 어덥터 적용
override fun onBeforeReady(
status: ReadyStatus,
module: ChannelModule,
viewModel: ChannelViewModel
) {
super.onBeforeReady(status, module, viewModel)
val channel = viewModel.channel ?: return
module.messageListComponent.setAdapter(CustomListAdapter(channel))
}
// MessageInputComponent에 클릭 리스너 전달, ViewModel으로 전달받은 숫자 메시지에 변화가 있으면 메시지 전송
override fun onBindMessageInputComponent(
inputComponent: MessageInputComponent,
viewModel: ChannelViewModel,
channel: GroupChannel?
) {
super.onBindMessageInputComponent(inputComponent, viewModel, channel)
if (inputComponent is CustomChannelInputComponent) {
val customInput = module.messageInputComponent as CustomChannelInputComponent
customInput.voiceInput = View.OnClickListener {
takeVoiceRecorder()
}
viewModels.count.observe(this) {
val params = UserMessageCreateParams(it.toString()).apply {
customType = StringSet.count_type
}
channel?.sendUserMessage(params) { _, e ->
if (e != null) {
Toast.makeText(context, "Error: $e", Toast.LENGTH_SHORT).show()
}
}
}
}
}
}
채팅 리스트와 크게 다르지 않으며 Component을 커스텀했다면 onCreateModule에서 모듈에 적용
어댑터를 커스텀했다면 onBeforeReady에서 Component으로 적용
유일하게 다른 점이라면 onBindMessageInputComponent으로 녹음을 시작하기 위한 함수를 넘겨주고
ViewModel에 있는 count liveData에 변화가 생기면 받아서 메시지를 전송하는 기능이 있습니다
여기서 중요한 건 반드시 customType이 있어야 다른 메시지 타입과 구분을 할 수 있습니다.
count는 CountFragment에서 변화가 생깁니다.
class CountFragment : Fragment() {
private lateinit var binding: FragmentCountBinding
private val viewModel: ChatViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentCountBinding.inflate(inflater, container, false)
initCount()
return binding.root
}
private fun initCount() {
binding.btnSend.setOnClickListener {
val count = binding.etCount.text.toString()
if (count.isNotEmpty()) {
viewModel.count.value = count.toInt()
}
}
}
}
그냥 간단한 게 숫자를 넣고 버튼을 클릭하면 viewModel에 있는 count에 변화를 주었습니다.
[마무리]
채팅방에 관련된 기능들은 정말로 많지만 그중 일부분만 넣었습니다
기초만으로 엄청 긴데 더 넣기에는 좀 그래서 이 정도만 보여드리겠습니다
긴 글 읽어주셔서 감사합니다
전체 코드는 여기서 참조 가능합니다
https://github.com/flash159483/sendbird_customization
'android > SendBird' 카테고리의 다른 글
(android/kotlin) 샌드버드 채팅 리스트 (ChannelList) 커스텀 해보기 (0) | 2023.08.26 |
---|---|
(Android, Kotlin) 샌드버드 UIKit과 Customization에 대해서 (0) | 2023.08.20 |