〈kotlin〉Paging3

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

アプリのUIに表示するデータを段階的に読み込むことが簡単に実装できるライブラリ
Paging3をCodelabを参考に勉強した。
Android Paging Basics  |  Android Developers
Android Paging Advanced codelab  |  Android Developers


サンプルコードはここから一部改変。
ページング データを読み込む、表示する  |  Android デベロッパー  |  Android Developers

コンポーネント

PagingSource データのソースと、そのソースからデータを取得する方法を定義
PagingConfig ページング動作を決めるパラメータ定義
(一度に読み込む量や初期読み込みサイズなど)
Pager PagingDataのインスタンスを公開する
PagingData PagingSource オブジェクトに対してクエリを実行した結果を格納する
PagingAdapter ページ分けされたデータを処理してUI更新

PagingSource

PagingSourceを作成するには、

  • データの読み込みに使う識別子となるページングキー
  • 読み込むデータの型
  • データの取得元

が必要となる。

今回の例ではString型のクエリとInt型のページ番号をRetrofitに渡し、
ネットワークからUser型のオブジェクトを取得するExampleBackendServiceを作成しているとする。

class ExamplePagingSource(
    val backend: ExampleBackendService,
    val query: String
) : PagingSource<Int, User>() {

  override suspend fun load( params: LoadParams<Int>): LoadResult<Int, User> {
    try {
      val nextPageNumber = params.key ?: 1
      val response = backend.searchUsers(query, nextPageNumber)
      return LoadResult.Page(
        data = response.users,
        prevKey =  if( nextPageNumber > 1) nextPageNumber - 1 else null,       
        nextKey =  nextPageNumber + 1
      )
    } catch (e: Exception) {
      ...
      return LoadResult.Error(e)
    }
  }

  override fun getRefreshKey(state: PagingState<Int, User>): Int? {
    return state.anchorPosition?.let { anchorPosition ->
      val anchorPage = state.closestPageToPosition(anchorPosition)
      anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
    }
  }
}

load() と getRefreshKey() という 2 つの関数を実装する。
load()
ユーザーがスクロールしたときに表示される追加のデータを非同期で取得する。
引数LoadParamsは読み込むページのキーとアイテム数を保持するオブジェクトで、
最初の読み込みではページキーがnullのためキーを定義しておく。
load()はLoadResultを返し、
読み込みが正常に完了するとLoadResult.Page オブジェクト、
読み込みが成功しなかったらLoadResult.Error オブジェクトが返される。
LoadResult.Pageの引数は
data: 取得したアイテムの List
prevKey: 現在のページより前のアイテムを取得する必要がある場合に使用するキー
nextKey: 現在のページより後のアイテムを取得する必要がある場合に使用するキー

getRefreshKey()
初期ロード後にデータが更新または無効化されたときに load() メソッドに渡すキーを返す。
PagingStateのanchorPositionはデータを正常に取得した最後のインデックスで、
これに一番近いページを取得して、次に読み込み始めるページのキーを返す。

PagingData

Pager クラスを使用してPagingSource からの PagingData オブジェクトを公開する。
通常はViewModel内に実装する。

val flow = Pager(
  PagingConfig(pageSize = 20)
) {
  ExamplePagingSource(backend, query)
}.flow
  .cachedIn(viewModelScope)

Pagerインスタンスを作成するには、
PagingConfig 構成オブジェクト、PagingSource実装のインスタンスを取得する方法を指示する関数を渡す。
cachedIn() はデータ ストリームを共有可能にし、指定された CoroutineScope で読み込まれたデータをキャッシュに保存する。
(設定またはナビゲーションの変更があってもページング状態を維持できる)

PagingAdapter

PagingDataAdapter を拡張するクラスを定義する。

class UserAdapter : PagingDataAdapter<User, UserViewHolder>(diffCallback) {
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
    ...
  }

  override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
   ...
  }
}

UIに表示

PagingData ストリームを監視し、生成されたそれぞれの値をアダプターの submitData() メソッドに渡す。

val pagingAdapter = UserAdapter()
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
recyclerView.adapter = pagingAdapter

lifecycleScope.launch {
  viewModel.flow.collectLatest { pagingData ->
    pagingAdapter.submitData(pagingData)
  }
}


ネットワークとローカル データベースから同時にページングするためのRemoteMediatorも
codelab内で登場したけど難しかったのでまたいつか...。
Android Paging Advanced codelab  |  Android Developers

〈kotlin〉Groupie

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

