見出し画像

JetpackComposeでQiitaのクライアントアプリを作ろう

こんにちは、まっこりです!
今回の記事では、JetpackComposeを使ってQiitaのクライアントアプリを作っていきます。Retrofitを使ったHTTP通信の方法や、MVVMアーキテクチャのシンプルな実装方法を知ることができる内容となっています。

完成時のソースコードをGitHubにアップしてます。必要な場合は確認してみてください。

私の開発環境:
・Android Studio Chipmunk | 2021.2.1 Patch 1

主な使用ライブラリ
・Retrofit
・Moshi
・Coil

1. 新規プロジェクトの作成

まずは、新規プロジェクトの作成とフォルダ構成を作っていきます。
プロジェクトのテンプレートはEmpty Compose Activityを選択してください。

プロジェクトのNameは「QiitaClient」と入力しましょう。

プロジェクトの新規作成ができたら、フォルダ構成を作っていきます。今回はMVVM+Rアーキテクチャを基本にして作っていこうと思うので、実際にコードを書いていく前にModelとView、Repositoryの三つのフォルダを作成しておきます。

MainActivity.ktファイルがある場所と同じところにview, repository, modelという名前で三つのフォルダを追加しましょう。以下のようなフォルダ構造になっていればOKです。

2. Viewの作成 - 画面遷移

記事の検索画面と詳細表示画面の二つの画面を作ります。
検索画面では、検索バーと検索結果リストを表示します。
詳細表示画面では、ユーザーが検索結果リストで選択した記事の内容を表示します。
この章では、検索画面から詳細表示画面へのナビゲーションを実装していきます。

ナビゲーションの実装を始める前に、テンプレートに含まれている不要なコードを削除します。MainActivity.ktを開いて、Greeting関数とDefaultPreview関数を削除します。またGreetingを呼び出している部分も削除します。不要なコード削除後のMainActivityは以下のようになります。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            QiitaClientTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                }
            }
        }
    }
}

不要なコードを消したので、画面遷移の実装をしていきます。まずは、必要な依存関係を追加します。Moduleレベルのbuild.gradleを開いて、以下の依存関係をdependenciesブロックの中に追加してください。

implementation "androidx.navigation:navigation-compose:2.4.2"

次は、NavControllerへの参照ををMainActivityのSurfaceブロックの中に追加します。NavControllerは画面遷移を管理しているAPIで、前の画面に戻る時や別画面に遷移するときに利用します。

val navController = rememberNavController()

NavHostを作成します。NavHostはNavControllerと画面遷移を定義しているナビゲーショングラフを関連付ける役割を果たします。

MainNavHostという名前のComposable関数をMainActivity.ktファイルの下に追加してください。(MaincActivityクラスの外)

@Composable
fun MainNavHost(navController: NavHostController) {
    NavHost(navController = navController, startDestination = "search") {
        composable(route = "search") {
            // ここで検索画面のコンポーザブル関数を呼び出す。
        }
        
        composable(route = "detail") {
            // ここで詳細画面のコンポザブル関数を呼び出す。
        }
    }
}

composableという名前の関数のroute引数はwebサイトのURLリンクと似たような役割を果たしていて、navControllerを"search"というルートに遷移させると、route引数に"search"が設定されたcomposable関数の中のcontentコンポーザブルが表示されます。({}の中身)

画面遷移の設定ができたので、画面のタイトルだけ表示する画面を作成してちゃんと画面遷移が実装できているか確認してみましょう。

viewフォルダの中に、searchという名前でパッケージを追加して、その中にSearchScreen.ktという名前のKotlinファイルを作成してください。ファイルを作成したら以下のように「検索画面」というテキストと「詳細画面へボタンのUIを定義します。

@Composable
fun SearchScreen(navController: NavController) {
    Scaffold {
        Column {
            Text(text = "検索画面")
            Button(onClick = { navController.navigate("detail") }) {
                Text(text = "詳細画面へ")
            }
        }
    }
}

