[Android] Custom Gallery with Paging3

이제일·2023년 6월 10일
0

Android

목록 보기
12/15
post-thumbnail

위 영상은 테스트용 랜덤 해상도 이미지 1000장을 로드하는 결과이다.


커스텀 갤러리 화면을 구현하고자 한다

기기 내의 대량의 이미지가 있다면 쿼리 실행 및 결과 로드가 늦어질 수 있다.
이를 위해 Paging3를 이용해 구현하기로 한다

구현 해야할 것들은 다음과 같다

  • permission 획득
  • 이미지 데이터 모델 정의
  • ContentResolver Query를 통해 이미지 데이터 가져오기
  • 페이징하기 위해 Paging Adapter와 Paging Source 구현
  • 이를 담을 ViewModel과 보여줄 Activity

이 외에 기본 사용 라이브러리로는 Hilt, Glide, ViewBinding정도가 있다.

퍼미션 획득

먼저 필요한 권한을 manifest에 등록한다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
  
  ...
  
    <uses-permission-sdk-23 android:name="android.permission.READ_MEDIA_IMAGES" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
        android:maxSdkVersion="32" />
  
    <application>
                 ...
    </application>

</manifest>

그리고 권한 획득을 위한 코드 작성

@AndroidEntryPoint
class GalleryActivity : BaseActivity<ActivityGalleryBinding>(R.layout.activity_gallery) {
    private val requiredPermissions = arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
    
    ...
    
    private fun fetchGalleryAdapter(){
        if (hasAllPermissions()) {
        	// TODO: fetch images
        } else {
            registerForActivityResult(
                ActivityResultContracts.RequestMultiplePermissions()
            ) { isGranted ->
                if (isGranted.all { it.value }) {
                    // TODO: fetch images
                } else {
                    // TODO("replace permission denied process")
                    Toast.makeText(this, "permission error", Toast.LENGTH_SHORT).show()
                    finish()
                }
            }.launch(requiredPermissions)
        }
    }


    private fun hasAllPermissions() = requiredPermissions.all {
        ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
    }

}

onRequestPermissionsResult가 deprecated 되었기에 registerForActivityResult로 처리
참조: 스택오버플로우


이미지 모델 정의

이제 처리할 이미지를 래핑할 모델을 생성한다

data class GalleryModel(
    val uri: Uri,
    val name: String,
    val fullName: String,
    val mimeType: String,
    val addedDate: Long,
    val folder: String,
    val size: Long,
    val width: Int,
    val height: Int,
){
    fun toGallery() = Gallery(
        uri,
        name,
        fullName,
        mimeType,
        Date(addedDate),
        folder,
        size,
        width,
        height,
    )

}


data class Gallery(
    val uri: Uri,
    val name: String,
    val fullName: String,
    val mimeType: String,
    val addedDate: Date,
    val folder: String,
    val size: Long,
    val width: Int,
    val height: Int,
)

이미지 데이터에서 뽑을 건 모델에 정의된 대로 아래와 같다

uri 이미지 Uri,
name 이름,
fullName 확장자를 포함한 이름,
mimeType Mime 형식의 type,
addedDate 저장된 날짜,
folder 저장된 폴더 명,
size 파일의 크기,
width 사진의 너비,
height 사진의 높이


ContentResolver Query

PagingSource를 구현하기 이전에 데이터를 페이징 해서 가져오는 구문

import android.provider.MediaStore.Images.Media

class GalleryDataSourceImpl @Inject constructor(
    @ApplicationContext context: Context
) : GalleryDataSource {
    private val contentResolver = context.contentResolver

    override fun fetchGalleryImages(limit: Int, offset: Int): List<GalleryModel> {
        val contentUri = Media.EXTERNAL_CONTENT_URI
        val projection = arrayOf(
            Media._ID,
            Media.TITLE,
            Media.DISPLAY_NAME,
            Media.MIME_TYPE,
            Media.DATE_TAKEN,
            Media.BUCKET_DISPLAY_NAME,
            Media.SIZE,
            Media.WIDTH,
            Media.HEIGHT,
        )
        val galleryImage = mutableListOf<GalleryModel>()
        val selection =
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Media.SIZE + " > 0"
            else null

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            contentResolver.query(
                contentUri,
                projection,
                Bundle().apply {
                    // Limit & Offset
                    putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
                    putInt(ContentResolver.QUERY_ARG_OFFSET, offset)

                    // Sort function
                    putStringArray(
                        ContentResolver.QUERY_ARG_SORT_COLUMNS,
                        arrayOf(Media.DATE_TAKEN)
                    )
                    putInt(
                        ContentResolver.QUERY_ARG_SORT_DIRECTION,
                        ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
                    )

                    // Selection
                    putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection)
                }, null
            )
        } else {
            val sortOrder =
                "${Media.DATE_TAKEN} DESC LIMIT $limit OFFSET $offset"
            contentResolver.query(
                contentUri,
                projection,
                selection,
                null,
                sortOrder
            )
        }?.use { cursor ->
            while (cursor.moveToNext()) {
                galleryImage.add(
                    GalleryModel(
                        uri = Uri.withAppendedPath(
                            contentUri,
                            cursor.getLong(cursor.getColumnIndexOrThrow(Media._ID)).toString()
                        ),
                        name = cursor.getString(cursor.getColumnIndexOrThrow(Media.TITLE)),
                        fullName = cursor.getString(cursor.getColumnIndexOrThrow(Media.DISPLAY_NAME)),
                        mimeType = cursor.getString(cursor.getColumnIndexOrThrow(Media.MIME_TYPE)),
                        addedDate = cursor.getLong(cursor.getColumnIndexOrThrow(Media.DATE_TAKEN)),
                        folder = cursor.getString(cursor.getColumnIndexOrThrow(Media.BUCKET_DISPLAY_NAME)),
                        size = cursor.getLong(cursor.getColumnIndexOrThrow(Media.SIZE)),
                        width = cursor.getInt(cursor.getColumnIndexOrThrow(Media.WIDTH)),
                        height = cursor.getInt(cursor.getColumnIndexOrThrow(Media.HEIGHT)),
                    )
                )
            }
        }![](https://velog.velcdn.com/images/dlwpdlf147/post/a0f7cad7-50f7-45a4-b4e0-de7482626044/image.mp4)

        return galleryImage
    }
}

