Snackbarのカスタマイズ - Android ViewとJetpack Compose比較

この記事は[Android Advent Calendar 2021](https://qiita.com/advent-calendar/2021/android)の18日目の記事です。

## 概要
あまり使う機会は無いかもしれませんが、Snackbarのカスタマイズ方法を整理してみました。
また従来のAndroid Viewでの方法とJetpack Composeでの方法を比較してみたいと思います。

以下で紹介するコードについて、こちらにサンプルプロジェクトをアップしてあります。
https://github.com/androhi/CustomSnackbarSample

## Android Viewでのカスタマイズ方法

Android Viewでは、以下のクラスを作成する必要があります。

* ContentViewCallbackクラスを継承したSnackbarのViewクラス
* BaseTransientBottomBarクラスを継承したカスタムSnackbarクラス
* 上記それぞれのレイアウトファイル

```kotlin:CustomSnackbarView.kt
class CustomSnackbarView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet,
    defaultStyle: Int = 0,
): ConstraintLayout(context, attributeSet, defaultStyle), ContentViewCallback {

    private val binding = ItemCustomSnackbarBinding.inflate(LayoutInflater.from(context), this, true)

    override fun animateContentIn(delay: Int, duration: Int) {
        binding.icon.run {
            alpha = 0f
            animate().alpha(1f).setDuration(duration.toLong()).setStartDelay(delay.toLong()).start()
        }
        binding.message.run {
            alpha = 0f
            animate().alpha(1f).setDuration(duration.toLong()).setStartDelay(delay.toLong()).start()
        }
    }

    override fun animateContentOut(delay: Int, duration: Int) {
        binding.icon.run {
            alpha = 1f
            animate().alpha(0f).setDuration(duration.toLong()).setStartDelay(delay.toLong()).start()
        }
        binding.message.run {
            alpha = 1f
            animate().alpha(0f).setDuration(duration.toLong()).setStartDelay(delay.toLong()).start()
        }
    }
}
```

```kotlin:CustomSnackbar.kt
class CustomSnackbar(
    parent: ViewGroup,
    content: CustomSnackbarView
) : BaseTransientBottomBar<CustomSnackbar>(parent, content, content) {

    init {
        getView().setBackgroundColor(ContextCompat.getColor(view.context, android.R.color.transparent))
        getView().setPadding(0, 0, 0, 0)
    }

    companion object {
        fun make(parent: ViewGroup): CustomSnackbar {
            val content = LayoutInflater.from(parent.context).inflate(
                R.layout.view_custom_snackbar,
                parent,
                false
            ) as CustomSnackbarView
            return CustomSnackbar(parent, content).setDuration(Snackbar.LENGTH_SHORT)
        }
    }
}
```

```xml:view_custom_snackbar.xml
<com.androhi.customsnackbarsample.CustomSnackbarView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp" />
```

```xml:item_custom_snackbar.xml
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@android:color/darker_gray"
    android:padding="16dp"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <ImageView
        android:id="@+id/icon"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/ic_baseline_self_improvement_24"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/message"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

    <TextView
        android:id="@+id/message"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:text="test message"
        app:layout_constraintStart_toEndOf="@+id/icon"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

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

## Android Viewでの使用例

Android ViewでSnackbarを使用するときは紐づくView Groupが必要になりますので、例えば以下のような使い方となります。

```kotlin:MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.showButton.setOnClickListener {
            (binding.root.rootView as? ViewGroup)?.let {
                CustomSnackbar.make(it).show()
            }
        }
    }
}
```

## Jetpack Composeでのカスタマイズ方法

Jetpack ComposeではSnackbarHost Composableを使ってSnackbarとして表示する部分をカスタマイズしていきます。

```kotlin
    @Composable
    fun Screen() {
        val scaffoldState = rememberScaffoldState()
        Scaffold(
            scaffoldState = scaffoldState,
        ) {
            // something to do
        }
        SnackbarHost(
            hostState = scaffoldState.snackbarHostState,
            snackbar = { snackbarData: SnackbarData ->
                Card(
                    shape = RoundedCornerShape(8.dp),
                    backgroundColor = Color.LightGray,
                    modifier = Modifier
                        .padding(16.dp)
                        .fillMaxWidth()
                ) {
                    Row(
                        modifier = Modifier.padding(8.dp),
                        horizontalArrangement = Arrangement.Start,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Icon(imageVector = Icons.Default.Face, contentDescription = "")
                        Spacer(modifier = Modifier.width(8.dp))
                        Text(text = snackbarData.message)
                    }
                }
            }
        )
    }
```

SnackbarHostにSnackbarHostStateを渡すことによって、Snackbarの状態を適切に管理してくれます。

## Jetpack Composeでの使用例

Jetpack ComposeでSnackbarを呼び出すためのメソッドはsuspend関数になっているため、coroutineの中で実行する必要があります。

```kotlin
    @Composable
    fun Screen() {
        val scaffoldState = rememberScaffoldState()
        val scope = rememberCoroutineScope()
        Scaffold(
            scaffoldState = scaffoldState,
        ) {
            Button(
                onClick = {
                    scope.launch {
                        scaffoldState.snackbarHostState.showSnackbar("message", duration = SnackbarDuration.Short)
                    }
                }
            ) {
                Text(text = "show snackbar")
            }
        }
        SnackbarHost(
            hostState = scaffoldState.snackbarHostState,
            snackbar = { snackbarData: SnackbarData ->
                Card(
                    shape = RoundedCornerShape(8.dp),
                    backgroundColor = Color.LightGray,
                    modifier = Modifier
                        .padding(16.dp)
                        .fillMaxWidth()
                ) {
                    Row(
                        modifier = Modifier.padding(8.dp),
                        horizontalArrangement = Arrangement.Start,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Icon(imageVector = Icons.Default.Face, contentDescription = "")
                        Spacer(modifier = Modifier.width(8.dp))
                        Text(text = snackbarData.message)
                    }
                }
            }
        )
    }
```

## まとめ

Android Viewの方は継承クラスなども少し複雑ですし、アニメーションなども自分で実装しなければいけないので面倒な部分が多いように見えます。一方、Jetpack Composeの方は面倒な部分はSnackbarHost内でよしなにやってくれるため、表示部分のカスタマイズに集中出来ます。
もしComposeを導入しているアプリであれば、SnackbarのカスタマイズはComposeでやるべきだと感じました。