インターネットから画像を読み込んで表示する

概要

前回の Codelab では、ウェブサービスからデータを取得し、レスポンスを解析して Kotlin オブジェクトに変換する方法を学習しました。この Codelab では、その知識に基づいて、ウェブ URL から写真を読み込んで表示します。また、RecyclerView を作成して概要ページに画像のグリッドを表示する方法もおさらいします。

前提条件

  • フラグメントの作成方法と使用方法。
  • REST ウェブサービスから JSON を取得する方法と、Retrofit ライブラリおよび Moshi ライブラリを使用してそのデータを解析し、Kotlin オブジェクトに変換する方法。
  • RecyclerView を使用してグリッド レイアウトを作成する方法。
  • Adapter、ViewHolder、DiffUtil の機能。

学習内容

  • Coil ライブラリを使用してウェブ URL から画像を読み込んで表示する方法。
  • RecyclerView とグリッド アダプターを使用して画像のグリッドを表示する方法。
  • 画像をダウンロードして表示する際に発生するエラーの処理方法。

作成するアプリの概要

  • MarsPhotos アプリを変更して、Mars データから画像 URL を取得し、Coil を使用してその画像を読み込んで表示します。
  • 読み込み中のアニメーションとエラーアイコンをアプリに追加します。
  • RecyclerView を使用して火星画像のグリッドを表示します。
  • ステータス処理とエラー処理を RecyclerView に追加します。

必要なもの

  • 最新のウェブブラウザ(Chrome の最新バージョンなど)を搭載したパソコン
  • パソコンでのインターネット アクセス

この Codelab では、前回の Codelab で作成した MarsPhotos というアプリを引き続き使用します。MarsPhotos アプリは、ウェブサービスに接続し、Retrofit を使用して取得した Kotlin オブジェクトの数を取得して表示します。これらの Kotlin オブジェクトには、NASA の火星探査機が撮影した火星表面の実際の写真の URL が含まれています。

この Codelab で作成するバージョンのアプリは概要ページにデータを書き込みます。概要ページでは火星の写真を画像グリッドで表示します。これらの画像は、アプリが Mars ウェブサービスから取得したデータの一部です。アプリは Coil ライブラリを使用して画像の読み込みと表示を行い、RecyclerView を使用して画像のグリッド レイアウトを作成します。また、アプリはネットワーク エラーを適切に処理します。

1b33675b009bee15.png

ウェブ URL からの写真を表示するのは簡単に思えますが、適切に処理するにはかなりの作業が必要です。画像をダウンロードして内部的に保存し、圧縮形式から Android が使用できる形式にデコードする必要があります。メモリ内キャッシュとストレージベースのキャッシュの両方またはいずれかに画像を保存する必要があります。これらすべてを優先度の低いバックグラウンド スレッドで行いつつ、UI の応答性を維持する必要があります。また、ネットワークと CPU のパフォーマンスを最適化するため、複数の画像を一度に取得してデコードする必要もあります。

幸いなことに、コミュニティで開発された Coil というライブラリを使用して、画像のダウンロード、バッファリング、デコード、キャッシュ保存を行うことができます。Coil を使用しないと、行うべき作業が格段に増えます。

Coil は、基本的に次の 2 つのものを必要とします。

  • 読み込んで表示する画像の URL。
  • 実際に画像を表示するための ImageView オブジェクト。

このタスクでは、Coil を使用して、Mars ウェブサービスから取得した単一の画像を表示する方法を学びます。ウェブサービスから返された火星写真のリストに含まれる最初の写真の画像を表示します。画像の表示前と表示後のスクリーンショットを次に示します。

Coil の依存関係を追加する

  1. 前回の Codelab で作成した MarsPhotos アプリの解答コードを開きます。
  2. アプリを実行して動作を確認します(取得した火星の写真の合計数が表示されます)。
  3. build.gradle(モジュール: app)を開きます。
  4. dependencies セクションで、Coil ライブラリ用に次の行を追加します。
    // Coil
    implementation "io.coil-kt:coil:1.1.1"

Coil のドキュメント ページでライブラリの最新バージョンを確認し、アップデートします。

  1. Coil ライブラリは mavenCentral() リポジトリでホストされており、そこから利用できます。build.gradle(プロジェクト: MarsPhotos)で、一番上の repositories ブロックに mavenCentral() を追加します。
repositories {
   google()
   jcenter()
   mavenCentral()
}
  1. [Sync Now] をクリックし、新しい依存関係でプロジェクトを再ビルドします。

ViewModel を更新する

このステップでは、受け取った Kotlin オブジェクト(MarsPhoto)を格納する LiveData プロパティを OverviewViewModel クラスに追加します。

  1. overview/OverviewViewModel.kt を開きます。_status プロパティの宣言のすぐ下に、単一の MarsPhoto オブジェクトを格納できる MutableLiveData 型の新しい可変プロパティを _photos という名前で追加します。
private val _photos = MutableLiveData<MarsPhoto>()

リクエストされたら、com.example.android.marsphotos.network.MarsPhoto をインポートします。

  1. _photos の宣言のすぐ下に、LiveData<MarsPhoto> 型の公開バッキング フィールドを photos という名前で追加します。
val photos: LiveData<MarsPhoto> = _photos
  1. getMarsPhotos() メソッドの try{} ブロック内で、ウェブサービスから取得したデータを listResult. に設定している行を見つけます。
