ZOZOTOWN Androidアプリホームバナーの実装をざっくり紹介

この記事は[ZOZO Advent Calendar 2022](https://qiita.com/advent-calendar/2022/zozo)その4の11日目の投稿です。本稿では、ZOZOTOWN Androidアプリホームバナーの実装を紹介しようと思います。

# 概要

ZOZOTOWNは2021年の3月にリニューアルしました。Impressさんの記事はこちら。

https://www.watch.impress.co.jp/docs/news/1312917.html

リニューアルにあたり、ホーム画面を新規で開発しています。本投稿では、このホーム画面に表示されるバナーの実装について紹介します。

<img width="350" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43438/162652e4-5f6c-3b0c-5993-a98208f4cbc8.png">


# ホーム画面の実装

まずホーム画面全体の実装について紹介します。ZOZOTOWNのホーム画面はEpoxy[^1]で実装しています。

[^1]:今実装するならJetpack Composeでの実装を検討すると思います。現在の実装もComposeで書き直したいですね。

https://github.com/airbnb/epoxy

ホーム画面では、表示する複数のコンテンツパターン毎にEpoxy Modelを作成しAPIレスポンスに応じて出し分けています。したがって性別の選択、バナーといった各UI要素もEpoxy Modelとして実装しています。

<img width="350" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43438/56a85b8d-787b-e631-c7ca-70e9c28316b5.png">

作成したEpoxy Modelは、次のようにEpoxyControllerを実装することでUI要素を組み立て画面に表示します。　`homeGenderSelector`, `homeBannerAreaModel`はEpoxy Modelです(Epoxyが生成したModelの実装)。Jetpack Composeでリストを表示する方法と考え方は一緒ですね。

```kotlin
class HomeModuleController : TypedEpoxyController<List<HomeContents>>() {
    override fun buildModels(data: List<HomeContents>) {
        data.forEachIndexed { index, content ->
            when (content) {
                is HomeContents.GenderSelector -> {
                    homeGenderSelector {
                        viewData(content.viewData)
                    }
                }
                is HomeContents.Banner -> {
                    homeBannerAreaModel {
                        viewData(content.viewData)
                    }
                }
            }
        }
    }
}
```

HomeContentsは次のようなsealed classです。ここに描画に必要なデータを持たせます。ホーム画面のAPIレスポンスをViewModelでHomeContentsに変換しStateFlowで公開、Fragment側で購読しEpoxyControllerに渡すことで、最終的に各Epoxy Modelに渡します。

```kotlin
sealed class HomeContents {
    data class GenderSelector(val viewData: HomeGenderSelectorViewData) : HomeContents()
    data class Banner(val viewData: BannersViewData) : HomeContents()
    .
    .
    .
}
```

# バナーの実装

前述の通りバナー部分はEpoxy Modelとして実装します。[Epoxy Modelを実装する方法は3種類](https://github.com/airbnb/epoxy/wiki/Epoxy-Models)ありますが、バナー部分はCustomViewに `@ModelView` アノテーションを付与する方法で作成しています。

```kotlin
@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class HomeBannerAreaModel @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    private val binding: ViewBannerAreaBinding = DataBindingUtil.inflate(
        LayoutInflater.from(context),
        R.layout.view_banner_area,
        this,
        true
    )
}
```

レイアウト`view_banner_area`は抜粋すると次のような形になります。

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

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/zozo_bg_white">

        <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/viewpager"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toTopOf="@+id/indicator"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_goneMarginBottom="22dp"　/>

        <com.google.android.material.tabs.TabLayout
            android:id="@+id/indicator"
            android:layout_width="0dp"
            android:layout_height="20dp"
            android:layout_marginBottom="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_goneMarginBottom="16dp"
            app:tabBackground="@drawable/selector_home_banner_area_tab"
            app:tabGravity="center"
            app:tabIndicatorHeight="0dp"
            app:tabMaxWidth="12dp"
            app:tabRippleColor="@null"　/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
```

バナーは複数枚表示しスクロールで切り替えることが可能なのでViewPager2で実装しています。

```kotlin
binding.viewpager.apply {
    adapter = homeBannerAreaAdapter
    offscreenPageLimit = 2
}.run {
　　　　TabLayoutMediator(binding.indicator, this) { _: TabLayout.Tab, _: Int -> }.attach()
}
```

また、ホームバナーは次に表示するバナーの端を表示する仕様なので[PageTransformer](https://developer.android.com/training/animation/screen-slide-2?hl=ja#pagetransformer)を使用してカスタマイズします。

<img width="500" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43438/75a01007-9cc5-51a3-d3ac-78465013834c.png">


```kotlin
binding.viewpager.apply {
    adapter = homeBannerAreaAdapter
    offscreenPageLimit = 2

    setPageTransformer { page, position ->
        val offset = position * (2 * offset + pageMargin)
        page.translationX = -offset
    }
}.run {
　　　　TabLayoutMediator(binding.indicator, this) { _: TabLayout.Tab, _: Int -> }.attach()
}
```

## バナーの自動スクロール

バナーは一定間隔で自動スクロールさせています。次のようにCoroutine[^2]を使い一定間隔でViewPager2のcurrentItemをセットしています。

[^2]: スクロールのスタート、ストップはCoroutine Flowを使うともっと簡潔に書けそうです。

```kotlin
private fun launchAutoScrollJob(): Job = launch(Dispatchers.Default) {
    while (true) {
        delay(AUTO_PAGING_DELAY)
        val itemCount = binding.viewpager.adapter?.itemCount ?: break
        val next = (binding.viewpager.currentItem + 1).let { if (it < itemCount) it else 0 }

        withContext(Dispatchers.Main) {
            binding.viewpager.currentItem = next
        }
    }
}
```

また、自動スクロールの開始・停止は`onVisibilityChanged`, `onDetachedFromWindow`のタイミングで行っています。これによってホーム画面をスクロールすることでバナーが隠れた場合や、ホーム画面のFragmentがbackstackに積まれた場合、アプリがbackgroundに入る場合に自動スクロールを停止しています。

```kotlin
override fun onVisibilityChanged(changedView: View, visibility: Int) {
    super.onVisibilityChanged(changedView, visibility)
    if (visibility == View.VISIBLE) {
        startAutoScroll()
    } else {
        stopAutoScroll()
    }
}

override fun onDetachedFromWindow() {
    stopAutoScroll()
    super.onDetachedFromWindow()
}

private fun stopAutoScroll() {
    autoScrollJob?.cancel()
}

private fun startAutoScroll() {
    autoScrollJob.let {
        if (it == null) {
            autoScrollJob = launchAutoScrollJob()
        } else {
            if (!it.isActive) {
                autoScrollJob = launchAutoScrollJob()
            }
        }
    }
}
```

自動スクロールについてもう一点考慮したポイントがあり、ユーザーがバナーをドラッグ中は自動スクロールを停止するようにしています。これは実装したホーム画面をチームで実際に触ってみた結果、違和感があったのでこのような実装にしています[^3]。

実装としては、`registerOnPageChangeCallback`でCallbackを登録し、`onPageScrollStateChanged`のタイミングで自動スクロールの開始・停止を行っています。

[^3]: 自動スクロールを止めるか止めないかのどちらの方がバナーがクリックされるかを見てみるのは面白そうです。

```kotlin
binding.viewpager.apply {
    adapter = homeBannerAreaAdapter
    offscreenPageLimit = 2

    setPageTransformer { page, position ->
        val offset = position * (2 * offset + pageMargin)
        page.translationX = -offset
    }

    registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
        override fun onPageSelected(position: Int) {
            super.onPageSelected(position)
        }

        override fun onPageScrollStateChanged(state: Int) {
            when (state) {
                ViewPager2.SCROLL_STATE_DRAGGING -> {
                    autoScrollJob?.let {
                        if (it.isActive) {
                            stopAutoScroll()
                        }
                    }
                }
                ViewPager2.SCROLL_STATE_IDLE -> {
                    autoScrollJob?.let {
                        if (!it.isActive) {
                            startAutoScroll()
                        }
                    }
                }
            }
        }
    })
}.run {
　　　　TabLayoutMediator(binding.indicator, this) { _: TabLayout.Tab, _: Int -> }.attach()
}
```

# まとめ

ZOZOTOWN Androidアプリのホーム画面とバナーについて簡単に実装を紹介しました。ZOZOTOWNでも最近はComposeで実装する機会が多いので、ホーム画面もComposeで再実装したいですね。

# 参考リンク

- https://github.com/airbnb/epoxy
- https://developer.android.com/guide/navigation/navigation-swipe-view-2?hl=ja