RecyclerView + Epoxy で目次機能付き画面を作ってみる

# 完成イメージ
目次パーツ、見出しパーツ、テキストパーツからなる。
画面上部に目次があり、タップすると対応する見出し部分にスクロールする。
![table_of_contents.gif](https://qiita-image-store.s3.amazonaws.com/0/141937/5c7d504f-5205-7f79-ea9c-02828e4baddc.gif)

# 開発環境の設定
開発言語にKotlinを使用し、 databindingを活用していく。その他、ライブラリとしてEpoxyを導入する。

```build.gradle
...
apply plugin: 'kotlin-kapt'

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

kapt {
    generateStubs = true
    correctErrorTypes = true
}

dependencies {
    ...
    implementation 'com.airbnb.android:epoxy:2.10.0'
    kapt 'com.airbnb.android:epoxy-processor:2.10.0'
    implementation 'com.airbnb.android:epoxy-databinding:2.10.0'
    ...
}
```

# メインのレイアウト
RecyclerViewのみを配置する。

```activity_main.xml
<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" >

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.v7.widget.RecyclerView
            android:id="@+id/recyclerView"
            app:layoutManager="android.support.v7.widget.LinearLayoutManager"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </FrameLayout>

</layout>
```

# 目次パーツのレイアウト
リンクのテキストを入れるためのLinearLayoutのみを配置しておく。

```table_of_contents.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android" >

    <LinearLayout
        android:id="@+id/container"
        android:background="#cccccc"
        android:layout_margin="20dp"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</layout>
```

中身のリンク部分のレイアウトは以下となる。上記のLinearLayoutに下記のレイアウトのviewを動的にaddViewしていく。
リンクがタップされたらコールバックを返す。

```link.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android" >

    <data>

        <variable name="link" type="com.example.tomo.tableofcontents.Link" />
        <variable name="callback" type="com.example.tomo.tableofcontents.TableOfContentsCallback" />

    </data>

    <FrameLayout
        android:onClick="@{() -> callback.onClickLink(link)}"
        android:background="?attr/selectableItemBackground"
        android:orientation="vertical"
        android:padding="10dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/title"
            android:text="@{link.title}"
            android:textColor="#000000"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

    </FrameLayout>

</layout>
```

# 見出しパーツのレイアウト

```caption_parts.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable name="caption" type="com.example.tomo.tableofcontents.Content.CaptionParts" />

    </data>

    <LinearLayout
        android:padding="20dp"
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:text="@{caption.title}"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            tools:text="text"
            />

        <View
            android:background="#000000"
            android:layout_marginTop="5dp"
            android:layout_width="match_parent"
            android:layout_height="2dp" />

    </LinearLayout>

</layout>
```

# テキストパーツのレイアウト

```text_parts.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android" >

    <data>

        <variable name="text" type="String" />

    </data>

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="200dp">

        <TextView
            android:text="@{text}"
            android:layout_gravity="center"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

    </FrameLayout>

</layout>
```

# package-info.javaの作成
EpoxyにレイアウトファイルからBindingModel_クラスを自動生成してもらうために、package-info.javaを作成する。[参考](https://qiita.com/tomo1139/items/2e3eb25cb81aa741f491)

```package-info.java
@EpoxyDataBindingLayouts({
        R.layout.table_of_contents_parts,
        R.layout.text_parts,
        R.layout.caption_parts,
})
package com.example.tomo.tableofcontents;

import com.airbnb.epoxy.EpoxyDataBindingLayouts;
```

# データクラスの作成
目次パーツ、見出しパーツ、テキストパーツのデータ定義を作成する。目次と見出しを紐付けるために、内部的にtagを持たせている。

```Data.kt
package com.example.tomo.tableofcontents

data class Data(val contents: List<Content>)

sealed class Content {
    data class TableOfContentsParts(val links: List<Link>) : Content()
    data class CaptionParts(val tag: String, val title: String) : Content()
    data class TextParts(val text: String) : Content()
}

data class Link(val tag: String, val title: String)
```
# EpoxyContollerの作成
データが設定されたら、それぞれviewを構築していく。
目次パーツの中のリンク部分については、動的にviewをinflateし、addViewしていく。

```EpoxyController.kt
package com.example.tomo.tableofcontents

import android.content.Context
import android.databinding.DataBindingUtil
import android.graphics.Paint
import android.view.LayoutInflater
import com.airbnb.epoxy.TypedEpoxyController
import com.example.tomo.tableofcontents.databinding.LinkBinding
import com.example.tomo.tableofcontents.databinding.TableOfContentsPartsBinding

class EpoxyController(private val context: Context, private val callback: TableOfContentsCallback) : TypedEpoxyController<Data>() {

    override fun buildModels(data: Data) {

        data.contents.forEach {
            when (it) {
                is Content.TableOfContentsParts -> {
                    val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
                    TableOfContentsPartsBindingModel_()
                            .onBind { model, view, position ->
                                val container = (view.dataBinding as TableOfContentsPartsBinding).container
                                if (container.childCount == 0) {
                                    it.links.forEach {
                                        val binding = DataBindingUtil.inflate<LinkBinding>(inflater, R.layout.link, null, false)
                                        binding.link = it
                                        binding.callback = callback
                                        binding.title.paintFlags = binding.title.paintFlags or Paint.UNDERLINE_TEXT_FLAG
                                        container.addView(binding.root)
                                    }
                                }
                            }
                            .id(modelCountBuiltSoFar)
                            .addTo(this)
                }

                is Content.CaptionParts -> {
                    CaptionPartsBindingModel_()
                            .id(modelCountBuiltSoFar)
                            .caption(it)
                            .addTo(this)
                }

                is Content.TextParts -> {
                    TextPartsBindingModel_()
                            .id(modelCountBuiltSoFar)
                            .text(it.text)
                            .addTo(this)
                }
            }
        }
    }
}
```

# MainActivity.ktの作成
テスト用のデータを作成し、EpoxyControllerに設定している。
目次のリンクがタップされたらコールバックメソッドが呼び出され、一致するタグの見出し位置が画面のトップとなるようsmooth scrollさせる。

``` MainActivity.kt
package com.example.tomo.tableofcontents

import android.databinding.DataBindingUtil
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.support.v7.widget.LinearLayoutManager
import com.example.tomo.tableofcontents.databinding.ActivityMainBinding
import android.support.v7.widget.LinearSmoothScroller

class MainActivity : AppCompatActivity(), TableOfContentsCallback {

    private val binding: ActivityMainBinding by lazy { DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main) }
    private val controller: EpoxyController by lazy { EpoxyController(this, this) }

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

        val contents = listOf(
                Content.TableOfContentsParts(listOf(
                        Link("tag1", "caption1"),
                        Link("tag2", "caption2"),
                        Link("tag3", "caption3"),
                        Link("tag4", "caption4"),
                        Link("tag5", "caption5"),
                        Link("tag6", "caption6"),
                        Link("tag7", "caption7"),
                        Link("tag8", "caption8"),
                        Link("tag9", "caption9"),
                        Link("tag10", "caption10")
                )),
                Content.CaptionParts("tag1", "caption1"), Content.TextParts("textParts1"),
                Content.CaptionParts("tag2", "caption2"), Content.TextParts("textParts2"),
                Content.CaptionParts("tag3", "caption3"), Content.TextParts("textParts3"),
                Content.CaptionParts("tag4", "caption4"), Content.TextParts("textParts4"),
                Content.CaptionParts("tag5", "caption5"), Content.TextParts("textParts5"),
                Content.CaptionParts("tag6", "caption6"), Content.TextParts("textParts6"),
                Content.CaptionParts("tag7", "caption7"), Content.TextParts("textParts7"),
                Content.CaptionParts("tag8", "caption8"), Content.TextParts("textParts8"),
                Content.CaptionParts("tag9", "caption9"), Content.TextParts("textParts9"),
                Content.CaptionParts("tag10", "caption10"), Content.TextParts("textParts10")
        )
        val data = Data(contents)

        controller.setData(data)
        binding.recyclerView.adapter = controller.adapter
    }

    override fun onClickLink(link: Link) {
        val smoothScroller = object : LinearSmoothScroller(this) {
            override fun getVerticalSnapPreference(): Int {
                return LinearSmoothScroller.SNAP_TO_START
            }
        }
        for (itemIdx in 0 until controller.adapter.itemCount) {
            val model = controller.adapter.getModelAtPosition(itemIdx)
            if (model is CaptionPartsBindingModel_ && link.tag == model.caption().tag) {
                val layoutManager = binding.recyclerView.layoutManager as LinearLayoutManager
                smoothScroller.targetPosition = itemIdx
                layoutManager.startSmoothScroll(smoothScroller)
                break
            }
        }
    }
}
```

```TableOfContentsCallback.kt
package com.example.tomo.tableofcontents

interface TableOfContentsCallback {
    fun onClickLink(link: Link)
}
```

# まとめ
RecyclerViewとEpoxyを使用して目次機能付き画面ができた。:ok_woman:
![table_of_contents.gif](https://qiita-image-store.s3.amazonaws.com/0/141937/22175237-7529-ba0c-e7a4-dc56b7b2ed5e.gif)

# 参考
[RecyclerViewの実装が楽になるEpoxyライブラリを使ってみる](https://qiita.com/tomo1139/items/2e3eb25cb81aa741f491)
[プロジェクトファイル一式](https://github.com/tomo1139/TableOfContents)