Paging3で写真ギャラリーをサクッと表示する

4 min read読了の目安(約3900字

Paging 3

言わずと知れた、Jetpackのページングライブラリがかなり使いやすくなっていました。

https://developer.android.com/topic/libraries/architecture/paging/v3-overview

https://medium.com/@star_zero/paging-3-の変更点-d5ae93b9e68a

時間が経ち、余計なものもたくさん入ってしまっていますが、関連リポジトリはこちら↓↓↓

https://github.com/Tsutou/your-gallery/blob/master/app/src/main/java/jp/geisha/yourgallery/gallery/GalleryAdapter.kt

依存関係

def paging_version = "3.0.0-alpha02"
implementation "androidx.paging:paging-runtime:$paging_version"

Repository(PagingSource)

PagingSourceを継承し、独自のDataSourceを作成します。引数はアプリに合わせて設定してください。今回は日付(timestamp)をキーにしたいのでLongです。

class GalleryPagingSource(private val context: Context) : PagingSource<Long, Media>()

MediaStoreの設定は以下のようによしなに...以下は最低限のUri取得用のIDとページングのキーにする日付をprojectionに指定しています。

const val PHOTO_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
const val PHOTO_ID = MediaStore.Images.ImageColumns._ID
const val PHOTO_DATE_ADDED = MediaStore.Images.ImageColumns.DATE_ADDED
val PHOTO_PROJECTION = arrayOf(
    PHOTO_ID,
    PHOTO_DATE_ADDED
)
const val PHOTO_SORT_ORDER = "$PHOTO_DATE_ADDED DESC, $PHOTO_ID ASC"
const val PHOTO_FROM_DATE_ADDED = "$PHOTO_DATE_ADDED <=?"

PagingSource.load

ここがポイントです。
以前だとここはloadInitial, loadBefore, loadAfter 3つのコールバックに分かれていましたが、1つに集約されています。
LoadResult.Pageに次のページのキーを渡してあげます。(初回はnullなので現在時刻)

pagedKeyDate == lastItemDateの部分は、終了時にキーが繰り返す事への対応です。キーの繰り返しを許可するためには、以下 keyReuseSupported を指定すればいいのですが、当然キーがループするので暫定でこの処理を入れています。もっといい対応あるかもなのであれば知りたい...

override val keyReuseSupported :Boolean = true
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, Photo> {
    return try {
        val pagedKeyDate = params.key ?: System.currentTimeMillis()
        val result = fetch(pagedKeyDate)
        val lastItemDate = result.last().detail.date
        if (pagedKeyDate == lastItemDate) {
            return LoadResult.Error(/**例外**/)
        }
        LoadResult.Page(
            data = result,
            prevKey = null,
            nextKey = lastItemDate
        )
    } catch (e: Exception) {
        LoadResult.Error(e)
    }
}

データの取得はContentProviderからよしなに。上記で、nextkeyを最後の写真の日付にしているのでここからPageを繰り返していくだけです。

suspend fun fetch(pageKey: Long) = withContext(Dispatchers.IO) {
    val photosList = mutableListOf<Photo>()
    context.contentResolver.query(
        PHOTO_URI,
        PHOTO_PROJECTION,
        PHOTO_FROM_DATE_ADDED,
        arrayOf(pageKey.toString()),
        PHOTO_SORT_ORDER
    )?.use { cursor ->
        val idIndex = cursor.getColumnIndexOrThrow(PHOTO_ID)
        val dateAddedIndex = cursor.getColumnIndexOrThrow(PHOTO_DATE_ADDED)
        while (cursor.moveToNext()) {
            val id = cursor.getLong(idIndex)
            val dateAdded = cursor.getLong(dateAddedIndex)
            val uri = ContentUris.withAppendedId(PHOTO_URI, id)
            photosList.add(
                Photo(uri,dateAdded)
            )
        }
        cursor.close()
    }
    photosList
}

ViewModel

ViewModelでは取ったデータをFlowとして受け取ります。
LiveDataObservableにも変換できますし、cachedInでコルーチンスコープやLifecycleの間でキャッシュしてくれるのでかなり柔軟性もあります。

class GalleryViewModel(app: Application) : AndroidViewModel(app) {
    companion object {
        const val PAGE_SIZE = 20
    }
    val galleryDataFlow = Pager(
        PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE)
    ) {
        GalleryPagingSource(getApplication())
    }.flow.cachedIn(viewModelScope)
}

UI(Adapter, Activity)

collectLatestは、FlowのAPIで最新以外をキャンセルしながら受け取ってくれる感じのやつです。PagingDataAdapterを継承したAdapterにsubmitしてあげましょう。ここは今までのPagingと同じくReccyclerView.Adapterのシンプルな拡張になっていて、DiffUtillを渡してあげるだけです。

viewModel.galleryDataFlow.collectLatest {
        galleryAdapter.submitData(it)
}

まとめ

とても簡単になっていました。