심화 - 이미지 전송 & 이미지 확대/축소

변현섭·2023년 9월 18일
0

텍스트 메시지를 주고 받는 기능을 추가해보았으니, 이제 사진을 주고 받는 기능도 추가해보도록 하겠습니다. 채팅 구현이 어렵다는 인식이 많아 이미지 전송까지는 엄두도 못내는 경우가 많은 것 같은데, 사실 일반 텍스트 메시지 전송과 크게 다르지 않습니다. 물론, 이미지 전송을 텍스트 전송과 비슷하게 구현하는 것이 효율적인 방법은 아니겠지만, 효율성은 차치하고 이미지 전송을 구현하는 데에 초점을 맞추는 것으로 하겠습니다.

5. 이미지 전송하기

1) 백엔드

① ChatRoomController에 아래의 API를 추가한다.

// 이미지 S3 업로드
@PostMapping("/image")
public BaseResponse<String> uploadImage(@RequestPart(value = "image", required = false) MultipartFile multipartFile) {
    try {
        return new BaseResponse<>(chatRoomService.uploadImage(multipartFile));
    } catch (BaseException exception) {
        return new BaseResponse<>(exception.getStatus());
    }
}

② ChatRoomService에 아래의 메서드를 추가한다.

@Transactional
public String uploadImage(MultipartFile multipartFile) throws BaseException {
    try {
        if(multipartFile == null) {
            throw new BaseException(BaseResponseStatus.REQUEST_ERROR);
        }
        GetS3Res getS3Res = s3Service.uploadSingleFile(multipartFile);
        return getS3Res.getImgUrl();
    } catch (BaseException exception) {
        throw new BaseException(exception.getStatus());
    }
}

2) 프론트엔드

① layout 디렉토리 하위로, image_recycler_view_item, image_recycler_view_item2를 추가한다.

② drawable 디렉토리 하위로, broken_image라는 이름의 깨진 이미지 아이콘을 추가한다.

③ ImageView에 round corner를 적용하기 위해 drawable 디렉토리 하위로 round_ractangle이라는 이름의 리소스 파일을 추가한다.

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/white"/>
            <corners android:radius="15dp"/>
        </shape>
    </item>
    <item android:right="3dp" android:left="3dp" android:top="3dp" android:bottom="3dp">
        <shape android:shape="rectangle">
            <solid android:color="@color/white"/>
            <corners android:radius="14dp"/>
        </shape>
    </item>
</layer-list>

④ image_recycler_view_item.xml에 아래의 내용을 입력한다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/imageContentsArea"
            android:layout_width="230dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="37dp"
            android:layout_marginEnd="8dp"
            android:src="@drawable/broken_image"
            android:layout_gravity="left"
            android:background="@drawable/round_ractangle"
            android:ellipsize="end"
            android:maxWidth="230dp"
            app:layout_constraintEnd_toStartOf="@+id/imageProfileArea"
            app:layout_constraintTop_toTopOf="parent" />

        <FrameLayout
            android:id="@+id/imageUnreadUserCountContainer"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="gone"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/imageContentsArea">

            <TextView
                android:id="@+id/imageUnreadUserCountTextView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginHorizontal="5dp"
                android:layout_marginBottom="20dp"
                android:gravity="center"
                android:text="10"
                android:textColor="#ffd400"
                android:textSize="10sp" />
        </FrameLayout>

        <TextView
            android:id="@+id/imageDateTime"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="5dp"
            android:layout_marginEnd="15dp"
            android:text="9/7 7:01 AM"
            android:textSize="10sp"
            app:layout_constraintEnd_toStartOf="@+id/imageProfileArea"
            app:layout_constraintTop_toBottomOf="@+id/imageContentsArea" />

        <de.hdodenhof.circleimageview.CircleImageView
            android:id="@+id/imageProfileArea"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:layout_marginTop="15dp"
            android:layout_marginRight="15dp"
            android:src="@drawable/profile"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:civ_border_color="@color/skyBlue"
            app:civ_border_overlay="true"
            app:civ_border_width="1dp" />

        <TextView
            android:id="@+id/imageNickName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="12dp"
            android:layout_marginBottom="2dp"
            android:gravity="center"
            android:text="nickname"
            android:textColor="@color/black"
            android:textSize="15sp"
            app:layout_constraintBottom_toTopOf="@+id/imageContentsArea"
            app:layout_constraintEnd_toStartOf="@+id/imageProfileArea" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</LinearLayout>

