PreviewActivityをHackする

# とにかくUIをもっと高速かつ正確に開発したい

- JetpackComposeはUI開発は高速化してくれた
- もっともっとUI開発を高速化するためにPreviewActivityをHackする

:::note info
本記事の内容はXML時代でも自分でrunConfigurationを用意すれば同様のことはできる（がIDE機能と統合されたPreviewActivityと同等のものを用意するのは**非常に面倒**）
:::


# なぜPreviewActivityをHackするのか

## エミュレータ要らずで確認できるけども・・・
- Composable関数に`@Preview`アノテーションをつけるとAndroid Studioとエミュレータまたは実機でプレビューを見ることができる
- `@Preview`アノテーションにさまざまなパラメータを設定することでAndroid Studio上で設定違いのUIを一覧で表示できる

https://developer.android.com/develop/ui/compose/tooling/previews?hl=ja#run-preview


| **Preview on Android Studio** | **Preview on Emulator** |
| --- | --- |
| ![Preview on Android Studio](https://developer.android.com/static/develop/ui/compose/images/tooling-preview-live-edit.gif) | ![Preview on Emulator](https://developer.android.com/static/develop/ui/compose/images/tooling-deploy-preview-demo.gif) |


## Android Studio上のプレビューは[com.android.tools.layoutlib:layoutlib](https://android.googlesource.com/platform/frameworks/layoutlib/+/refs/heads/main)を使って描画されている

- Layoutlib故の問題点もある
    1. 実際と異なるpadding、marginが表示されてしまう
    2. SystemUIを表示できるが調整はできないためwindowInsetsの確認に使えない
    3. [Coil](https://github.com/coil-kt/coil)などでネットワーク経由の画像を表示できない
- UIを作っているタイミングで実際と異なる表示がなされるプレビューを信じ切ることができない

> 制限事項とベスト プラクティス
Android Studio は、プレビュー コードをプレビュー領域で直接実行します。Android フレームワークのポートされた部分（Layoutlib）を利用するため、エミュレータや物理デバイスを実行する必要はありません。Layoutlib は、Android デバイスの外部で実行するように設計された Android フレームワークのカスタム バージョンです。このライブラリの目的は、デバイスでのレンダリングに非常に近いレイアウトのプレビューを Android Studio で提供することである。
https://developer.android.com/develop/ui/compose/tooling/previews?hl=ja#best-practices

:::note info
Layoutlibは[cashapp/paparazzi](https://github.com/cashapp/paparazzi)でも使われている
:::

## 実例
| **Preview on Android Studio** | **Preview on Emulator** |
| --- | --- |
| ![Android Studio Preview](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/608715/e78e5164-f732-3dbc-d381-86f1841289c8.png) | ![Emulator](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/608715/04a47954-bec8-2d37-39bd-32f2ff2c8ba0.png) |

:::note warn
1. 背景色赤のTextの右側に余計なmarginが入っている
2. Buttonの上下のpaddingが描画されていない
:::

**実測値で見れば入っている可能性はあるがinspector上では入っていることが確認できないため混乱する**
**そのため、エミュレータまたは実機で確認できるPreviewActivityをメインで使用することにした**

# PreviewActivityとは

- PreviewActivityは`@Preview`アノテーションが付与された関数を実行するために用意されたAcitvity
- `androidx.compose.ui:ui-tooling`に含まれている

:::note info
なぜdebugImplementationなのかは後述するが👇の形でセットアップすることが多い
```gradle
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
```
:::

<details><summary>PreviewActivity.android.kt</summary><div>

```kotlin:PreviewActivity.android.kt
class PreviewActivity : ComponentActivity() {

    private val TAG = "PreviewActivity"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) {
            Log.d(TAG, "Application is not debuggable. Compose Preview not allowed.")
            finish()
            return
        }
        intent?.getStringExtra("composable")?.let { setComposableContent(it) }
    }

    private fun setComposableContent(composableFqn: String) {
        val className = composableFqn.substringBeforeLast('.')
        val methodName = composableFqn.substringAfterLast('.')
        intent.getStringExtra("parameterProviderClassName")?.let { parameterProvider ->
            setParameterizedContent(className, methodName, parameterProvider)
            return@setComposableContent
        }
        setContent {
            ComposableInvoker.invokeComposable(
                className,
                methodName,
                currentComposer
            )
        }
    }

    private fun setParameterizedContent(
        className: String,
        methodName: String,
        parameterProvider: String
    ) {
        val previewParameters = getPreviewProviderParameters(
            parameterProvider.asPreviewProviderClass(),
            intent.getIntExtra("parameterProviderIndex", -1)
        )

        if (previewParameters.size > 1) {
            setContent {
                val index = remember { mutableIntStateOf(0) }
                Scaffold(
                    content = { padding ->
                        Box(Modifier.padding(padding)) {
                            ComposableInvoker.invokeComposable(
                                className,
                                methodName,
                                currentComposer,
                                previewParameters[index.intValue]
                            )
                        }
                    },
                    floatingActionButton = {
                        ExtendedFloatingActionButton(
                            text = { Text("Next") },
                            onClick = {
                                index.intValue = (index.intValue + 1) % previewParameters.size
                            }
                        )
                    }
                )
            }
        } else {
            setContent {
                ComposableInvoker.invokeComposable(
                    className,
                    methodName,
                    currentComposer,
                    *previewParameters
                )
            }
        }
    }
}
```
</div></details>

<details><summary>androidx.compose.ui.tooling:AndroidManifest.xml</summary><div>

```xml:AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <activity
            android:name=".PreviewActivity"
            android:exported="true" />
    </application>
</manifest>
```
</div></details>


https://github.com/androidx/androidx/blob/dcaa116fbfda77e64a319e1668056ce3b032469f/compose/ui/ui-tooling/src/androidMain/kotlin/androidx/compose/ui/tooling/PreviewActivity.android.kt

https://github.com/androidx/androidx/blob/dcaa116fbfda77e64a319e1668056ce3b032469f/compose/ui/ui-tooling/src/androidMain/AndroidManifest.xml


- PreviewActivityはthemeなどのtag属性なしでAndroidManifestに定義されている
- PreviewActivityはComposable関数とPreviewParameterProviderのFQCNを受け取り、その関数を実行する
- `parameterProviderClassName`が指定されている場合はComposable関数を**androidx.compose.material.Scaffoldで包まれた状態**で実行し、指定がない場合は直接実行する
- ガターアイコンをクリックし、実機プレビューを実行するとPreviewActivityを起動する形でプレビュー用のアプリが実行される

![ガターアイコン](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/608715/5f0b353e-3dcd-59f2-f339-53b364afddce.png)

- つまり実態としては👇のようなadbコマンドが実行されていると考えればいい

```shell
adb shell am start -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -n $applicationId/androidx.compose.ui.tooling.PreviewActivity --es composable $ComposableFQCN --es parameterProviderClassName $PreviewParameterProviderFQCN --ei parameterProviderIndex $index
```

:::note warn
さまざまな互換機能を提供する`androidx.appcompat.app.AppCompatActivity`では**ない**ので古い端末で実行する際は注意が必要
:::


# どうHackするのか

- 実機プレビューはmoduleの種類によってPreviewActivityの格納先が異なる

| | **Application module** | **Library module** |
| --- | --- | --- |
| variant | debug | debugAndroidTest |
| apk | `app/build/outputs/apk/debug/app-debug.apk` | `app/build/outputs/apk/androidTest/debug/app-debug.apk` |


:::note info
>なぜdebugImplementationなのかは後述するが👇の形でセットアップすることが多い

- Application moduleの場合は普通のapkにPreviewActivityを格納するためにdebugImplementationを使う必要がある
- Library moduleの場合はInstrumented Test用apkにPreviewActivityが格納されていればよいので`androidTestImplementation`でも構わない
:::

- この違いを踏まえて追加設定や実装を行うことでHackできる

## AndroidManifest.xmlをハックする

- 実行環境と対応する`AndroidManifest.xml`をいじることでTheme、Permissionなどが変更できる

| **Application module** | **Library module** |
| --- | --- |
| `src/main/AndroidManifest.xml`<br>or<br>`src/debug/AndroidManifest.xml` | `src/androidTest/AndroidManifest.xml`<br>or<br>`src/debug/AndroidManifest.xml` |


- Library moduleの実機Previewでリモート画像が表示できない問題は、`src/androidTest/AndroidManifest.xml`に`<uses-permission android:name="android.permission.INTERNET" />`を追加することで解決できる


:::note info
AndroidManifestはマージされるのでマージ前提で上手に定義してあげるとよい
https://developer.android.com/build/manage-manifests
:::


### case1: WindowInsets

- android15で強制されたEdge To Edgeを対応する際にもPreviewActivity上で確認できる
- `@Preview`アノテーションが付与されたComposable関数は`ComponentActivity#onCreate`で実行する関数と考えればよい
- -> `@Preview`アノテーションが付与されたComposable関数内で`androidx.activity.enableEdgeToEdge`を呼べば良い


```kotlin:PreviewEdgeToEdge.kt
private fun Context.getActivity(): ComponentActivity? = when (this) {
    is ComponentActivity -> this
    is ContextWrapper -> baseContext.getActivity()
    else -> null
}

@Preview
@Composable
private fun PreviewEdgeToEdge() {
    // 単にComponentActivityで実行されている関数と考えて良い
    val activity = LocalContext.current.getActivity() ?: return
    activity.enableEdgeToEdge()
    SuburiTheme {
        Scaffold {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(it),
            )
        }
    }
}
```

:::note alert
`PreviewParameterProvider`を使用すると`androidx.compose.material.Scaffold`に包まれた状態でComposable関数が実行されてしまう
WindowInsetsの確認をしたい場合はPreviewParameterProviderを使わないようにするのが望ましい（特にandroidx.compose.material3を使用している場合は）
:::


- AndroidManifest.xmlをいじることでWindowInsetsAnimationの確認も可能

```xml:AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application>
        <activity
            android:name="androidx.compose.ui.tooling.PreviewActivity"
            android:windowSoftInputMode="adjustResize"
            tools:node="merge" />
    </application>

</manifest>
```


:::note info
余談だがEdge To Edgeの確認はscroll effectが要素が伸縮するようになった**android12より古い端末**で確認したほうが正しい位置にinsetを適用できているか確認がスムーズに行える
:::

### case2: PictureInPicture

- `PictureInPicture`時のUIについてもPreviewActivityでお手軽に確認できる

```xml:AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application>
        <activity
            android:name="androidx.compose.ui.tooling.PreviewActivity"
            android:supportsPictureInPicture="true"
            tools:node="merge" />
    </application>

</manifest>
```

```kotlin:PreviewPictureInPicture.kt
@Preview
@Composable
private fun PreviewPictureInPicture() {
    val activity = LocalContext.current.getActivity() ?: return
    SuburiTheme {
        Scaffold {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(it),
            )
        }
    }

    LaunchedEffect(activity) {
        // composable関数のレンダリング終了後に`enterPictureInPictureMode`を実行するためのdelay
        delay(300)
        activity.enterPictureInPictureMode(PictureInPictureParams.Builder().build())
    }
}
```


## 関数をハックする

- Composable関数は`ComponentActivity#onCreate`で実行する関数だが`androidx.activity.compose.ComponentActivityKt#setContent`のラムダ内で実行されている
- ラムダ内で実行することを配慮できればActivityの機能をフルで使うことができる


### case1: Inflate XML layout

- PreviewActivityは`AndroidView`なしでXML layoutの確認にも使える
- ComposeViewはViewGroupだがcompositionが作成された後でなければaddViewができないため、直接Inflateできない

```kotlin:AbstractComposeView.kt
abstract class AbstractComposeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    private var creatingComposition = false

    private fun checkAddView() {
        if (!creatingComposition) {
            throw UnsupportedOperationException(
                "Cannot add views to " +
                    "${javaClass.simpleName}; only Compose content is supported"
            )
        }
    }
}
```

https://github.com/androidx/androidx/blob/dcaa116fbfda77e64a319e1668056ce3b032469f/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ComposeView.android.kt#L196-L205


- ただしcomposition作成を待ちさえすれば、windowのdecoreViewごと上書きできる
- Choreographerが絡むAndroidUiDispatcherとは別のスレッド（純粋なUIスレッド）で待てば, ActivityのwindowのdecoreViewを上書きできる


```kotlin:PreviewXmlInflate.kt
@Preview
@Composable
private fun PreviewXmlInflate() {
    val activity = LocalContext.current.getActivity() ?: return
    // delay for composition
    Handler(Looper.getMainLooper()).postDelayed({ activity.setContentView(R.layout.view_sample) }, 300)
}
```

### case2: 

・・・なにか他によい例があればください・・・・


# まとめ
- 正確なUIの確認にはPreviewActivityを使う
- `@Preview`アノテーションが付与されたComposable関数は`ComponentActivity#onCreate`で実行する関数と考えればよい
- `androidx.activity.compose.ComponentActivityKt#setContent`の制限事項に配慮すればActivityの機能をフルで使うことができる
- AndroidManifestをいじって確認したい設定のActivityを構成する
- `androidx.compose.material.Scaffold`に包まれてしまうので実機Preview用のComposable関数で`PreviewParameterProvider`は使わない