오늘은 사진첩 만드는 과정을 진행할 겁니다. 틀은 다음과 같습니다.
이런 느낌의 사진첩을 만들건데 여기서 필요한 것은 다음과 같습니다.
1) RecyclerView (GridLayout을 통한 레이아웃매니저 관리)를 활용한 뷰규성
2) 이미지는 불러온 만큼 들어오고 나머지엔 사진 불러오기.. 문구가 들어가는것으로 보아
item.xml은 두 개가 필요하며 하나는 이미지뷰 다른 하나는 TextView로 셋팅하면 될 것입니다.
3) 사진첩 접근에 대한 권한 확인
4) 사진을 가져올 때 Uri로 넣고 사진 불러오기 + 사진불러오기 선택시 해당 itemView에 리스너달기
5) sealed class 안에 data class(모델 -> 이미지(uri)), object 형태(싱글톤 느낌으로 상속 중인 클래스에 쉽게 접근하여 함수호출)
우선 xml부터 만져보겠습니다.
버튼을 하나 만들고 RecyclerView를 두도록 하겠습니다.
이는 권한 호출에 대한 버튼과 사진을 가져왔을때 이미지를 격자 형태로 넣어주기 위함입니다.
[activity_main.xml]
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/loadImageButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="이미지 가져오기"
app:layout_constraintVertical_bias="0.9"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/imageRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toTopOf="@+id/loadImageButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
다음은 item으로 쓰일 이미지뷰 담는 xml이랑 TextView를 담는 xml을 각가 만들어보겠습니다.
이는 어댑터에서 인플레이팅 할 때 viewType에 따라서 어떤 xml을 인플레이팅 할 지 정하고
거기서 사용할 바인딩 객체를 RecyclerView.ViewHolder를 상속하는 클래스의 인자로 두어 item에 접근할 것입니다.
어댑터를 사용할 때 일반적인 객체 가져올 때 내부적으로 이미 정해진 내용이 있어서 자동 셋팅되는 부분이 있습니다.
처음 공부할 땐 이게 왜? 라는 생각도 들긴 하지만 [ctrl+클릭]을 통해 타고 들어가서 확인해보면 이해가 됩니다.
1) currentList -> 어댑터에서 사용중인 현재의 리스트
2) itemView -> item.xml을 가져와서 바인딩 한 후 여기서 뷰 내 데이터 접근할 때 부모 레이아웃 안에 자식 레이아웃을
인플레이팅 한 것이기 때문에 자동으로 itemView를 통해 접근할 수 있음
[item_image.xml]
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/previewImageView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/white"
android:scaleType="fitXY"
app:layout_constraintDimensionRatio="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
[item_load_more.xml]
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/loadMoreTextView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:gravity="center"
android:text="사진 불러오기"
android:textColor="@color/black"
android:textSize="18sp"
app:layout_constraintDimensionRatio="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
이제 xml 구성은 끝났습니다.
다음으로 할 것은 MainActivity에서 버튼 클릭시 퍼미션을 확인하는 방법을 알아보겠습니다.
1) 권한이 있는지 확인을 먼저합니다.
1-1) 권한이 없으면 권한이 필요하다는 내용을 설명해야합니다.
1-2) 권한이 있으면 그대로 진행(사진가져오기)
2) 1-1에서 권한이 없어서 권한 필요하다는 설명을 하고 나서 OK하면 권한 부여하는 로직 진행
3) 2에서 No하면 사용X, 단! 앱이 죽지 않고 사용자가 이용하는데 불편함이 없어야 합니다.
[MainActivity.kt]
private fun checkPermission(){
// [ContextCompat.checkSelfPermission]
// 퍼미션이 있는지 스스로 확인 먼저 할 것
when {
ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.READ_EXTERNAL_STORAGE
) // PackageManager를 통해 퍼미션이 부여됫는지 거절됫는지 확인이 가능함
== PackageManager.PERMISSON_GRANTED -> {
loadImage()
}
// 없는 경우라면 설명이 필요
shouldShowRequestPermissionRationale (
android.Manifest.permission.READ_EXTERNAL_STORAGE
) -> {
showPermissionInfoDialog() // 설명하기 위해 다이얼로그 만들기
}
// 두 케이스가 모두 아닌 경우 권한 제공
else -> {
requestReadExternalStorage()
}
}
}
앞서 설명한대로 [권한 확인, 권한 설명, 권한 제공] 로직을 따릅니다.
private fun loadImage(){
imageLauncher.launch("image/*") // 이미지 가져올 때도 registerForActivityResult를 쓰는데
// MimeType에는 image/* 로 두어 모든 이미지 형태를 가져올 수 있도록 합니다.
}
private fun showPermissionInfoDialog(){
AlertDialog.Builder(this).apply {
setMessage("이미지 가져오기를 위해선 외부 저장소 권한 읽기가 필요함")
setNegativeButton("취소", null)
setPositiveButton("동의") { _, _ ->
requestReadExternalStorage()
}
}.show()
}
// checkSelfPermission은 ContextCompat, requestPermissions는 ActivityCompat!
// 현재 컨텍스트에서 권한 줄 것을 배열 형태로 받고 요청값을 준다.
// requestPermissions로 주게되면 이 부분은 onRequestPermissionsResult 함수에서 받는다.
private fun requestReadExternalStorage(){
ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE),
REQUEST_READ_EXTERNAL_STORAGE
) // requestCode == 100
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when(requestCode) {
REQUEST_READ_EXTERNAL_STORAGE -> {
val resultCode = grantResults.firstOrNull() ?: PackageManager.PERMISSION_DENIED
if(resultCode == PackageManager.PERMISSION_GRANTED){
loadImage()
}
}
}
}
권한 제공 할 때 ActivityCompat.requestPermissions로 해서 권한 제공 받을 것들을 배열로 보내는데,
이는 onRequestPermissionsResult라는 오버라이딩 된 함수에서 결과값을 받아 구현합니다.
다음으로 구현할 것은 MainActivity.kt 안에서 RecyclerView 구현과 어댑터 꼽는걸 하겠습니다.
class MainActivity: AppCompatAcitivty() {
private lateinit var binding: ActivityMainBinding
// loadImage 함수에서 launch("image/*")로 진행했는데
// 이거에 대한 콜백으로 선택한 이미지를 uri 형태의 리스트로 받기 때문에 다음과 같이 진행함
private val imageLoadLauncher = registerForActivityResult(ActivityResultContracts.GetMultipleContents()) { uriList ->
updateImages(uriList)
}
private var imageAdapter: ImageAdapter? = null
companion object {
const val REQUEST_READ_EXTERNAL_STORAGE = 100
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.loadImageButton.setOnClickListener {
checkPermission()
}
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
initRecyclerView()
}
}
바인딩 가져오는것은 더 설명을 안해도 될 것 같고, 앞서 사진첩 가져올 때에도 requestPermissions 진행할 때 세 번째 인자로
requestCode를 넘기는데 이 부분은 companion object 내 상수로 셋팅해두었습니다.
또한 imageLoadLauncher를 만들었는데, 권한이 있을때 loadImage() 라는 함수를 호출하고 이 함수 내에서는
imageLoadLauncher.launch("image/*")로 셋팅해두었습니다. 따라서 결과적으로 사진첩에 가서
이미지를 선택했을때 해당 이미지들의 uri는 콜백형태로 런처에서 받아 uriList형태로 들어옵니다.
따라서 updateImages(uriList)는 현재 사진 uri 리스트와 선택해 가져온 사진 uri 리스트를 합쳐서 관리해야 한다고
미리 생각을 해두면 좋을 것 같습니다.
private fun initRecyclerView() {
imageAdapter = ImageAdapter(object : ImageAdapter.ItemClickListener {
override fun onLoadMoreClick() {
checkPermission()
}
})
binding.imageRecyclerView.apply {
adapter = imageAdapter
layoutManager = GridLayoutManager(context, 2)
}
}
RecyclerView는 어댑터를 꼽고 layoutManager를 격자 형태인 GridLayoutManager로 셋팅합니다.
이제 ImageAdapter를 고민해봅시다.
어떻게 만들어져야 할까요?
우선 이미지가 들어가야하고 이는 리스트 형태로 들어갈 것입니다.
uri 형태일 것이고 binding에서 itemView를 접근할 때 현 리스트에 포지션별로 하나씩해서 uri를 담아줘야 할 것입니다.
또한 사진불러오기 라는 텍스트를 선택했을때 권한을 확인하는 로직이 진행될 것 입니다.
이 부분은 위 코드에서 현 아이템 클릭이 진행된 경우 구현 부를 정의하여 처리 가능하도록 만들어놨습니다.
여기까지의 정보로 어댑터를 만들어 보겠습니다.
[ImageAdapter.kt]
package com.example.mygallery.adapters
import android.content.ClipData.Item
import android.content.Context
import android.net.Uri
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.mygallery.databinding.ItemImageBinding
import com.example.mygallery.databinding.ItemLoadMoreBinding
// 1인자 : item, 2인자 : RecyclerView.ViewHolder
class ImageAdapter(private val itemClickListener: ItemClickListener?= null) :
ListAdapter<ImageItems, RecyclerView.ViewHolder >(object : DiffUtil.ItemCallback<ImageItems>() {
// 같은 데이터인지
override fun areItemsTheSame(oldItem: ImageItems, newItem: ImageItems): Boolean {
return oldItem === newItem // kt = equal 값 같음
}
// 같은 데이터를 참조하는건지
override fun areContentsTheSame(oldItem: ImageItems, newItem: ImageItems): Boolean {
return oldItem == newItem // == 참조
}
}) {
// footer 구현
override fun getItemCount(): Int {
val originSize = currentList.size // ListAdapter에서 가지고 있는 현재 리스트
return if(originSize == 0) 0 else originSize.inc() // Footer가 추가되어서 하나 넣는 느낌
}
// Item이 둘 이상의 요소가 생기면서 타입도 체크해야하는 경우가 생김
override fun getItemViewType(position: Int): Int {
// 마지막엔 꼭 Footer
return if(itemCount.dec() == position){
ITEM_LOAD_MORE
} else {
ITEM_IMAGE
}
// return super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = parent.context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
return when(viewType){
ITEM_IMAGE -> {
val binding = ItemImageBinding.inflate(inflater, parent, false)
ImageViewHolder(binding)
}
// ITEM_LOAD_MORE
else -> {
val binding = ItemLoadMoreBinding.inflate(inflater, parent, false)
LoadMoreViewHolder(binding)
}
}
}
// 자식 클래스로 sealed class가 부모다보니 자식을 알아서 셋팅 알아서 함
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when(holder){
is ImageViewHolder -> {
holder.bind(currentList[position] as ImageItems.Image)
}
is LoadMoreViewHolder -> {
itemClickListener?.let { holder.bind(it) }
}
}
}
companion object {
const val ITEM_IMAGE = 0
const val ITEM_LOAD_MORE = 1
}
interface ItemClickListener {
fun onLoadMoreClick()
}
}
sealed class ImageItems {
data class Image(
val uri: Uri,
) : ImageItems()
object LoadMore : ImageItems()
}
class ImageViewHolder(private val binding: ItemImageBinding): RecyclerView.ViewHolder(binding.root){
fun bind(item: ImageItems.Image){
binding.previewImageView.setImageURI(item.uri)
}
}
class LoadMoreViewHolder(private val binding: ItemLoadMoreBinding): RecyclerView.ViewHolder(binding.root){
fun bind(itemClickListener: ImageAdapter.ItemClickListener){
itemView.setOnClickListener { itemClickListener.onLoadMoreClick() }
}
}
너무도 중요한 것들이 많아서 하나씩 설명하겠습니다.
우선 ListAdapter입니다. 이건 리스트에 아이템의 모델과 RecyclerView.ViewHolder를 인자로 받아 구현되는데, 가장 중요한
object: DiffUtil.ItemCallback 부분을 구현하는 것입니다.
이 ListAdapter는 기존 RecyclerView의 어댑터와 다르게 데이터의 변화에 따라 notify 없이도
구성을 동적으로 관리할 수 있다는 장점이 있습니다.
해당 어댑터의 submitList에 변화 내용만 적용한다면 자동으로 notify가 되는 장점이 있는것이지요
DiffUtil 부분을 관리하는데, areItemsTheSame은 값 자체가 같은지를 비교하는 로직이고 areContentsTheSame은
값 참조하는 부분이 같은지를 확인하는 구조입니다.
따라서 다름을 체크할 때 값과 참조부까지 검증하여 현 리스트에 데이터를 관리하는 구조입니다.
다음은 getItemCount() 함수인데, 이미지 외에 "사진 불러오기" 와 같은 로드 형태의 텍스트가 있기 때문에
현재 어댑터에서 관리하는 리스트의 사이즈가 0이 아니라면 1 증가해서 넣어주는 것이 맞습니다.
그리고 앞서 onCreateViewHolder로 item.xml을 바인딩 및 인플레이팅 하는 과정을 넣을때
ViewType을 가지고 나눠서 구현하기로 했었기 때문에 getItemViewType을 오버라이딩해서 정의합니다.
// Item이 둘 이상의 요소가 생기면서 타입도 체크해야하는 경우가 생김
override fun getItemViewType(position: Int): Int {
// 마지막엔 꼭 Footer
return if(itemCount.dec() == position){
ITEM_LOAD_MORE
} else {
ITEM_IMAGE
}
// return super.getItemViewType(position)
}
itemCount는 현재 currentList안에 들어간 아이템 갯수를 의미하며, 하나가 줄었을 때 현 위치라하면
"사진 불러오기"를 보여줘야 하므로 ITEM_LOAD_MORE (상수로 구현) 호출하고 그게 아니라면
이미지를 보여줘야 하므로 ITEM_IMAGE(상수 구현) 이를 호출합니다.
이 부분은 추후에 onCreateViewHolder에서 구분자로 두어 인플레이팅 하는데 쓰일 것입니다.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = parent.context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
return when(viewType){
ITEM_IMAGE -> {
val binding = ItemImageBinding.inflate(inflater, parent, false)
ImageViewHolder(binding)
}
// ITEM_LOAD_MORE
else -> {
val binding = ItemLoadMoreBinding.inflate(inflater, parent, false)
LoadMoreViewHolder(binding)
}
}
}
다음과 같이 inflater를 불러오는데, 이 부분은 다음 어댑터가 쓰일 부모 컨텍스트에 대해 레이아웃 인플레이터를 가져오는 형태고
이를 앞서 viewType으로 ITEM_IMAGE 인지 아니면 그 외(ITEM_LOAD_MORE) 인지로 체크해서 바인딩을 가져옵니다.
단, 현재 item.xml로 되어 있는 곳에서 바인딩을 가져와 inflate하는 부분이고 이 inflater도 부모 뷰에 붙은 자식 뷰 바인딩이기에
.inflate(inflater, parent, false) 까지 수행해주셔야 합니다.
companion object {
const val ITEM_IMAGE = 0
const val ITEM_LOAD_MORE = 1
}
interface ItemClickListener {
fun onLoadMoreClick()
}
}
sealed class ImageItems {
data class Image(
val uri: Uri,
) : ImageItems()
object LoadMore : ImageItems()
}
다음은 앞서 viewType을 정수형태로 상수로 관리하고자 companion object를 쓴 것이고 현재 리스트의 아이템을
선택한 경우에 대한 인터페이스를 만들어놓고 함수 정의부만 셋팅해둡니다.
그리고 실제로 이 부분은 onBindViewHolder에서 아이템뷰에 바인딩을 해두고 그러면 자식뷰의 아이템과
데이터 모델이 되는 클래스와의 바인딩 그리고 선택에 대한 바인딩까지 구현되어 있기에
MainActivity.kt에서 어댑터가지고 쉽게 접근할 수 있습니다.
또한 ItemClickListener 인터페이스만 봤을 때에도 정의부 onLoadMoreClick()에 대해서 구현을
MainActivity.kt에서 진행하면 됩니다.
데이터 클래스 부분은 원래 data class로 따로 파일을 생성해도 되고 이렇게 어댑터 클래스 쪽에다가 생성해도 되는데,
sealed class를 두고 그 안에 Image라는 data class를 두어 Model 형태로 관리한 이유는 object 키워드를 같이 사용하기 위함입니다.
원래는 "object 객체명 : 클래스()" 를 통해 접근하고 해당 클래스는 open으로 구현되어 상속받을 수 있도록 하는것이 기본 규칙입니다.
그리고 다른 곳에서는 "객체명.함수명()" 처럼 싱글턴 방식으로 접근하는 형태인데, 이를 sealed class 안에 넣어둔다면 바로 상속이
가능하며, onBindViewHolder에서 바인딩 작업을 할 때 holder 즉, 뷰에 바인딩을 해두면 LoadMore()을 바로 접근이 가능합니다.
설명하기 어려운 부분이니 그림을 참조하겠습니다.
그래서 실제로 onCreateViewHolder에서 데이터 모델이나 object로 정의된 함수를 붙이는 생성하는 절차를
진행하는데, onCreateViewHolder에서의 binding을 인자로 받아서 Recycler.ViewHolder(binding.root)로
뷰에 대해 바인딩 작업을 진행하게 된다면, (= 데이터 모델이나 object를 실제 아이템 뷰에 셋팅하는 과정)
onBindViewHolder에서 바인딩 작업을 통해 변화된 아이템뷰의 내용이 실제 뷰로 볼 수 있게 되는 것입니다.
class ImageViewHolder(private val binding: ItemImageBinding): RecyclerView.ViewHolder(binding.root){
fun bind(item: ImageItems.Image){
binding.previewImageView.setImageURI(item.uri)
}
}
class LoadMoreViewHolder(private val binding: ItemLoadMoreBinding): RecyclerView.ViewHolder(binding.root){
fun bind(itemClickListener: ImageAdapter.ItemClickListener){
itemView.setOnClickListener { itemClickListener.onLoadMoreClick() }
}
}
이처럼 onCreateViewHolder에서 이루어질 것을 RecyclerView.ViewHolder 상속하는 클래스 만들어서 바인딩 보낸걸로 쓰되,
ViewHolder의 인자가 현재 binding의 root로 뷰를 셋팅하여 뷰 생성 작업을 진행하는 것입니다.
ImageViewHolder에서 bind 함수는 onBindViewHolder에서 현재 리스트 내 이미지와 바인드 작업을 진행할 것이고
이는 현재 리스트 내 이미지가 하나의 아이템이 되어 uri를 가져와 아이템 뷰에 이미지에 setImageURI로 담아 넣을 것입니다.
LoadMoreViewHolder 또한 앞의 경우와 마찬가지로 진행되지만, bind 내 ImageAdapter.ItemClickListener를 두었고
itemView는 현 리스트의 아이템을 선택했을때이며, 리스너를 달 때 itemClickListener에서 onLoadMoreClick()을 object를 통해
바로 접근이 가능합니다.
onBindViewHolder에서는 ItemClickListener를 직접 구현할 곳을 택하여 바인딩 해야하므로
LoadMoreViewHolder 자체에 binding 부분을 엮어두면 됩니다.
// 자식 클래스로 sealed class가 부모다보니 자식을 알아서 셋팅 알아서 함
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when(holder){
is ImageViewHolder -> {
holder.bind(currentList[position] as ImageItems.Image)
}
is LoadMoreViewHolder -> {
itemClickListener?.let { holder.bind(it) }
}
}
}
이처럼 자식 뷰에서 뭘 만들어둔지 알기 때문에 ImageItems.Image를 현재 리스트의 이미지와 연결할 수 있는 것이고
itemClickListener 또한 결국 뷰에서 구현될 부분이기에 현재 뷰를 바인딩 한 것입니다.
나중에 이 부분은 어댑터 객체를 생성할 때 ImageAdapter.ItemClickListener를 구현하는 과정에서
해소가 될 수 있는 부분입니다. 결국 View에서 다음 리스너가 구현되기 때문입니다.
설명하면서 이해하기가 쉽지는 않았지만 조금이나마 도움이 되길 바라며 개인적으로 다음 코딩 로직을 체화하는 것이 중요한 것 같습니다.
다음 포스팅에서는 해당 내용 이어서 개발해보도록 하겠습니다. 감사합니다.
'Android' 카테고리의 다른 글
WebView & ViewPager2 / Fragment (0) | 2024.08.16 |
---|---|
ViewPager2와 TabLayout (0) | 2024.08.14 |
RoomDB 활용 방법과 Coroutine 사용 (0) | 2024.07.29 |
단어장앱-1 (RecyclerView) (2) | 2024.07.26 |
타이머(AlertDialog/CustomView/Thread/Progress) 앱 (7) | 2024.07.24 |