⑤ image_recycler_view_item2.xml에 아래의 내용을 입력한다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/imageContentsArea2"
            android:layout_width="230dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="37dp"
            android:src="@drawable/broken_image"
            android:background="@drawable/round_ractangle"
            android:ellipsize="end"
            android:maxWidth="230dp"
            app:layout_constraintStart_toEndOf="@+id/imageProfileArea2"
            app:layout_constraintTop_toTopOf="parent" />

        <FrameLayout
            android:id="@+id/imageUnreadUserCountContainer2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="gone"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toEndOf="@+id/imageContentsArea2">

            <TextView
                android:id="@+id/imageUnreadUserCountTextView2"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginHorizontal="5dp"
                android:layout_marginBottom="20dp"
                android:gravity="center"
                android:text="10"
                android:textColor="#ffd400"
                android:textSize="10sp" />
        </FrameLayout>

        <TextView
            android:id="@+id/imageDateTime2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="9/7 7:01 AM"
            android:textSize="10sp"
            android:layout_marginTop="5dp"
            android:layout_marginStart="15dp"
            app:layout_constraintStart_toEndOf="@+id/imageProfileArea2"
            app:layout_constraintTop_toBottomOf="@+id/imageContentsArea2" />

        <de.hdodenhof.circleimageview.CircleImageView
            android:id="@+id/imageProfileArea2"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:layout_marginTop="15dp"
            android:layout_marginLeft="15dp"
            android:src="@drawable/profile"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:civ_border_color="@color/skyBlue"
            app:civ_border_overlay="true"
            app:civ_border_width="1dp" />


        <TextView
            android:id="@+id/imageNickName2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="12dp"
            android:layout_marginBottom="2dp"
            android:gravity="center"
            android:text="nickname"
            android:textColor="@color/black"
            android:textSize="15sp"
            app:layout_constraintBottom_toTopOf="@+id/imageContentsArea2"
            app:layout_constraintStart_toEndOf="@+id/imageProfileArea2" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</LinearLayout>

⑥ MessageModel에 imageUrl 필드를 추가하고, viewType도 수정한다.

  • 이 때 그냥 null을 넣으면 파이어베이스에 아무 것도 업로드되지 않으므로, 문자열로 null을 넣는다.
data class MessageModel (
    val senderUid : String = "",
    val senderNickName : String = "",
    val senderProfileUrl : String = "",
    val contents : String = "",
    val sendTime : String = "",
    val readerUids: MutableMap<String, Boolean> = mutableMapOf(), // 메시지 읽은 유저의 UID
    var unreadUserCount : Int = 0, // 메시지를 읽지 않은 사람의 수
    val imageUrl : String? = "null",
    var viewType: Int = VIEW_TYPE_ME
) {
    companion object {
        const val VIEW_TYPE_YOU = 0
        const val VIEW_TYPE_ME = 1
        const val VIEW_TYPE_YOU_IMAGE = 3
        const val VIEW_TYPE_ME_IMAGE = 4
    }
}

⑦ MessageAdapter에 아래의 내용을 입력한다.

  • 이미지 전송(ME, YOU)을 위한 ViewType을 별도로 만들어주었다.
  • round corner가 잘 적용되려면 이미지에 clipToOutline 속성을 true로 설정해주어야 한다. clipToOutline은 ImageView의 background 속성 값에 알맞게 이미지를 자동으로 맞춰준다.
class MessageAdapter(private val context: Context, val items: MutableList<MessageModel>)
    : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    class ItemViewHolder1(private val view: View) : RecyclerView.ViewHolder(view) {
        val contents : TextView = view.findViewById(R.id.messageContentsArea)
        val dateTime : TextView = view.findViewById(R.id.dateTime)
        val nickName : TextView = view.findViewById(R.id.messageNickName)
        val profile : ImageView = view.findViewById(R.id.messageProfileArea)
        val unreadUserContainer : FrameLayout = view.findViewById(R.id.unreadUserCountContainer)
        val unreadUserCount : TextView = view.findViewById(R.id.unreadUserCountTextView)
    }

    class ItemViewHolder2(private val view: View) : RecyclerView.ViewHolder(view) {
        val contents : TextView = view.findViewById(R.id.messageContentsArea2)
        val dateTime : TextView = view.findViewById(R.id.dateTime2)
        val nickName : TextView = view.findViewById(R.id.messageNickName2)
        val profile : ImageView = view.findViewById(R.id.messageProfileArea2)
        val unreadUserContainer : FrameLayout = view.findViewById(R.id.unreadUserCountContainer2)
        val unreadUserCount : TextView = view.findViewById(R.id.unreadUserCountTextView2)
    }

    class ItemViewHolder3(private val view: View) : RecyclerView.ViewHolder(view) {
        val image : ImageView = view.findViewById(R.id.imageContentsArea)
        val dateTime : TextView = view.findViewById(R.id.imageDateTime)
        val nickName : TextView = view.findViewById(R.id.imageNickName)
        val profile : ImageView = view.findViewById(R.id.imageProfileArea)
        val unreadUserContainer : FrameLayout = view.findViewById(R.id.imageUnreadUserCountContainer)
        val unreadUserCount : TextView = view.findViewById(R.id.imageUnreadUserCountTextView)
    }

    class ItemViewHolder4(private val view: View) : RecyclerView.ViewHolder(view) {
        val image : ImageView = view.findViewById(R.id.imageContentsArea2)
        val dateTime : TextView = view.findViewById(R.id.imageDateTime2)
        val nickName : TextView = view.findViewById(R.id.imageNickName2)
        val profile : ImageView = view.findViewById(R.id.imageProfileArea2)
        val unreadUserContainer : FrameLayout = view.findViewById(R.id.imageUnreadUserCountContainer2)
        val unreadUserCount : TextView = view.findViewById(R.id.imageUnreadUserCountTextView2)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val adapterLayout : View?
        return when(viewType) {
            MessageModel.VIEW_TYPE_ME -> {
                adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.message_recycler_view_item, parent, false)
                ItemViewHolder1(adapterLayout)
            }
            MessageModel.VIEW_TYPE_YOU -> {
                adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.message_recycler_view_item2, parent, false)
                ItemViewHolder2(adapterLayout)
            }
            MessageModel.VIEW_TYPE_ME_IMAGE -> {
                adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.image_recycler_view_item, parent, false)
                ItemViewHolder3(adapterLayout)
            }
            MessageModel.VIEW_TYPE_YOU_IMAGE -> {
                adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.image_recycler_view_item2, parent, false)
                ItemViewHolder4(adapterLayout)
            }
            else -> throw RuntimeException("Invalid View Type Error")
        }

    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val item = items[position]
        when (item.viewType) {
            MessageModel.VIEW_TYPE_ME -> {
                (holder as ItemViewHolder1).contents.text = item.contents
                holder.dateTime.text = item.sendTime
                holder.nickName.text = item.senderNickName
                holder.unreadUserCount.text = item.unreadUserCount.toString()

                if (item.unreadUserCount.toString().toInt() > 0) {
                    holder.unreadUserContainer!!.visibility = View.VISIBLE
                    holder.unreadUserCount.visibility = View.VISIBLE
                } else {
                    holder.unreadUserContainer!!.visibility = View.GONE
                    holder.unreadUserCount.visibility = View.GONE
                }

                if (item.senderProfileUrl != "null") {
                    Glide.with(context)
                        .load(item.senderProfileUrl)
                        .into(holder.profile)
                } else {
                    holder.profile.setImageResource(R.drawable.profile)
                }
                holder.itemView.setOnClickListener {
                    itemClickListener.onClick(it, position)
                }
            }

            MessageModel.VIEW_TYPE_YOU -> {
                (holder as ItemViewHolder2).contents.text = item.contents
                holder.dateTime.text = item.sendTime
                holder.nickName.text = item.senderNickName
                holder.unreadUserCount.text = item.unreadUserCount.toString()

                if (item.unreadUserCount.toString().toInt() > 0) {
                    holder.unreadUserContainer!!.visibility = View.VISIBLE
                    holder.unreadUserCount.visibility = View.VISIBLE
                } else {
                    holder.unreadUserContainer!!.visibility = View.GONE
                    holder.unreadUserCount.visibility = View.GONE
                }

                if (item.senderProfileUrl != "null") {
                    Glide.with(context)
                        .load(item.senderProfileUrl)
                        .into(holder.profile)
                } else {
                    holder.profile.setImageResource(R.drawable.profile)
                }
                holder.itemView.setOnClickListener {
                    itemClickListener.onClick(it, position)
                }
            }

            MessageModel.VIEW_TYPE_ME_IMAGE -> {
                (holder as ItemViewHolder3).dateTime.text = item.sendTime
                holder.nickName.text = item.senderNickName
                holder.unreadUserCount.text = item.unreadUserCount.toString()

                if(item.imageUrl != "null") {
                    Glide.with(context)
                        .load(item.imageUrl)
                        .into(holder.image)
                    holder.image.clipToOutline = true
                } else {
                    holder.profile.setImageResource(R.drawable.broken_image)
                }
                if (item.unreadUserCount.toString().toInt() > 0) {
                    holder.unreadUserContainer!!.visibility = View.VISIBLE
                    holder.unreadUserCount.visibility = View.VISIBLE
                } else {
                    holder.unreadUserContainer!!.visibility = View.GONE
                    holder.unreadUserCount.visibility = View.GONE
                }

                if (item.senderProfileUrl != "null") {
                    Glide.with(context)
                        .load(item.senderProfileUrl)
                        .into(holder.profile)
                } else {
                    holder.profile.setImageResource(R.drawable.profile)
                }
                holder.itemView.setOnClickListener {
                    itemClickListener.onClick(it, position)
                }
               
            }

            MessageModel.VIEW_TYPE_YOU_IMAGE -> {
                (holder as ItemViewHolder4).dateTime.text = item.sendTime
                holder.nickName.text = item.senderNickName
                holder.unreadUserCount.text = item.unreadUserCount.toString()

                if (item.unreadUserCount.toString().toInt() > 0) {
                    holder.unreadUserContainer!!.visibility = View.VISIBLE
                    holder.unreadUserCount.visibility = View.VISIBLE
                } else {
                    holder.unreadUserContainer!!.visibility = View.GONE
                    holder.unreadUserCount.visibility = View.GONE
                }

                if (item.senderProfileUrl != "null") {
                    Glide.with(context)
                        .load(item.senderProfileUrl)
                        .into(holder.profile)
                } else {
                    holder.profile.setImageResource(R.drawable.profile)
                }

                if(item.imageUrl != "null") {
                    Glide.with(context)
                        .load(item.imageUrl)
                        .into(holder.image)
                    holder.image.clipToOutline = true
                } else {
                    holder.profile.setImageResource(R.drawable.broken_image)
                }
                holder.itemView.setOnClickListener {
                    itemClickListener.onClick(it, position)
                }
               
            }
        }
    }
    ...