次は詳細画面です。viewフォルダの中にdetailという名前でパッケージを追加し、その中にDetailScreen.ktという名前のKotlinファイルを作成してください。ファイルを作成したら以下のように「詳細画面」と表示するUIを定義します。

@Composable
fun DetailScreen() {
    Scaffold {
        Text(text = "詳細画面")
    }
}

これで、各画面のとりあえずのUIを作成できたので、ナビゲーショングラフに二つの画面を追加してあげます。

MainActivity.ktファイルを開いて、MainNavHost関数を以下のように変更してください。

@Composable
fun MainNavHost(navController: NavHostController) {
    NavHost(navController = navController, startDestination = "search") {
        composable(route = "search") {
            SearchScreen(navController = navController) // 変更箇所
        }

        composable(route = "detail") {
            DetailScreen() // 変更箇所
        }
    }
}

ここまでできたら、一旦エミュレータか実機で動作確認をしてみましょう。最初に表示される検索画面で「詳細画面へ」ボタンを押したら詳細画面に画面遷移するかと思います。

これで画面遷移の実装は終了です!
Navigation周りの実装は結構難しいので、一回の実装で理解するのは難しいと思います。もう少し理解を含めたい場合はdevelopersのページを確認してみてください。

もし、上手くいかない場合はこちらのソースを確認してみてください。

3. Viewの作成 - 検索画面

画面遷移の実装が終わったので、次は検索画面の実装をしていきます。検索画面のUIパーツとしては大きく分けて二つで、検索バーと検索結果リストです。

検索バーの作成

まずは検索バーから作成していきます。JetpackComposeにはViewに用意されているSearchViewのような検索バーのUIが用意されていないので、自作していく必要があります。今回は、Composeにデフォルトで用意されているTextFieldを拡張して検索バーコンポーネントを作成します。

viewフォルダの中に、componentという名前のパッケージを追加して、その中にSearchView.ktという名前のKotlinファイルを追加してください。

ファイルを作成したら、SearchView.ktの中に以下のようにコードを追加してください。詳細はコードの下で解説します。

@Composable
fun SearchView(
    textFieldState: MutableState<TextFieldValue>,
    onSubmit: (text: String) -> Unit,
) {
    val focusManager = LocalFocusManager.current

    TextField(
        value = textFieldState.value,
        onValueChange = { value ->
            textFieldState.value = value
        },
        modifier = Modifier
            .fillMaxWidth(),
        textStyle = TextStyle(color = Color.White, fontSize = 18.sp),
        leadingIcon = {
            Icon(
                Icons.Default.Search,
                contentDescription = "search icon",
                modifier = Modifier
                    .padding(15.dp)
                    .size(24.dp)
            )
        },
        trailingIcon = {
            if (textFieldState.value != TextFieldValue("")) {
                IconButton(
                    onClick = {
                        textFieldState.value =
                            TextFieldValue("")
                    }
                ) {
                    Icon(
                        Icons.Default.Close,
                        contentDescription = "delete icon",
                        modifier = Modifier
                            .padding(15.dp)
                            .size(24.dp)
                    )
                }
            }
        },
        singleLine = true,
        colors = TextFieldDefaults.textFieldColors(
            cursorColor = Color.White,
            backgroundColor = Purple700,
            leadingIconColor = Color.White,
            trailingIconColor = Color.White,
        ),
        keyboardActions = KeyboardActions {
            focusManager.clearFocus()
            onSubmit(textFieldState.value.text)
        }
    )
}

引数について、