try {
   val listResult = MarsApi.retrofitService.getPhotos()
   ...
}
  1. 最初に取得した火星写真を新しい変数 _photos に割り当てます。listResult を _photos.value に変更します。インデックス 0 の最初の写真 URL を割り当てます。エラーがスローされますが、これは後で修正します。
try {
   _photos.value = MarsApi.retrofitService.getPhotos()[0]
   ...
}
  1. 次の行で、status.value を以下のように更新します。listResult ではなく、新しいプロパティのデータを使用します。写真リストの最初の画像 URL を表示します。.
try {
   ...
   _status.value = "   First Mars image URL : ${_photos.value!!.imgSrcUrl}"

}
  1. 完全な try{} ブロックは次のようになります。
try {
   _photos.value = MarsApi.retrofitService.getPhotos()[0]
   _status.value = "   First Mars image URL : ${_photos.value!!.imgSrcUrl}"
}
  1. アプリを実行します。TextView により、最初の火星写真の URL が表示されます。ここまでの作業では、その URL 用に ViewModel と LiveData を設定しただけです。

b8ac93805b69b03a.png

バインディング アダプターを使用する

バインディング アダプターは、ビューのカスタム プロパティ用のカスタム セッターを作成するために使用される、アノテーション付きのメソッドです。

通常は、android:text="Sample Text" というコードを使用して XML で属性を設定する場合に使用します。Android システムは、setText(String: text) メソッドで設定された、text 属性と同じ名前のセッターを自動的に探します。setText(String: text) メソッドは、Android フレームワークが提供するいくつかのビューのためのセッター メソッドです。バインディング アダプターを使用すると、同様の動作をカスタマイズできます。つまり、データ バインディング ライブラリによって呼び出されるカスタム属性とカスタム ロジックを指定できます。

例:

単に画像ビューでセッターを呼び出す以上に複雑な処理を行うには、ドローアブル画像を設定します。インターネットから UI スレッド(メインスレッド)外の画像を読み込むことを検討してください。最初に、画像を ImageView に割り当てるカスタム属性を選択します。次の例では、imageUrl がそれに該当します。

<ImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:imageUrl="@{product.imageUrl}"/>

それ以上コードを追加しなければ、システムは ImageView で setImageUrl(String) メソッドを検索し、見つけられずにエラーをスローします。これは、フレームワークが提供していないカスタム属性だからです。app:imageUrl 属性を実装して ImageView に設定する方法を考案する必要があります。そのためには、バインディング アダプター(アノテーション付きメソッド)を使用します。

バインディング アダプターの例:

@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    imgUrl?.let {
        // Load the image in the background using Coil.
        }
    }
}

@BindingAdapter アノテーションは、パラメータとして属性名を受け取ります。

bindImage メソッドの 1 つ目のメソッド パラメータはターゲット ビューの型で、2 つ目のメソッド パラメータは属性に設定される値です。

このメソッド内で、Coil ライブラリは UI スレッド外の画像を読み込み、それを ImageView に設定します。

バインディング アダプターを作成し、Coil を使用する

  1. com.example.android.marsphotos パッケージに、BindingAdapters という名前の Kotlin ファイルを作成します。このファイルは、アプリ全体で使用するバインディング アダプターを保持します。

a04afbd6ae8ccfcd.png

  1. BindingAdapters.kt 内に、パラメータとして ImageView と String を受け取る bindImage() 関数を作成します。
fun bindImage(imgView: ImageView, imgUrl: String?) {

}

リクエストされたら、android.widget.ImageView をインポートします。

  1. 関数に @BindingAdapter アノテーションを付けます。@BindingAdapter アノテーションは、ビューアイテムに imageUrl 属性がある場合にこのバインディング アダプターを実行するようデータ バインディングに指示します。
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {

}

リクエストされたら、androidx.databinding.BindingAdapter をインポートします。

let スコープ関数

let は Kotlin のスコープ関数のひとつであり、オブジェクトのコンテキスト内でコードブロックを実行することを可能にします。Kotlin には 5 つのスコープ関数があります。詳しくは、ドキュメントをご覧ください。

使用方法:

let を使用して、コールチェーンの結果に対して 1 つ以上の関数を呼び出します。

let 関数と safe call 演算子(?.)を併用して、null セーフな演算をオブジェクトに実行します。この場合、オブジェクトが null でない場合にのみ、let コードブロックが実行されます。

  1. bindImage() 関数内で、safe call 演算子を使用して let{} ブロックを imageURL 引数に追加します。
imgUrl?.let {
}
  1. let{} ブロック内に次の行を追加し、toUri() メソッドを使用して URL 文字列を Uri オブジェクトに変換します。HTTPS スキームを使用するには、buildUpon.scheme("https") を toUri ビルダーの末尾に追加します。build() を呼び出してオブジェクトを作成します。
val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()

リクエストされたら、androidx.core.net.toUri をインポートします。

  1. let{} ブロック内の imgUri 宣言の後で、Coil からの load(){} を使用して、imgUri オブジェクトから画像を imgView に読み込みます。
imgView.load(imgUri) {
}

リクエストされたら、coil.load をインポートします。

  1. 完全なメソッドは次のようになります。
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    imgUrl?.let {
        val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
        imgView.load(imgUri)
    }
}

レイアウトとフラグメントを更新する

前のセクションでは、Coil 画像ライブラリを使用して画像を読み込みました。次のステップとして、画面に画像を表示するため、新しい属性で ImageView を更新し、単一の画像を表示します。