⑧ 이미지 전송에 사용할 아이콘을 send_image라는 이름으로 drawable 디렉토리 하위에 넣는다. 이후 activity_chat_room.xml 파일에 이미지 전송 버튼을 추가한다.

<ImageView
    android:id="@+id/send_image"
    android:layout_width="40dp"
    android:layout_height="40dp"
    android:layout_marginLeft="10dp"
    android:src="@drawable/send_image"
    android:layout_gravity="center"/>

⑨ ChatApi에 아래의 API를 추가한다.

@Multipart
@POST("/chat/image")
suspend fun uploadImage(
    @Part image : MultipartBody.Part?
) : BaseResponse<String>

⑩ 이제 ChatRoomActivity를 아래와 같이 수정한다.

  • send_image 버튼에 대한 클릭이벤트 리스너를 등록한다.
  • uploadImage 메서드를 추가한다.
  • send_image 버튼을 클릭한 후 사진을 선택하면 사진이 채팅방에 업로드된다.
	...
    val sendImageBtn = findViewById<ImageView>(R.id.send_image)
    val getAction = registerForActivityResult(
        ActivityResultContracts.GetContent(),
        ActivityResultCallback { uri ->
            CoroutineScope(Dispatchers.IO).launch {
                val response = uploadImage(uri)
                if (response.isSuccess) {
                    val selectedImageUri = response.result!!
                    Log.d("selectedImageUri", selectedImageUri)
                    val sendTime = getSendTime()
                    CoroutineScope(Dispatchers.IO).launch {
                        val response = getUserInfo(myUid)
                        if (response.isSuccess) {
                            myNickName = response.result?.nickName.toString()
                            myProfileUrl = response.result?.imgUrl.toString()
                            val readerUids = mutableMapOf<String, Boolean>()
                            Log.d("selectedImageUriChk", selectedImageUri)
                            messageModel = MessageModel(myUid, myNickName, myProfileUrl, "이미지",
                                sendTime, readerUids, 0, selectedImageUri)
                            Log.d("readerUid", readerUids.size.toString())
                            FirebaseRef.message.child(chatRoomId!!).push().setValue(messageModel)
                            val connectedUidsRef = FirebaseRef.connected.child(chatRoomId!!)
                            val connectedUidsList: MutableList<String> = mutableListOf() // 현재 접속 중인 유저의 UID 목록
                            connectedUidsRef.addListenerForSingleValueEvent(object : ValueEventListener {
                                override fun onDataChange(dataSnapshot: DataSnapshot) {
                                    for (childSnapshot in dataSnapshot.children) { // 난수 값
                                        val originalString = childSnapshot.value.toString()
                                        Log.d("originalString", originalString)
                                        // originalString이 {sRuRu1YVJMSj4csAUPOmaFfKfWJ2=true}와 같이 나오므로,
                                        // 앞에서 1글자, 뒤에서 6글자를 제거해 UID만 추출
                                        val modifiedString = originalString.substring(1, originalString.length - 6)
                                        Log.d("modifiedString", modifiedString)
                                        connectedUidsList.add(modifiedString)
                                        Log.d("connectedUidsList", connectedUidsList.toString())
                                    }
                                    CoroutineScope(Dispatchers.IO).launch {
                                        val response = getUserUidList(chatRoomId!!) // 채팅방에 참여한 모든 유저의 UID 목록
                                        Log.d("userUidList", response.toString())
                                        if (response.isSuccess) {
                                            val uidList = response.result!!
                                            Log.d("userUidList", response.result.toString())
                                            Log.d("userUidList", uidList.toString())
                                            val difference = uidList.subtract(connectedUidsList) // 차집합 List
                                            Log.d("difference", connectedUidsList.toString())
                                            Log.d("difference", difference.toString())
                                            val deviceTokenList = mutableListOf<String>()
                                            val addList = difference.map { uid ->
                                                async(Dispatchers.IO) {
                                                    val response = getDeviceTokenByUid(uid)
                                                    if (response.isSuccess) {
                                                        deviceTokenList.add(response.result.toString())
                                                    } else {
                                                        Log.d("ChatRoomActivity", "디바이스 토큰 정보를 불러오지 못함")
                                                    }
                                                }
                                            }
                                            // 모든 작업이 완료될 때까지 대기
                                            addList.awaitAll()
                                            Log.d("deviceTokenList", deviceTokenList.toString())
                                            val noticeModel = NoticeModel(myNickName, "이미지 파일")
                                            for (token in deviceTokenList) {
                                                val pushNotice = PushNotice(noticeModel, token)
                                                Log.d("Push", pushNotice.toString())
                                                Log.d("Push", tokenList.toString())
                                                createNotificationChannel()
                                                pushNotification(pushNotice)
                                            }
                                        } else {
                                            Log.d("userUidList", "유저의 정보를 불러오지 못함")
                                        }
                                    }
                                }
                                override fun onCancelled(databaseError: DatabaseError) {
                                    // 처리 중 오류 발생 시 처리
                                }
                            })
                        } else {
                            Log.d("ChatRoomActivity", "유저의 정보를 불러오지 못함")
                        }
                    }
                    message.text?.clear()
                } else {
                    Log.d("ChatRoomActivity", "이미지 정보를 불러오지 못함")
                }
            }
        }
    )
    sendImageBtn.setOnClickListener {
        getAction.launch("image/*")
    }
}

