Android NestedScrollView内でRecyclerViewを使用してみた

## はじめに
ScrollViewの中にRecyclerViewを置くケースです。
通常のScrollViewは使えないので、ネスト可能なNestedScrollViewを使用します。


画面全体がスクロール可能で、下の方にリスト表示がある構成です。
<img src="https://qiita-image-store.s3.amazonaws.com/0/112186/6ecbafca-2022-5db0-9b4d-f1330c73cb45.gif" width=350px>

## レイアウト構成
ざっくりですが、こんな感じです。
`NestedScrollView`の中に`コンテンツA`と`コンテンツB`のレイアウトを包含しており、
コンテンツBのレイアウトに`RecyclerView`が定義されております。
<img src="https://qiita-image-store.s3.amazonaws.com/0/112186/8ffbeff6-cde2-fbee-8a7e-16d1014f4288.png" width=400px>

Activityのレイアウト

```xml:activity_main.xml
<androidx.coordinatorlayout.widget.CoordinatorLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/contentRoot"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
            android:layout_height="wrap_content"
            android:layout_width="match_parent"
            android:theme="@style/AppTheme.AppBarOverlay">

        <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:background="?attr/colorPrimary"
                app:popupTheme="@style/AppTheme.PopupOverlay"/>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.core.widget.NestedScrollView
            android:id="@+id/nestedScrollView"
            android:overScrollMode="never"
            android:layout_marginTop="?attr/actionBarSize"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        <LinearLayout
                android:focusableInTouchMode="true"
                android:orientation="vertical"
                android:layout_width="match_parent"
                android:layout_height="match_parent">
            <!-- コンテンツA -->
            <include layout="@layout/view_content_box"/>
            <!-- コンテンツB -->
            <include layout="@layout/view_content_list"/>

        </LinearLayout>

    </androidx.core.widget.NestedScrollView>

    <FrameLayout android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="?attr/actionBarSize">
        
        <include layout="@layout/view_section" android:id="@+id/sectionHeader"/>
    </FrameLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>
```

コンテンツAのレイアウト

```xml:view_content_box.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:orientation="vertical"
              android:id="@+id/contentBox"
              android:layout_height="wrap_content">

    <View
            android:layout_marginTop="60dp"
            android:layout_marginStart="60dp"
            android:layout_marginEnd="60dp"
            android:background="@color/contentA"
            android:layout_width="match_parent"
            android:layout_height="100dp"/>

            ... 省略

    <include android:visibility="invisible" layout="@layout/view_section" android:id="@+id/sectionBox"/>

</LinearLayout>
```

コンテンツBのレイアウト

```xml:view_content_list.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:app="http://schemas.android.com/apk/res-auto"
              android:layout_width="match_parent"
              android:orientation="vertical"
              android:id="@+id/contentList"
              android:layout_height="wrap_content">

    <include layout="@layout/view_section" android:id="@+id/sectionList"/>

    <androidx.recyclerview.widget.RecyclerView
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            android:id="@+id/recyclerView"
            android:nestedScrollingEnabled="false"
            android:overScrollMode="never"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>

</LinearLayout>
```

## 実装のポイント
#### RecyclerViewの高さは動的に設定する必要がある
`layout_height="match_parent"`で指定すると、
全レコードを表示する高さのRecyclerViewが出来てしまい、
結果セルの使い回しができなくなる。

onCreateなどでRecyclerViewのリサイズを行う。
(以下の青枠領域の高さをコードで設定する)
<img src="https://qiita-image-store.s3.amazonaws.com/0/112186/79328e96-1cff-1ec3-9b52-780797e61caa.png" width="180px" />

```kotlin
recyclerView.setOnGlobalLayout {
    val contentHeight = findViewById<View>(android.R.id.content).height
    val height = contentHeight - nestedScrollView.y - sectionHeader.height
    recyclerView.layoutParams = LinearLayout.LayoutParams(
        LinearLayout.LayoutParams.MATCH_PARENT,
        height.toInt()
    )
    // ... 省略
```

以下は、addOnGlobalLayoutListenerの拡張関数

```kotlin:
fun View.setOnGlobalLayout(block: () -> Unit) {
    viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
        override fun onGlobalLayout() {
            block()
            viewTreeObserver.removeOnGlobalLayoutListener(this)
        }
    })
}
```

#### isNestedScrollingEnabledを設定する
RecyclerViewに`isNestedScrollingEnabled`のプロパティがある。
本フラグを適切なタイミングで制御しないと、正しいスクロールが得られなくなる。

試しに、isNestedScrollingEnabledをtrue(デフォルト)にした結果が以下である。
<img src="https://qiita-image-store.s3.amazonaws.com/0/112186/e54726a6-1a98-30ea-7894-34c1f6fd732b.gif" width=350px>
このように外側の`NestedScrollView`と、
内側の`RecyclerView`が独立してスクロールしてしまう。
（要求仕様を満たすのであれば別に問題はない)

NestedScrollViewのスクロールイベントでフラグの制御を行う

```kotlin:
nestedScrollView.setOnScrollChangeListener { _: NestedScrollView?, _: Int, scrollY: Int, _: Int, _: Int ->
    // コンテンツBの位置までスクロールしてきたら、
    // RecyclerViewをスクロール可能にする
    recyclerView.isNestedScrollingEnabled = scrollY >= contentList.y
}
```

また、XML側も初期値を設定しておく

```xml:
<androidx.recyclerview.widget.RecyclerView
    android:nestedScrollingEnabled="false"
```

### 全体のソースコード(XMLは除く)

```kotlin
class MainActivity : AppCompatActivity() {

    private val contentListAdapter = ContentListAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(toolbar)
        initView()
    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menuInflater.inflate(R.menu.menu_main, menu)
        return true
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
            R.id.list_items_1 -> {
                contentListAdapter.listItems = 1
                true
            }
            R.id.list_items_4 -> {
                contentListAdapter.listItems = 4
                true
            }
            R.id.list_items_100 -> {
                contentListAdapter.listItems = 100
                true
            }
            else -> super.onOptionsItemSelected(item)
        }
    }

    private fun initView() {
        recyclerView.adapter = contentListAdapter

        val sectionHeaderText = sectionHeader.findViewById<TextView>(R.id.sectionText)
        val sectionBoxText = sectionBox.findViewById<TextView>(R.id.sectionText)
        val sectionListText = sectionList.findViewById<TextView>(R.id.sectionText)

        sectionBoxText.text = "Content Box"
        sectionListText.text = "Content List"

        fun updateView(scrollY: Int) {
            recyclerView.isNestedScrollingEnabled = scrollY >= contentList.y
            when {
                scrollY >= contentList.y - sectionHeader.height && scrollY < contentList.y -> {
                    sectionHeader.isVisible = false
                    sectionBox.isVisible = true
                }
                scrollY >= contentList.y -> {
                    sectionHeader.isVisible = true
                    sectionHeaderText.text = sectionListText.text
                }
                else -> {
                    sectionHeader.isVisible = true
                    sectionBox.isInvisible = true
                    sectionHeaderText.text = sectionBoxText.text
                }
            }
        }
        nestedScrollView.setOnScrollChangeListener { _: NestedScrollView?, _: Int, scrollY: Int, _: Int, _: Int ->
            updateView(scrollY)
        }
        recyclerView.setOnGlobalLayout {
            val contentHeight = findViewById<View>(android.R.id.content).height
            val height = contentHeight - nestedScrollView.y - sectionHeader.height
            recyclerView.layoutParams = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                height.toInt()
            )
            updateView(0)
        }
    }

}

fun View.setOnGlobalLayout(block: () -> Unit) {
    viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
        override fun onGlobalLayout() {
            block()
            viewTreeObserver.removeOnGlobalLayoutListener(this)
        }
    })
}

fun ViewGroup.inflate(@LayoutRes layoutRes: Int, attachToRoot: Boolean = false): View {
    return LayoutInflater.from(context).inflate(layoutRes, this, attachToRoot)
}

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

    var listItems: Int = 50
        set(value) {
            field = value
            notifyDataSetChanged()
        }

    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ContentListAdapter.ViewHolder {
        return ContentListAdapter.ViewHolder(viewGroup.inflate(R.layout.view_list_cell))
    }

    override fun getItemCount(): Int {
        return listItems
    }

    override fun onBindViewHolder(holder: ContentListAdapter.ViewHolder, position: Int) {
        holder.setViewData(position + 1)
    }

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

        private val iconImage = itemView.iconImage

        fun setViewData(number: Int) {
            iconImage.text = number.toString()
        }
    }
}
```

## おわり
プロジェクトファイルはGitHubに上げております。
https://github.com/unpii/android-scroll-example