2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NTTテクノクロスAdvent Calendar 2023

Day 20

Compose Multiplatformで作ったQiitaリーダーにさらに機能を追加した

Last updated at Posted at 2023-12-19

この記事はNTTテクノクロス Advent Calendar 2023 シリーズ2の20日目です。

こんにちは、NTTテクノクロスの戸部@etctaroと申します。

普段は社内でモバイルアプリ開発関連の技術支援や社内向けのノウハウ記事執筆、社内研修講師活動、社内コミュニティ活動などを行なっています。

今年はすでにシリーズ1の2日目の記事を書いています。
この記事はその続きとなります。

Compose Multiplatformでちょっと気になるあれこれも色々とやりましたので、
ぜひこちらもお楽しみください。

はじめに

シリーズ1の2日目の記事のダイジェスト

  • Compose Multiplatformの環境が整ってきたよ。
  • いつものQiitaリーダーのアプリを作ってみたよ。
  • みんなも作ってみよう!

想定読者

  • Jetpack Composeについては知っているが、Compose Multiplatformについては知らない人
  • クロスプラットフォームアプリ開発を色々と模索している人
  • とりあえず新しいものに触ってみたい人
  • Kotlinが好きな人

読んで得られること

  • Compose Multiplatformでこのような対応もできることがわかる
  • よくあるTipsを他のアプリを作る時にも適用できる

今回のソースコード

  • サンプルコードはこちらのリポジトリにあります。
  • あくまでもサンプルコードですので、エラー処理などは最低限です。比較的応用しやすいと思いますので、ぜひ参考にご利用ください。そして、あなたもComposeの世界に入りましょう。

色々あれこれなお題

今回は以下のような内容を考えてみます。

  • ローディングの追加
  • エラー処理
  • 永続化+フィルタリング

ローディングの追加

  • Composeでローディングといえば、CircularProgressIndicatorなどのComposableが使えますが、Compose Multiplatformでも同様です。

まずは、以下の通り、CircularProgressIndicatorを追加します。

    @OptIn(ExperimentalResourceApi::class)
    @Composable
    override fun Content() {
        var loading by remember { mutableStateOf(false) }

        // Scaffold(...) {...
            // 中略...
            // ローディングを追加
            if (loading) {
                Box(modifier = Modifier.fillMaxSize()) {
                    CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
                }
            }
    }

ポイントとして、CircularProgressIndicatorは後の行に書きます。
後の方が上に重なるためです。