RecyclerViewに表示する1つ1つのアイテムのレイアウトを
ViewHolderなしでシンプルに書けるライブラリGroupieを触ってみた。
GroupieはRecyclerViewの中に複数のレイアウトでアイテムを追加していきたいときに便利に使えそう。

Gradle

setting.gradle

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url "https://jitpack.io" }   //ここを追加
    }
}

build.gradle

implementation "com.github.lisawray.groupie:groupie:$version"
implementation "com.github.lisawray.groupie:groupie-databinding:$version"

基本的にDataBindingと合わせて使うのが良さそうだったので2つ目の依存関係を追加。

レイアウト

RecyclerViewに表示したいアイテム1つ分のレイアウトを作成。

<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="item"
            type="String" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:text="@{item}"/>

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

BindableItem

GroupieのBindableItemクラスを使用して、後からadapterに渡すデータのクラスを作成する。

class ListItem(val text : String) : BindableItem<SampleListItemBinding>() {

    override fun bind(viewBinding: ListItemBinding, position: Int) {
        viewBinding.item = text
        //itemはxmlでdatabindingに設定した変数
    }

    override fun getLayout(): Int  = R.layout.sample_list_item
}

adapterを作成してデータを渡す

val adapter = GroupieAdapter()
binding.recyclerview.adapter = adapter

adapter.add(ListItem("sample1"))


val listItems = listOf(ListItem("sample2"), ListItem("sample3"))
adapter.update(listItems)


val section = Section()
section.setHeader(HeaderItem())
section.addAll(listItems)
adapter.add(section)

adapterはGroupieAdapter()のインスタンスを用意するだけで良い。
アイテムを追加するときはaddで追加するか、updateで更新するか、Sectionを使って追加する。
Sectionは移動、更新などの差分やアニメーションをサポートしているGroupieリストで、
ヘッダーやフッターを設定することができそう。


参考
groupie/README.md at bc3c799b8cbb08ddf5f450b8f9b62f84aff33a84 · lisawray/groupie · GitHub
Groupie 基本系からドラッグ&ドロップ、スワイプなど - Qiita

〈kotlin〉LiveDataの変換

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

あるLiveDataが変更された場合に、
それに応じて別のLiveDataの値も変更したい時に使える Transformations クラスについて。

Transformations.map()

val useId = MutableLiveData<Int>()
val userName: LiveData<String> = Transformations.map(userId) {  id -> 
    "userName: ${id} "
}

この例ではuserIdというLiveDataをTransformations.map()の引数に渡すことで、
userIdに対して関数が適用されて別の値が作成され、
userNameという別のLiveDataには作成された値が入る。

Transformations.switchMap()

private fun getUser(id: String): LiveData<User> {
  ...
}
val userId: LiveData<String> = ...
val user = Transformations.switchMap(userId) { id -> getUser(id) }

switchMap()を使う場合も同様にuserIdを渡すと関数が適用されるが、
このときに渡す関数は戻り値としてLiveDataを返す必要がある。

複数のLiveDataから変換したいとき

MediatorLiveDataを使用すると複数のLiveDataを監視して、
どちらかの値が変更された場合に値が変更されるLiveDataを作ることができる。

val liveData1 = MutableLiveData<String>("")
val liveData2 = MutableLiveData<String>("")