private suspend fun uploadImage(uri: Uri?) : BaseResponse<String> {
    val imagePath = getImagePathFromUri(uri!!)
    val imageFile = imagePath?.let { File(it) }
    Log.d("ProfileActivity", "path : " + imagePath.toString())
    Log.d("ProfileActivity", "file : " + imageFile.toString())

    val requestFile = RequestBody.create(MediaType.parse("multipart/form-data"), imageFile)
    val imagePart = MultipartBody.Part.createFormData("image", imageFile?.name, requestFile)

    return RetrofitInstance.chatApi.uploadImage(imagePart)
}

private fun getImagePathFromUri(uri: Uri): String? {
    val projection = arrayOf(MediaStore.Images.Media.DATA)
    val cursor = contentResolver.query(uri, projection, null, null, null)
    cursor?.moveToFirst()
    val columnIndex = cursor?.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
    val imagePath = cursor?.getString(columnIndex!!)
    cursor?.close()
    return imagePath
} 

이제 코드를 실행시켜보자. 이미지가 잘 전송될 것이며, 기존 텍스트 메시지와 마찬가지로, 읽지 않은 유저의 수가 나타나고 푸시 알림이 전송될 것이다. 그리고 아래와 같이 사진의 크기에 알맞게 메시지 박스의 크기가 잘 조절되어야 한다. (물론, 극단적으로 얇고 긴 사진의 경우는 알맞게 안 나오겠지만, 일반적인 사진을 담는 데에는 무리가 없어야 한다.)