Codelab の後半では、RecyclerView 内の各グリッド アイテムのレイアウト リソース ファイルとして、res/layout/grid_view_item.xml を使用します。このタスクでは、このファイルを一時的に使用して、前のタスクで取得した画像 URL で画像を表示します。fragment_overview.xml の代わりに、このレイアウト ファイルを一時的に使用します。

  1. res/layout/grid_view_item.xml を開きます。
  2. <ImageView> 要素の上にデータ バインディング用の <data> 要素を追加して、OverviewViewModel クラスにバインドします。
<data>
   <variable
       name="viewModel"
       type="com.example.android.marsphotos.overview.OverviewViewModel" />
</data>
  1. 新しい画像読み込みバインディング アダプターを使用するため、ImageView 要素に app:imageUrl 属性を追加します。photos には、サーバーから取得されたリスト MarsPhotos が含まれていることを思い出してください。最初のエントリ URL を imageUrl 属性に割り当てます。
    <ImageView
        android:id="@+id/mars_image"
        ...
        app:imageUrl="@{viewModel.photos.imgSrcUrl}"
        ... />
  1. overview/OverviewFragment.kt を開きます。onCreateView() メソッドで、FragmentOverviewBinding クラスをインフレートする行をコメントアウトし、バインディング変数に割り当てます。この行を削除したことにより、エラーが表示されます。これは一時的なものであり、後で修正します。
//val binding = FragmentOverviewBinding.inflate(inflater)
  1. fragment_overview.xml. の代わりに grid_view_item.xml を使用します。次の行を追加して、GridViewItemBinding クラスをインフレートします。
val binding = GridViewItemBinding.inflate(inflater)

リクエストされた場合は、com.example.android.marsphotos. databinding.GridViewItemBinding をインポートします。

  1. アプリを実行します。単一の火星画像が表示されます。

e59b6e849e63ae2b.png

読み込み中の画像とエラーの画像を追加する

Coil を使用すると、画像の読み込み中のプレースホルダ画像と、読み込みが失敗した場合(画像がない、画像が破損しているなど)のエラー画像を表示して、ユーザー エクスペリエンスを改善できます。このステップでは、それらの機能をバインディング アダプターに追加します。

  1. res/drawable/ic_broken_image.xml を開いて、右側の [Design] タブをクリックします。エラー画像としては、組み込みのアイコン ライブラリにある破損した画像のアイコンを使用します。このベクター型ドローアブルでは、android:tint 属性によりアイコンがグレーに色付けされています。

467c213c859e1904.png

  1. res/drawable/loading_animation.xml を開きます。このドローアブルは、中心点の周りで画像ドローアブル(loading_img.xml)が回転するアニメーションです(プレビューではアニメーションは確認できません)。

6c1f87d1c932c762.png

  1. BindingAdapters.kt ファイルに戻ります。bindImage() メソッドで、imgView.load(imgUri) の呼び出しを更新して、以下のように末尾のラムダを追加します。このコードは、読み込み時に使用する読み込み中のプレースホルダ画像(loading_animation ドローアブル)を設定します。また、画像の読み込みが失敗した場合に使用する画像(broken_image ドローアブル)も設定します。
imgView.load(imgUri) {
   placeholder(R.drawable.loading_animation)
   error(R.drawable.ic_broken_image)
}
  1. 完全な bindImage() メソッドは次のようになります。
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    imgUrl?.let {
        val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
        imgView.load(imgUri) {
            placeholder(R.drawable.loading_animation)
            error(R.drawable.ic_broken_image)
        }
    }
}
  1. アプリを実行します。ネットワーク接続の速度によっては、Glide がプロパティ画像をダウンロードして表示する間、一瞬だけ読み込み中の画像が表示されます。しかし、ネットワークを切断しても、破損した画像のアイコンは表示されません。この問題は、Codelab の最後のタスクで修正します。

80553d5e5c7641de.gif

  1. overview/OverviewFragment.kt に加えた一時的な変更を元に戻します。メソッド onCreateview() で、FragmentOverviewBinding をインフレートする行のコメント化を解除します。GridViewIteMBinding をインフレートする行を削除するか、コメントアウトします。
val binding = FragmentOverviewBinding.inflate(inflater)
 // val binding = GridViewItemBinding.inflate(inflater)

以上で、アプリはインターネットから火星の写真を読み込むようになりました。最初の MarsPhoto リストアイテムからのデータを使用して ViewModel に LiveData プロパティを作成し、その火星写真データからの画像 URL を使用して ImageView にデータを入力しました。しかし、アプリの目標は画像のグリッドを表示することです。そこで、このタスクでは、グリッド レイアウト マネージャーで RecyclerView を使用して、画像のグリッドを表示します。

ビューモデルを更新する

前のタスクでは、OverviewViewModel に _photos という名前の LiveData オブジェクトを追加しました。このオブジェクトは、ウェブサービスからのレスポンス リストに含まれる最初のオブジェクトである MarsPhoto オブジェクトを保持します。このステップでは、この LiveData を変更して、MarsPhoto オブジェクトのリスト全体を保持するようにします。

  1. overview/OverviewViewModel.kt を開きます。
  2. _photos の型を MarsPhoto オブジェクトのリストに変更します。
private val _photos = MutableLiveData<List<MarsPhoto>>()
  1. 同様に、バッキング プロパティ photos の型を List<MarsPhoto> 型に置き換えます。
 val photos: LiveData<List<MarsPhoto>> = _photos
  1. 下にスクロールして、getMarsPhotos() メソッド内の try {} ブロックを表示します。MarsApi.retrofitService.getPhotos() は、