val data  = MediatorLiveData<String>()
init{
   data.addSource(LiveData1){
        data.value = it
   data.addSource(LiveData2){
        data.value = it
    }
}

こんな感じで、定義したMediatorLiveDataに対して
addSource()を使って監視対象にしたいLiveDataを渡し、
値が変更されたときにしたい処理observerも一緒に渡す。

こちらの記事にあったように関数を作るとわかりやすそう。
DroidKaigi 2020アプリの「combine」が便利という話 - Qiita

参考
LiveData の概要  |  Android デベロッパー  |  Android Developers

〈kotlin〉チェックボックス付きダイアログ

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

ボタンをタップしてダイアログを表示させたとき、
リストからチェックボックスを作成し、選択したアイテムを保存する処理を実装してみた。

val items = arrayOf("sample1", "sample2", "sample3")
val checkedItems = BooleanArray(item.size)

val selectedItems = ArrayList<Int>()

MaterialAlertDialogBuilder(this)
       .setTitle(R.string.title)
       .setMultiChoiceItems(items, checkedItems) { dialog, which, isChecked ->
            if (isChecked) {
                selectedItems.add(which)
            } else if (selectedItems.contains(which)) {
                selectedItems.remove(which)
            }
        }
        .setPositiveButton(R.string.ok_button) { _, _ ->
            makeList(selectedItems, items)
        }
        .setNegativeButton(R.string.cancel_button) { _, _ ->
        }
        .setCancelable(false)
        .show()
  • items

チェクボックスに表示したい項目を配列で用意する。

  • checkedItems

配列の各項目に対するチェックボックスがデフォルトでチェックされている(true)かいないか(false)。
今回はすべてfalseで、itemsの配列が変わっても対応できるようにBooleanArrayを作成。

  • seletedItems

ここに選択されたアイテムのポジションを保存する。

  • MaterialAlertDialogBuilder

MaterialDialogを使用。引数はcontext。

  • setMultiChoiceItems()

複数選択できるチェックボックスを配置する。
1つだけ選択させたい時はsetSingleChoiceItems()を使う。
項目の配列、選択状態の配列、選択時のリスナーの3つを引数にとる。
isChecked(選択状態)がtrueならwhich(項目のポジション)をseletedItemsに入れ、
falseならリストから削除する。

  • setPositiveButton()

ボタンに表示したいテキストとリスナーを渡し、OKボタンが押された場合の処理を記述する。
今回は別で作成したmakeList()メソッドの中でselectedItemsに対してforループを回して、
itemsの中の選択された要素に対する処理を実行するようにしてみた。

  • setNegativeButton()

キャンセルボタンが押された場合の処理があれば記述。

  • setCancelable()

ダイアログが表示されていないところを表示された時に、ダイアログがキャンセルできるかどうか。
デフォルトではtrueになっているが、
OKかCancelのどちらかを確実に選択させたい時はfalseにしておく。



参考
ダイアログ  |  Android デベロッパー  |  Android Developers
MaterialAlertDialogBuilder  |  Android Developers
【Android】 MaterialAlertDialogで色々実装する - Qiita

〈kotlin〉Retrofit

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

Retrofit ライブラリを使用して、インターネット上の REST ウェブサービスに接続し、レスポンスを取得する。
以下の一連のcodelabを参考に勉強したので基本的な使い方をまとめる。
インターネットからデータを取得して表示する  |  Kotlin を用いた Android の基本 - インターネット - データを取得して表示する  |  Android Developers

依存関係の追加

implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation "com.squareup.moshi:moshi-kotlin:1.12.0"

MoshiはJson文字列をkotlinオブジェクトに変換してくれる。
RetrofitはMoshiと連携するコンバータを持っている。
(コンバータはウェブサービスから返されたデータをどのように処理するか伝える役割)

権限の宣言

アプリがインターネットにアクセスするためにAndroidManifest.xmlに権限を追加する。

<uses-permission android:name="android.permission.INTERNET" />

Jsonの戻り値を格納するデータクラスの作成

Jsonオブジェクトにはkey-valueペアが含まれており、これに対応したデータクラスを作成する。
Jsonのkeyを変数名、valueのデータ型を変数のデータ型として作成する。

data class MarsPhoto(
   val id: String, 
   val img_src: String
)

Jsonのkeyと異なる変数名を付けたい場合は、@Json アノテーションを使用する。

data class MarsPhoto(
   val id: String, 
   @Json(name = "img_src") val imgSrcUrl: String
)

Retrofitオブジェクトの作成

ベースURL

ベースURLを定数に入れておく。
このURLの後ろに、欲しい情報に対応したエンドポイントを追加することになる。

private const val BASE_URL = "https://android-kotlin-fun-mars-server.appspot.com"
Moshiオブジェクト
private val moshi = Moshi.Builder()
   .add(KotlinJsonAdapterFactory())
   .build()
Retrofitオブジェクト

上で作成したmoshiのインスタンスをコンバータファクトリとして渡し、
ベースURLを追加する。

private val retrofit = Retrofit.Builder()
   .addConverterFactory(MoshiConverterFactory.create(moshi))
   .baseUrl(BASE_URL)
   .build()

ウェブサーバーと通信する方法を定義するインターフェースの作成

HTTPメソッドを使用してサーバーへのリクエストを行う。

GET サーバーデータを取得する
POST または PUT サーバーに対して新しいデータの追加、作成、更新を行う
DELETE サーバーからデータを削除する

データを受け取るには@GETアノテーションを使用し、エンドポイントを指定する。
メソッドはコルーチンで使用できるようにsuspendにする。

interface MarsApiService {
    @GET("photos")
    suspend fun getPhotos():  List<MarsPhoto>
}

APIサービスをアプリの他の部分に公開

オブジェクト宣言を使用して Retrofit API サービスのインスタンスをシングルトンで宣言し、
アプリの他の部分で使用できるようにする。

object MarsApi {
    val retrofitService : MarsApiService by lazy {
       retrofit.create(MarsApiService::class.java) }
}

サービスを呼び出す

シングルトン オブジェクトMarsApiを使用して、
retrofitService インターフェースの getPhotos() メソッドを呼び出す。
サーバー接続時には例外が起こることがよくあるため、
アプリが突然終了しないようにtry-catchで例外処理を記述しておく。

class OverviewViewModel : ViewModel() {

   private val _status = MutableLiveData<MarsApiStatus>()
   val status: LiveData<MarsApiStatus> = _status

   private val _photos = MutableLiveData<List<MarsPhoto>>()
   val photos: LiveData<List<MarsPhoto>> = _photos

   init {
       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()
            }
        }
    }
}