이미지를 받는 입장에서는 메시지가 좌측에 표시된다. 또한, 채팅방 목록에서 마지막 메시지를 보여줄 때, 마지막 메시지가 이미지 파일이면, "이미지"라고 뜨게 된다.

6. 이미지 클릭 시, 확대된 이미지 보여주기

현재 상태에서 메지지를 클릭하면 메시지를 전송한 유저의 세부 정보 화면으로 전환된다. 이는 RecyclerView 전체에 클릭 이벤트 리스너를 부여했기 때문이다. 프로필을 클릭하면 유저의 세부 정보 화면으로 전환되고, 메시지(이미지)를 클릭하면 이미지 크게 보기 화면(ProfileImageActivity)으로 전환되게 만들자.

① MessageAdapter의 onBindViewHolder를 아래와 같이 수정한다.

  • RecylerView 전체에 대한 클릭 이벤트 리스너를 profile에 대한 클릭 이벤트 리스너로 바꾼다.
  • holder3와 holder4에 대해서는 image에 대한 클릭 이벤트 리스너를 추가해야 하며, 별도의 클릭 이벤트 리스너를 할당해야 한다.
class MessageAdapter(private val context: Context, val items: MutableList<MessageModel>)
    : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    class ItemViewHolder1(private val view: View) : RecyclerView.ViewHolder(view) {
        val contents : TextView = view.findViewById(R.id.messageContentsArea)
        val dateTime : TextView = view.findViewById(R.id.dateTime)
        val nickName : TextView = view.findViewById(R.id.messageNickName)
        val profile : ImageView = view.findViewById(R.id.messageProfileArea)
        val unreadUserContainer : FrameLayout = view.findViewById(R.id.unreadUserCountContainer)
        val unreadUserCount : TextView = view.findViewById(R.id.unreadUserCountTextView)
    }

    class ItemViewHolder2(private val view: View) : RecyclerView.ViewHolder(view) {
        val contents : TextView = view.findViewById(R.id.messageContentsArea2)
        val dateTime : TextView = view.findViewById(R.id.dateTime2)
        val nickName : TextView = view.findViewById(R.id.messageNickName2)
        val profile : ImageView = view.findViewById(R.id.messageProfileArea2)
        val unreadUserContainer : FrameLayout = view.findViewById(R.id.unreadUserCountContainer2)
        val unreadUserCount : TextView = view.findViewById(R.id.unreadUserCountTextView2)
    }

    class ItemViewHolder3(private val view: View) : RecyclerView.ViewHolder(view) {
        val image : ImageView = view.findViewById(R.id.imageContentsArea)
        val dateTime : TextView = view.findViewById(R.id.imageDateTime)
        val nickName : TextView = view.findViewById(R.id.imageNickName)
        val profile : ImageView = view.findViewById(R.id.imageProfileArea)
        val unreadUserContainer : FrameLayout = view.findViewById(R.id.imageUnreadUserCountContainer)
        val unreadUserCount : TextView = view.findViewById(R.id.imageUnreadUserCountTextView)
    }

    class ItemViewHolder4(private val view: View) : RecyclerView.ViewHolder(view) {
        val image : ImageView = view.findViewById(R.id.imageContentsArea2)
        val dateTime : TextView = view.findViewById(R.id.imageDateTime2)
        val nickName : TextView = view.findViewById(R.id.imageNickName2)
        val profile : ImageView = view.findViewById(R.id.imageProfileArea2)
        val unreadUserContainer : FrameLayout = view.findViewById(R.id.imageUnreadUserCountContainer2)
        val unreadUserCount : TextView = view.findViewById(R.id.imageUnreadUserCountTextView2)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val adapterLayout : View?
        return when(viewType) {
            MessageModel.VIEW_TYPE_ME -> {
                adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.message_recycler_view_item, parent, false)
                ItemViewHolder1(adapterLayout)
            }
            MessageModel.VIEW_TYPE_YOU -> {
                adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.message_recycler_view_item2, parent, false)
                ItemViewHolder2(adapterLayout)
            }
            MessageModel.VIEW_TYPE_ME_IMAGE -> {
                adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.image_recycler_view_item, parent, false)
                ItemViewHolder3(adapterLayout)
            }
            MessageModel.VIEW_TYPE_YOU_IMAGE -> {
                adapterLayout = LayoutInflater.from(parent.context).inflate(R.layout.image_recycler_view_item2, parent, false)
                ItemViewHolder4(adapterLayout)
            }
            else -> throw RuntimeException("Invalid View Type Error")
        }

    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val item = items[position]
        when (item.viewType) {
            MessageModel.VIEW_TYPE_ME -> {
                (holder as ItemViewHolder1).contents.text = item.contents
                holder.dateTime.text = item.sendTime
                holder.nickName.text = item.senderNickName
                holder.unreadUserCount.text = item.unreadUserCount.toString()

                if (item.unreadUserCount.toString().toInt() > 0) {
                    holder.unreadUserContainer!!.visibility = View.VISIBLE
                    holder.unreadUserCount.visibility = View.VISIBLE
                } else {
                    holder.unreadUserContainer!!.visibility = View.GONE
                    holder.unreadUserCount.visibility = View.GONE
                }

                if (item.senderProfileUrl != "null") {
                    Glide.with(context)
                        .load(item.senderProfileUrl)
                        .into(holder.profile)
                } else {
                    holder.profile.setImageResource(R.drawable.profile)
                }
                holder.profile.setOnClickListener {
                    profileClickListener.onClick(it, position)
                }
            }

            MessageModel.VIEW_TYPE_YOU -> {
                (holder as ItemViewHolder2).contents.text = item.contents
                holder.dateTime.text = item.sendTime
                holder.nickName.text = item.senderNickName
                holder.unreadUserCount.text = item.unreadUserCount.toString()

                if (item.unreadUserCount.toString().toInt() > 0) {
                    holder.unreadUserContainer!!.visibility = View.VISIBLE
                    holder.unreadUserCount.visibility = View.VISIBLE
                } else {
                    holder.unreadUserContainer!!.visibility = View.GONE
                    holder.unreadUserCount.visibility = View.GONE
                }

                if (item.senderProfileUrl != "null") {
                    Glide.with(context)
                        .load(item.senderProfileUrl)
                        .into(holder.profile)
                } else {
                    holder.profile.setImageResource(R.drawable.profile)
                }
                holder.profile.setOnClickListener {
                    profileClickListener.onClick(it, position)
                }
            }

            MessageModel.VIEW_TYPE_ME_IMAGE -> {
                (holder as ItemViewHolder3).dateTime.text = item.sendTime
                holder.nickName.text = item.senderNickName
                holder.unreadUserCount.text = item.unreadUserCount.toString()

                if(item.imageUrl != "null") {
                    Glide.with(context)
                        .load(item.imageUrl)
                        .into(holder.image)
                    holder.image.clipToOutline = true
                } else {
                    holder.profile.setImageResource(R.drawable.broken_image)
                }
                if (item.unreadUserCount.toString().toInt() > 0) {
                    holder.unreadUserContainer!!.visibility = View.VISIBLE
                    holder.unreadUserCount.visibility = View.VISIBLE
                } else {
                    holder.unreadUserContainer!!.visibility = View.GONE
                    holder.unreadUserCount.visibility = View.GONE
                }

                if (item.senderProfileUrl != "null") {
                    Glide.with(context)
                        .load(item.senderProfileUrl)
                        .into(holder.profile)
                } else {
                    holder.profile.setImageResource(R.drawable.profile)
                }
                holder.profile.setOnClickListener {
                    profileClickListener.onClick(it, position)
                }
                holder.image.setOnClickListener {
                    imageClickListener.onClick(it, position)
                }
            }

            MessageModel.VIEW_TYPE_YOU_IMAGE -> {
                (holder as ItemViewHolder4).dateTime.text = item.sendTime
                holder.nickName.text = item.senderNickName
                holder.unreadUserCount.text = item.unreadUserCount.toString()

                if (item.unreadUserCount.toString().toInt() > 0) {
                    holder.unreadUserContainer!!.visibility = View.VISIBLE
                    holder.unreadUserCount.visibility = View.VISIBLE
                } else {
                    holder.unreadUserContainer!!.visibility = View.GONE
                    holder.unreadUserCount.visibility = View.GONE
                }

                if (item.senderProfileUrl != "null") {
                    Glide.with(context)
                        .load(item.senderProfileUrl)
                        .into(holder.profile)
                } else {
                    holder.profile.setImageResource(R.drawable.profile)
                }

                if(item.imageUrl != "null") {
                    Glide.with(context)
                        .load(item.imageUrl)
                        .into(holder.image)
                    holder.image.clipToOutline = true
                } else {
                    holder.profile.setImageResource(R.drawable.broken_image)
                }
                holder.profile.setOnClickListener {
                    profileClickListener.onClick(it, position)
                }
                holder.image.setOnClickListener {
                    imageClickListener.onClick(it, position)
                }
            }
        }
    }

    override fun getItemCount(): Int {
        return items.size
    }

    override fun getItemViewType(position: Int): Int {
        return items[position].viewType
    }

    interface  OnItemClickListener {
        fun onClick(view : View, position : Int)
    }

    fun setProfileClickListener(onItemClickListener: OnItemClickListener) {
        this.profileClickListener = onItemClickListener
    }

    fun setImageClickListener(onItemClickListener: OnItemClickListener) {
        this.imageClickListener = onItemClickListener
    }

    private lateinit var profileClickListener : OnItemClickListener
    private lateinit var imageClickListener : OnItemClickListener
}

