Android Jetpack Composeを試してみた

# はじめに
[CYBIRD Advent Calendar 2022](https://qiita.com/advent-calendar/2022/cybird "CYBIRD Advent Calendar 2022")の11日目担当の@chikako_ikedaです。
スマホアプリを専門に担当するチームにてAndroid側の担当をしています。
10日目は@kyukkyu81さんの「[クリスマスの夜に向けて密かに歌を練習するアレクサ](https://qiita.com/kyukkyu81/private/569c6b84be6e7687b068 "クリスマスの夜に向けて密かに歌を練習するアレクサ")」でした。
まさか、アレクサに歌を歌わせることができるとは…昨年よりもパワーアップしてますね！

# 概要
最近のAndroidアプリ開発の動向を追っている方であればご存知かもしれませんが、Googleから「JetPack Compose」というコンポーネントが正式リリースされました。
このコンポーネントは、**素早くデザイン込みの開発**ができることが売りとなっています。
とても良いモノに聞こえるので、試してみて既存のアプリにどう組み込んでいくのがいいのか、
考えてみようと思います。
ちょっと長いですが、最後までお付き合いください。

# 目次
- Jetpack Composeとはなんぞや
- 必要な環境
- 実際に試してみた

# Android Jetpack Composeを試してみた
## Jetpack Composeとはなんぞや
そもそも「JetPack Composeとは？」という人も多いかと思います。
公式サイトである[Android Developers](https://developer.android.com/jetpack/compose?hl=ja "UI アプリ開発ツールキット Jetpack Compose - Android デベロッパー ")曰く
**「ネイティブ UI をビルドする際に推奨される Android の最新ツールキットです。」**
とのこと。
Jetpackという開発ツールキットがありますが、その中でも特にUI部分に特化しています。
コードを書くだけで、ちょっと面倒だった画面レイアウトがサクサク作れるようになっています。
最近のアップデートで、Wear OSアプリ用もリリースされています。

**直感的にUIが作成**できることで、**コードが削減**でき、パワフルにAPIにアクセスできる…

プレビューをパッと見ただけだと違いは分かりにくいですが、
とても良いものに思えてきませんか？

## 必要な環境
Jetpack Composeを使うには、以下が必要です。
+ Android Studio
→Bumblebee(2021.1.1)以降が必要です。
　新機能を使いたい場合は、最新版を使う方が良いでしょう。

以上です。
既にAndroid Studioをインストール済みだという方は、バージョンアップするだけで使えます。
ちょっと…いえ、結構容量を使うので、PC内のお掃除は必要かと思います。

ところで、この記事を読んでいるAndroidアプリ開発者の皆様は、おそらくきっとKotlinで
開発されているかと思います。
2022年のGoogle I/Oでも「Kotlinで書かれたアプリは全体の8割以上！」と言われていました。
確かに、KotlinがAndroidに正式採用された時のAndroid開発者の興奮ぶりはすごいものでした。

なぜ、開発言語の話をするのかといえば、**Jetpack ComposeはKotlinでしか使えないからです。**
GoogleはKotlinでネイティブ開発をして欲しいのではないかと思えてきます。

なお、サイバードの一部のアプリはJavaで書かれているので…Kotlinと併用できる状態にしないと
Jetpack Composeは使えません…　:sob:

## 実際に試してみた
それはさておき、実際にJetpack Composeを試してみようと思います。
なお、[チュートリアル](https://developer.android.com/jetpack/compose/tutorial?hl=ja "Jetpack Compose チュートリアル")があるのでそちらを参考に、従来の作り方と比較してみようと思います。
作る流れとしては、大体こんな感じになります。

1. Androidプロジェクトを作る
2. Jetpack Composeで画面を作る
3. 従来の作り方で同じ画面を作る

### Androidプロジェクトを作る
Android Studioでプロジェクトを新規作成します。
「File」> 「New」 > 「New Project」を選択すると、以下のような画面が出てきます。
![新規プロジェクト作成ダイアログ_プロジェクト形式選択](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294629/e4140819-93b2-d9bf-770b-b929be4fb43c.png "プロジェクト形式選択")
ここで **「Empty Compose Activity」** を選択すると、Jetpack Composeが画面が1つ作られた状態の
新規プロジェクトが作られます。

「Next」を押下すると、プロジェクト名や作成するパスなどの設定画面に移動します。
![新規プロジェクト作成ダイアログ_プロジェクト設定入力](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294629/b14196fa-4f73-12b0-b6c9-c14d66569119.png "プロジェクト設定入力")
今回は以下の内容で設定しました。参考にして作る場合は自分の環境で置き換えてくださいね。
| 項目 | 項目内容 | 説明 |
|:-:|:-|:-|
| Name | アプリの名称 | アプリ名です。「Advend Calendar Sample」にしました。 |
| Package Name | アプリのパッケージ名<br>例)jp.co.cysample.myapplication |アプリを識別するIDでもあります。|
| Save location | プロジェクトパス |プロジェクトの作成パスを入力します。<br>フォルダマークを押下すれば、場所選択のダイアログが出てくると思います。|
| Language | Kotlin|固定されているので、変更はできません…|
| Minimum SDK | 最小SDKバージョン | アプリをインストールできる最小OSバージョンの設定です。<br>下限バージョンに迷う場合はHelp me chooseを押下して、各OSバージョンのシェア率を見て決めると良いです。|

設定後「Finish」ボタンを押下すると、Androidプロジェクトが設定した内容で作られます。
なお、作られたプロジェクトはJetpack Composeのチュートリアルのレッスン1が終わった状態になっています。
(surface関数とかちょっと違う…？という状態にはなっています)

### Jetpack Composeで画面を作る
チュートリアルと同じ画面を作ってみようかと思います。
出来上がりのコードは以下に畳んで置いておきます。

<details>
<summary>サンプルコード</summary>
※Themeに関しての指定はデフォルトで作られたままのものを使っているので、省略


```MainActivity.kt 
package jp.co.cysample.advendcalendarsample

import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import jp.co.cysample.advendcalendarsample.ui.theme.AdvendCalendarSampleTheme

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

data class Message(val author: String, val body: String)

@Composable
fun Greeting(msg: Message) {
    // Row関数 → 要素を垂直に揃えることができる
    Row(modifier = Modifier.padding(all = 8.dp)) {
        // Image関数 → 要素として画像を追加 (ImageViewのようなもの)
        Image(
            painter = painterResource(R.drawable.profile_image),
            contentDescription = "Contact profile picture.",
            // Modifierで画像のサイズや形を設定
            modifier = Modifier
                .size(40.dp) // 画像のサイズ
                .clip(CircleShape) // 画像の形
                .border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
        )

        // 画像と文字Columnの間に隙間を作る
        Spacer(modifier = Modifier.width(8.dp))

        var isExpanded by remember { mutableStateOf(false) }
        val surfaceColor by animateColorAsState(
            if (isExpanded) MaterialTheme.colors.primary else MaterialTheme.colors.surface
        )

        // Column関数 → 要素を垂直に揃えることができる
        Column(modifier = Modifier.clickable { isExpanded = !isExpanded }) {
            // Text関数 → 要素として文字列を追加 (TextViewのようなもの)
            Text(
                text = msg.author,
                color = MaterialTheme.colors.secondaryVariant,
                style = MaterialTheme.typography.subtitle2
            )
            // authorとbodyの間に隙間を作る
            Spacer(modifier = Modifier.height(4.dp))

            Surface(
                shape = MaterialTheme.shapes.medium,
                elevation = 1.dp,
                color = surfaceColor,
                modifier = Modifier.animateContentSize().padding(1.dp)
            ) {
                Text(
                    text = msg.body,
                    modifier = Modifier.padding(all = 4.dp),
                    maxLines = if (isExpanded) Int.MAX_VALUE else 1,
                    style = MaterialTheme.typography.body2
                )
            }
        }
    }
}

@Preview(showBackground = true)
@Preview(name = "Light Mode")
@Preview (
    uiMode = Configuration.UI_MODE_NIGHT_YES,
    showBackground = true,
    name = "Dark Mode"
        )
@Composable
fun DefaultPreview() {
    AdvendCalendarSampleTheme {
        Conversation(SampleData.conversationSample)
    }
}

@Composable
fun Conversation(message: List<Message>) {
    // LazyColumn関数 = 垂直方向のリスト
    LazyColumn {
        message.map { item { Greeting(it)}}
    }
}
```

```DataSample.kt
package jp.co.cysample.advendcalendarsample

object SampleData {
    // Sample conversation data
    val conversationSample = listOf(
        Message(
            "Colleague",
            "Test...Test...Test..."
        ),
        Message(
            "Colleague",
            "List of Android versions:\n" +
                    "Android KitKat (API 19)\n" +
                    "Android Lollipop (API 21)\n" +
                    "Android Marshmallow (API 23)\n" +
                    "Android Nougat (API 24)\n" +
                    "Android Oreo (API 26)\n" +
                    "Android Pie (API 28)\n" +
                    "Android 10 (API 29)\n" +
                    "Android 11 (API 30)\n" +
                    "Android 12 (API 31)\n"
        ),
        Message(
            "Colleague",
            "I think Kotlin is my favorite programming language.\n" +
                    "It's so much fun!"
        ),
        Message(
            "Colleague",
            "Searching for alternatives to XML layouts..."
        ),
        Message(
            "Colleague",
            "Hey, take a look at Jetpack Compose, it's great!\n" +
                    "It's the Android's modern toolkit for building native UI." +
                    "It simplifies and accelerates UI development on Android." +
                    "Less code, powerful tools, and intuitive Kotlin APIs :)"
        ),
        Message(
            "Colleague",
            "It's available from API 21+ :)"
        ),
        Message(
            "Colleague",
            "Writing Kotlin for UI seems so natural, Compose where have you been all my life?"
        ),
        Message(
            "Colleague",
            "Android Studio next version's name is Arctic Fox"
        ),
        Message(
            "Colleague",
            "Android Studio Arctic Fox tooling for Compose is top notch ^_^"
        ),
        Message(
            "Colleague",
            "I didn't know you can now run the emulator directly from Android Studio"
        ),
        Message(
            "Colleague",
            "Compose Previews are great to check quickly how a composable layout looks like"
        ),
        Message(
            "Colleague",
            "Previews are also interactive after enabling the experimental setting"
        ),
        Message(
            "Colleague",
            "Have you tried writing build.gradle with KTS?"
        ),
    )
}
``` 
</details>

チュートリアル通りに進めてみましたが、感動レベルでオーソドックスな「リスト表示」が作れました。
ここでは「チャット」とされていましたが、画像＋複数段の文字列というのは、「○○の一覧」という形で
よく作られる画面でもあり、汎用性は高いと思います。

ちなみにAndroidStudioには元からプレビュー画面があるのですが、JetpackComposeを使う場合は
赤枠で囲った部分を押下すると、クリックした状態も確認できたりします。
![プレビュー画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294629/91238e45-9c35-df2b-7c22-4df86a20e65e.png)
従来の作り方でも触れますが、今までこういうチェックは実行するまで確認できませんでした。
これだけでもかなり便利です。

:::note warn
サンプルコード内でクリックしたかどうかのステータスは画面の再描画があった場合は
クリアされてしまいます。
実際は、画面のライフサイクルやその他イベントに合わせてステータスを保存してくださいね。
:::

とはいえ、今回の実装内容ですと画面遷移やボタンの実装などはまだまだ未知数…という感じがあります。
噂では画面遷移はこれまでの手法からかけ離れているとか…
要調査ですね。

### 従来の作り方で同じ画面を作る
さて、Jetpack Composeで作った画面をそのまま従来の作り方で作るとどうなるでしょうか。
なお、作るにあたり以下の条件で作っています。
そのため、「今の主流の開発方法」から外れている可能性があります。。。

:::note info
・画面を表示する最短の方法
・サードパーティー製のUI系ライブラリは使わない
:::

<details>
<summary>サンプルコード</summary>
※SampleDataとMessageに関しては同じなので、省略します。

```MainActivity.kt
package jp.co.cysample.advendcalendartraditional

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.ListView

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.traditional_main)

        var listView : ListView = findViewById(R.id.listview)
        val dataList = ArrayList<Message>(SampleData.conversationSample)
        val adapter = MessageAdapter(this, dataList)
        listView.adapter = adapter

    }
}
```

``` MessageAdapter.kt
package jp.co.cysample.advendcalendartraditional

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView

class MessageAdapter(context: Context, list: ArrayList<Message>) : ArrayAdapter<Message>(context, 0, list) {
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        var view  = convertView
        if (view == null) {
            // 一行分のレイアウトを生成
            view  = LayoutInflater.from(context).inflate(R.layout.listview_layout, parent, false)
        }
        // 一行分のデータを取得
        var data = getItem(position) as Message

        // 一行分のレイアウトに取得したdataの情報をセットする
        view?.findViewById<TextView>(R.id.list_title)?.text = data.author
        view?.findViewById<TextView>(R.id.list_value)?.text = data.body
        view?.findViewById<TextView>(R.id.list_value_2)?.text = data.body

        // Clickした時の挙動を設定
        val listener = View.OnClickListener {
            val valueView : TextView = it.findViewById<TextView>(R.id.list_value)
            val valueView2 : TextView = it.findViewById<TextView>(R.id.list_value_2)

            // 背景を変更したいため、TextViewの表示を入れ替える
            if (valueView.visibility == View.VISIBLE) {
                valueView.visibility = View.GONE
                valueView2.visibility = View.VISIBLE
            } else {
                valueView.visibility = View.VISIBLE
                valueView2.visibility = View.GONE
            }
       }
        view?.setOnClickListener(listener)
        return view!!
    }

}
```

```traditional_main\.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ListView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/listview"
        android:listSelector="@android:color/transparent">

    </ListView>
</LinearLayout>
```

```listview_layout\.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <androidx.cardview.widget.CardView
            xmlns:card_view="http://schemas.android.com/apk/res-auto"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_margin="8dp"
            android:layout_gravity="center_vertical"
            android:layout_alignParentStart="true"
            android:id="@+id/profile_image_card"
            card_view:cardBackgroundColor="@color/teal_700"
            card_view:cardCornerRadius="20dp">
            <ImageView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/profile_image_view"
                android:src="@drawable/profile_image"
                android:background="@drawable/textview_background"/>
        </androidx.cardview.widget.CardView>

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:layout_toEndOf="@id/profile_image_card"
            android:layout_alignParentEnd="true"
            android:paddingTop="4dp"
            android:paddingBottom="4dp">

            <!-- RecycleViewとか使ってやる方が良いかも… -->
            <TextView
                android:id="@+id/list_title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:layout_marginBottom="4dp"
                android:padding="1dp"
                android:text="aaaaaa"
                android:textColor="@color/teal_200"
                android:textSize="16sp" />

            <TextView
                android:id="@+id/list_value"
                style="@style/TextAppearance.MaterialComponents.Body1"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_margin="4dp"
                android:letterSpacing="0.25"
                android:backgroundTint="@color/black"
                android:ellipsize="end"
                android:lines="1"
                android:textSize="14sp" />
            <TextView
                android:id="@+id/list_value_2"
                style="@style/TextAppearance.MaterialComponents.Body1"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_margin="4dp"
                android:letterSpacing="0.25"
                android:background="@drawable/textview_background"
                android:visibility="gone"
                android:ellipsize="end"
                android:textSize="14sp" />
        </LinearLayout>

    </RelativeLayout>
</LinearLayout>
```

```textview_background\.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle"
    android:useLevel="false">
    <corners
        android:radius="5dp"/>
    <solid android:color="@color/purple_200" />

</shape>
```
</details>

![acs_tra_capture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294629/7437b959-0406-9e91-718c-c76baef20e1c.png)
出来上がり！
※1行分しかない項目をクリックすると、色が変わってしまうというバグ付き。

1画面を作るのに、7ファイル。しかも、半分はXMLです。
今回は１画面だけだったのでほぼ分けていませんが、全体で共通するような画面部品を分けたり、
画像やボタンに動きを出すためにshapeやselectorを増やす必要もあるので、まだまだ増えていきます。

それに、やはり分岐でのデザインチェックを全て「実行」しないと確認できないのは、
開発効率が良いとは言い難いかも。

### おまけ
今回、比較のために1つのアプリに新旧の画面を実装しようとしましたが、ライブラリのバージョンで
うまくビルドできなくなってしまうことがわかり、泣く泣く２つに分けることにしました。
ライブラリ内で違うKotlinのバージョンを参照しているらしいです。
→２つに分けた途端に、どのライブラリがどのバージョンになっていればビルド可能になるのか判明するという… :weary:

# さいごに
こうして比較してみると、どれほど強力な開発ツールかはイメージできたかと思います。
使える部分には積極的に使っていきたいですね。

明日の[CYBIRD Advent Calendar 2022](https://qiita.com/advent-calendar/2022/cybird "CYBIRD Advent Calendar 2022")12日目は、@whitemage_yuさんの「[Discord用のbotを作ってみた](https://qiita.com/whitemage_yu/private/ad5c4563614a6bdd146b)」です。
すぐにでも使えそうな内容ですので、是非読んでみてください。