MarsPhoto オブジェクトのリストを返します。これを _photos.value に割り当てることができます。

_photos.value = MarsApi.retrofitService.getPhotos()
_status.value = "Success: Mars properties retrieved"
  1. try/catch ブロック全体は次のようになります。
try {
    _photos.value = MarsApi.retrofitService.getPhotos()
    _status.value = "Success: Mars properties retrieved"
} catch (e: Exception) {
    _status.value = "Failure: ${e.message}"
}

グリッド レイアウト

RecyclerView の GridLayoutManager は、以下に示すように、スクロール可能なグリッドとしてデータを配置します。

fcf0fc4b78f8650.png

デザインの観点から見ると、グリッド レイアウトはアイコンまたは画像として表現できるリスト(たとえば、火星写真を閲覧するアプリ内のリスト)に最適です。

グリッド レイアウトがアイテムを配置する仕組み

グリッド レイアウトは、行と列からなるグリッドにアイテムを配置します。縦方向のスクロールを想定した場合、デフォルトでは、各行の各アイテムは 1 つの「スパン」を占有します。1 つのアイテムは複数のスパンを占有できます。下記の例では、1 つのスパンは 3 つある列の 1 つの列の幅に相当します。

下記の 2 つの例では、各行は 3 つのスパンで構成されています。デフォルトでは、GridLayoutManager は、指定したスパン数に達するまで、各アイテムを 1 つのスパンに配置します。指定したスパン数に達すると、次の行に折り返します。

RecyclerView を追加する

このステップでは、アプリのレイアウトを変更して、単一の画像ビューを使用する代わりに、グリッド レイアウトを持つ RecyclerView を使用するようにします。

  1. layout/gridview_item.xml を開きます。viewModel データ変数を削除します。
  2. <data> タグ内に、MarsPhoto 型の photo 変数(以下を参照)を追加します。
<data>
   <variable
       name="photo"
       type="com.example.android.marsphotos.network.MarsPhoto" />
</data>
  1. <ImageView> 内で app:imageUrl 属性を変更して、MarsPhoto オブジェクトに含まれる画像 URL を参照するようにします。上記の変更により、前のタスクで行った一時的な変更が取り消されます。
app:imageUrl="@{photo.imgSrcUrl}"
  1. layout/fragment_overview.xml を開きます。<TextView> 要素全体を削除します。
  2. 代わりに以下の <RecyclerView> 要素を追加します。ID を photos_grid に設定します。width および height 属性を 0dp に設定して、親 ConstraintLayout 全体を埋めるようにします。グリッド レイアウトを使用するため、layoutManager 属性を androidx.recyclerview.widget.GridLayoutManager に設定します。spanCount を 2 に設定して、2 つの列を持つようにします。
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/photos_grid"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layoutManager=
       "androidx.recyclerview.widget.GridLayoutManager"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:spanCount="2" />
  1. 上記のコードをデザインビューでプレビューするため、tools:itemCount を使用して、レイアウトに表示するアイテムの数を 16 に設定します。itemCount 属性は、[Preview] ウィンドウで Layout Editor によりレンダリングされるアイテムの数を規定します。tools:listitem を使用して、リストアイテムのレイアウトを grid_view_item に設定します。
<androidx.recyclerview.widget.RecyclerView
            ...
            tools:itemCount="16"
            tools:listitem="@layout/grid_view_item" />
  1. デザインビューに切り替えると、次のスクリーンショットのようなプレビューが表示されます。これは火星の写真ではありませんが、RecyclerView のグリッド レイアウトがどのように表示されるかを示しています。プレビューでは、recyclerview のグリッド アイテムごとに、パディングと grid_view_item レイアウトが使用されています。

20742824367c3952.png

  1. マテリアル デザイン ガイドラインに従うには、リストの上下左右に 8dp、各アイテム間に 4dp のスペースが必要です。これを実現するには、fragment_overview.xml レイアウトと gridview_item.xml レイアウトのパディングを組み合わせて使用します。

a3561fa85fea7a8f.png

  1. layout/gridview_item.xml を開きます。お気づきのとおり、padding 属性では、各アイテム間とコンテンツ全体の周囲に 2dp のパディングがすでに設定されています。これにより、各アイテム間に 4dp、コンテンツ全体の周囲に 2dp のスペースが空いています。つまり、デザイン ガイドラインに従うには、コンテンツ全体の周囲に 6dp のパディングを追加する必要があります。
  2. layout/fragment_overview.xml に戻ります。RecyclerView に 6dp のパディングを追加します。これにより、ガイドラインが示すとおり、コンテンツ全体の周囲に 8dp、コンテンツ内のアイテム間に 4dp のスペースが空けられます。
<androidx.recyclerview.widget.RecyclerView
            ...
            android:padding="6dp"
            ...  />
  1. 完全な <RecyclerView> 要素は次のようになります。
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/photos_grid"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:padding="6dp"
    app:layoutManager=
        "androidx.recyclerview.widget.GridLayoutManager"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:spanCount="2"
    tools:itemCount="16"
    tools:listitem="@layout/grid_view_item"  />

写真グリッド アダプターを追加する

以上で、fragment_overview レイアウトに、グリッド レイアウトを持つ RecyclerView が含まれるようになりました。このステップでは、RecyclerView アダプターを使用して、ウェブサーバーから取得したデータを RecyclerView にバインドします。

ListAdapter(リフレッシャー)

