Android RecyclerViewを用いた横方向グリッドページネーションの実装

実装概要

この記事ではViewPagerやFragmentのネストを排除し、RecyclerView単体で横方向グリッドページネーションを実現する方法を解説します。グリッドセルの行数・列数指定、ページ間アニメーション、インジケーター表示機能を含む完全な実装例を提供します。

スクロール補助クラスの実装

class GridPagerHelper(private val recyclerView: RecyclerView) {
    private var scrollOffsetX = 0
    private var scrollOffsetY = 0
    private var startX = 0
    private var startY = 0
    private var pageAnimator: ValueAnimator? = null
    private var pageChangeListener: ((Int) -> Unit)? = null

    enum class ScrollDirection { HORIZONTAL, VERTICAL, NONE }

    private var currentDirection = ScrollDirection.NONE

    init {
        setupRecyclerView()
    }

    private fun setupRecyclerView() {
        recyclerView.apply {
            onFlingListener = createFlingListener()
            addOnScrollListener(createScrollListener())
            setOnTouchListener(createTouchListener())
        }
        updateLayoutManagerDirection()
    }

    private fun updateLayoutManagerDirection() {
        recyclerView.layoutManager?.let {
            currentDirection = when {
                it.canScrollHorizontally() -> ScrollDirection.HORIZONTAL
                it.canScrollVertically() -> ScrollDirection.VERTICAL
                else -> ScrollDirection.NONE
            }
        }
        pageAnimator?.cancel()
        scrollOffsetX = 0
        scrollOffsetY = 0
    }

    private fun createFlingListener() = object : RecyclerView.OnFlingListener() {
        override fun onFling(velocityX: Int, velocityY: Int): Boolean {
            val currentPage = calculateCurrentPage()
            val targetPage = when(currentDirection) {
                ScrollDirection.HORIZONTAL -> {
                    val pageWidth = recyclerView.width
                    if (velocityX < 0) currentPage - 1 else currentPage + 1
                }
                ScrollDirection.VERTICAL -> {
                    val pageHeight = recyclerView.height
                    if (velocityY < 0) currentPage - 1 else currentPage + 1
                }
                else -> currentPage
            }

            animateScrollToPage(targetPage)
            return true
        }
    }

    private fun animateScrollToPage(page: Int) {
        val targetX = when(currentDirection) {
            ScrollDirection.HORIZONTAL -> page * recyclerView.width
            else -> 0
        }

        pageAnimator = ValueAnimator.ofInt(scrollOffsetX, targetX).apply {
            duration = 300
            addUpdateListener { animator ->
                val currentX = animator.animatedValue as Int
                val dx = currentX - scrollOffsetX
                recyclerView.scrollBy(dx, 0)
                scrollOffsetX = currentX
            }
            addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator) {
                    pageChangeListener?.invoke(calculateCurrentPage())
                }
            })
            start()
        }
    }

    fun scrollToPage(position: Int) {
        pageAnimator?.let {
            it.setIntValues(scrollOffsetX, position * recyclerView.width)
            it.start()
        } ?: animateScrollToPage(position)
    }

    private fun calculateCurrentPage() = 
        if (recyclerView.width == 0) 0 else scrollOffsetX / recyclerView.width
}

カスタムレイアウトマネージャー

class GridLayoutManager(
    private val spanCount: Int, 
    private val columnCount: Int
) : RecyclerView.LayoutManager(), PageIndicator {

    private val itemFrames = SparseArray<Rect>()
    private var pageWidth = 0
    private var pageHeight = 0
    private var scrollOffsetX = 0
    private val itemsPerPage = spanCount * columnCount

    override fun generateDefaultLayoutParams() = 
        RecyclerView.LayoutParams(
            RecyclerView.LayoutParams.WRAP_CONTENT,
            RecyclerView.LayoutParams.WRAP_CONTENT
        )

    override fun canScrollHorizontally() = true

    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
        detachAndScrapAttachedViews(recycler)
        val newOffset = scrollOffsetX + dx
        val maxScroll = (getPageCount(state) - 1) * pageWidth
        val effectiveDx = when {
            newOffset > maxScroll -> maxScroll - scrollOffsetX
            newOffset < 0 -> -scrollOffsetX
            else -> dx
        }
        scrollOffsetX += effectiveDx
        offsetChildrenHorizontal(-effectiveDx)
        layoutVisibleItems(recycler, state)
        return effectiveDx
    }

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        if (itemCount == 0) return

        pageWidth = width - paddingLeft - paddingRight
        pageHeight = height - paddingTop - paddingBottom

        val itemWidth = pageWidth / columnCount
        val itemHeight = pageHeight / spanCount

        detachAndScrapAttachedViews(recycler)
        itemFrames.clear()

        for (pageIndex in 0 until getPageCount(state)) {
            for (row in 0 until spanCount) {
                for (col in 0 until columnCount) {
                    val position = pageIndex * itemsPerPage + row * columnCount + col
                    if (position >= itemCount) break
                    
                    val view = recycler.getViewForPosition(position)
                    addView(view)
                    measureChildWithMargins(view, 0, 0)

                    val left = pageIndex * pageWidth + col * itemWidth
                    val top = row * itemHeight
                    val right = left + getDecoratedMeasuredWidth(view)
                    val bottom = top + getDecoratedMeasuredHeight(view)
                    
                    itemFrames.put(position, Rect(left, top, right, bottom))
                }
            }
            removeAndRecycleAllViews(recycler)
        }

        layoutVisibleItems(recycler, state)
    }

    private fun layoutVisibleItems(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        val visibleRect = Rect(scrollOffsetX, 0, scrollOffsetX + width, height)
        
        // Recycle invisible views
        for (i in 0 until childCount) {
            val child = getChildAt(i)!!
            if (!Rect.intersects(visibleRect, getDecoratedBounds(child))) {
                removeAndRecycleView(child, recycler)
            }
        }

        // Layout visible items
        for (position in 0 until itemCount) {
            if (Rect.intersects(visibleRect, itemFrames.get(position, Rect()))) {
                val view = recycler.getViewForPosition(position)
                addView(view)
                measureChildWithMargins(view, 0, 0)
                val frame = itemFrames[position]!!
                layoutDecorated(view, frame.left - scrollOffsetX, frame.top, frame.right - scrollOffsetX, frame.bottom)
            }
        }
    }

    override fun computeHorizontalScrollRange(state: RecyclerView.State): Int {
        return getPageCount(state) * width
    }

    override fun computeHorizontalScrollOffset(state: RecyclerView.State): Int {
        return scrollOffsetX
    }

    override fun computeHorizontalScrollExtent(state: RecyclerView.State): Int {
        return width
    }

    private fun getPageCount(state: RecyclerView.State): Int {
        val fullPages = state.itemCount / itemsPerPage
        return if (state.itemCount % itemsPerPage == 0) fullPages else fullPages + 1
    }

    // PageIndicator implementation
    override fun isLastRow(position: Int): Boolean {
        val indexInPage = position % itemsPerPage
        return indexInPage >= (spanCount - 1) * columnCount
    }

    override fun isLastColumn(position: Int): Boolean {
        return (position % columnCount) == columnCount - 1
    }

    override fun isPageLast(position: Int): Boolean {
        return (position + 1) % itemsPerPage == 0
    }
}

ページインジケーターインターフェース

interface PageIndicator {
    fun isLastRow(position: Int): Boolean
    fun isLastColumn(position: Int): Boolean
    fun isPageLast(position: Int): Boolean
}

アクティビティでの使用例

class GridActivity : AppCompatActivity() {
    private lateinit var pagerHelper: GridPagerHelper
    private lateinit var indicatorDots: Array<ImageView>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_grid)

        val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
        val indicatorLayout = findViewById<LinearLayout>(R.id.indicator_layout)

        val layoutManager = GridLayoutManager(3, 4)
        recyclerView.layoutManager = layoutManager

        pagerHelper = GridPagerHelper(recyclerView).apply {
            setPageChangeListener { position ->
                updateIndicator(position)
            }
        }

        // 初期表示
        pagerHelper.scrollToPage(0)

        // インジケーター初期化
        val pageCount = layoutManager.getPageCount(recyclerView.state)
        setupIndicatorLayout(pageCount, indicatorLayout)
    }

    private fun setupIndicatorLayout(pageCount: Int, container: LinearLayout) {
        val params = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.WRAP_CONTENT,
            LinearLayout.LayoutParams.WRAP_CONTENT
        ).apply {
            setMargins(16, 0, 16, 0)
        }

        indicatorDots = Array(pageCount) { index ->
            ImageView(this).apply {
                layoutParams = params
                setImageResource(R.drawable.indicator_dot)
                isSelected = index == 0
            }.also { container.addView(it) }
        }
    }

    private fun updateIndicator(currentPage: Int) {
        indicatorDots.forEachIndexed { index, view ->
            view.isSelected = index == currentPage
        }
    }
}

レイアウトファイル

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="250dp"/>

    <LinearLayout
        android:id="@+id/indicator_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:orientation="horizontal"
        android:padding="8dp"/>

</LinearLayout>

タグ: Android RecyclerView カスタムレイアウトマネージャー ValueAnimator ページネーション

6月8日 18:57 投稿