〈kotlin〉Activity Result API

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

アプリ内の別のアクティビティや他のアプリのアクティビティを起動して結果を取得するとき、
startActivityForResult() API と onActivityResult()を使ったコードではなく、
Activity Result API を使った書き方が推奨されている。

コールバックの登録

まずregisterForActivityResult()を使って結果が返ってきた時の処理を指定する。
registerForActivityResult() は ActivityResultContract と ActivityResultCallback を受け取って、
他のアクティビティを開始するために使用する ActivityResultLauncher を返す。

val launcher = registerForActivityResult(GetContent()) { uri: Uri? ->
    // ここに返ってきたuriに対する処理を定義
}

この例ではGetContent()が ActivityResultContractにあたる。
ActivityResultContractはどのアクティビティを呼び出してどんな結果を受け取るのかを定義したもので、
基本的なアクションに対してはデフォルトのコンストラクタが用意されている。
(例えばGetContent()は保存されているコンテンツの中から選ぶとそのUriを返す)
コンストラクタは自分で作成することもできる。
https://developer.android.com/training/basics/intents/result?hl=ja#custom

ActivityResultCallbackには、ActivityResultContractで定義した出力に対する処理を記載する。

アクティビティの起動

上で定義したlauncherに対してlaunch()を呼び出すとアクティビティが起動される。
この時、指定したActivityResultContractのタイプに一致する入力を渡す。

val launcher = registerForActivityResult(GetContent()) { uri: Uri? ->
}

override fun onCreate(savedInstanceState: Bundle?) {
    // ...

    val selectButton = findViewById<Button>(R.id.select_button)

    selectButton.setOnClickListener {
        launcher.launch("image/*")
    }
}

アクテビティが完了して復帰すると、コールバックに登録した処理が実行される。



参考
アクティビティの結果を取得する  |  Android デベロッパー  |  Android Developers
ActivityResultContracts  |  Android Developers

〈kotlin〉入力後にキーボードを非表示

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

EditTextはフォーカスが当たるとキーボードが表示され、
入力後も表示されたままになり、手動で閉じる必要がある。
これを、入力後Enterキーを押すことで自動で閉じるように実装する。

キーボードを非表示にするメソッド
private fun handleKeyEvent(view: View, keyCode: Int): Boolean {
   if (keyCode == KeyEvent.KEYCODE_ENTER) {
       val inputMethodManager =
           getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
       inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0)
       return true
   }
   return false
}

キーボードには入力できるそれぞれのキーに対してキーコードが設定されている。
今回は押されたキーのキーコードがKeyEvent.KEYCODE_ENTERと一致している場合に、
非表示の処理が進むように条件分岐する。

InputMethodManagerはソフトキーボードの表示と非表示を制御するクラスで、
この中のhideSoftInputFromWindowでキーボードを非表示にできる。
hideSoftInputFromWindowはリクエストを行うウィンドウのトークンと、
非表示の設定フラグ(Int型)を引数に持つ。
フラグは通常0で良さそうだが、
InputMethodManager.HIDE_IMPLICIT_ONLY / HIDE_NOT_ALWAYS も指定できそう。
InputMethodManager  |  Android Developers

EditTextにキーリスナーを設定
override fun onCreate(savedInstanceState: Bundle?) {
   ...
   binding.editText.setOnKeyListener { 
       view, keyCode, _ -> handleKeyEvent(view, keyCode)
   }
}

EditTextにはOnKeyListenerインターフェースを渡し、
キーの押下が発生したときにトリガーされる onKey() メソッドを設定する。
onKey() メソッドは ビュー、押されたキーを表すコード、キーイベントの3つを受け取るので、
この中で最初に作ったhandleKeyEvent()メソッドを呼び出してビューとキーコードを渡す。


参考
より洗練されたユーザー エクスペリエンスを作成する  |  Android デベロッパー  |  Android Developers
InputMethodManager  |  Android Developers
View.OnKeyListener  |  Android Developers