10
5

More than 1 year has passed since last update.

【Android✖️kotlin】ZOZOTOWNのホーム画面の上の方にある性別とかを切り替えできるタブを作ってみた

Last updated at Posted at 2021-12-08

完成形

「性別とか切り替えできるタブ」とはこの部分です。
(なんかGIFに変換すると遅いな...)

動作の特徴としては以下が挙げられます。

  • 男性・女性・子供のアイコンをクリックすると色が変化する
  • 男性・女性・子供のアイコンをクリックするとサイズが大きくなる

ですので、今回のポイントは

  1. 何のビューを利用して切り替えタブを実装するのか
  2. クリック時のアニメーションのプログラムをどのように実装するのか

の大きく二つが挙げられるかと思います。

注:実は本物のZOZOTOWNの場合だとアイコンを二回連続タップすると全てのアイコンが選択状態になるというクソムズムーブ付きなのですが...今回は華麗にスルーしていきます(^^)

1. 何のビューを利用して切り替えタブを実装するのか

TabLayoutとか色々考えましたが、最終的にはImageViewを横に三つポンポンポンっと並べるという方法を取りました。また、前提としてこのタブはRecyclerViewの一行目に入れます。ZOZOTOWNのアプリ見てもらったらわかるのですが切り替えタブの下にも色々な色々が配置されてるんですよね。そして切り替えタブも含めてスクロールが可能になっています。なので切り替えタブはRecyclerViewの一行目に入れることになります。それを踏まえて、レイアウトファイルを見ていきましょう。

これはホーム画面の中でも特に「全て」が選択されている画面のxmlです。
注:ホーム画面には「すべて」「シューズ」「コスメ」の画面があります。
詳細:【Android✖️kotlin】ZOZOTOWNのホーム画面の上の方を作ってみた

fragment_home_all.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">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/all_recycler_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

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

内容としてはただRecyclerViewをはっつけてるだけです。

次に、こちらは切り替えタブの方のレイアウトxmlです。

person_switch_tab.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">

    <data>
        <variable
            name="listener"
            type="com.nemo.androiduitraining.view.fragment.home.SwitchTabItem.OnClickListener" />
        <import type="com.nemo.androiduitraining.view.fragment.home.Gender"/>
        <variable
            name="selectedGender"
            type="com.nemo.androiduitraining.view.fragment.home.Gender"/>
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="8dp"
        android:orientation="horizontal">

        <ImageView
            android:id="@+id/mens_tab"
            android:paddingVertical="8dp"
            app:onClick="@{()->listener.onGenderClick(Gender.MAN)}"
            app:tintColor="@{selectedGender==Gender.MAN ? @color/person_image_blue : @color/text_gray}"
            app:isSelected="@{selectedGender==Gender.MAN}"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:src="@drawable/ic_mens_before"
            android:layout_weight="1" />

        <ImageView
            android:id="@+id/ladies_tab"
            android:paddingVertical="8dp"
            app:onClick="@{()->listener.onGenderClick(Gender.WOMAN)}"
            app:tintColor="@{selectedGender==Gender.WOMAN ? @color/person_image_pink : @color/text_gray}"
            app:isSelected="@{selectedGender==Gender.WOMAN}"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:src="@drawable/ic_ladies_before"
            android:layout_weight="1" />

        <ImageView
            android:id="@+id/kids_tab"
            android:paddingVertical="8dp"
            app:onClick="@{()->listener.onGenderClick(Gender.KIDS)}"
            app:tintColor="@{selectedGender==Gender.KIDS ? @color/person_image_yellow : @color/text_gray}"
            app:isSelected="@{selectedGender==Gender.KIDS}"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_kids_before"
            android:layout_gravity="center"
            android:layout_weight="1" />

    </LinearLayout>
</layout>

内容としては、シンプルに横にImageViewを三つ並べてるだけですが、DataBindingを導入しています。
DataBinding部分は、後ほど流れにそって説明していきますので一旦無視させてください。

2. クリック時のアニメーションのプログラムをどのように実装するのか

登場人物紹介

ここからタブの実装を行うにあたって様々なクラスが登場するので、あらかじめ登場人物を紹介します。

登場人物

  • HomeAllFragment...ホーム画面
  • HomeAdapter...RecyclerViewにアイテムを表示させるのに必要
  • HomeAllViewModel...どのタブが選択されたかをLiveDataに渡す
  • SwitchTabItem...RecyclerViewに表示したいアイテムを作成する。
  • ImageViewExt.kt...ImageViewの拡張関数を作成して色の変化を行う
  • ViewExt.kt...Viewの拡張関数を作成してサイズの変化を行う

関係性を図に示すと以下の通りになります

上記の図がかなりごちゃごちゃしてるのとコードを省略しているので、以下から実際のコードを見ながら説明していきます。

person_switch_tab.xml

すでにコードを載せてますが上記の図に沿ってもう一度コードを載せます。
図にある部分以外のコードは一旦割愛します。