② ChatRoomActivity를 아래와 같이 수정한다.

  • 두 클릭 이벤트 리스너를 구분한다.
messageAdapter.setProfileClickListener(object : MessageAdapter.OnItemClickListener {
    override fun onClick(view: View, position: Int) {
        val uid = messageList!![position].senderUid
        val imgUrl = messageList!![position].senderProfileUrl
        val nickName = messageList!![position].senderNickName
        var intent : Intent? = null
        if(uid == FirebaseAuthUtils.getUid()) {
            intent = Intent(this@ChatRoomActivity, MyProfileActivity::class.java)
            intent.putExtra("imgUrl", imgUrl)
            intent.putExtra("nickName", nickName)
        }
        else {
            intent = Intent(this@ChatRoomActivity, UserDetailActivity::class.java)
            intent.putExtra("uid", uid)
            intent.putExtra("imgUrl", imgUrl)
            intent.putExtra("nickName", nickName)
        }
        startActivity(intent)
    }
})
messageAdapter.setImageClickListener(object : MessageAdapter.OnItemClickListener {
    override fun onClick(view: View, position: Int) {
        val imgUrl = messageList!![position].imageUrl
        val intent = Intent(this@ChatRoomActivity, ProfileImageActivity::class.java)
        intent.putExtra("imgUrl", imgUrl)
        startActivity(intent)
    }
})