fun SearchView(
    textFieldState: MutableState<TextFieldValue>,
    onSubmit: (text: String) -> Unit,
) {

引数ではTextFieldに入力される文字を管理するためのMutableStateと、キーボードで送信ボタンが押された時の処理を受け取るonSubmitコールバックを受け取ります。SearchViewはあくまでユーザーのアクションの受け渡しと、UIの表示のみの実装で、通信処理はSearchViewの呼び出し元で定義します。

val focusManager = LocalFocusManager.current

この箇所では、キーボードの送信ボタンが押されたときにキーボードを非表示にするのに必要なFocusManagerへの参照を取得しています。

leadingIcon = {
    Icon(
        Icons.Default.Search,
        contentDescription = "search icon",
        modifier = Modifier
            .padding(15.dp)
            .size(24.dp)
    )
},
trailingIcon = {
    if (textFieldState.value != TextFieldValue("")) {
        IconButton(
            onClick = {
                textFieldState.value =
                    TextFieldValue("")
            }
        ) {
            Icon(
                Icons.Default.Close,
                contentDescription = "delete icon",
                modifier = Modifier
                    .padding(15.dp)
                    .size(24.dp)
            )
        }
    }
},

SearchViewの左端に表示する検索アイコンと、入力中のテキストをクリアするための✖︎ボタンを設定しています。テキストが入力されていない状態では✖︎ボタンは非表示です。

keyboardActions = KeyboardActions {
    focusManager.clearFocus()
    onSubmit(textFieldState.value.text)
}

keyboardActionsでは、キーボードの左下のボタン(送信, 完了などのボタン)が押された時のコールバックを設定しています。送信ボタンが押されたら、SearchViewからカーソルを外して、キーボードを非表示にします。また、引数で受け取っているonSubmitコールバックを実行します。

SearchViewコンポーザブルが作れたので、SearchScreenから呼び出してあげましょう。SearchScreenに以下のようにSearchViewを追加してください。

@Composable
fun SearchScreen(navController: NavController) {
    Scaffold {
        Column {
            // ここから下が変更箇所
            val textFieldState = remember { mutableStateOf(TextFieldValue("")) }
            SearchView(textFieldState = textFieldState) {
                // TODO 検索バーに入力された文字でQiitaのAPIを叩く
            }
            // ここまでが変更箇所
            Text(text = "検索画面")
            Button(onClick = { navController.navigate("detail") }) {
                Text(text = "詳細画面へ")
            }
        }
    }
}

ここで一度エミュレータ・実機で表示を確認してみましょう。下の画像のように検索バーが表示されているはずです。

検索結果リストの作成

検索バーコンポーザブルの実装ができたので、次は検索結果リストを作っていきます。検索結果リストは、LazyColumnを使用して実装します。LazyColumnはColunmと同じくコンポーザブルを縦に並べていくのに使用するのですが、描画が必要な部分の描画しか行いません。APIからデータを取得して表示するときなど、多数のアイテムをリスト表示する場合はColumnを使うと描画パフォーマンスが悪いので、LazyColumnを使います。検索結果一件分のUIを作って、それを検索でヒットした記事の数だけ表示します。

UIの作成に入る前に、使用するライブラリの依存関係を追加します。Moduleレベルbuild.gradleファイルのdependenciesブロックに以下の一行を追加してください。

implementation "io.coil-kt:coil-compose:2.1.0"

coilというライブラリの依存関係を追加しました。coilは画像urlから画像を表示する際に使用します。

画像の取得にはネットを使用するため、AndroidManifest.xmlにパーミッションを追加します。applicationタグの上に以下の一行を追加してください。

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

では、UIの作成に入っていきます。まずは、記事一件のデータを表示するリスト一行分のコンポーザブルを作成します。searchディレクトリの中に、SearchResultCell.ktという名前でKotlinファイルを作成して、以下のようにコードを追加してください。

@Composable
fun SearchResultCell() {
    Column(
        modifier = Modifier.padding(horizontal = 10.dp),
    ) {
        Spacer(modifier = Modifier.height(10.dp))
        Text(text = "記事タイトル")
        Row(
            modifier = Modifier.height(32.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            AsyncImage(
                model = "https://assets.st-note.com/production/uploads/images/63638586/rectangle_large_type_2_036bad6b2400148e2bab52d71576f4cc.jpg",
                contentDescription = "author icon",
                contentScale = ContentScale.Crop,
                modifier = Modifier
                    .fillMaxHeight(0.7f)
                    .aspectRatio(1f)
                    .clip(CircleShape),
            )
            Spacer(modifier = Modifier.width(10.dp))
            Text(text = "@username")
        }
        Divider()
    }
}

記事タイトルと、記事の作者アイコン、作者の名前のみを表示するシンプルなリストです。今は仮のデータを入れています。

要点の説明

AsyncImage(
    model = "https://assets.st-note.com/production/uploads/images/63638586/rectangle_large_type_2_036bad6b2400148e2bab52d71576f4cc.jpg",
    contentDescription = "author icon",
    contentScale = ContentScale.Crop,
    modifier = Modifier
        .fillMaxHeight(0.7f)
        .aspectRatio(1f)
        .clip(CircleShape),
)

AsyncImageは先ほど追加したcoilのAPIで、modelで指定した画像urlから画像データを非同期で取得して表示してくれています。

検索データ一件分を表示するUIができたので、SearchScreenでLazyColumnを使って50件分のリストを表示してみましょう。SearchScreen.ktを開いて、以下のコードをSearchViewの下に追加してください。

LazyColumn {
    items(50) {
        SearchResultCell()
    }
}

これで、記事50件分のリストが表示されるようになりました。エミュレータ・実機で一度表示を確認してみましょう。

もし、うまくリストが表示されないようでしたら以下のソースを確認してみてください。

ここまでで、SearchScreenの作成は完了です。ModelとViewModelを作ってから、実際にQiitaのAPIから取得したデータを表示するように変更します。

Viewとしては、記事の詳細を表示するDetailScreenもありますが、モデルクラスのインスタンスを元に、データを表示するだけの画面なので、先にモデルクラスを作ってしまします。

3. モデルの作成

使用するライブラリの依存関係を追加

QIitaのAPIから取得できるレスポンスJsonをKotlinオブジェクトに変換するのに、converter-moshiを使用します。Moduleレベルのbuild.gradleのdependenciesブロックに以下の1行を追加してください。

implementation "com.squareup.retrofit2:converter-moshi:2.9.0"

モデルの作成に使用するライブラリは以上です。次は、記事一件のデータを表すモデルクラスを作成します。modelディレクトリの中に、Article.ktという名前でKotlinのData Classファイルを作成してください。

モデルクラスの中身を作っていく前に、まず、今回利用するQiitaのitemsAPIから得られるJSONレスポンスを確認しておきましょう。ドキュメントをこちらから確認してみてください。ちなみに、このAPIのqueryに先ほど作成したSearchViewの検索文字を当てはめて記事検索を行います。

QiitaAPIのJSONから得られる情報の中で、今回利用する項目は以下の5つです。

・url
・title
・url
・user.id
・user.profile_image_url

この項目をAriticleモデルクラスに定義します。Article.ktのコードは以下のようになります。

data class Article(
    val url: String,
    val title: String,
    val user: User,
) {
    data class User(
        val id: String,
        @Json(name = "profile_image_url")
        val profileImageUrl: String,
    )
}

@Json(name = …)は、JsonからKotlinオブジェクトに変換する際に、jsonとは違う変数名を使用したいときに使用します。Kotlinでは基本的に変数名をキャメルケースで定義するので、JSON内でスネークケースで定義されている「profile_image_url」をprofileImageUrlに変換します。

モデルの実装は以上で完了です。モデルが完了したので、次は記事詳細画面の方を作っていきます。

4. Viewの作成 - 記事詳細画面

ここから先は

10,644字 / 5画像
この記事のみ ¥ 250
期間限定!PayPayで支払うと抽選でお得

この記事が気に入ったらチップで応援してみませんか?