【Jetpack Compose】簡単なGuidesスタイルのOnboarding画面を作成する

この記事は、[リンクバルアドベントカレンダー2022](https://qiita.com/advent-calendar/2022/linkbal)の14日目の記事です。

# はじめに
よくスマホを使えば、多分新しいアプリケーションをインストールして初めて起動しようとするたびに、紹介や使用方法が表示されることがよくあります。 みなさんはそれらを何と呼びますか？ チュートリアル、ウォークスルー、ガイド等?
これらは、Onboardingという一般名で知られています。 この記事では、[Jetpack Compose](https://developer.android.com/jetpack/compose)を使用して簡単に作成する方法を紹介します。

# Onboarding画面とは
Onboarding画面は、ユーザーがアプリケーションを起動したときに最初に目にするものです。[First User Experience (FUX)](https://www.bunkercreative.co.uk/posts/what-the-fux-is-ux-exactly.html)として知られることもあります。通常、アプリケーションの使用方法、機能、アプリケーションがユーザーにどのように役立つかなど、アプリケーションに関するいくつかのものを示すために使用されます。これは、複雑な機能や紛らわしいUIを持つアプリケーションで一般的です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2991103/59a217dd-6824-0e05-06e7-7fd00dbd6dc6.png" width="500">

## この記事のOnboarding画面
「Onboarding」の画像をGoogleで検索すると、ほとんどの結果が上の画像のようになります。 しかし実際には、OnboardingのUI/UXデザインには他にも多くの形式があります。それらの一つはガイド/ウォークスルー（Guides/Walkthrough）です。

また、現在、Jetpack Composeを使用してガイド/ウォークスルー オンOnboardingを作成する方法に関する記事を見つけるのは困難です :frowning2:。 現在の記事のほとんどは、[HorizontalPager](https://google.github.io/accompanist/pager/)を使用したスライドショー形式です。
この記事では、ガイド/ウォークスルーOnboarding画面を作成する簡単な方法を紹介します。

結果を見ましょう!! :point_down:

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2991103/37b33031-e05c-5a77-80cd-cd5ba6abc997.gif" width="300">

# 実装
## 準備するもの
まず、次の準備/知識が必要です。
1. プロジェクト。
1. Preferences ([SharedPreferences](https://developer.android.com/reference/kotlin/android/content/SharedPreferences)/[DataStore](https://developer.android.com/topic/libraries/architecture/datastore))。
1. [Jetpack Composeアーキテクチャ](https://developer.android.com/jetpack/compose/architecture)。
1. アプリの編集したスクリーンショット。

**プロジェクト**: もちろん、アプリの紹介画面を実装するので、最も重要なことはそのアプリのコードですね。サンプル用のため、この記事でJetpack Composeの組み込みテンプレートの[Jetchat](https://github.com/android/compose-samples/tree/main/Jetchat)を使用します。
[この指示](https://developer.android.com/jetpack/compose/setup#sample)に従ってください :point_right:。

**Preferences**: Googleは、開発者が`DataStore`を使用することを推奨しています。 ただし、この記事が長くなりすぎないように、代わりに`SharedPreferences`を使用します。
後で`DataStore`に変換することもお勧めします。

**アプリの編集したスクリーンショット**: この記事のマイナスポイントは、ガイド/ウォークスルーOnboarding画面のような`Compound Shape`を作成する方法がまだ見つかなかったことです。 現在の回避策はスクリーンショットを使用することですが、これでは相互運用性がなくなります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2991103/9c02d026-4a41-f533-004f-fccbb88c63c4.png" width="150">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2991103/d5056fc0-0575-24d0-837f-b9d4bce9922d.png" width="150">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2991103/af73e66e-07fc-1d51-f32d-c3ee9f6f64cf.png" width="150">

## はじめましょう
### ステップ1: 現在のチュートリアルステップを保存する
まず、今のチュートリアルステップを格納する`key-value`のペアを保存する必要があります。
`SharedPreferences` を使用すると、次のようになります。

```kotlin:data/MyPreferences.kt
object MyPreferences {
    const val KEY_CHAT_STEP = "key_chat_step"
    // ここにさらに画面を追加できる

    lateinit var prefs: SharedPreferences

    fun get(key: String): Int {
        return prefs.getInt(key, 0)
    }

    fun set(key: String, value: Int) {
        with(prefs.edit()) {
            putInt(key, value)
            apply()
        }
    }
}
```
```diff_kotlin:NavActivity.kt
class NavActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
+       MyPreferences.prefs = getPreferences(Context.MODE_PRIVATE)
        // ...
```
### ステップ2: ViewModelを作成してFragment/Screenに追加する
`Jetchat`を使用する場合は、package `conversation`に移動して`ViewModel`を作成しましょう。
```kotlin:conversation/ConversationViewModel.kt
class ConversationViewModel : ViewModel() {
    // 初期ステップを0で初期化する
    private val _tutorialStep = MutableStateFlow(0)
    val tutorialStep: StateFlow<Int> = _tutorialStep

    init {
        getTutorialStep()
    }

    // 次のステップに移動すると、新しい値を更新する
    fun setTutorialStep(currentStep: Int = 0) {
        _tutorialStep.value = currentStep
        MyPreferences.set(MyPreferences.KEY_CHAT_STEP, currentStep)
    }

    // 実機に格納されているステップ値を取得する
    private fun getTutorialStep() {
        _tutorialStep.value = MyPreferences.get(MyPreferences.KEY_CHAT_STEP)
    }
}
```

プロジェクトのコードスタイルによっては、違うかもしれません。 Jetchatの場合は`ConversationFragment.kt`にコードを追加します :point_down:。

```diff_kotlin:conversation/ConversationFragment.kt
class ConversationFragment : Fragment() {

+   private val viewModel: ConversationViewModel by viewModels()
    private val activityViewModel: MainViewModel by activityViewModels()

    override fun onCreateView(
        // ...
```
### ステップ3: チュートリアル画面を作る
チュートリアル画面の共通コンポーネントを作成します。

```kotlin:tutorial/TutorialScreen.kt
@Composable
fun TutorialScreen(
    backgroundResId: Int,   // Screen background Resource Id
    contentXOffset: Float,  // 説明文のxOffset
    contentYOffset: Float,  // 説明文のyOffset
    onGoNext: () -> Unit,   // [Next]ボタンをクリックすると実行されるアクション
    goNextText: String = "Next",
    content: @Composable () -> Unit
) {
    Box(modifier = Modifier
        .fillMaxSize()
        .systemBarsPadding()
    ) {
        // スクリーンショットを背景として
        Image(
            painter = painterResource(backgroundResId),
            contentScale = ContentScale.FillBounds,
            contentDescription = null,
        )
        // 説明文
        Surface(
            color = Color.Transparent,
            contentColor = Color.White,
            modifier = Modifier.offset(x = contentXOffset.dp, y = contentYOffset.dp)
        ) {
            content()
        }
        // 画面左上のアクションボタン
        Button(
            onClick = onGoNext,
            modifier = Modifier
                .align(Alignment.TopEnd)
                .padding(16.dp)
        ) {
            Text(goNextText)
        }
    }
}
```
### ステップ4: Conversationのチュートリアル画面を作成しする
`conversation` packageに戻り、`ConversationTutorial.kt`という名前の新しいファイルを作成します。
最大のステップ値を`3`に設定します。つまり、チュートリアルが3つのステップがあります。

```kotlin:conversation/ConversationTutorial.kt
const val CONVERSATION_MAX_STEP = 3
```
```kotlin:conversation/ConversationTutorial.kt
@Composable
fun ConversationTutorial(step: Int, onClick: () -> Unit) {
    when (step) {
        0 -> ConversationTutorial1(onClick = onClick)
        1 -> ConversationTutorial2(onClick = onClick)
        2 -> ConversationTutorial3(onClick = onClick)
    }
}
```

それぞれのチュートリアル画面を作成します。下記は例です :point_down:。

```kotlin:conversation/ConversationTutorial.kt
@Composable
private fun ConversationTutorial1(onClick: () -> Unit) {
    TutorialScreen(
        backgroundResId = R.drawable.chat_step_1,
        contentXOffset = 20f,
        contentYOffset = 600f,
        onGoNext = onClick
    ) {
        Column(
            modifier = Modifier.wrapContentWidth(),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {

            Text("Write your message here")
            Icon(Icons.Default.ArrowDownward, null)
        }
    }
}
```
上記のコードの結果は次のとおりです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2991103/e5c2c449-f60b-25c7-1e09-a5f19c275f03.png" width="200">

### ステップ5：ConversationFragmentにチュートリアル画面を追加する
```diff_kotlin:conversation/ConversationFragment.kt
class ConversationFragment : Fragment() {
    // ...
    override fun onCreateView(
        // ...
        JetchatTheme {
+           if (tutorialStep < CONVERSATION_MAX_STEP) {
+              ConversationTutorial(step = tutorialStep, onClick = toNextStep)
+           } else {
                ConversationContent(
                    // ..
```
### ステップ6: アニメーションを追加してインターフェイスをより魅力的にする
この記事では、`Infinite Animation`を使用して`Next/Finish`ボタンをドキドキさせて注意を引きます。
その他のJetpack Composeアニメーションについては、[Jetpack Composeアニメーション](https://developer.android.com/jetpack/compose/animation)で見てください。 

```diff_kotlin:tutorial/TutorialScreen.kt
@Composable
fun TutorialScreen(
    backgroundResId: Int,
    contentXOffset: Float,
    contentYOffset: Float,
    onGoNext: () -> Unit,
    goNextText: String = "Next",
    content: @Composable () -> Unit
) {
+   val infiniteTransition = rememberInfiniteTransition()
+   val scale by infiniteTransition.animateFloat(
+       initialValue = 1f,
+       targetValue = 1.1f,
+       animationSpec = infiniteRepeatable(
+           animation = tween(500, easing = LinearEasing),
+           repeatMode = RepeatMode.Reverse
+       )
+   )
    Box(
    // ..   
        Button(
            onClick = onGoNext,
            modifier = Modifier
                .align(Alignment.TopEnd)
                .padding(16.dp)
+               .graphicsLayer(
+                   scaleX = scale,
+                   scaleY = scale
+               )
        ) {
            Text(goNextText)
        }
    }
```
結果:
![ezgif.com-gif-maker (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2991103/8143c8e9-dc06-bf4f-9adb-3babea3ec348.gif)

# おわりに
多くのアプリケーションがOnboarding画面を無視してきましたが、機能が複雑だったりインターフェースがわかりにくいアプリケーションの場合、Onboarding画面が必要になると思います。 Onboarding画面はユーザーに利益をもたらすだけでなく、ユーザーを感動させることでビジネスにも利益をもたらします。

`shadow`背景の上に`transparent`背景があるものを作成するための回避策は見つかりませんでした（`Combound Shape`の作成）。 回避策の提案があれば、遠慮なくコメントしてください :bow: 。 
ここまで読んでいただきありがとうございました :bow:  。

# 参考
1. [How to Properly Design Onboarding Screens](https://bootcamp.uxdesign.cc/how-to-properly-design-onboarding-screens-876f6a772c1c#:~:text=The%20onboarding%20screens%20are%20the,application%20can%20help%20the%20users)
1. https://developer.android.com/jetpack/compose/setup#sample