ListAdapter は RecyclerView.Adapter クラスのサブクラスで、RecyclerView にリストデータを表示するために使用します。バックグラウンド スレッドでリスト間の差分計算も行います。

このアプリでは、ListAdapter. で DiffUtil 実装を使用します。DiffUtil を使用するメリットは、RecyclerView でアイテムが追加、削除、変更されるたびにリスト全体が更新されるのを避けられることです。変更されたアイテムのみが更新されます。

アプリに ListAdapter を追加します。

  1. overview パッケージに、PhotoGridAdapter.kt という名前の Kotlin クラスを作成します。
  2. 下記のコンストラクタ パラメータを使って、ListAdapter から PhotoGridAdapter クラスを拡張します。PhotoGridAdapter クラスは ListAdapter を拡張します。このクラスのコンストラクタには、リストアイテムの型、ビューホルダー、DiffUtil.ItemCallback 実装が必要です。
class PhotoGridAdapter : ListAdapter<MarsPhoto,
        PhotoGridAdapter.MarsPhotoViewHolder>(DiffCallback) {
}

リクエストされた場合は、androidx.recyclerview.widget.ListAdapter クラスと com.example.android.marsphoto.network.MarsPhoto クラスをインポートします。次のステップでは、このコンストラクタに欠けている(それによってエラーを引き起こしている)別の機能を実装します。

  1. 上記のエラーを解決するため、このステップで必要なメソッドを追加し、このタスクの後半でそれらを実装します。PhotoGridAdapter クラスをクリックし、赤い電球アイコンをクリックして、プルダウン メニューから [Implement members] を選択します。表示されたポップアップで、ListAdapter のメソッドである onCreateViewHolder() と onBindViewHolder() を選択します。依然としてエラーが表示されますが、それらはこのタスクの最後で修正します。
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoGridAdapter.MarsPhotoViewHolder {
   TODO("Not yet implemented")
}

override fun onBindViewHolder(holder: PhotoGridAdapter.MarsPhotoViewHolder, position: Int) {
   TODO("Not yet implemented")
}

onCreateViewHolder メソッドと onBindViewHolder メソッドを実装するには、次のステップで追加する MarsPhotoViewHolder が必要です。

  1. PhotoGridAdapter 内で、RecyclerView.ViewHolder を拡張する MarsPhotoViewHolder の内部クラス定義を追加します。MarsPhoto をレイアウトにバインドするために GridViewItemBinding 変数が必要なので、変数を MarsPhotoViewHolder に渡します。基本クラス ViewHolder ではコンストラクタ内にビューが必要なので、バインディング ルートビューを渡します。
class MarsPhotoViewHolder(private var binding:
                   GridViewItemBinding):
       RecyclerView.ViewHolder(binding.root) {
}

リクエストされた場合は、androidx.recyclerview.widget.RecyclerView と com.example.android.marsrealestate.databinding.GridViewItemBinding をインポートします。

  1. MarsPhotoViewHolder 内で、MarsPhoto オブジェクトを引数として受け取り、binding.property をそのオブジェクトに設定する bind() メソッドを作成します。プロパティを設定した後、executePendingBindings() を呼び出します。これにより、更新が直ちに実行されます。
fun bind(MarsPhoto: MarsPhoto) {
   binding.photo = MarsPhoto
   binding.executePendingBindings()
}
  1. 引き続き onCreateViewHolder() の PhotoGridAdapter クラス内で、TODO を削除して下記の行を追加します。onCreateViewHolder() メソッドは新しい MarsPhotoViewHolder を返す必要があります。このビューホルダーは、GridViewItemBinding をインフレートし、親 ViewGroup コンテキストからの LayoutInflater を使用することにより、作成されます。
   return MarsPhotoViewHolder(GridViewItemBinding.inflate(
      LayoutInflater.from(parent.context)))

リクエストされた場合は、android.view.LayoutInflater をインポートします。

  1. onBindViewHolder() メソッドで、TODO を削除して下記の行を追加します。ここで getItem() を呼び出し、現在の RecyclerView の位置に関連付けられている MarsPhoto オブジェクトを取得して、そのプロパティを MarsPhotoViewHolder の bind() メソッドに渡します。
val marsPhoto = getItem(position)
holder.bind(marsPhoto)
  1. 以下に示すように、PhotoGridAdapter 内に DiffCallback のコンパニオン オブジェクト定義を追加します。
    DiffCallback オブジェクトは、比較したいオブジェクト(MarsPhoto)の汎用の型を使って DiffUtil.ItemCallback を拡張します。この実装内で、2 つの火星写真オブジェクトを比較します。
companion object DiffCallback : DiffUtil.ItemCallback<MarsPhoto>() {
}

リクエストされたら、androidx.recyclerview.widget.DiffUtil をインポートします。

  1. 赤い電球アイコンをクリックして、DiffCallback オブジェクトのコンパレータ メソッド(areItemsTheSame() と areContentsTheSame())を実装します。
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   TODO("Not yet implemented")
}

override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   TODO("Not yet implemented") }
  1. areItemsTheSame() メソッド内の TODO を削除します。このメソッドは、2 つのオブジェクトが同じアイテムを表しているかどうかを判定するために、DiffUtil によって呼び出されます。DiffUtil は、このメソッドを使用して、新しい MarsPhoto オブジェクトが古い MarsPhoto オブジェクトと同じかどうかを確認します。すべてのアイテム(MarsPhoto オブジェクト)の ID は一意です。oldItem と newItem の ID を比較して結果を返します。