person_switch_tab.xml
<ImageView
            android:id="@+id/kids_tab"
            android:paddingVertical="8dp"
            app:onClick="@{()->listener.onGenderClick(Gender.KIDS)}"
            app:tintColor="@{selectedGender==Gender.KIDS ? @color/person_image_yellow : @color/text_gray}"
            app:isSelected="@{selectedGender==Gender.KIDS}"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_kids_before"
            android:layout_gravity="center"
            android:layout_weight="1" />

このImageViewは切り替えタブのうち子供アイコンを表示させているImageViewです。
app:onClick="@{()->listener.onGenderClick(Gender.KIDS)}"とある通り、このImageViewがクリックされるとonClickに設定してあるonGenderClick()メソッドが発火します。

databindingを利用しているので、このonGenderClickselectedGenderなどのメソッドや変数はこのレイアウトxmlの上部でこのようにインポート??というかデータバインディングを導入するためのコードを書かれています。

person_switch_tab.xml
<data>
        <variable
            name="listener"
            type="com.nemo.androiduitraining.view.fragment.home.SwitchTabItem.OnClickListener" />
        <import type="com.nemo.androiduitraining.view.fragment.home.Gender"/>
        <variable
            name="selectedGender"
            type="com.nemo.androiduitraining.view.fragment.home.Gender"/>
</data>

listenerの型はSwitchTabItem.OnClickListenerなので、ImageViewがクリックされるとSwitchTabItemクラスのOnClickListenerで宣言されているインタフェースの中にあるonGenderClick()に処理が飛びます。

onGenderClickの処理を見てみましょう。

SwitchTabItem.kt

SwitchTabItemクラスの全貌は以下の通りです。

SwitchTabItem.kt
class SwitchTabItem(private val selectedGender: Gender, private val listener: OnClickListener) : BindableItem<PersonSwitchTabBinding>() {
    override fun bind(viewBinding: PersonSwitchTabBinding, position: Int) {
        viewBinding.listener = listener
        viewBinding.selectedGender = selectedGender
        viewBinding.executePendingBindings()

    }

    override fun getLayout(): Int = R.layout.person_switch_tab

    override fun initializeViewBinding(view: View): PersonSwitchTabBinding = PersonSwitchTabBinding.bind(view)
    override fun isSameAs(other: Item<*>): Boolean = other is SwitchTabItem
    override fun hasSameContentAs(other: Item<*>): Boolean = (other as? SwitchTabItem)?.selectedGender == selectedGender

    interface OnClickListener {
        fun onGenderClick(gender: Gender)
    }
}

順番に説明していきます。
まず上半分。

SwitchTabItem.kt
class SwitchTabItem(private val selectedGender: Gender, private val listener: OnClickListener) : BindableItem<PersonSwitchTabBinding>() {
    override fun bind(viewBinding: PersonSwitchTabBinding, position: Int) {
        viewBinding.listener = listener
        viewBinding.selectedGender = selectedGender
        viewBinding.executePendingBindings()

    }
    ...

このクラスはRecyclerViewに表示するアイテムのクラスで、今回のプロジェクトではGroupieを使用しているので、BindableItemを継承しています。
Groupieを使う場合、RecyclerViewに表示させるアイテムのクラスにはBindableItemを継承させ、AdapterにはGroupAdapterを継承させます。

viewBinding.listener = listener
viewBinding.selectedGender = selectedGender
viewBinding.executePendingBindings()

この部分は単純にxmlで使うものを渡してあげてます。
例えばlistenerとかselectedGenderとかをxml側で使ってましたよね?
この部分です。↓

person_switch_tab.xml
<data>
        <variable
            name="listener"
            type="com.nemo.androiduitraining.view.fragment.home.SwitchTabItem.OnClickListener" />
        <import type="com.nemo.androiduitraining.view.fragment.home.Gender"/>
        <variable
            name="selectedGender"
            type="com.nemo.androiduitraining.view.fragment.home.Gender"/>
</data>

xml側でlistenerselectedGenderをxml側で上記のように使用するために渡してあげてる感じです。
viewBinding.executePendingBindings()ではDataBindingを更新してあげてます。

次に、下半分をみていきます。
この部分です。

override fun getLayout(): Int = R.layout.person_switch_tab

    override fun initializeViewBinding(view: View): PersonSwitchTabBinding = PersonSwitchTabBinding.bind(view)
    override fun isSameAs(other: Item<*>): Boolean = other is SwitchTabItem
    override fun hasSameContentAs(other: Item<*>): Boolean = (other as? SwitchTabItem)?.selectedGender == selectedGender

