본문 바로가기

Android

[Android] Multi Select Gallery만들기 with Paging

Pictured by sson_peace

앱에서 사진을 불러와야 할 때 어떻게 하셨나요??

보통 Intent를 사용하여 핸드폰의 갤러리를 열고 사진을 가져왔을 것입니다.

이 방식으로 했을 때 제 스마트폰은 삼성갤러리로 접근이 되어 이미지 다중 선택이 되지 않습니다.

(아니면 제가 방법을 모르는걸지도,,?)

 

그래서 저는 MediaStore에서 이미지 파일을 모두 불러온 뒤 Recyclerview에 로드해주었습니다.

많은 스마트폰에는 보통 수천개에서 수만개의 사진이 저장되어 있기 때문에 한번에 뷰에 로딩하는 것이 아니라 Paging을 이용하여 스크롤할 때마다 원하는 청크만큼 데이터를 요청하여 가져옵니다.

이렇게 하면 갤러리앱을 열지 않고 성능 저하 없이 직접 커스텀해서 사용할 수 있습니다!

 

 

1️⃣ Dependency추가

- version은 3.0.0-alpha버전에서 최신꺼 찾아서 쓰시면 됩니다.

    //Paging
    implementation "androidx.paging:paging-runtime-ktx:$paging_version"
    implementation "androidx.paging:paging-rxjava2-ktx:$paging_version"

 

2️⃣ 리사이클러뷰 만들기

- 각자 입맛대로 Recyclerview와 아이템을 만드시면 됩니다.

<activity_main.xml>

<layout 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">
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".view.MainActivity">

        <TextView
            android:id="@+id/textview_gallery"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Multiple Selection"
            android:textSize="30sp"
            android:textStyle="bold"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintVertical_bias="0.05"/>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerview_images"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginTop="25dp"
            app:layout_constraintHeight_percent="0.68"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/textview_gallery"
            app:layout_constraintVertical_bias="0.114"
            tools:listitem="@layout/item_image" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

<item_image.xml>

<layout xmlns:tools="http://schemas.android.com/tools">
    <data>
        <variable
            name="data"
            type="com.example.mediaplayer.data.model.ImageModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="120dp"
        android:layout_height="wrap_content"
        xmlns:app="http://schemas.android.com/apk/res-auto">

        <androidx.cardview.widget.CardView
            android:id="@+id/cardview_picture"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintDimensionRatio="1:1"
            app:layout_constraintTop_toTopOf="parent"
            app:cardCornerRadius="15dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:elevation="@null">

            <ImageView
                android:id="@+id/imageview_picture"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scaleType="fitXY"
                colorFilter="@{data.isSelected}"
                uploadImage="@{data.imageDataPath}" />

        </androidx.cardview.widget.CardView>

        <ImageView
            android:id="@+id/imageview_select"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/round_btn_heart_2"
            selectedMarker="@{data.isSelected}"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:elevation="2dp"
            tools:src ="@drawable/round_btn_heart_2"/>


    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

- BindingAdapter를 사용하여 uri를 적용할 수 있게 하였고, 클릭여부를 판단하여 뷰를 갱신하도록 하였습니다.

 

3️⃣ DataClass 생성

data class ImageModel(
    val imageDataPath: Uri,
    val dateTaken : Long,
    val displayName: String,
    val id: Long,
    var isSelected: Boolean
)

- 자신이 가져오고 싶은 형태에 맞추어 데이터 클래스를 만듭니다.

- 여기까지는 일반적인 Recyclerview만드는 것과 똑같죠?

 

4️⃣ PagingSource 생성

    override fun getRefreshKey(state: PagingState<Int, ImageModel>): Int? {
        return state.anchorPosition
    }

Paging Source<Key, Value>는 두가지 유형의 매개변수가 있습니다.

Key는 데이터를 로드하는데 사용되는 식별자를 정의하며, 값은 데이터 자체의 유형입니다.

저는 페이지 번호를 Int로 하고 ImageModel형태로 데이터를 로드하고자 합니다.

getRefreshKey()

- 로드된 페이지 데이터의 중간에서 새로고침을 다시 시작하는 방법을 정의해야한다.

- state.anchorPosition을 최근에 액세스한 색인으로 사용하여 정확한 초기키를 매핑해야한다.

load()

- 메서드명 그대로 데이터를 load한다.

- PagingState객체를 매개변수로 취하고 데이터가 새로고침되거나 첫 로드 후 무효화 되었을 때 키를 반환하여 load()로 전달한다.

- 데이터 로드 요청은 여러가지 이유로 실패할 수 있다. 특히 네트워크를 통해서 데이터를 로드 하는 경우에는 더 빈번하다. 이러한 이유로 우리는 Error Handling을 해주어야한다.

- 데이터 로드 중 발생한 에러는 load() 메서드의 LoadResult.Error 객체를 반환하여 알 수 있다.