override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   return oldItem.id == newItem.id
}
  1. areContentsTheSame() 内の TODO を削除します。このメソッドは、2 つのアイテムが同じデータを持つかどうかをチェックする際に、DiffUtil によって呼び出されます。MarsPhoto の重要なデータは画像 URL です。oldItem と newItem の URL を比較して結果を返します。
override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
   return oldItem.imgSrcUrl == newItem.imgSrcUrl
}

エラーなしでアプリをコンパイルして実行できることを確認します。ただし、エミュレータでは空白の画面が表示されます。以上で RecyclerView の準備が完了しましたが、まだデータが渡されていません。次のステップでは、この機能を実装します。

バインディング アダプターを追加して要素を接続する

このステップでは、BindingAdapter を使用して、MarsPhoto オブジェクトのリストで PhotoGridAdapter を初期化します。BindingAdapter を使用して RecyclerView のデータを設定すると、LiveData に MarsPhoto オブジェクトのリストがあるかどうかがデータ バインディングによって自動的に監視されるようになります。MarsPhoto リストが変更されると、バインディング アダプターが自動的に呼び出されます。

  1. BindingAdapters.kt を開きます。
  2. ファイルの末尾に、RecyclerView と MarsPhoto オブジェクトのリストを引数として受け取る bindRecyclerView() メソッドを追加します。そのメソッドに、listData 属性を含む @BindingAdapter アノテーションを付けます。
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
    data: List<MarsPhoto>?) {
}

リクエストされた場合は、androidx.recyclerview.widget.RecyclerView と com.example.android.marsphotos.network.MarsPhoto をインポートします。

  1. bindRecyclerView() 関数内で、recyclerView.adapter を PhotoGridAdapter にキャストして、新しい val プロパティ adapter. に割り当てます。
val adapter = recyclerView.adapter as PhotoGridAdapter
  1. bindRecyclerView() 関数の最後で、火星写真リストのデータを指定して adapter.submitList() を呼び出します。これにより、新しいリストが利用可能であることが RecyclerView に通知されます。
adapter.submitList(data)

リクエストされた場合は、com.example.android.marsrealestate.overview.PhotoGridAdapter をインポートします。

  1. 完全な bindRecyclerView バインディング アダプターは次のようになります。
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
                    data: List<MarsPhoto>?) {
   val adapter = recyclerView.adapter as PhotoGridAdapter
   adapter.submitList(data)

}
  1. すべての要素を結合するため、res/layout/fragment_overview.xml を開きます。RecyclerView 要素に app:listData 属性を追加し、データ バインディングを使用してそれを viewmodel.photos に設定します。これは、前のタスクで ImageView について行った作業と似ています。
app:listData="@{viewModel.photos}"
  1. overview/OverviewFragment.kt を開きます。onCreateView() の return ステートメントの直前で、binding.photosGrid の RecyclerView アダプターを新しい PhotoGridAdapter オブジェクトに初期化します。
binding.photosGrid.adapter = PhotoGridAdapter()
  1. アプリを実行します。スクロール可能な火星画像のグリッドが表示されます。スクロールすると新しい画像が表示されますが、少し違和感があるはずです。スクロール中も RecyclerView の上下にパディングが残っているため、アクションバーの下でリストがスクロールされているように見えません。

5d03641aa1589842.png

  1. これを修正するには、android:clipToPadding 属性を使用して、内側のコンテンツをパディングに合わせてクリッピングしないよう RecyclerView に指示する必要があります。そうすれば、パディング領域にスクロール ビューが描画されます。layout/fragment_overview.xml に戻ります。RecyclerView に android:clipToPadding 属性を追加して false に設定します。
<androidx.recyclerview.widget.RecyclerView
            ...
            android:clipToPadding="false"
            ...  />
  1. アプリを実行します。お気づきのとおり、目的の画像自体が表示される前に、読み込みが進行中であることを示すアイコンが表示されます。これは、Coil 画像ライブラリに渡した読み込み中のプレースホルダ画像です。

3128b84aa22ef97e.png

  1. アプリの実行中に機内モードをオンにします。エミュレータで画像をスクロールします。まだ読み込まれていない画像が、破損した画像のアイコンとして表示されます。これは、ネットワーク エラーが発生したときや画像を取得できなかったときに表示するために Coil 画像ライブラリに渡した画像ドローアブルです。

28d2cbba564f35ff.png

おつかれさまです。作業はあと少しで完了します。次の最後のタスクでは、エラー処理をアプリに追加して、ユーザー エクスペリエンスをさらに改善します。

MarsPhotos アプリは、画像を取得できない場合、破損した画像のアイコンを表示します。しかし、ネットワーク接続がない場合は、空白の画面を表示します。次のステップでは、空白の画面を確認します。

  1. デバイスまたはエミュレータで機内モードをオンにします。Android Studio からアプリを実行します。空白の画面が表示されます。

492011786c2dd7f7.png

これはユーザー エクスペリエンスとしては不適切です。このタスクでは、基本的なエラー処理を追加して、何が起こっているかをユーザーが理解できるようにします。インターネットが利用できない場合、アプリは接続エラーアイコンを表示します。一方、MarsPhoto リストの取得中は、読み込み中のアニメーションを表示します。

ViewModel にステータスを追加する

このタスクでは、ウェブ リクエストのステータスを表すプロパティを OverviewViewModel に作成します。考慮する必要があるステータスは、読み込み中、成功、失敗の 3 つです。「読み込み中」ステータスは、データを待機している状態を示します。「成功」ステータスは、ウェブサービスからデータが正常に取得されたことを示します。「失敗」ステータスは、ネットワーク エラーまたは接続エラーが発生したことを示します。