코드를 실행해보면, 메시지의 프로필을 클릭하면 유저의 상세 정보 화면으로 이동되고, 이미지를 클릭하면 이미지를 크게 보는 화면으로 이동될 것이다. 아래는 이미지를 클릭했을 때 나타나는 화면이다.

7. 이미지 확대/축소하기

① settings.gradle의 dependencyResolutionManagement > repositories에 아래의 코드를 추가한다.

maven { url = uri("https://jitpack.io") }

② Module 수준의 build.gradle에 아래의 의존성을 추가한다.

implementation("com.github.chrisbanes:PhotoView:2.3.0")

③ activity_profile_image.xml의 ImageView를 PhotoView로 바꿔주자.

<com.github.chrisbanes.photoview.PhotoView
    android:id="@+id/profileImage"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:src="@drawable/profile"/>

코드를 실행시켜보면, 두 손가락으로 화면을 줌인 줌아웃할 수 있게 된다.

현재는 S3에 올라간 이미지를 DB에 저장하고 있지 않다. 조금 더 완성도를 높이고 싶다면, 채팅방에 업로드한 이미지를 저장하는 Entity를 만들어 채팅방과 일대다 관계를 매핑해주어야 할 것이다. 그래서 채팅방에 아무도 남아있지 않게 될 때, 채팅방에서 주고 받은 사진을 모두 S3와 DB에서 삭제하고, (당연히 메시지도 삭제하고) 채팅방이 삭제되도록 처리해야 할 것이다.

하지만, 이 부분은 굳이 코드를 보여주지 않더라도, 지금까지의 내용을 잘 이해하였으면 충분히 혼자 해볼 수 있을 것이다. 따라서 이 내용은 생략하기로 한다.

profile
Java Spring, Android Kotlin, Node.js, ML/DL 개발을 공부하는 인하대학교 정보통신공학과 학생입니다.

0개의 댓글