次に、FABの処理です。やることはシンプルで、最初に表示して、必要なくなったら消すのみ。

    floatingActionButton = { FloatingActionButton(onClick = {
        loading = true
        MainScope().launch {
            val result = ExampleApi().getArticles()
            resultList = result
            loading = false
            // エラー処理も入れること
        }
    //...

WebViewを使っているDetailScreenの方は以下のように、LinearProgressにしてみました(WebView側のサンプルと同様です。)

progressの値が取れるので、「それらしい」動きにできます。

    val loadState = state.loadingState
    Column {
        // ローディングを追加
        if (loadState is LoadingState.Loading) {
            LinearProgressIndicator(progress = loadState.progress, modifier = Modifier.fillMaxWidth())
        }
        WebView(state)
    }

エラー処理

  • さて、実際に試してみると、通信状況が悪い時にAPIリクエストをすると、(いうまでもないですが)エラーになります。ということでその対応をします。
    • エラーになったらローディングを消す。
    • エラーになったらその旨わかるメッセージを出力する
  • エラー処理については一般的なtry-catch-finallyで実現できます。
    • loadingを変更するのはfinallyの中で実施。
  • メッセージの表示にはSnackbarを使います。
    • 以下の通り、専用のState(SnackbarHostState)を生成して使います。
object ListScreen : Screen {

    @OptIn(ExperimentalResourceApi::class)
    @Composable
    override fun Content() {

        var loading by remember { mutableStateOf(false) }
        val hostState = remember { SnackbarHostState() }
        Scaffold(
            //...
            snackbarHost = { SnackbarHost(hostState) },
            floatingActionButton = { FloatingActionButton(onClick = {
                loading = true
                MainScope().launch {
                    try {
                        val result = ExampleApi().getArticles()
                        resultList = result
                    } catch (e: Exception) {
                        // なんかメッセージを出そう。
                        hostState.showSnackbar("取得に失敗しました")
                    } finally {
                        loading = false
                    }

永続化

  • お気に入り的な機能を持たせることを考えてみました。
  • 例えば以下のようにトグルボタンを追加し、ボタンをONにするとお気に入り、OFFにするとお気に入りから削除するイメージです。

image.png

永続化の方法としては、

  • DB (RDB/NoSQL)
  • SharedPreference/KVS
  • ファイル保存、読み込み

などがあげられます。関連ライブラリについてはかなり色々あり特徴もそれぞれです。
自分の持っているイメージは以下のような感じです。

  • 構造化されている、複雑性のある、繰り返しの情報を扱うならRDB(SQLite)
  • 設定などのように、更新頻度が少ないもの、かつ複雑さを求めない(それこそkey-value形式の)情報を扱うならKVS
  • これらの間くらいならNoSQLの選択も念頭に。
  • 画像が必要、アプリの要件として記録しておくべきデータがあるなどの場合はファイルを使う。

今回は取得した記事の情報を保存して、あるかないかが確認できれば十分なので、
NoSQLである Realm を使うことにしました。

Realmの利用準備

基本的には上記サイトの手順に沿って実施すれば利用することが可能です。

  • dependenciesおよびVersion Catalog
plugins {
    //...
    id("io.realm.kotlin") version "1.11.0"
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            //...
            implementation(libs.realm.library.base)
            implementation(libs.realm.library.sync) 

Versiln Catalogには以下を追加します。

[versions]
realm = "1.11.0"

[libraries]
realm-library-base = { module = "io.realm.kotlin:library-base", version.ref = "realm" }
realm-library-sync = { module = "io.realm.kotlin:library-sync", version.ref = "realm" }

リストの各行のComposable更新

各アイテムにトグルを追加することにします。
単純にRowで実装することも可能ですが、可読性を考慮した時に以下のようにListItemを使うとわかりやすくなります。

★なお、トグルを追加することで幅が狭まると改行が増え見づらくなるので、URLは表示しないようにしました。

  • Listの各行の実装

ListItemの導入

    LazyColumn {
        items(resultList) { article ->
            ListItem(
                modifier = Modifier.clickable {
                    navigator.push(DetailScreen(article.url))
                }
            ) {
                Column {
                    Text(article.title, style = MaterialTheme.typography.body1)
                }
            }
            Divider()
        }
    }

ListItemには引数でtrailingというパラメータを持っています。
つまり、右端に何か共通的に要素を追加したい時に設定するものです。わかりやすいですね。

このtrailingにIconToggleButtonを設定することになります。以下の通りとなります。

        var isFavorite by rememberSaveable { mutableStateOf(false) }
        ListItem(trailing = {
            IconToggleButton(checked = isFavorite,
                onCheckedChange = {
                }) {
                    Icon(imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
                        contentDescription = "favorite"
                    )
                }
            },
            //...

しかし、ここにさらにonCheckedChangeイベントを入れたりすると読みづらくなるので、FavoriteToggleという名前で新しいComposableを作ってみます。

    @Composable
    private fun FavoriteToggle(
        isFavorite: Boolean,
        onToggleChanged: (Boolean) -> Unit
    ) {
        IconToggleButton(checked = isFavorite,
            onCheckedChange = onToggleChanged
        ) {
            Icon(
                imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
                contentDescription = "favorite"
            )
        }
    }

これをtrailingに設定して、以下の通りになります。

        var isFavorite by rememberSaveable { mutableStateOf(false) }
        ListItem(trailing = {
            FavoriteToggle(isFavorite) {
                isFavorite = it
            }
        },

Realmを使った情報の保存

Realmを使う場合、モデルとなるクラスを定義する必要があります。
条件が少し厳しく、今回の場合は試行錯誤したところ以下の条件を満たすことになります。

  • RealmObjectの実装であること
  • 普通のclassであること(data classだとNG)
  • プロパティがvarであること(valだとNG)
  • 引数なしコンストラクタが定義されていること
  • ネストする場合、ネストした子クラスもRealmObjectクラスの実装であること。

Realmに保存する情報ですが、基本的には通信処理を実装する時に作成したモデルクラスと同等な情報が格納できれば十分なので、QiitaArticleを流用することにしました。
一部変更を入れています。

@Serializable
class QiitaArticle (
    @SerialName("id")
    var id: String = "",

    @SerialName("title")
    var title: String = "",

    @SerialName("url")
    var url: String = ""
) : RealmObject {
    constructor() : this("")
}

さて、このモデルを使って、保存の部分について実装します。

必要なことは以下の通りです。

  • Realmのコネクションを開き、Realmオブジェクトを取得する
    val configuration = RealmConfiguration.create(schema = setOf(QiitaArticle::class))
    val realm = Realm.open(configuration)
  • writeトランザクションを開始する
  • copyToRealmでオブジェクトの内容を保存できる。
    MainScope().launch(Dispatchers.IO) {
        realm.write {
                copyToRealm(
                    QiitaArticle(
                        id = article.id,
                        title = article.title,
                        url = article.url
                    )
                )
        }
    }

削除する場合もwriteトランザクションを開始するのは共通で、あとは以下を行います。

  • queryで該当のデータを探す
  • deleteで削除する
    MainScope().launch(Dispatchers.IO) {
        realm.write {
            val query =
                query<QiitaArticle>(
                    QiitaArticle::class,
                    "id = $0",
                    article.id
                )
            delete(query)
        }
    }

永続化+フィルタリング

永続化したお気に入りの情報だけをリストに表示するようなこともしてみます。

あまり深くは考慮せず、アクションにボタンを用意して、お気に入りに入れた情報を表示するだけのリストを表示する、という感じにします。

イメージはこのような感じ

  • 通常の取得時

image.png

  • お気に入りのリスト

image.png

この要件であれば、やることはさほど難しくはないと思います。以下のようなことを行います。

  • お気に入り用のリストを用意する
  • actionにフィルタリング用のトグルを用意する
  • トグルがONの時にお気に入り用のリストを表示するように切り替える
  • トグルがONの時だけ、ハートアイコンは表示しないようにする

ということで、それぞれを見ていきます。

  • お気に入り用のリストを用意する

例えば以下の通り。初期値として、realmから取得した情報をとっておきます。

        var favoriteList by rememberSaveable { mutableStateOf(
            realm.query<QiitaArticle>().find().map {
                QiitaArticle(it.id, it.title, it.url)
            }
        ) }
  • actionにフィルタリング用のトグルを用意する、トグルがONの時にお気に入り用のリストを表示するように切り替える

トグルボタンは使い回しです。この形にしておくと使い回しやすいですね。

        var filtered by rememberSaveable { mutableStateOf(false)}

        Scaffold(
            topBar = {
                TopAppBar(
                    // 略
                    actions = {
                        FavoriteToggle(isFavorite = filtered,
                            onToggleChanged = {
                                filtered = !filtered
                                favoriteList = realm.query<QiitaArticle>().find().map {
                                    QiitaArticle(it.id, it.title, it.url)
                                }
                                resultList = if (filtered) {
                                    favoriteList
                                } else {
                                    apiResultList
                                }
                        })

                    
  • トグルがONの時だけ、ハートアイコンは表示しないようにする

単純にfilteredがtrueだったらtrailingで何もしない、としているだけです。

    LazyColumn {
        items(resultList) { article ->
            var isFavorite by remember { mutableStateOf(realm.query<QiitaArticle>(QiitaArticle::class, "url = $0", article.url).find().size > 0) }

            ListItem(trailing = {
                if (!filtered) {
                    FavoriteToggle(isFavorite) {

おわりに

ライブラリ類がかなり充実していて、この辺りの話であれば特に問題なくサクサクっと実装できます。
ただし、Starが少なく叩かれきっていないライブラリもあるので、その点は注意が必要です。

さて、今回は永続化まで入れたのでアーキテクチャ周りの話の対応などの対応も入れたくなりました。
他にも、テストについても。
この辺りまでカバーできれば概ね困らないかと思います。

今回は時間切れなので、また別の機会で。

令和こそこそ噂話

シーズン2の21日目も引き続きよろしくおねがいします!
もう直ぐ終わりが見えてきましたね。ラストスパートもみなさん、頑張りましょう!

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?