Kotlin の enum クラス

アプリ内で上記の 3 つのステータスを表現するには、enum を使用します。enum は列挙型の略称で、コレクションに含まれるすべてのアイテムの順序付きリストを意味します。個々の enum 定数は、enum クラスのオブジェクトです。

Kotlin の enum は、定数のセットを保持できるデータ型です。以下に示すように、クラス定義の前にキーワード enum を追加することにより定義します。enum 定数はカンマで区切ります。

定義:

enum class Direction {
    NORTH, SOUTH, WEST, EAST
}

使用方法:

var direction = Direction.NORTH;

上記のように、enum オブジェクトは、クラス名の後にドット(.)演算子と定数名を付けることにより参照できます。

ステータス値を含む enum クラス定義を ViewModel に追加します。

  1. overview/OverviewViewModel.kt を開きます。ファイルの先頭部分(インポートの後、クラス定義の前)に、すべての使用可能なステータスを表す enum を追加します。
enum class MarsApiStatus { LOADING, ERROR, DONE }
  1. スクロールして _status と status プロパティの定義を表示し、型を String から MarsApiStatus. MarsApiStatus に変更します。これは、前のステップで定義した enum クラスです。
private val _status = MutableLiveData<MarsApiStatus>()

val status: LiveData<MarsApiStatus> = _status
  1. getMarsPhotos() メソッドで、"Success: ..." 文字列を MarsApiStatus.DONE 状態に変更し、"Failure..." 文字列を MarsApiStatus.ERROR に変更します。
try {
    _photos.value = MarsApi.retrofitService.getPhotos()
    _status.value = MarsApiStatus.DONE
} catch (e: Exception)
     _status.value = MarsApiStatus.ERROR
}
  1. try {} ブロックの上で、ステータスを MarsApiStatus.LOADING に設定します。これは、コルーチンを実行してデータを待機しているときの初期ステータスです。完全な viewModelScope.launch {} ブロックは次のようになります。
viewModelScope.launch {
            _status.value = MarsApiStatus.LOADING
            try {
                _photos.value = MarsApi.retrofitService.getPhotos()
                _status.value = MarsApiStatus.DONE
            } catch (e: Exception) {
                _status.value = MarsApiStatus.ERROR
            }
        }
  1. catch {} ブロックのエラー状態の後で、_photos を空のリストに設定します。これにより、RecyclerView がクリアされます。
} catch (e: Exception) {
   _status.value = MarsApiStatus.ERROR
   _photos.value = listOf()
}
  1. 完全な getMarsPhotos() メソッドは次のようになります。
private fun getMarsPhotos() {
   viewModelScope.launch {
        _status.value = MarsApiStatus.LOADING
        try {
           _photos.value = MarsApi.retrofitService.getPhotos()
           _status.value = MarsApiStatus.DONE
        } catch (e: Exception) {
           _status.value = MarsApiStatus.ERROR
           _photos.value = listOf()
        }
    }
}

以上で、ステータスに enum 状態を定義し、コルーチンの開始時に「読み込み中」状態を設定しました。アプリがウェブサーバーからのデータの取得を完了したときは完了状態を設定し、例外が発生したときはエラー状態を設定しました。次のタスクでは、バインディング アダプターを使用して、対応するアイコンを表示します。

ステータス ImageView のバインディング アダプターを追加する

enum 状態のセットを使用して、OverviewViewModel に MarsApiStatus を設定しました。このステップでは、アプリでそれが表示されるようにします。ImageView のバインディング アダプターを使用して、読み込み中とエラーの状態を表すアイコンを表示します。アプリが読み込み中またはエラーの状態にあるときは、ImageView を表示する必要があります。アプリが読み込みを完了したときは、ImageView を非表示にする必要があります。

  1. BindingAdapters.kt を開いて、ファイルの末尾にスクロールし、別のアダプターを追加します。ImageView と MarsApiStatus 値を引数として受け取る bindStatus() という名前の新しいバインディング アダプターを追加します。メソッドに @BindingAdapter アノテーションを付け、パラメータとしてカスタム属性 marsApiStatus を渡します。
@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView,
          status: MarsApiStatus?) {
}

リクエストされた場合は、com.example.android.marsrealestate.overview.MarsApiStatus をインポートします。

  1. ステータスに応じて処理を切り替えるため、bindStatus() メソッド内に when {} ブロックを追加します。
when (status) {

}
  1. when {} 内に、「読み込み中」状態(MarsApiStatus.LOADING)の処理を追加します。この状態の場合は、ImageView を「表示」に設定し、読み込み中のアニメーションを割り当てます。これは、前のタスクで Coil について使用したのと同じアニメーション ドローアブルです。
when (status) {
   MarsApiStatus.LOADING -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.loading_animation)
   }
}

リクエストされた場合は、android.view.View をインポートします。

  1. エラー状態(MarsApiStatus.ERROR)の処理を追加します。LOADING 状態の処理と同様に、ステータス ImageView を「表示」に設定し、接続エラー ドローアブルを使用します。
MarsApiStatus.ERROR -> {
   statusImageView.visibility = View.VISIBLE
   statusImageView.setImageResource(R.drawable.ic_connection_error)
}
  1. 完了状態(MarsApiStatus.DONE)の処理を追加します。この場合は成功レスポンスが返されるので、ステータス ImageView の表示を View. GONE に設定して、非表示にします。
