Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

はじめに

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

画面全体がスクロール可能で、下の方にリスト表示がある構成です。

レイアウト構成

ざっくりですが、こんな感じです。
NestedScrollViewの中にコンテンツAコンテンツBのレイアウトを包含しており、
コンテンツBのレイアウトにRecyclerViewが定義されております。

Activityのレイアウト

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のレイアウト

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のレイアウト

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のリサイズを行う。
(以下の青枠領域の高さをコードで設定する)

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の拡張関数

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

isNestedScrollingEnabledを設定する

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

試しに、isNestedScrollingEnabledをtrue(デフォルト)にした結果が以下である。

このように外側のNestedScrollViewと、
内側のRecyclerViewが独立してスクロールしてしまう。
(要求仕様を満たすのであれば別に問題はない)

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

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

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

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

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

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

unpi
大阪のエンジニアです。 メインはAndroidです。
i-enter
「効果」をつねに提供します。スマホアプリ開発No.1の実績。最新のIoTに対応した開発も行います。
https://www.i-enter.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした