일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- coroutine
- 플레이스토어
- Android
- Kotlin
- 안드로이드
- 커스텀뷰
- 컴포즈
- 뷰
- Flow
- NavController
- 리사이클러뷰
- 알고리즘
- MVVM
- 클린아키텍처
- 로그인
- Authentication
- sharedFlow
- 회원가입
- 코틀린
- NavHost
- cleanarchitecture
- ListAdapter
- 코딩테스트
- UiState
- Build variants
- Jetpack
- XML
- Compose
- 파이어베이스
- DiffUtil
- Today
- Total
Grusie 안드로이드 개발 기술 블로그
[Android] 커스텀 갤러리 만들기(이미지 불러오기, 다중 이미지 선택) 본문
안드로이드 개발을 하면서, 갤러리에서 이미지를 가져오는 기능을 안 해볼 수는 없을 것이다.
이번에도 디자이너의 요청에 의해 갤러리에서 이미지를 가져와서, 순서대로 최대 10개까지 선택이 가능하도록 구현 했어야 했다.
갤러리에서 직접 선택하는 것이 아닌, 앱 내 화면에서 사용했어야 했기에 content-provider를 사용하여 이미지 uri를 가져왔어야 했다.
Paging라이브러리를 사용하는 것이 빠른 속도를 낼 것으로 생각되나, DiffUtil을 곁들인 ListAdapter만으로도 충분히 빠른 속도를 낼 수 있었다.
+ 이미지 피커 라이브러리를 사용하지 않은 이유는 원하는 대로 커스텀 하기에 불편하다는 점이 있었다.
만약 라이브러리를 사용하다가, 일정부분 수정을 해야하는데 막혀있다면, 결국 새로 만들어야 했기에 커스텀하게 구현해보려고 한다.
퍼미션
매니페스트
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
매니페스트에 퍼미션 추가
32버전까지는 READ_EXTERNAL_STORAGE, 그 이후부터는 READ_MEDIA_IMAGES와 같이 세분화 되었다.
PermissionUtils
private fun checkPermissionAndRequestPermissions(activity: Activity, permissions: Array<String>, requestCode: Int, grantedCallback : () -> Unit) {
val permissionResults = permissions.map {
ContextCompat.checkSelfPermission(activity, it) == PackageManager.PERMISSION_GRANTED
}
if(permissionResults.all { it }) {
grantedCallback()
} else {
ActivityCompat.requestPermissions(activity, permissions, requestCode)
}
}
fun requestTiramisuPhotoPermission(activity: Activity, grantedCallback: () -> Unit) {
checkPermissionAndRequestPermissions(activity, arrayOf(IMAGE_PERMISSION), IMAGE_PERMISSION_REQUEST_CODE, grantedCallback)
}
fun requestPhotoPermission(activity: Activity, grantedCallback: () -> Unit) {
checkPermissionAndRequestPermissions(activity, arrayOf(MEDIA_PERMISSION), MEDIA_PERMISSION_REQUEST_CODE, grantedCallback)
}
fun isUpperVersion(targetVersion: Int): Boolean{
return Build.VERSION.SDK_INT >= targetVersion
}
현재 build버전과 비교할 버전이 어떤지 알려주는 isUpperVersion, 각 구현에 버전에 맞게 request를 요청하는 코드들을 구현해둔 Util이다.
grantCallback을 받아서, 권한이 허용 되어 있을 경우 처리하도록 구현되어있다.
액티비티
private fun requestPermission() {
if (PermissionUtils.isUpperVersion(VERSION_CODES.TIRAMISU)) {
PermissionUtils.requestTiramisuPhotoPermission(this) {
viewModel.fetchPhotoList(this)
}
} else {
PermissionUtils.requestPhotoPermission(this) {
viewModel.fetchPhotoList(this)
}
}
}
액티비티에서, 현재 버전이 어느 상태인지 비교한 뒤, 그에 해당하는 퍼미션 요청을 보내는 코드이다.
onResume일 때 요청하는 것을 추천한다.
이미지 화면에 뿌리기
이미지 아이템VO
data class PhotoItem(
val id: Int,
val uri: Uri,
var selectedNo: Int? = null
)
이미지 아이템의 정보를 담을 data class이다. 기본적으로 id값을 가지고 있으며, 선택 되었을 시, 해당 순서를 담고있어야 하기에 selectedNo를 선언해준다.
ImagePickerViewModel
@SuppressLint("Range")
fun fetchPhotoList(context: Context) {
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val projection = arrayOf(
INDEX_MEDIA_ID,
INDEX_MEDIA_URI,
INDEX_ALBUM_NAME,
INDEX_DATE_ADDED
)
val selection =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) MediaStore.Images.Media.SIZE + " > 0"
else null
val sortOrder = "$INDEX_DATE_ADDED DESC"
val cursor = context.contentResolver.query(uri, projection, selection, null, sortOrder)
viewModelScope.launch {
withContext(Dispatchers.IO) {
val tempImageItemList = mutableListOf<PhotoItem>()
cursor?.let {
var id = 0
while (cursor.moveToNext()) {
val mediaPath = cursor.getString(cursor.getColumnIndex(INDEX_MEDIA_URI))
tempImageItemList.add(
PhotoItem(id = id++, uri = Uri.fromFile(File(mediaPath)))
)
}
}
withContext(Dispatchers.Main) {
_photoItemList.emit(tempImageItemList)
setUiState(ImagePickerUiState.Success)
}
cursor?.close()
}
}
}
뷰모델이다. context를 전달하는 것이 마음에 안들었으나, 로직에 관련한 것은 ViewModel에서 처리하는 것이 맞다고 생각하여 이렇게 구현하였다.
추 후, 앨범 이름, 정렬 등을 사용하게 될지도 모르겠으나 우선 이렇게 처리하였다.
날짜를 기준으로 내림차순 하여, 외부 컨텐츠에서 id, uri, 앨범이름, 날짜를 가져오도록 구현되어 있다.
id는 그냥 가져온 순서대로 지정해두었다.
백그라운드 스레드에서 cursor를 사용하여, while문을 돌리고, Main스레드에서 값을 변경해준다.
뷰
lifecycleScope.launch {
viewModel.photoItemList.collectLatest { photoList ->
photoAdapter.submitList(photoList)
}
}
액티비티에서는 lifecycleScope를 활용해서 photoItemList를 collect해주며, 받아온 정보를 가지고 adapter에 submitList로 변경사항을 전달한다.
어댑터
class PhotoAdapter(private val photoClickListener: (Int) -> Unit) :
ListAdapter<PhotoItem, PhotoViewHolder>(PhotoDiffUtilCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoViewHolder {
return PhotoViewHolder.from(parent, photoClickListener)
}
override fun getItemCount(): Int {
return currentList.size
}
override fun onBindViewHolder(holder: PhotoViewHolder, position: Int) {
onBindViewHolder(holder, position, mutableListOf())
}
override fun onBindViewHolder(
holder: PhotoViewHolder,
position: Int,
payloads: MutableList<Any>
) {
val item = getItem(position)
if (payloads.isEmpty() || payloads[0] !is Bundle) {
holder.bind(item, position)
} else {
holder.update(payloads[0] as Bundle)
}
}
companion object {
const val SELECT_PAYLOAD = "select_payload"
}
}
일반 리사이클러뷰 어댑터와 다른 점은 없으나, payloads를 파라미터로 받는 OnBindViewHolder를 오버라이딩 해, 구현하였다. 클릭이벤트가 발생했을 때, 선택된 selectedNo에 변경이 발생하면 payloads를 발생시키도록 구현하기 위함이다.
뷰홀더
class PhotoViewHolder(
private val binding: ItemPhotoBinding,
private val photoClickListener: (Int) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(photoItem: PhotoItem, position: Int) {
binding.run {
this.photoItem = photoItem
this.position = position
Glide.with(itemView).load(photoItem.uri).placeholder(R.drawable.ic_sticker)
.into(ivPhoto)
itemView.setOnClickListener { photoClickListener(position) }
}
}
fun update(bundle: Bundle) {
if (bundle.containsKey(PhotoAdapter.SELECT_PAYLOAD)) {
val selectedNo = bundle.getInt(PhotoAdapter.SELECT_PAYLOAD)
binding.photoItem = binding.photoItem?.copy(
selectedNo =
if (selectedNo == -1) null else selectedNo
)
}
}
뷰홀더에서는 처음 생성되었을 때에는 bind, payloads가 발생했을 때에는 update함수를 호출하도록 구현 해두었고, payloads가 발생하면, selectedNo를 받아와서 copy를 사용해 데이터 바인딩을 실시한다.
만약 -1이 넘어올 경우 null을 넣어 화면에서 표시되지 않도록 구현하였다.(data binding 사용)
이미지 선택하기
fun togglePhotoItem(position: Int) {
viewModelScope.launch {
val updatedSelectedItemList = _selectedPhotoItemList.value.toMutableList()
val photoItem = _photoItemList.value[position]
val index = updatedSelectedItemList.indexOfFirst { it.id == photoItem.id }
if (index > -1) {
val removedItem = updatedSelectedItemList.removeAt(index)
val updatedPhotoItemList = _photoItemList.value.toMutableList()
updatedPhotoItemList[removedItem.id] = removedItem.copy(selectedNo = null)
for (i in index until updatedSelectedItemList.size) {
val updatedSelectedItem = updatedSelectedItemList[i]
val updatedPhotoItem = updatedPhotoItemList[updatedSelectedItem.id]
updatedSelectedItemList[i] = updatedSelectedItem.copy(selectedNo = i + 1)
updatedPhotoItemList[updatedSelectedItem.id] =
updatedPhotoItem.copy(selectedNo = i + 1)
}
_photoItemList.emit(updatedPhotoItemList)
} else {
if (updatedSelectedItemList.size >= 10) {
_eventState.emit(ImagePickerEventState.Alert("사진 선택은 최대 10개 까지만 가능합니다."))
return@launch
}
val selectedNo = updatedSelectedItemList.size + 1
val updatedItemList = _photoItemList.value.toMutableList()
updatedItemList[position] = updatedItemList[position].copy(selectedNo = selectedNo)
_photoItemList.emit(updatedItemList)
updatedSelectedItemList.add(photoItem.copy(selectedNo = selectedNo))
}
_selectedPhotoItemList.emit(updatedSelectedItemList)
}
}
selectedPhotoItemList를 생성해두고, 아이템이 선택되었을 시, 그 아이템을 넣도록 구현하였다.
선택 해제시엔, 이미 있는 값을 없애고, 그 이후에 들어온 아이템들의 selectedNo를 수정하도록 구현하였다.
selectedPhotoItemList는 추 후 이미지 크롭을 위해 넘길 때 필요할 것으로 예상된다.
copy를 사용하여 값이 변경되었기에 photoItemList도 변경 알림을 보낼 것이고, collect에서 수집할 수 있다.
그렇게 수집된 데이터로 submitList를 통해 Adapter로 변경사항을 알리고 DiffUtils에서 변경점을 파악한다.
DiffUtil
object PhotoDiffUtilCallback : DiffUtil.ItemCallback<PhotoItem>() {
override fun areItemsTheSame(oldItem: PhotoItem, newItem: PhotoItem): Boolean {
return oldItem.uri == newItem.uri
}
override fun areContentsTheSame(oldItem: PhotoItem, newItem: PhotoItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: PhotoItem, newItem: PhotoItem): Any? {
if (oldItem.id == newItem.id) {
return if (oldItem.selectedNo == newItem.selectedNo) {
super.getChangePayload(oldItem, newItem)
} else {
val diff = Bundle()
newItem.selectedNo.let {
diff.putInt(PhotoAdapter.SELECT_PAYLOAD, it ?: -1)
}
diff
}
}
return super.getChangePayload(oldItem, newItem)
}
}
처음에는 id를 넣지 않고 url로만 비교를 하려고 했었기에, areItemsTheSame을 url로 넣어둔 모습이다.
id로 비교하여도 똑같은 결과가 나오니, 상황에 맞게 사용하면 될 것 같다.
getChangePayload를 오버라이딩 해, 이전 아이템과 같은데 selectedNo가 다르다면, 새로운 selectedNo를 bundle로 담아 보낸다.
저렇게 보내진 bundle은 Adapter의 Payloads를 파라미터로 받는 OnBindViewHolder를 통해 ViewHolder로 넘어가게 된다.
결과
휴대폰이 가지고 있는 이미지들을 화면에 띄워주고, 10개까지 선택 가능하고, 이미 선택된 값 클릭 시 선택 해제와 이후의 선택 순서들을 변경해주는 로직이 잘 동작하는 것을 볼 수 있다.
참고
https://github.com/ParkSangGwon/TedBottomPicker
후기
사실 찰스님이 만드신 이미지피커 라이브러리 Pickle을 사용하고 싶었는데, Github가 왜인지 사라져 있었다...
그래서 커스텀이 어느정도까지 가능한지 확인해보려고 했으나 불가능하였다. 박상권님의 TedPicker는, 원하는 만큼 커스텀을 하지 못했던 것 같고, 사실 뷰를 원하는대로까지 커스텀이 가능한 라이브러리는 없을 것 같다.
갤러리에서 이미지를 불러오는 것은 여러번 해도 어렵다고 생각이 들었던 것 같다. 하지만 페이징 처리없이 그냥 불러오기만 하는 거면 그렇게 어려울 것도 없는 것 같다.
'안드로이드 개발' 카테고리의 다른 글
[Android] 뷰모델에 런타임 오브젝트 의존성 주입하기(@AssistedInject) (1) | 2024.06.26 |
---|---|
[Android] 이미지 저장 및 삭제 로직 공유 (1) | 2024.06.14 |
[Android] release 버전에서 Gson 파싱이 안 되던 오류(코드 난독화 문제) (0) | 2024.05.07 |
[Android] 플레이 스토어 버전 업데이트 관리하기 (1) | 2024.05.07 |
[Android] 리사이클러뷰 DiffUtil 사용법 및 단일 체크 처리하기 (0) | 2024.04.30 |