MarsApiStatus.DONE -> {
   statusImageView.visibility = View.GONE
}

以上で、ステータス画像ビューのバインディング アダプターの設定が完了しました。次のステップでは、新しいバインディング アダプターを使用する画像ビューを追加します。

ステータス ImageView を追加する

このステップでは、先ほど定義したステータスを表示する画像ビューを fragment_overview.xml に追加します。

  1. res/layout/fragment_overview.xml を開きます。RecyclerView 要素の下の ConstraintLayout 内に、下記の ImageView を追加します。
<ImageView
   android:id="@+id/status_image"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:marsApiStatus="@{viewModel.status}" />

上記の ImageView には、RecyclerView と同じ制約があります。ただし、幅と高さについては wrap_content が使用され、画像を拡大してビューを埋めるのではなく、中央に配置します。また、app:marsApiStatus 属性を viewModel.status に設定します。これにより、ViewModel のステータス プロパティが変更されたときに BindingAdapter が呼び出されます。

  1. 上記のコードをテストするには、エミュレータまたはデバイスで機内モードをオンにして、ネットワーク接続エラーをシミュレートします。アプリをコンパイルして実行します。エラー画像が表示されることを確認してください。

a91ddb1c89f2efec.png

  1. 戻るボタンをタップしてアプリを閉じ、機内モードをオフにします。最近使ったアプリの画面を使用してアプリに戻ります。ネットワーク接続の速度によっては、画像の読み込みを開始する前にアプリがウェブサービスをクエリしている間、一瞬だけ読み込み中のアイコンが表示されることがあります。

おつかれさまです。これで Codelab は完了し、MarsPhotos アプリが完成しました。アプリを使って家族や友人に実際の火星の写真を見せてあげましょう。

この Codelab の解答コードは、下記のプロジェクトにあります。main ブランチを使用して、コードを取得またはダウンロードします。

この Codelab のコードを取得して Android Studio で開くには、以下の手順に沿って操作します。

コードを取得する

  1. 指定された URL をクリックします。プロジェクトの GitHub ページがブラウザで開きます。
  2. プロジェクトの GitHub ページで、[Code] ボタンをクリックすると、ダイアログが表示されます。

Eme2bJP46u-pMpnXVfm-bS2N2dlyq6c0jn1DtQYqBaml7TUhzXDWpYoDI0lGKi4xndE_uJw8sKfwfOZ1fC503xCVZrbh10JKJ4iEHdLDwFfdvnOheNxkokITW1LW6UZTncVJJUZ5Fw

  1. ダイアログで、[Download ZIP] をクリックして、プロジェクトをパソコンに保存します。ダウンロードが完了するまで待ってください。
  2. パソコンに保存したファイルを見つけます([ダウンロード] フォルダなど)。
  3. ZIP ファイルをダブルクリックして展開します。プロジェクト ファイルが入った新しいフォルダが作成されます。

Android Studio でプロジェクトを開く

  1. Android Studio を起動します。
  2. [Welcome to Android Studio] ウィンドウで [Open an existing Android Studio project] をクリックします。

Tdjf5eS2nCikM9KdHgFaZNSbIUCzKXP6WfEaKVE2Oz1XIGZhgTJYlaNtXTHPFU1xC9pPiaD-XOPdIxVxwZAK8onA7eJyCXz2Km24B_8rpEVI_Po5qlcMNN8s4Tkt6kHEXdLQTDW7mg

注: Android Studio がすでに開いている場合は、メニューから [File] > [New] > [Import Project] を選択します。

PaMkVnfCxQqSNB1LxPpC6C6cuVCAc8jWNZCqy5tDVA6IO3NE2fqrfJ6p6ggGpk7jd27ybXaWU7rGNOFi6CvtMyHtWdhNzdAHmndzvEdwshF_SG24Le01z7925JsFa47qa-Q19t3RxQ

  1. [Import Project] ダイアログで、展開したプロジェクト フォルダがある場所([ダウンロード] フォルダなど)に移動します。
  2. そのプロジェクト フォルダをダブルクリックします。
  3. Android Studio でプロジェクトが開かれるまで待ちます。
  4. 実行ボタン j7ptomO2PEQNe8jFt4nKCOw_Oc_Aucgf4l_La8fGLCMLy0t9RN9SkmBFGOFjkEzlX4ce2w2NWq4J30sDaxEe4MaSNuJPpMgHxnsRYoBtIV3-GUpYYcIvRJ2HrqR27XGuTS4F7lKCzg をクリックして、アプリをビルドし、実行します。期待どおりに動作することを確認します。
  5. [Project] ツール ウィンドウでプロジェクト ファイルを表示して、アプリがどのように実装されているかを確認します。
  • Coil ライブラリを使用すると、アプリで画像を管理するプロセス(ダウンロード、バッファリング、デコード、キャッシュ保存など)を簡素化できます。
  • バインディング アダプターは、ビューとビューのバインドされたデータをつなげる拡張メソッドです。バインディング アダプターは、データが変更されたときのカスタム動作を提供します。たとえば、Coil を呼び出して URL から ImageView に画像を読み込むことができます。
  • バインディング アダプターは、@BindingAdapter アノテーション付きの拡張メソッドです。
  • 画像のグリッドを表示するには、GridLayoutManager で RecyclerView を使用します。
  • プロパティが変更されたときにプロパティのリストを更新するには、RecyclerView とレイアウトの間でバインディング アダプターを使用します。

Android デベロッパー ドキュメント:

その他: