この記事はZOZO Advent Calendar 2022その4の11日目の投稿です。本稿では、ZOZOTOWN Androidアプリホームバナーの実装を紹介しようと思います。
概要
ZOZOTOWNは2021年の3月にリニューアルしました。Impressさんの記事はこちら。
リニューアルにあたり、ホーム画面を新規で開発しています。本投稿では、このホーム画面に表示されるバナーの実装について紹介します。
ホーム画面の実装
まずホーム画面全体の実装について紹介します。ZOZOTOWNのホーム画面はEpoxy1で実装しています。
ホーム画面では、表示する複数のコンテンツパターン毎にEpoxy Modelを作成しAPIレスポンスに応じて出し分けています。したがって性別の選択、バナーといった各UI要素もEpoxy Modelとして実装しています。
作成した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を使用してカスタマイズします。
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で再実装したいですね。
参考リンク
- https://github.com/airbnb/epoxy
- https://developer.android.com/guide/navigation/navigation-swipe-view-2?hl=ja