実装概要
この記事では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>