Be simple

”当たり前”が誰かのためになる

RecyclerViewでのカルーセル表示の実装方法について

はじめに

こんにちは。お久しぶりです。

今回は、AndroidでRecyclerViewを用いてカルーセル表示を実現するための方法について書き残しておきたいと思います。

 

カルーセル表示というのは、Google Play Storeを例にとると次の画像の赤枠の部分です。

f:id:rozkey59:20190422142827p:plain

Play ストア

 

実装する際にパッと思い出せるように、最近、UIの実装方法についてまとめたリポジトリも作成しました。随時更新予定です。

今回に関しても、次のサンプルを参考にしてください。

 

github.com

 

作成したサンプルは次のような感じです。

f:id:rozkey59:20190422143447g:plain

サンプル

 

今回触れる内容

  1. RecyclerViewを用いて横スクロールのカルーセル表示を作りながら学ぶ
  2. カルーセルのセルごとに異なるレイアウトを適用する
  3. SnapHelperを用いてカルーセルの位置を定位置に固定する
  4. ライブラリを用いてGoogle Play Storeのようなスナップした後にStart位置を固定する

読者想定

  • Androidの開発を多少やったことがある人
  • RecyclerViewでのカルーセル表示を実装したことがない人
  • Javaでの実装方法ではなく、Kotlinでの実装方法について知りたい人

 

 

では、実際に作っていきましょう。

 

開発前準備

開発をする前に、今回はKotlinとDataBindingを利用することと、androidXに対応した形で行なっています。最新の技術に合わせていく方針でやっていくため、対応していない方は対応した上で読み進めていただけますと幸いです。

 

DataBindingを利用するために、app配下のbuild.gradleに下記のように追加してください。...は中略を意味しており、gradleファイルに記載する必要はないです。以後も同様です。

 

android {
...
dataBinding {
enabled = true
}
...
}

 

androidXの対応に関しては、先人の知恵を借りて以下を参考にしてください。

qiita.com

新しく開発する場合には、下記の赤矢印の指しているチェックを入れるだけで対応は完了です。LanguageもKotlinにしてください。

f:id:rozkey59:20190422145356p:plain

新しく作成した時

これで、開発準備は完了です。

 

RecyclerViewを用いて横スクロールのカルーセル表示を作りながら学ぶ

レイアウトを用意する

まず、xmlでレイアウトを作成しましょう。

カルーセル表示を行うactivityのレイアウトxmlファイルを作成してください。

自分は、activity_carousel.xmlを次のように作成しました。

<?xml version="1.0" encoding="utf-8"?>
<layout>

<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="36sp"
android:textStyle="bold"
android:text="Carousel"
android:layout_marginStart="12dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:layout_constraintTop_toBottomOf="@+id/title"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
/>

<TextView
android:id="@+id/title2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="36sp"
android:textStyle="bold"
android:text="End of See More"
android:layout_marginStart="12dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/recycler_view"
/>

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:layout_constraintTop_toBottomOf="@+id/title2"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
/>

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

recycler_viewは、1つのレイアウトを適用するように用意して、recycler_view_2は異なる複数のレイアウトを出し分けるために用意しています。

1つのカルーセル表示を作りたい場合は、recycler_view1つで大丈夫です。

 

次に、カルーセル1つ1つのセルのレイアウトについて別に用意しましょう。

セルというのは、1つの要素、上のgifでいうとカードの要素のことを指して言っています。

セルのレイアウトは、item_carousel_card.xmlとして私は用意しました。名前は分かりやすければ、どんな名前でも構いません。

次のように書きます。

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

<data>

<variable name="data"
type="www.rozkey59.tokyo.androiduitips.carousel.CarouselListData"/>

</data>

<androidx.cardview.widget.CardView
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:cardCornerRadius="16dp"
app:cardElevation="4dp"
>

<LinearLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#F5F5F5"
android:padding="12dp"
>

<ImageView android:layout_width="match_parent"
android:layout_height="wrap_content"
android:src="@{data.image}"
android:tint="@{data.color}"
tools:src="@drawable/ic_android"
tools:tint="@color/colorPrimary"
/>

<TextView android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{data.title}"
android:textStyle="bold"
android:textSize="16sp"
android:layout_margin="16dp"
android:gravity="center_vertical"
android:textColor="@android:color/black"
tools:text="Test"
/>

</LinearLayout>

</androidx.cardview.widget.CardView>

</layout>

 

DataBindingを利用しているため<data>タグの中で、<variable>を定義し、nameにはこのリソースないで利用する変数名を、typeには表示したいデータのクラスのフルpathを記載します。

今回、ImageViewには、Droid君の画像とカラーを入れ、TextViewには、タイトルを利用するためdataからそれぞれ入れています。

DataBindingでvariableタグに定義した変数をattributeに記載するときは、"@{name.hoge}"のようにし、nameはvariableタグにて宣言した名前を、hogeには自分が作成したDataClassの変数名を記載します。

 

今回、自分はカルーセルに表示するデータのクラスをCarouselListData.ktとして次のように書いています。

data class CarouselListData(
val image: Drawable,
val imageResId: Int = 0,
val title: String,
val color: Int
)

hogeの部分はここで定義したimageなどを入れたいViewに対して記載します。

宣言したクラスのパスが間違っていたり、変数がなかったりするとビルド時にDataBindingのエラーが出るので、ここら辺を見直してみてください。

 

Adapterを用意する

RecyclerViewのAdapterに適用するためのAdapterクラスを作成します。

RecyclerView.Adapterを継承した独自Adapterを作成しましょう。

自分はCarouselAdapterとして次のように作成しました。全体は次のとおりです。

class CarouselAdapter(val dataList: List<CarouselListData>): RecyclerView.Adapter<CarouselAdapter.CarouselViewHolder>() {

lateinit var listener: OnItemClickListener

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CarouselViewHolder {
setOnItemClickListener(listener)
return CarouselViewHolder(
ItemCarouselCardBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}

override fun getItemCount(): Int {
return dataList.size
}

override fun onBindViewHolder(holder: CarouselViewHolder, position: Int) {
val data = dataList[position]
holder.binding.data = data
holder.binding.container.setOnClickListener {
listener.onClick(it, position, data)
}
}

interface OnItemClickListener {
fun onClick(view: View, position: Int, data: CarouselListData)
}

fun setOnItemClickListener(listener: OnItemClickListener) {
this.listener = listener
}

class CarouselViewHolder(val binding: ItemCarouselCardBinding): RecyclerView.ViewHolder(binding.root)
}

Adapterを作成するときにViewHolderも用意する必要があります。

 

RecyclerView.ViewHolderを継承した独自ViewHolderクラスを最初に作成しましょう。

class CarouselViewHolder(val binding: ItemCarouselCardBinding): RecyclerView.ViewHolder(binding.root)

bindingの方は、先ほど作成したitem_carousel_card.xmlをlayoutタグでくくっているため、自動生成されたBindingクラスを指定しましょう。最初にビルドしないと参照できないと思いますので、ビルドしてから指定しましょう。

 

次に、RecyclerView.Adapterを継承したことで、次の3つの関数をoverrideして必要な処理を記載する必要があります。

  1. onCreateViewHolder
  2. onBindViewHolder
  3. getItemCount

それぞれについてみていきましょう。

 

onCreateViewHolder

新たに作成した独自ViewHolderをインスタンス化し作成します。

次のように指定して作成しましょう。

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CarouselViewHolder {
setOnItemClickListener(listener)
return CarouselViewHolder(
ItemCarouselCardBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}

CarouselViewHolderは引数にDataBindigでinflateした値を渡します。

このときに、各セルをクリックした際の処理をもたせたいのでsetOnItemClickListenerでlistenerをセットしています。

listenerはメンバにもたせて、interfaceを次のように定義します。

interface OnItemClickListener {
fun onClick(view: View, position: Int, data: CarouselListData)
}

setter関数も作りましょう。

fun setOnItemClickListener(listener: OnItemClickListener) {
this.listener = listener
}

createViewHolderはこのように作成します。

onBindViewHolder

onBindViewHolderでは各セルの処理を書いたりします。

今回は次のようにしています。

override fun onBindViewHolder(holder: CarouselViewHolder, position: Int) {
val data = dataList[position]
holder.binding.data = data
holder.binding.container.setOnClickListener {
listener.onClick(it, position, data)
}
}

各positionのセルにdataをセットします。こうすることで、DataBindingで指定したViewに値が渡るようになります。

setOnClickListenerではクリックしたときにlistenerのonClickを呼ぶようにしてあげます。こうすることでActivity側でクリック処理を記載することができるようになります。

getItemCount

Adapterにセットされるのアイテムのデータの総数です。

今回は、CarouselListDataのリストをAdapterの引数で渡しているので、これのサイズを指定します。実際に書くと次のようになります。

override fun getItemCount(): Int {
return dataList.size
}

 

ここまでで、独自Adapterの実装については終わりです。

次は、Activityの実装についてです。

 

Activityの実装

最初に作成したactivity_carousel.xmlのRecyclerViewに先ほどまで作成してきたAdapterをセットし、作成して行きます。

全体の実装は次のとおりです。

class CarouselActivity: AppCompatActivity() {

private lateinit var binding: ActivityCarouselBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUp()
}

private fun setUp() {
binding = DataBindingUtil.setContentView(this, R.layout.activity_carousel)
binding.apply {
val adapter = CarouselAdapter(createDataList())
recyclerView.adapter = adapter
recyclerView.layoutManager = LinearLayoutManager(
this@CarouselActivity,
LinearLayoutManager.HORIZONTAL,
false
)
recyclerView.addItemDecoration(
CarouselItemDecoration(resources.getDimension(R.dimen.carousel_margin).toInt())
)
adapter.setOnItemClickListener(object : CarouselAdapter.OnItemClickListener {
override fun onClick(view: View, position: Int, data: CarouselListData) {
Toast.makeText(this@CarouselActivity, data.title, Toast.LENGTH_SHORT).show()
}
})
}
}

private fun createDataList() : List<CarouselListData> {
val dataList = mutableListOf<CarouselListData>()
for(i in 0 until 20) {
dataList.add(
CarouselListData(
image = resources.getDrawable(R.drawable.ic_android, null),
title = "Carousel Item $i",
color = getRandomColor()
)
)
}
return dataList
}

private fun getRandomColor(): Int {
val colorList = resources.getIntArray(R.array.color_list)
return colorList[(0 until colorList.size).random()]
}
}

 

まず、Activityのレイアウトファイルを適用します。

メンバに次のようにbindingファイルを型にもつ変数を定義します。

private lateinit var binding: ActivityCarouselBinding

次に、onCreateのなかでDataBindingUtilを用いて、セットします。lazyで行っても構いません。

binding = DataBindingUtil.setContentView(this, R.layout.activity_carousel)

今回はsetUp()という関数を作ってその中で行っています。

 

それが終わったら、先ほど作成した独自AdapterをRecyclerViewのAdapterにセットします。

binding.apply {
val adapter = CarouselAdapter(createDataList())
recyclerView.adapter = adapter
}

この時に、Viewに渡すDataのリストを作成します。createDataListという関数でListを作成するようにしました。

private fun createDataList() : List<CarouselListData> {
val dataList = mutableListOf<CarouselListData>()
for(i in 0 until 20) {
dataList.add(
CarouselListData(
image = resources.getDrawable(R.drawable.ic_android, null),
title = "Carousel Item $i",
color = getRandomColor()
)
)
}
return dataList
}

今回はAPIコールなどしていないので、自身で適当な長さのリストにDataを適当に作って当てはめました。

 

カルーセル表示をするのに忘れてはいけないのが、LayoutManagerです。

今回はLinearLayoutManagerを用意します。

binding.apply {
recyclerView.layoutManager = LinearLayoutManager(
this@CarouselActivity,
LinearLayoutManager.HORIZONTAL,
false
)
}

LinearLayoutManagerの引数は3つありますが、第二引数でLinearLayoutManagerのHORIZONTALを指定してください。これでカルーセル表示ができます。

ちなみに、VERTICALを指定すると縦方向のリストが作成できます。

 

ここまでできてから実行すると、次のようになります。

f:id:rozkey59:20190422160809g:plain

CarouselSample(No ItemDecoration)

カルーセル表示はできていますが、間が詰まっていて変な感じがしますよね。

要素間のマージンを取るためにはItemDecorationを指定しましょう。

次のようにRecyclerViewに指定してあげます。

binding.apply {
recyclerView.addItemDecoration(
CarouselItemDecoration(resources.getDimension(R.dimen.carousel_margin).toInt())
)
}

addItemDecorationでRecyclerView.ItemDecoration()を継承したカスタムクラスを指定します。

今回、自分はCarouselItemDecorationとして次のようなカスタムクラスを作成しました。

class CarouselItemDecoration(private val margin: Int): RecyclerView.ItemDecoration() {

override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
with(outRect) {
top = margin
left = margin / 2
right = margin / 2
bottom = margin
}
}
}

marginを引数に渡し、getItemOffsetsという関数をoverrideしてきて左右のマージンのみ半分にしています。左右を半分にしないと両隣が空きすぎてしまうためですね。

これで、実行すると次のようになります。

f:id:rozkey59:20190422161337g:plain

CarouselSample(Use ItemDecoration)

マージンを入れたいだけならItemDecorationを利用して実装しちゃいましょう。

これで、カルーセル表示の完成です。


次は、要素によってレイアウトを出し分けたい場合の実装についてです。

カルーセルのセルごとに異なるレイアウトを適用する

よくブラウザで検索すると次の赤枠のようなレイアウトを見かけますよね。

カードのセルの後にすべて表示のような別のレイアウトがセルに入ってくるパターン。

f:id:rozkey59:20190422161708p:plain

サンプル web

これはwebの例ですが、Androidアプリでも実装できちゃいます。

ここは先人の知恵をそのまま利用しましたので、こちらを参考にしてください。

qiita.com

 

方針としては、次のとおりです。

  1. 異なるレイアウトファイルを用意して、それぞれの独自ViewHolderクラスを用意する
  2. ViewTypeで出し分けを行うので、enum Classを定義する
  3. onCreateViewHolderでenumで定義した型によって、ViewHolderを作成する
  4. onBindViewHolderでviewTypeによって処理を分けて書く
  5. getItemViewTypeにてリストの長さ + 1の時かそうでないかによって、viewTypeのIdを返す
  6. getItemCountをリストの長さ + 1にする

この通りにやるとできます。

Qiitaの記事に載ってある通りなので上記リンクを参考にしてください。一応、書いたサンプルのコードは下記リンクの中の、app > java > www.rozkey59.tokyo.androiduitips > carousel > CarouselAndSeeMoreAdapter.ktの中にあります。

 

github.com

サンプルを実行すると次のようになります。

f:id:rozkey59:20190422163119p:plain

異なるレイアウトを適用した場合

赤枠の部分にて、異なるレイアウトファイルを適用できているのが確認できます。

 

SnapHelperを用いてカルーセルの位置を定位置に固定する

ここでは、スナップをした時にカルーセルの位置を固定したい時があると思うのでその場合の実装についてです。

スナップがどのような動作なのか、また、SnapHelperを利用しないときとするときでどうなるのか、gifで動作確認しましょう。

SnapHelperを利用しないとき

f:id:rozkey59:20190422163605g:plain

SnapHelperを利用しないとき

手で弾くような動作をしている時のことをスナップとしています。

SnapHelperを利用していない時は、弾くと自由にスクロールします。

次に、SnapHelperを利用するとどういった動きになるのかを見ていきましょう。

 

SnapHelperを利用するとき

f:id:rozkey59:20190422163850g:plain

SnapHelperを利用するとき

SnapHelperを利用すると、スナップしてもセルの要素が、真ん中に必ず位置が固定されるようになります。

スクロールしても、要素を定位置に固定したい時はSnapHelperを使います。

使い方は簡単です。次のように実装します。

        binding.apply {
val helper = LinearSnapHelper()
helper.attachToRecyclerView(recyclerView)
}

LinearSnapHelperをインスタンス化して、attachToRecyclerViewで適用したいRecyclerViewを指定してやるだけで実装できます。

 

今度は、真ん中ではなくてGoogle Playのようにリストの先頭に毎回固定するようにするための実装について紹介します。


ライブラリを用いてGoogle Play Storeのようなスナップした後にStart位置を固定する

SnapHelperで定位置に固定することはできましたが、次のようにGoogle Playストアで表示されているカルーセルのような動作を実現したいと思います。

f:id:rozkey59:20190422164519g:plain

Google Play ストア

スターターキットの枠をスナップしても必ずリストの先頭に要素の位置が固定されていることが確認できます。

これについては、RecyclerViewSnapというライブラリを用いることで簡単に動作を実現できます。ライブラリのGitHubは次のリンクから見れます。スターしましょう。

github.com

 

 実装は簡単です。

まず、app配下のbuild.gradleのdependenciesを追加しましょう。2019年4月現在、最新のバージョンは、2.0です。次のように追加してGradle Syncしてください。

dependencies {
// SnapHelper For setting Start
implementation 'com.github.rubensousa:gravitysnaphelper:2.0'
}

 

Syncしたら、RecyclerViewをレイアウトに含めているActivityにて次のように書きます。

        binding.apply {
GravitySnapHelper(Gravity.START).attachToRecyclerView(recyclerView)
}

GravityHelperの引数にGravity.STARTを渡して、attachToRecyclerViewにて該当するRecyclerViewを指定するだけです。

GravityHelperは先ほどのLinearSnapHelperを継承していて、位置をうまくライブラリ側で調整してくれています。

 

これを実行すると、次のようになります。

f:id:rozkey59:20190422165408g:plain

ライブラリを使用した場合

gifを見るとGoogle Playストアのような動きを実現できていることがわかります。

 

おわりに

RecyclerViewでのカルーセル表示に関してのTipsをまとめてみました。少しでも参考になれば幸いです。

 

説明がうまくできていない部分などあると思いますので、もし、不明点やもっとこうしたほうがいいなどあれば、コメントにて指摘などしていただけると嬉しいです。

 

今後も、不定期ではありますが、こういったTipsをまとめて発信していきたいと思います。

それでは、またの機会に。