2021.07.25 ソート順をDATE_ADDEDに変更しました。android-kotlin-extensionsを使ってViewにアクセスしていた部分を、DataBindingオブジェクト経由でアクセスするように変更しました。
前回は、最小限の実装でフォトギャラリー機能を実装し、MediaStore APIの基本的な使い方を理解しました。ただ、パフォーマンスについて一切考慮していなかったため、実際に動かすとRecyclerViewがまともにスクロールできないくらい重たい動作になっていました。そこで今回は、MediaStoreへのアクセスや画像の読み込みを、KotlinのCoroutineを使って非同期に行い、より実用的なフォトギャラリーにしていきます。
今回もソースコードはGithubに載せていますので参考にしてください。
それでは早速説明していきます。
目次
機能
以下の機能を実装することにします。
- 端末内の画像を新しい順に表示する。
- 選択した画像のURIを取得する。
モジュール構成
実際のアプリに組み込んで再利用することを考慮して、FragmentにRecyclerViewを配置します。また、選択した画像のURIは、ViewModel経由でFragmentからActivityに渡します。さらに、Activityは呼び出し元のActivityにURIを返します。Activity間のやり取りは、ActivityResultContractを使ってカプセル化します。
- MainActivityの役割
- ボタンクリックで
PhotoGalleryActivity
を起動する。 PhotoGalleryActivity
から受け取ったUri
をImageView
にセットし、画像を表示する。
- ボタンクリックで
- PhotoGalleryActivityの役割
PhotoGalleryFragment
を表示するPhotoGalleryViewModel
からURI選択イベントを受信したら、呼び出し元のMainActivity
にURIをActivityResultContract
経由で渡す。
- PhotoGalleryFragmentの役割
- READ_EXTERNAL_STORAGE権限を要求する。
- 権限が拒否された場合に、メッセージを表示する。
- 権限が承認された場合に、RecyclerViewを表示する。
準備
Manifest
端末内の他のアプリ(カメラアプリなど)が作成した画像ファイルにアクセスするため、READ_EXTERNAL_STORAGE
権限が必要です。あらかじめAndroidManifestに登録しておきます。
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
build.gradle(app)
gradleに対してもいくつか準備をしておきます。
- Data Bindingのための、
kotlin-kapt
プラグインとdataBinding
のenable
- androidxの
Activity
やFragment
を使うための、androidx.activity:activity-ktx
とandroidx.fragment:fragment-ktx
- 依存関係挿入ライブラリには、Koinを使用
- 画像の非同期読み込みには、Picassoを使用
plugins {
...
id 'kotlin-kapt'
}
android {
...
dataBinding {
enabled = true
}
}
dependencies {
...
implementation 'androidx.activity:activity-ktx:1.2.2'
implementation 'androidx.fragment:fragment-ktx:1.3.3'
def koin_version = "2.2.2"
implementation "org.koin:koin-android:$koin_version"
implementation "org.koin:koin-android-scope:$koin_version"
implementation "org.koin:koin-android-viewmodel:$koin_version"
implementation "com.squareup.picasso:picasso:2.8"
}
ソースコード
ここからは、具体的なソースコードの説明になります。データの流れにそって説明した方が分かりやすいと思いますので、より低いレイヤーから順に説明してきます。ソースコードの全体像が見えなくなってしまったら、上に載せたモジュール構成図や、Githubのソースコードも見ながら、説明を読み進めて下さい。
写真データクラスを定義する
はじめに、フォトギャラリーの写真一枚一枚の情報を格納するデータクラス、PhotoGalleryItem
を定義します。RecyclerView
の各アイテムは、このクラスの情報を描画します。現状は写真のURIのみを保持していますが、必要に応じて撮影日やフォルダなどの情報を追加することも考えられます。
data class PhotoGalleryItem(
val uri: Uri /* 現状はURIのみ保持する */
) {
companion object {
/* DiffUtilの定義 */
val DIFF_UTIL = object: DiffUtil.ItemCallback<PhotoGalleryItem>() {
override fun areItemsTheSame(oldItem: PhotoGalleryItem, newItem: PhotoGalleryItem)
: Boolean {
return oldItem.uri == newItem.uri
}
override fun areContentsTheSame(oldItem: PhotoGalleryItem, newItem: PhotoGalleryItem)
: Boolean {
return oldItem == newItem
}
}
}
}
RecyclerView
のAdapter
が利用するDiffUtil
も、このクラス内に定義しています。必ずしもこのクラス内で定義する必要はなく、Adapter
側に定義するサンプルもよく見かけます。ただ、将来の仕様変更でRecyclerView
に画像以外の情報も一緒に表示するようになった場合などは、PhotoGalleryItem
クラスにURI以外の情報も保持するようになるでしょう。その場合、DiffUtil
も同時に変更することになりますので、同じ場所で定義しておくとスマートだと私は思います。
さて、そのDiffUtil
ですが、2つの関数をオーバーライドする必要があります。areItemsTheSame()
と、areContentsTheSame()
です。
areItemsTheSame()
は、RecyclerView
のAdapter
が2つの要素を比較する際に、最初に呼ばれます。2つのオブジェクトが同じアイテムを表しているかどうかをBooleanで返します。PhotoGalleryItem
が持っているのは画像の情報です。画像が同じかどうかはURIを比べればわかりますので(というかPhotoGalleryItem
はURIしか保持していないので)、URIが一致していればtrue
を、そうでなければfalse
を返します。
areContentsTheSame()
は、areItemsTheSame()
がtrue
を返した場合に、追加で呼ばれ、アイテムの内容が完全に同じかどうかをBooleanで返します。areItemsTheSame()
との違いが分かりづらいですが、まずはareItemsTheSame()
でふるいにかけて、areContentsTheSame()
で最終的に判断するイメージです。RecyclerView
で利用する場合、表示するすべての情報が同一であれば、areContentsTheSame()
でtrue
を返すようにします。今回は==
で判定しています。PhotoGalleryItem
はdata class
なので、==
演算子は、すべてのプロパティが同一であることを意味します。これで、将来的にPhotoGalleryItem
にプロパティが追加されても大丈夫です。
写真データのリストを作成する
次に、MediaStore APIを利用してデバイスの写真を検索し、PhotoGalleryItem
オブジェクトのリストを作成する処理を、PhotoGalleryViewModel
クラスに実装します。MediaStore APIを使う際にContext
が必要になるため、PhotoGalleryViewModel
クラスはAndroidViewModel
クラスを継承します。
写真のリストphotoList
はList<PhotoGalleryItem>
のMutableLiveData
にしています。これにより、非同期の写真の読み込みが完了したタイミングでRecyclerView
に反映させることができます。
loadPhotoList
では、コルーチンを使ってIOスレッドでMediaStoreにアクセスしています。MediaStore APIの実装で前回と異なるのは、以下の点です。
- projectionにIDと撮影日時を指定し、必要最小限の列のみを読み込むようにしている。
- データベースへの追加日時順(DATE_ADDED)にソートしている。
AndroidViewModel
のgetApplication
経由でcontentResolver
を取得している。
ソート順にDATE_ADDEDを使う理由は、こちらの記事でも説明しています。他にDATE_TAKENやDATE_MODIFIEDなど似たようなColumnがありますが、DATE_ADDEDが一番使い勝手が良いと思います。
写真のリストを作成できたら、コルーチンの最後でpostValue
を呼び出し、メインスレッドでphotoList
にリストをセットしています。
getPhotoItem
は指定したインデックスのアイテムを返す関数で、後ほどRecyclerViewのAdapterから呼び出します。
class PhotoGalleryViewModel(app: Application) : AndroidViewModel(app) {
/* 写真のリスト */
val photoList = MutableLiveData<List<PhotoGalleryItem>>()
/* 写真のリストを読み込む */
fun loadPhotoList() {
/* IOスレッドでMediaStoreから写真を検索する */
viewModelScope.launch(Dispatchers.IO) {
val list = mutableListOf<PhotoGalleryItem>()
/* 読み込む列の指定 */
val projection = arrayOf(
MediaStore.Images.Media._ID, /* ID : URI取得に必要 */
MediaStore.Images.Media.DATE_ADDED /* 撮影日時 */
)
val selection = null /* 行の絞り込みの指定。nullならすべての行を読み込む。*/
val selectionArgs = null /* selectionの?を置き換える引数 */
/* 並び順の指定 : 撮影日時の新しい順 */
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
getApplication<Application>().contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection, selection, selectionArgs, sortOrder
)?.use { cursor -> /* cursorは、検索結果の各行の情報にアクセスするためのオブジェクト。*/
/* 必要な情報が格納されている列番号を取得する。 */
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
while (cursor.moveToNext()) { /* 順にカーソルを動かしながら、情報を取得していく。*/
val id = cursor.getLong(idColumn)
/* IDからURIを取得してリストに格納 */
val uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
list.add(PhotoGalleryItem(uri))
}
}
/* メインスレッドでphotoListにセットする */
photoList.postValue(list)
}
}
/* 指定したインデックスのPhotoGalleryItemを取得 */
fun getPhotoItem(index: Int) = photoList.value?.getOrNull(index)
}
ViewModelの依存関係を挿入する
FragmentやActivityにViewModelへの依存関係を挿入するには、Koinを使うのが簡単でお気に入りです。
はじめに、ViewModelの依存関係を定義したmodule
を定義しておき、Application
を継承したMainApplication
のonCreate
でKoinを開始します。
@JvmField
val appModule = module {
viewModel {
PhotoGalleryViewModel(androidApplication())
}
}
class MainApplication: Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MainApplication)
modules(appModule)
}
}
}
AndroidManifestでapplication
にMainApplication
を指定してあげれば、アプリ起動時にKoinを開始することができます。
<application
android:name=".MainApplication"
... >
</application>
これで、ActivityやFragmentにPhotoGalleryViewModel
を挿入することができます。PhotoGalleryActivity
とPhotoGalleryFragment
で同じインスタンスを共有するため、PhotoGalleryFragment
側ではby sharedViewModel()
を使います。
class PhotoGalleryActivity : AppCompatActivity() {
private val viewModel: PhotoGalleryViewModel by viewModel()
}
class PhotoGalleryFragment : Fragment() {
private val viewModel: PhotoGalleryViewModel by sharedViewModel()
}
権限を取得する
フォトギャラリーの実装を進める前に、ここで権限取得の処理を実装しておきます。面倒ですが、これをやらないと動きません。。。
まず、ViewModelに権限が承認されたこと、拒否されたことを保持するLiveDataを作成しておきます。このLiveDataは後ほどViewにBindingし、フォトギャラリーの表示・非表示の切り替えや、メッセージの表示に使います。
/* Permission承認されたかどうか */
val isPermissionGranted = MutableLiveData<Boolean>().apply { value = false }
/* Permission拒否されたかどうか */
val isPermissionDenied = MutableLiveData<Boolean>().apply { value = false }
Fragmentには、権限要求のためのActivityResultLauncher
を作成します。権限が承認・拒否されたらViewModelに通知します。また、権限が拒否された場合にはshouldShowRequestPermissionRationale()
で追加の説明が必要かどうかを確認し、必要であればダイアログに説明を表示します。
companion object {
private const val REQ_PERMISSION = Manifest.permission.READ_EXTERNAL_STORAGE
}
/* Permissionの結果を受け取る */
private val permissionRequest = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
/* 承認された。*/
viewModel.isPermissionGranted.value = true
} else {
/* 拒否された */
if (shouldShowRequestPermissionRationale(REQ_PERMISSION)) {
/* Permissionの必要性を説明するダイアログを表示する */
showRationaleDialog()
} else {
/* 拒否(ファイナルアンサー)*/
viewModel.isPermissionDenied.value = true
}
}
}
/* Permissionの必要性を説明するダイアログを表示する */
private fun showRationaleDialog() {
RationaleDialog().show(childFragmentManager, viewLifecycleOwner) {
if (it == RationaleDialog.RESULT_OK) {
/* Permissionを要求 */
permissionRequest.launch(REQ_PERMISSION)
} else {
/* あきらめる */
viewModel.isPermissionDenied.value = true
}
}
}
class RationaleDialog: DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let {
AlertDialog.Builder(it)
.setMessage("デバイス内の写真を表示するには、アクセスを許可してください。")
.setPositiveButton("OK") { _, _ ->
setFragmentResult(REQUEST_KEY, bundleOf(Pair(RESULT_KEY, RESULT_OK)))
}
.setNegativeButton("Cancel") { _, _ ->
setFragmentResult(REQUEST_KEY, bundleOf(Pair(RESULT_KEY, RESULT_CANCEL)))
}
.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
fun show(manager: FragmentManager, lifecycleOwner: LifecycleOwner, callback: (Int)->Unit) {
val listener = FragmentResultListener { _, result ->
val resultValue = result.getInt(RESULT_KEY)
callback(resultValue)
}
manager.setFragmentResultListener(REQUEST_KEY, lifecycleOwner, listener)
show(manager, TAG)
}
companion object {
private const val TAG = "TAG_RATIONALE_DIALOG"
private const val REQUEST_KEY = "RATIONALE_DIALOG_REQUEST_KEY"
private const val RESULT_KEY = "RATIONALE_DIALOG_RESULT_KEY"
const val RESULT_OK = 1
const val RESULT_CANCEL = -1
}
}
実際の権限の要求は、onCreate()
内で実行します。checkSelfPermission()
で、権限が取得済みかどうかを確認し、未取得の場合は、先ほど用意したActivityResultLanucher
のlaunch()
に必要な権限を指定して要求します。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
context?.let {
/* 必要なPermissionが与えられているかどうかを確認 */
val result = PermissionChecker.checkSelfPermission(it, REQ_PERMISSION)
if (result == PermissionChecker.PERMISSION_GRANTED) {
/* 承認済み */
viewModel.isPermissionGranted.value = true
} else {
/* 要求する */
permissionRequest.launch(REQ_PERMISSION)
}
}
}
companion object {
private const val REQ_PERMISSION = Manifest.permission.READ_EXTERNAL_STORAGE
}
権限取得の処理は以上です。あとは、onResume
が呼ばれたタイミングで権限取得済みであれば、デバイスから写真を読み込みます。
override fun onResume() {
super.onResume()
/* Permission承認済みなら、デバイスの写真にアクセスする */
if (viewModel.isPermissionGranted.value == true){
viewModel.loadPhotoList()
}
}
Fragmentのレイアウトを作成する
PhotoGalleryFragment
のレイアウトXMLは以下のようになっています。PhotoGalleryViewModel
をデータバインディングし、権限が拒否された場合はメッセージのTextView
を、承認された場合はフォトギャラリーのRecyclerView
を表示する構成になっています。
<?xml version="1.0" encoding="utf-8"?>
<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">
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="net.engawapg.app.photogallery.gallery.PhotoGalleryViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
... >
<TextView
android:visibility="@{viewModel.isPermissionDenied ? View.VISIBLE : View.INVISIBLE}"
android:id="@+id/permissionExplanation"
...
android:text="デバイス内の写真を表示するには、アプリの設定画面でアクセスを許可してください。"
... />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
...
android:visibility="@{viewModel.isPermissionGranted ? View.VISIBLE : View.INVISIBLE}"
... />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Fragment側では、onCreateView
でデータバインディングの初期化を行います。Fragment内のViewにbindingオブジェクト経由でアクセスするため、bindingオブジェクトはクラス変数として保持しておきます。(※記事公開当初はandroid-kotlin-extensionsを使ってrecyclerViewにアクセスしていましたが、DataBindingオブジェクト経由でアクセスするように変更しました。移行に関してはこちらの記事もご参考ください。)
private var _binding: FragmentPhotoGalleryBinding? = null
private val binding get() = binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = DataBindingUtil.inflate(
inflater, R.layout.fragment_photo_gallery, container, false)
binding.viewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
RecyclerViewに画像を非同期で表示する
さて、ようやくRecyclerView
の設定までたどり着きました。FragmentのonViewCreated
で、Adapterを初期化し、recyclerView
に設定します。データソースであるPhotoGalleryViewModel#photoList
は非同期で更新されますので、observe
で変更を監視し、変更があったらAdapterにsubmitList
します。
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
/* RecyclerViewの設定 */
val imageAdapter = ImageAdapter()
/* リストは後から更新する */
viewModel.photoList.observe(viewLifecycleOwner, Observer {
imageAdapter.submitList(it)
})
binding.recyclerView.apply {
setHasFixedSize(true)
layoutManager = GridLayoutManager(this@PhotoGalleryFragment.context, SPAN_COUNT)
adapter = imageAdapter
}
}
RecyclerView
の一つ一つのViewのレイアウトXMLは以下のようになっています。PhotoGalleryViewModel
とPhotoGalleryItem
をバインディングし、PhotoGalleryViewModel
のクリックイベントにPhotoGalleryItem
を渡しています。ViewModel側のイベント処理は後で説明します。
layout_constraintDimensionRatio="1"
を指定して正方形にしているのもポイントです。
<?xml version="1.0" encoding="utf-8"?>
<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">
<data>
<variable
name="viewModel"
type="net.engawapg.app.photogallery.gallery.PhotoGalleryViewModel" />
<variable
name="item"
type="net.engawapg.app.photogallery.gallery.PhotoGalleryItem" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/imageView"
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="Image in the device"
app:layout_constraintDimensionRatio="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:onClick="@{() -> viewModel.onClick(item)}"
tools:ignore="HardcodedText" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
AdapterはPhotoGalleryFragment
のinner class
で定義しています。こうすることにより、PhotoGalleryFragment
が保持しているviewModel
へのアクセスが楽になります。
画像の読み込みとImageView
へのセットには、Picassoライブラリを使用しています。Picassoは画像の読み込みやリサイズなどの変換を非同期に実行してくれるとても便利なライブラリです。公式説明に
Handling
ImageView
recycling and download cancelation in an adapter.Complex image transformations with minimal memory use.
Automatic memory and disk caching.
https://square.github.io/picasso/#introduction
と書かれている通り、ImageViewが再利用される際のダウンロードのキャンセルや、データのキャッシュなども自動でやってくれます。これらをすべて自前で実装しようとすると、Androidのバージョンによっても実装が変わってきますし、かなりの手間ですので、ありがたくライブラリを使わせてもらいましょう。また、
Resources, assets, files, content providers are all supported as image sources.
https://square.github.io/picasso/#features
とあるとおり、MediaStore APIでContentResolverから取得したURIからも画像をload
できます。今回は、PhotoGalleryViewModel#getPhotoItem
で取得したPhotoGalleryItem
が保持しているURIをload
し、fit
でImageViewのサイズに合わせ、centerCrop
で中央に表示し、into
でImageViewにセットしています。RecyclerViewのAdapter側では特にスレッドやコルーチンのことを考えなくても、非同期に画像の読み込みができるのでとても便利です。
inner class ImageAdapter
: ListAdapter<PhotoGalleryItem, ImageViewHolder>(PhotoGalleryItem.DIFF_UTIL) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = DataBindingUtil.inflate<ViewPhotoGalleryImageBinding>(
layoutInflater, R.layout.view_photo_gallery_image, parent, false)
return ImageViewHolder(binding)
}
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
val item = viewModel.getPhotoItem(position)
holder.binding.viewModel = viewModel
holder.binding.item = item
holder.binding.executePendingBindings()
item?.let {
/* Picassoライブラリを使って非同期に画像を読み込む */
Picasso.get().load(it.uri)
.fit()
.centerCrop()
.into(holder.binding.imageView)
}
}
}
inner class ImageViewHolder(val binding: ViewPhotoGalleryImageBinding)
: RecyclerView.ViewHolder(binding.root)
onBindViewHolder
でデータバインディング変数を代入した後、executePendingBindings
を呼ぶのも重要です。データバインディング変数は、ソースコード側で代入してすぐにView側に反映されるとは限りません。executePendingBindings
を呼ぶことにより、即座にViewに反映させることができます。もしexecutePendingBindings
を呼ばないと、RecyclerViewをスクロールしてViewが再利用されるときに、正しいデータが表示されない可能性があります。
タップした画像のURIを取得する
ここまでで画像の表示ができたので、次はタップした画像のURIを取得する処理を実装します。ImageView側は、PhotoGalleryViewModel#onClick
を呼び出すようにバインディングを記述済みです。android:onClick="@{() -> viewModel.onClick(item)}"
の部分です。
PhotoGalleryViewModel
側の実装は下記のとおりです。onSelect
にURIのEvent
をセットしています。こうすることで、PhotoGalleryViewModel
を参照するUIのどの部分からでもURIを取得することができます。今回はPhotoGalleryActivity
でURIを参照しますが、同じActivityの別のFragmentからURIを参照するというようなことも可能になります。
val onSelect = MutableLiveData<Event<Uri>>()
fun onClick(item: PhotoGalleryItem) {
/* URIをイベントとして渡す */
onSelect.value = Event(item.uri)
}
Event
については「ViewModelからFragmentへ画面遷移イベントを送りたい」をご覧ください。Event
を使うと、ViewModelでMutableLiveData
に値をセットしたときに、そのMutableLiveData
をobserve
している側の処理を1回だけ呼び出すことができます。
ActivityResultContractを使って呼び出し元にURIを返す
PhotoGalleryActivity
では、PhotoGalleryViewModel#onSelect
イベントをobserve
し、URIが変化したらActivityにsetResult
して、finish
でActivityを終了します。これにより、呼び出し元のActivityにResultとしてURIが渡されることになります。
class PhotoGalleryActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
/* ViewModelでURIが選択されたイベントを受信し、Activityの結果としてセットする */
viewModel.onSelect.observe(this, EventObserver {
setResult(RESULT_OK, Intent().putExtra(INTENT_URI, it.toString()))
finish()
})
}
companion object {
const val INTENT_URI = "Uri"
}
}
しかしこのままでは、呼び出し元ActivityがIntentのExtraのKey(ここではINTENT_URI
)を知っている必要がありますし、取得されるデータが、URIを変換した文字列である、ということも知っている必要があります。そこで、これらをカプセル化するために、ActivityResultContract
のサブクラスを定義します。ここで定義したResultContract
は、PhotoGalleryActivity
と、呼び出し元のMainActivity
を関連付けるインターフェースの役割を担います。
class PhotoGalleryActivity : AppCompatActivity() {
/* ActivityResultContractのカスタマイズ。このActivityのResultとしてURIを返す */
class ResultContract: ActivityResultContract<Unit, Uri?>() {
override fun createIntent(context: Context, input: Unit?) =
Intent(context, PhotoGalleryActivity::class.java)
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return if (resultCode == RESULT_OK) {
intent?.getStringExtra(INTENT_URI)?.let {
Uri.parse(it)
}
} else {
null
}
}
}
}
Activityを呼び出し、結果を受け取る
最後に、MainActivity
からPhotoGalleryActivity
を呼び出し、結果を受け取ります。上で定義したPhotoGalleryActivity.ResultContract
によって、Intentはカプセル化されるので、MainActivity
側の実装は非常にシンプルになります。
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
/* PhotoGalleryActivityの結果をURIで受け取る */
private val launcher = registerForActivityResult(PhotoGalleryActivity.ResultContract()) {
binding.imageView.setImageURI(it)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.button.setOnClickListener {
launcher.launch()
}
}
}
まず、registerForActivityResult
の引数にResultContract
を指定し、ランチャーを作成します。launch
を呼び出すと、内部ではResultContract#createIntent
で作成したIntentが使われ、PhotoGalleryActivity
が起動します。PhotoGalleryActivity
から結果を受け取る際は、ResultContract#parseResult
でIntentから取り出したURIが、registerForActivityResult
のコールバック関数に引数として渡されます。MainActivity
側では、受け取ったURIを処理するだけです。ここではImageViewにセットし、選択した画像を表示しています。
まとめ
長くなりましたが、実用に耐えるフォトギャラリーを実装することができました。他のアプリに移植する場合には、PhotoGalleryActivity
ごと移植することも可能ですし、PhotoGalleryFragment
だけでも可能です。Fragmentだけ移植する場合は、PhotoGalleryViewModel
経由で移植先のActivityに選択した画像のURIを受け渡すことができます。