- 성공적으로 로드가 된다면 LoadResult.Page 객체를 반환한다.

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ImageModel> {
        return try {
            val pagedKeyDate = params.key ?: System.currentTimeMillis()
            val images = getImages()
            val lastItemDate = images.last().dateTaken

            if (pagedKeyDate == lastItemDate) {
                return LoadResult.Error(Throwable())
            }

            LoadResult.Page(
                data = images,
                prevKey = null,
                nextKey = images.last().id.toInt()
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

 

5️⃣ MediaStore에서 이미지 읽어오기

- MediaStore를 통해서 이미지 데이터를 읽어오는 코드는 다음과 같습니다.

 private suspend fun getImages()= withContext(Dispatchers.IO) {
        val projection = arrayOf(
            MediaStore.Images.Media._ID,
            MediaStore.Images.Media.DISPLAY_NAME,
            MediaStore.Images.Media.DATE_TAKEN
        )

        val selection = "${MediaStore.Images.Media.DATE_TAKEN} >= ?"
        val selectionArgs = arrayOf(
            dateToTimestamp(
            day = 1, month = 1, year = 1970
        ).toString()
        )

        val sortOrder = "${MediaStore.Images.Media.DATE_TAKEN} DESC"
        val cursor = contentResolver.query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            projection,
            selection,
            selectionArgs,
            sortOrder
        )

        cursor?.use {
            val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val dateTakenColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
            val displayNameColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)

            while (it.moveToNext()){
                val id = it.getLong(idColumn)
                val dateTaken = it.getLong(dateTakenColumn)
                val displayName = it.getString(displayNameColumn)
                val contentUri = ContentUris.withAppendedId(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                    id
                )
                Log.d("images", "id : $id, contentUri: $contentUri, diplayName: $displayName")
                images.add(ImageModel(contentUri, dateTaken, displayName, id, false))
            }
        }
        images
    }

    private fun dateToTimestamp(day: Int, month: Int, year: Int): Long =
        SimpleDateFormat("dd.MM.yyyy").let { formatter ->
            formatter.parse("$day.$month.$year")?.time ?: 0
        }

 

6️⃣Repository 생성

class GalleryRepository @Inject constructor(
    private val datasource: GalleryDataSource
) {
    fun getImageList(): Flow<PagingData<ImageModel>> {
        return Pager(
            config = PagingConfig(
                pageSize = 20,
                enablePlaceholders = false,
                initialLoadSize = 20
        ),
            pagingSourceFactory = {
                datasource
            }).flow
    }
}

-Pager클래스는 PagingSource에서 PagingData객체의 반응형 스트림을 노출하는 메서드를 제공합니다.

-Pager스트림을 만들어 반응형 스트림을 설정할 때는PagingConfig구성 객체와PagingSource구현 인스턴스를 가져오는 방법을Pager에 지시하는 함수를 인스턴스에 제공해야 합니다.

 

7️⃣ ViewModel 작성

@HiltViewModel
class ImageViewModel @Inject constructor(
    private val galleryRepository: GalleryRepository
) : ViewModel() {

    fun galleryLiveData(): Flow<PagingData<ImageModel>> =
        galleryRepository.getImageList().cachedIn(viewModelScope)

    private val _imageList = MutableLiveData<List<ImageModel>>()
    val imageList: LiveData<List<ImageModel>>
    get() = _imageList

    fun changeImageList(list: List<ImageModel>){
        _imageList.value = list
    }
}

- cachedIn()연산자는 데이터 스트림을 공유 가능하게 하며 제공된 CoroutineScope를 사용하여 로드된 데이터를 캐시합니다. lifecycle-viewmodel-ktx가 제공하는 viewModelScope를 사용하였습니다.

- Pager객체는 PagingSource객체에서 load() 메서드를 호출하여 LoadParams객체를 제공하고 반환되는 LoadResult 객체를 수신합니다.

 

8️⃣ RecyclerAdapter

class GalleryAdapter(val listener: (ImageModel) -> Unit):
    PagingDataAdapter<ImageModel, GalleryAdapter.GalleryViewHolder>(diffCallback){

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GalleryViewHolder {
        val binding = ItemImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return GalleryViewHolder(binding)
    }

    override fun onBindViewHolder(holder: GalleryViewHolder, position: Int) {
        val item = getItem(position)
        holder.binding.setVariable(BR.data, item)
        holder.binding.root.setOnClickListener {
            listener(item!!)
            notifyItemChanged(position)
        }
    }

    companion object {
        val diffCallback = object : DiffUtil.ItemCallback<ImageModel>() {
            override fun areItemsTheSame(oldItem: ImageModel, newItem: ImageModel): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(oldItem: ImageModel, newItem: ImageModel): Boolean {
                return oldItem == newItem
            }
        }
    }

    inner class GalleryViewHolder(val binding: ItemImageBinding) : RecyclerView.ViewHolder(binding.root)
}

- 일반적인 Recyclerview의 Adapter를 생성하는 법과 거의 유사하다. PagedListAdapter만 쓰면 된다.

- DiffUtil.ItemCallback 또한 일반적인 방법으로 작성하여 파라미터로 넣어주면된다.

 

9️⃣ UI에 페이징된 데이터 띄우기

 private fun getImages() {
      lifecycleScope.launch {
          viewModel.galleryLiveData().collect{ pagingData ->
              galleryAdapter.submitData(pagingData)
              viewModel.changeImageList(galleryAdapter.snapshot().items)
          }
      }
}

PagingData 스트림을 확인하고 생성된 각 값을 Adatper의 submitData() 메서드에 전달한다.