기기 내의 데이터는 contentResolver로 접근을 해야한다
쿼리 형식은 아래와 같고 Curosr를 반환한다.

하지만 안드로이드 11부터는 LIMIT에 관한 쿼리 내용이 바뀌었기에 분기를 두고 진행한다. 참조
이미지 컬럼에 관한 문서


PagingSource

이제 구현한 DataSource를 가지고 PagingSource를 만들어 본다.

class GalleryRepositoryImpl @Inject constructor(
    private val galleryLocalDataSource: GalleryLocalDataSource,
): GalleryRepository {
    override fun getGalleryImagePagingSource():PagingSource<Int, Gallery> {
        return object: PagingSource<Int, Gallery>() {
            override fun getRefreshKey(state: PagingState<Int, Gallery>): Int? {
                return state.anchorPosition?.let { anchorPosition ->
                    val anchorPage = state.closestPageToPosition(anchorPosition)
                    anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
                }
            }
            override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Gallery> {
                try {
                    val pageNumber = params.key ?: 0
                    val response = galleryLocalDataSource.fetchGalleryImages(params.loadSize, pageNumber*params.loadSize)
                    return LoadResult.Page(
                        data = response.map { it.toGallery() },
                        prevKey = if(pageNumber==0) null else pageNumber-1,
                        nextKey = if(response.isEmpty()) null else pageNumber+1
                    )
                } catch (e: Exception) {
                    // TODO("error process")
                    throw e
                }
            }
        }
    }
}

PagingAdapter

PagingSource를 가지고 PagingAdapter 클래스를 구현한다.
recyclerView에 들어갈 item layout인 row_gallery_image.xml파일을 생성했다고 가정한다

class GalleryRecyclerViewAdapter :
    PagingDataAdapter<Gallery, GalleryRecyclerViewAdapter.ImageViewHolder>(diffCallback) {

    companion object {
        val diffCallback = object : DiffUtil.ItemCallback<Gallery>() {
            override fun areItemsTheSame(oldItem: Gallery, newItem: Gallery) =
                oldItem.uri == newItem.uri

            override fun areContentsTheSame(oldItem: Gallery, newItem: Gallery) =
                oldItem == newItem
        }
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): ImageViewHolder = ImageViewHolder(
        RowGalleryImageBinding.inflate(
            LayoutInflater.from(parent.context),
            parent,
            false
        )
    )

    override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
        val item = getItem(position)
        item?.let {
            holder.bind(it)
        }
    }

    inner class ImageViewHolder(
        private val binding: RowGalleryImageBinding
    ) : RecyclerView.ViewHolder(binding.root) {
        fun bind(item: Gallery) {
            binding.run {
                Glide.with(binding.root.context)
                    .load(item.uri)
                    .into(binding.rowGalleryImageView)
            }
        }
    }
}

ViewModel과 Activity

Pager를 보관할 ViewModel

@HiltViewModel
class GalleryViewModel @Inject constructor(
    private val galleryImageFetch: GalleryRepository
): BaseViewModel() {
    val galleryPager = Pager(
        config = PagingConfig(pageSize = 50)
    ) {
        galleryImageFetch.getGalleryImagePagingSource()
    }.flow.cachedIn(viewModelScope)
}

이를 보여줄 Activity

// Activity
    private fun setRecyclerView(){
        galleryRecyclerViewAdapter = GalleryRecyclerViewAdapter()
        bind.galleryRv.adapter = galleryRecyclerViewAdapter
        
        lifecycleScope.launch {
        	viewModel.galleryPager.collectLatest { pagingData ->
            	galleryRecyclerViewAdapter.submitData(pagingData)
            }
        }
    }
profile
세상 제일 이제일

1개의 댓글

comment-user-thumbnail
2024년 1월 15일

GalleryLocalDataSource, GalleryRepository 가 어떻게 짜여있는지 알수 있을까요 아니면 전체코드를 확인할수있는 git 등을 공유받을수 있을까요? GalleryDataSourceImpl는 어디서 사용하고있는지 써둔게 달라서 보면서 이해가 안되네용

답글 달기