Pairs Android JPにおけるさがす画面のレイアウトの実現方法について

この記事はeureka Native Engineer Advent Calendar 2017 6日目の記事です。
5日目は@yuyakaidoさんのAndroidにおける状態管理をスマートに実装するためにFluxを採用した話でした。

はじめに

こんにちは!エンジニアの二川(@futabooo)です。
最近はKotlinでAndroidやったりGolangでサーバーサイドやったりスクラムマスターとして開発チームのスループット上げるために悩んだりしています。

さて、今回は恋愛・婚活マッチングサービスである「Pairs」の Android JPアプリ(以下Pairs Android)におけるさがす画面のレイアウト実現方法について紹介します。

Pairs Android JPのさがす画面

Pairs Androidのさがす画面は下記のようなデザインになっています。
カラムが2つのグリッドを基本として、ユーザーの表示の代わりに何かしらのアクションを訴求するようなバナー画像を表示したり、縦のリストの途中に横スクロールが可能なViewを複数種類のレイアウトで差し込んだりしています。

実装

基本方針

基本的な実装方針として一つのRecyclerViewでgetItemViewType()によるレイアウトの変更で対応していきます。

Adapterに渡すデータの表現

Adapterに渡すListのitemはすべて同じinterfaceのSearchItemを実装する形で表現しました。
同じinterfaceを実装することで、Adapterに渡す型を統一できるメリットがあります。
プロダクションのコードとは変更している部分もありますが、雰囲気下記のような感じでdata classを用意しています。

interface SearchItem
data class UserItem(val position: Int, val user: User) : SearchItem
data class BannerItem(val position: Int, val banner: Banner) : SearchItem
data class CarouselUserItem(val position: Int, val users: List<User>,) : SearchItem

Adapterの実装パターン1: そのまま実装する

getItemViewType()をoverrideして用意したdata classごとにintのviewTypeを返すようにしています。

class SearchAdapter(var items: List<SearchItem>):RecyclerView.Adapter<SearchAdapter.ViewHolder>() {

  companion object {
    const val USER     = 0
    const val BANNER   = 1
    const val CAROUSEL = 2
  }

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    // getItemViewType()の返り値によってlayoutを変更する
    if (viewType == USER) {
      ViewHolder(UserBinding.inflate(LayoutInflater.from(parent.context), parent, false))
    } else if (viewType == BANNER) {
      ViewHolder(BannerBinding.inflate(LayoutInflater.from(parent.context), parent, false))
    } else if (viewType == CAROUSEL) {
      ViewHolder(CarouselBinding.inflate(LayoutInflater.from(parent.context), parent, false)))
    }
  }

  override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    if (holder.binding is UserBinding) {
       // layout処理
    } else if (holder.binding is BannerBinding) {
       // layout処理
    } else if (holder.binding is CarouselBinding) {
       // layout処理
    }
  }

  override fun getItemViewType(position: Int): Int {
    val item = items[position]

    // itemの型によってlayoutを変更する
    if (item is UserItem) {
      return USER
    } else if (item is BannerItem) {
      return BANNER
    } else if (item is CarouselUserItem) {
      return CAROUSEL
    }
  }

  class ViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
}

Adapterの実装パターン2: enumを使って実装する

viewTypeにenumを使うことデータごとにlayoutの実装を分けられるので多少見通しが良くなります。

class SearchAdapter():RecyclerView.Adapter<SearchAdapter.ViewHolder>() {

  enum class ViewType(val id: Int) {
    USER(0) {
      override fun onCreateViewHolder(parent: ViewGroup, position: Int): RecyclerView.ViewHolder {
        return ViewHolder(UserBinding.inflate(LayoutInflater.from(parent.context), parent, false))
      }

      override fun onBindView(binding: ViewDataBinding, position: Int) {
        // layout処理
      }
    },
    BANNER(1) {
      override fun onCreateViewHolder(parent: ViewGroup, position: Int): RecyclerView.ViewHolder {
        return ViewHolder(BannerBinding.inflate(LayoutInflater.from(parent.context), parent, false))
      }

      override fun onBindView(binding: ViewDataBinding, position: Int) {
        // layout処理
      }
    },
    CAROUSEL(2) {
      override fun onCreateViewHolder(parent: ViewGroup, position: Int): RecyclerView.ViewHolder {
        return ViewHolder(CarouselBinding.inflate(LayoutInflater.from(parent.context), parent, false))
      }

      override fun onBindView(binding: ViewDataBinding, position: Int) {
        // layout処理
      }
    }