    interface OnClickListener {
        fun onGenderClick(gender: Gender)
    }

overrideしているやつは。。もう。。。おまじないです(白目)
そしてSwitchTabItem.OnClickListenerの型を作るためにinterfaceを作成し、そこにonGenderClickメソッドを宣言しておきます。
切り替えタブがクリックされた時に実行される関数です。

また、Groupieの話が出たので、念のためAdapterのソースコードも載せておきます。
update()の引数のlistenerの型がSwitchTabItem.OnClickListenerになっています。
Groupieを使っているのでGroupAdapterを継承しております。

HomeAdapter.kt
class HomeAdapter : GroupAdapter<GroupieViewHolder>() {
    fun update(renderData: HomeAllViewModel.RenderData, listener: SwitchTabItem.OnClickListener) {
        val group = mutableListOf<BindableItem<out ViewBinding>>()
        group.add(SwitchTabItem(renderData.selectedGender, listener))

        updateAsync(group)
    }
}

さて、DataBindingのonClickに設置されているメソッドの処理はViewModelに書くのが一般的です。
今回の場合だと、onGenderClickです。
インタフェースはSwitchTabItemクラスに書きましたが、実際の処理はViewModelに書いていきます。

HomeAllViewModel.kt

HomeAllViewModelの全貌はこちらの通りです。

HomeAllViewModel.kt
@HiltViewModel
class HomeAllViewModel @Inject constructor() : ViewModel(), SwitchTabItem.OnClickListener {
    val renderData = MutableLiveData<RenderData>(RenderData(Gender.KIDS))
    data class RenderData(val selectedGender: Gender)

    override fun onGenderClick(gender: Gender) {
        renderData.value = renderData.value?.copy(selectedGender = gender)
    }
}


enum class Gender {
    MAN,
    WOMAN,
    KIDS
}

Hilt使っているので@HiltViewModelしてます。
@Inject constractor()の部分もHiltやからって感じです。

override fun onGenderClick(gender: Gender) {
     renderData.value = renderData.value?.copy(selectedGender = gender)
}

この部分でonGenderClickの具体的な内容を実装しています。
具体的なと言ってもこの一行なのですが。
やっていることとしてはselectedGenderの値を変更して、renderDataを更新しております。

selectedGenderを変更することで切り替えタブがクリックされた時にperson_switch_tab.xmlのImageViewの中のこの部分

person_switch_tab.xml
<ImageView
   ...
   app:isSelected="@{selectedGender==Gender.MAN}"
   ... />

selectedGenderが変更されます。
これが変更されることによって@{selectedGender==Gender.MAN}部分がtrueになり、isSelectedが実行されます。
isSelectedって何やねんとなったところでViewExt.ktを見ていきましょう。

ViewExt.kt

ViewExt.ktの全貌は以下の通りです。

ViewExt.kt
@BindingAdapter("isSelected")
fun View.setScale(isSelected: Boolean?) {
    isSelected ?: return

    val animation =  if (isSelected) {
        ScaleAnimation(1f, 1.5f, 1f, 1.5f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF,0.5f)
    } else {
        ScaleAnimation(1.5f, 1f, 1.5f, 1f, Animation.RELATIVE_TO_SELF,0.5f, Animation.RELATIVE_TO_SELF,0.5f)
    }.apply { fillAfter = true }
    startAnimation(animation)
}

ここでは、isSelectedの中身?である@{selectedGender==Gender.MAN}trueになれば

ScaleAnimation(1f, 1.5f, 1f, 1.5f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF,0.5f)

が実行されて選択されたタブのアイコンが拡大されます。

また、falseの場合は以下のプログラムが実行されて

ScaleAnimation(1.5f, 1f, 1.5f, 1f, Animation.RELATIVE_TO_SELF,0.5f, Animation.RELATIVE_TO_SELF,0.5f)

拡大状態のアイコンを元に戻します。

こんな感じで、切り替えタブ選択時のアニメーションは実装できました。

ちなみに、ViewModelLiveData(今回の場合だとrenderDataという名前の変数)の値をFragmentで監視することもできます。最後にHomeAllFragmentを見ていきましょう!

HomeAllFragment.kt

HomeAllFragmentの全貌は以下の通りです。

HomeAllFragment.kt
class HomeAllFragment : Fragment(R.layout.fragment_home_all) {
    companion object {
        fun newInstance() = HomeAllFragment()
    }

    private var _binding: FragmentHomeAllBinding? = null
    private val binding
        get() = _binding!!

    private val adapter = HomeAdapter()
    private val viewModel: HomeAllViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        _binding = FragmentHomeAllBinding.bind(view)
        binding.allRecyclerView.adapter = adapter
        viewModel.renderData.observe(viewLifecycleOwner) {
            adapter.update(it, viewModel)
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

先ほどの「監視できる」という部分に該当するのは以下のソースコード部分です。

viewModel.renderData.observe(viewLifecycleOwner) {
     adapter.update(it, viewModel)
}

はい、いかにも監視している感じです。萌えます。はぁ????????

終わり

終わりです。
Androidってむずいですね〜〜〜
何でこんなに難しいことを楽しいと思ってるんですかね私は。
AndroidエンジニアってみんなドMですよね。
そういえば私の知り合いのAndroidエンジニアにすごいドMの方がいました。
あなたはAndroidが好きですか?
あなたは...ドMですか??
〜完〜

10
5
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
10
5