26
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ZOZOAdvent Calendar 2022

Day 11

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

Last updated at Posted at 2022-12-11

この記事はZOZO Advent Calendar 2022その4の11日目の投稿です。本稿では、ZOZOTOWN Androidアプリホームバナーの実装を紹介しようと思います。

概要

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

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

image.png

ホーム画面の実装

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

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

image.png

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

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に渡します。

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

バナーの実装

前述の通りバナー部分はEpoxy Modelとして実装します。Epoxy Modelを実装する方法は3種類ありますが、バナー部分はCustomViewに @ModelView アノテーションを付与する方法で作成しています。

@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 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で実装しています。

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

また、ホームバナーは次に表示するバナーの端を表示する仕様なのでPageTransformerを使用してカスタマイズします。

image.png
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()
}

バナーの自動スクロール

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

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に入る場合に自動スクロールを停止しています。

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のタイミングで自動スクロールの開始・停止を行っています。

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で再実装したいですね。

参考リンク

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

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

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

26
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?