    companion object {
      fun fromId(id: Int): ViewType {
        return ViewType.values().first { it.id == id }
      }
    }

    abstract fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder
    abstract fun onBindView(binding: ViewDataBinding, position: Int)
  }

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    return ViewType.fromId(viewType).onCreateViewHolder(parent)
  }

  override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    ViewType.fromId(viewType).onBindView(holder.binding)
  }

  override fun getItemViewType(position: Int): Int {
    val item = items[position]

    // itemの型によってlayoutを変更する
    if (item is UserItem) {
      return USER
    } else if (item is BannerItem) {
      return BANNER
    } else if (item is CarouselUserItem) {
      return CAROUSEL
    }
  }

  class ViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
}

課題感と解決策

これまで見てきた実装でも表現したいデータとレイアウトが3つぐらいならまだそんなに気にならない気もしますが、今後増えていった場合に見通しが悪くなることが予想されます。
そこで今回はsockeqwe/AdapterDelegatesというライブラリを導入することで対応することにしました。

sockeqwe/AdapterDelegatesを使うことで、データとレイアウトごとにクラスを分けて実装することができるので見通しの改善が見込めるのと、今後誰かが追加実装したい時に何をやればいいのかがわかりやすくなると考えたからです。

Adapterの実装パターン3: sockeqwe/AdapterDelegatesを使う

sockeqwe/AdapterDelegatesを適用して実装を変更すると下記のようになります。

メインのSearchAdapter内に存在していた処理はほとんどを委譲先に移すことでかなりスッキリしました。

class SearchAdapter(var items: List<SearchItem>):RecyclerView.Adapter<RecyclerView.ViewHolder>() {

  private val delegatesManager: AdapterDelegatesManager<List<SearchItem>> = AdapterDelegatesManager()

  companion object {
    const val USER     = 0
    const val BANNER   = 1
    const val CAROUSEL = 2
  }

  init {
      delegatesManager.addDelegate(USER, UserAdapterDelegate())
      delegatesManager.addDelegate(BANNER, BannerAdapterDelegate())
      delegatesManager.addDelegate(CAROUSEL, CarouselAdapterDelegate())
  }

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    return delegatesManager.onCreateViewHolder(parent, viewType)
  }

  override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    delegatesManager.onBindViewHolder(items, position, holder)
  }

  override fun getItemViewType(position: Int): Int {
    return delegatesManager.getItemViewType(items, position)
  }
}

それぞれ処理を委譲するクラスは、sockeqwe/AdapterDelegatesAdapterDelegateクラスを継承して作成します。

class UserAdapterDelegate() : AdapterDelegate<List<SearchAdapterItem>>() {

  override fun isForViewType(items: List<SearchItem>, position: Int): Boolean {
    return items[position] is UserItem
  }

  override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
    return ViewHolder(UserBinding.inflate(LayoutInflater.from(parent.context), parent, false))
  }

  override fun onBindViewHolder(items: List<SearchItem>, position: Int, holder: RecyclerView.ViewHolder, payloads: List<Any>) {
    // layoutの処理
  }

  class ViewHolder(val binding: UserBinding) : RecyclerView.ViewHolder(binding.root)
}

SearchAdaptergetItemViewType()が呼ばれるとdelegatesManagergetItemViewType()が呼ばれ、delegatesManagerにaddしたAdapterDelegateクラスの中からどのクラスを使うべきか?を判定し、あとはAdapterDelegateクラスのonCreateViewHolder()onBindBiewHolder()が呼ばれるという流れになります。

今後何か新しくレイアウトを追加したい場合にはSearchItemを実装したdata classとhoge-layout.xmlとAdapterDelegateを継承したHogeAdapterDelegateを作ることで実現可能です。

おわりに

今回はRecyclerViewのgetItemViewType()によるレイアウトの出し分けの実装にフォーカスしてみました。
表現したいレイアウトが増えてくるとクラス数も増えていってしまうのですが、構成自体はシンプルで迷うことがないのではないかと思っています。
こっちのほうがシンプルだよとか、こんな実装はどうかなとかあればぜひお話したいので新オフィスに遊びに来てください!

明日は栗村さんRxJava,Kotlin,Databindingでイケてる入力フォームをスッキリ実装するです!

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

その案件、Reactで本当に大丈夫ですか?

DroidKaigi 2017に参加してきました