Browse Source
[android] Update carousel view (#254)
[android] Update carousel view (#254)
- Cherry picked the patches from xbzk PR. Signed-off-by: Aleksandr Popovich <alekpopo@pm.me> Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/254 Co-authored-by: Aleksandr Popovich <alekpopo@pm.me> Co-committed-by: Aleksandr Popovich <alekpopo@pm.me>pull/262/head
committed by
CamilleLaVey
No known key found for this signature in database
GPG Key ID: BA8734FD0EE46976
12 changed files with 544 additions and 346 deletions
-
2src/android/app/build.gradle.kts
-
26src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
-
6src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/MidScreenSwipeRefreshLayout.kt
-
10src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
-
88src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
-
287src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt
-
409src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt
-
46src/android/app/src/main/res/layout-land/card_game_carousel.xml
-
3src/android/app/src/main/res/layout-land/fragment_games.xml
-
1src/android/app/src/main/res/values/dimens.xml
-
8src/android/app/src/main/res/values/fractions.xml
-
4src/android/app/src/main/res/values/integers.xml
@ -1,287 +0,0 @@ |
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project |
|||
// SPDX-License-Identifier: GPL-3.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.ui |
|||
|
|||
import android.content.Context |
|||
import android.graphics.Rect |
|||
import android.util.AttributeSet |
|||
import android.view.View |
|||
import android.view.KeyEvent |
|||
import androidx.recyclerview.widget.LinearLayoutManager |
|||
import androidx.recyclerview.widget.PagerSnapHelper |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import kotlin.math.abs |
|||
import org.yuzu.yuzu_emu.R |
|||
|
|||
/** |
|||
* JukeboxRecyclerView encapsulates all carousel/grid/list logic for the games UI. |
|||
* It manages overlapping cards, center snapping, custom drawing order, and mid-screen swipe-to-refresh. |
|||
* Use setCarouselMode(enabled, overlapPx) to toggle carousel features. |
|||
*/ |
|||
class JukeboxRecyclerView @JvmOverloads constructor( |
|||
context: Context, |
|||
attrs: AttributeSet? = null, |
|||
defStyle: Int = 0 |
|||
) : RecyclerView(context, attrs, defStyle) { |
|||
|
|||
// Carousel/overlap/snap state |
|||
private var overlapPx: Int = 0 |
|||
private var overlapDecoration: OverlappingDecoration? = null |
|||
private var pagerSnapHelper: PagerSnapHelper? = null |
|||
|
|||
var flingMultiplier: Float = resources.getFraction(R.fraction.carousel_fling_multiplier, 1, 1) |
|||
|
|||
var useCustomDrawingOrder: Boolean = false |
|||
set(value) { |
|||
field = value |
|||
setChildrenDrawingOrderEnabled(value) |
|||
invalidate() |
|||
} |
|||
|
|||
init { |
|||
setChildrenDrawingOrderEnabled(true) |
|||
} |
|||
|
|||
/** |
|||
* Returns the horizontal center given width and paddings. |
|||
*/ |
|||
private fun calculateCenter(width: Int, paddingStart: Int, paddingEnd: Int): Int { |
|||
return paddingStart + (width - paddingStart - paddingEnd) / 2 |
|||
} |
|||
|
|||
/** |
|||
* Returns the horizontal center of this RecyclerView, accounting for padding. |
|||
*/ |
|||
private fun getRecyclerViewCenter(): Float { |
|||
return calculateCenter(width, paddingLeft, paddingRight).toFloat() |
|||
} |
|||
|
|||
/** |
|||
* Returns the horizontal center of a LayoutManager, accounting for padding. |
|||
*/ |
|||
private fun getLayoutManagerCenter(layoutManager: RecyclerView.LayoutManager): Int { |
|||
return if (layoutManager is LinearLayoutManager) { |
|||
calculateCenter(layoutManager.width, layoutManager.paddingStart, layoutManager.paddingEnd) |
|||
} else { |
|||
width / 2 |
|||
} |
|||
} |
|||
|
|||
private fun updateChildScalesAndAlpha() { |
|||
val center = getRecyclerViewCenter() |
|||
|
|||
for (i in 0 until childCount) { |
|||
val child = getChildAt(i) |
|||
val childCenter = (child.left + child.right) / 2f |
|||
val distance = abs(center - childCenter) |
|||
val minScale = resources.getFraction(R.fraction.carousel_min_scale, 1, 1) |
|||
val scale = minScale + (1f - minScale) * (1f - distance / center).coerceAtMost(1f) |
|||
child.scaleX = scale |
|||
child.scaleY = scale |
|||
|
|||
val maxDistance = width / 2f |
|||
val norm = (distance / maxDistance).coerceIn(0f, 1f) |
|||
val minAlpha = resources.getFraction(R.fraction.carousel_min_alpha, 1, 1) |
|||
val alpha = minAlpha + (1f - minAlpha) * kotlin.math.cos(norm * Math.PI).toFloat() |
|||
child.alpha = alpha |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Enable or disable carousel mode. |
|||
* When enabled, applies overlap, snap, and custom drawing order. |
|||
*/ |
|||
fun setCarouselMode(enabled: Boolean, overlapPx: Int = 0, cardSize: Int = 0) { |
|||
this.overlapPx = overlapPx |
|||
if (enabled) { |
|||
// Add overlap decoration if not present |
|||
if (overlapDecoration == null) { |
|||
overlapDecoration = OverlappingDecoration(overlapPx) |
|||
addItemDecoration(overlapDecoration!!) |
|||
} |
|||
// Attach PagerSnapHelper |
|||
if (pagerSnapHelper == null) { |
|||
pagerSnapHelper = CenterPagerSnapHelper() |
|||
pagerSnapHelper!!.attachToRecyclerView(this) |
|||
} |
|||
useCustomDrawingOrder = true |
|||
flingMultiplier = resources.getFraction(R.fraction.carousel_fling_multiplier, 1, 1) |
|||
|
|||
// Center first/last card |
|||
post { |
|||
if (cardSize > 0) { |
|||
val sidePadding = (width - cardSize) / 2 |
|||
setPadding(sidePadding, 0, sidePadding, 0) |
|||
clipToPadding = false |
|||
} |
|||
} |
|||
// Handle bottom insets for keyboard/navigation bar only |
|||
androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> |
|||
val imeInset = insets.getInsets(androidx.core.view.WindowInsetsCompat.Type.ime()).bottom |
|||
val navInset = insets.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom |
|||
// Only adjust bottom padding, keep top at 0 |
|||
view.setPadding(view.paddingLeft, 0, view.paddingRight, maxOf(imeInset, navInset)) |
|||
insets |
|||
} |
|||
} else { |
|||
// Remove overlap decoration |
|||
overlapDecoration?.let { removeItemDecoration(it) } |
|||
overlapDecoration = null |
|||
// Detach PagerSnapHelper |
|||
pagerSnapHelper?.attachToRecyclerView(null) |
|||
pagerSnapHelper = null |
|||
useCustomDrawingOrder = false |
|||
// Reset padding and fling |
|||
setPadding(0, 0, 0, 0) |
|||
clipToPadding = true |
|||
flingMultiplier = 1.0f |
|||
// Reset scaling |
|||
for (i in 0 until childCount) { |
|||
val child = getChildAt(i) |
|||
child?.scaleX = 1f |
|||
child?.scaleY = 1f |
|||
} |
|||
} |
|||
} |
|||
|
|||
// trap past boundaries navigation |
|||
override fun focusSearch(focused: View, direction: Int): View? { |
|||
val lm = layoutManager as? LinearLayoutManager ?: return super.focusSearch(focused, direction) |
|||
val vh = findContainingViewHolder(focused) ?: return super.focusSearch(focused, direction) |
|||
val position = vh.bindingAdapterPosition |
|||
val itemCount = adapter?.itemCount ?: return super.focusSearch(focused, direction) |
|||
|
|||
return when (direction) { |
|||
View.FOCUS_LEFT -> { |
|||
if (position > 0) { |
|||
findViewHolderForAdapterPosition(position - 1)?.itemView ?: super.focusSearch(focused, direction) |
|||
} else { |
|||
focused |
|||
} |
|||
} |
|||
View.FOCUS_RIGHT -> { |
|||
if (position < itemCount - 1) { |
|||
findViewHolderForAdapterPosition(position + 1)?.itemView ?: super.focusSearch(focused, direction) |
|||
} else { |
|||
focused |
|||
} |
|||
} |
|||
else -> super.focusSearch(focused, direction) |
|||
} |
|||
} |
|||
|
|||
// Custom fling multiplier for carousel |
|||
override fun fling(velocityX: Int, velocityY: Int): Boolean { |
|||
val newVelocityX = (velocityX * flingMultiplier).toInt() |
|||
val newVelocityY = (velocityY * flingMultiplier).toInt() |
|||
return super.fling(newVelocityX, newVelocityY) |
|||
} |
|||
|
|||
private var scaleUpdatePosted = false |
|||
// Custom drawing order for carousel (for alpha fade) |
|||
override fun getChildDrawingOrder(childCount: Int, i: Int): Int { |
|||
if (!useCustomDrawingOrder || childCount == 0) return i |
|||
val center = getRecyclerViewCenter() |
|||
val children = (0 until childCount).map { idx -> |
|||
val child = getChildAt(idx) |
|||
val childCenter = (child.left + child.right) / 2f |
|||
val distance = abs(childCenter - center) |
|||
Pair(idx, distance) |
|||
} |
|||
val sorted = children.sortedWith( |
|||
compareByDescending<Pair<Int, Float>> { it.second } |
|||
.thenBy { it.first } |
|||
) |
|||
// Post scale update once per frame |
|||
if (!scaleUpdatePosted && i == childCount - 1) { |
|||
scaleUpdatePosted = true |
|||
post { |
|||
updateChildScalesAndAlpha() |
|||
scaleUpdatePosted = false |
|||
} |
|||
} |
|||
//Log.d("JukeboxRecyclerView", "Child $i got order ${sorted[i].first} at distance ${sorted[i].second} from center $center") |
|||
return sorted[i].first |
|||
} |
|||
|
|||
// --- OverlappingDecoration (inner class) --- |
|||
inner class OverlappingDecoration(private val overlapPx: Int) : ItemDecoration() { |
|||
override fun getItemOffsets( |
|||
outRect: Rect, view: View, parent: RecyclerView, state: State |
|||
) { |
|||
val position = parent.getChildAdapterPosition(view) |
|||
if (position > 0) { |
|||
outRect.left = -overlapPx |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Enable proper center snapping |
|||
inner class CenterPagerSnapHelper : PagerSnapHelper() { |
|||
|
|||
// NEEDED: fixes center snapping, but introduces ghost movement |
|||
override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? { |
|||
if (layoutManager !is LinearLayoutManager) return null |
|||
val center = (this@JukeboxRecyclerView).getLayoutManagerCenter(layoutManager) |
|||
var minDistance = Int.MAX_VALUE |
|||
var closestChild: View? = null |
|||
for (i in 0 until layoutManager.childCount) { |
|||
val child = layoutManager.getChildAt(i) ?: continue |
|||
val childCenter = (child.left + child.right) / 2 |
|||
val distance = kotlin.math.abs(childCenter - center) |
|||
if (distance < minDistance) { |
|||
minDistance = distance |
|||
closestChild = child |
|||
} |
|||
} |
|||
return closestChild |
|||
} |
|||
|
|||
//NEEDED: fixes ghost movement when snapping, but breaks inertial scrolling |
|||
override fun calculateDistanceToFinalSnap( |
|||
layoutManager: RecyclerView.LayoutManager, |
|||
targetView: View |
|||
): IntArray? { |
|||
if (layoutManager !is LinearLayoutManager) return super.calculateDistanceToFinalSnap(layoutManager, targetView) |
|||
val out = IntArray(2) |
|||
val center = (this@JukeboxRecyclerView).getLayoutManagerCenter(layoutManager) |
|||
val childCenter = (targetView.left + targetView.right) / 2 |
|||
out[0] = childCenter - center |
|||
out[1] = 0 |
|||
return out |
|||
} |
|||
|
|||
// NEEDED: fixes inertial scrolling (broken by calculateDistanceToFinalSnap) |
|||
override fun findTargetSnapPosition( |
|||
layoutManager: RecyclerView.LayoutManager, |
|||
velocityX: Int, |
|||
velocityY: Int |
|||
): Int { |
|||
if (layoutManager !is LinearLayoutManager) return RecyclerView.NO_POSITION |
|||
val firstVisible = layoutManager.findFirstVisibleItemPosition() |
|||
val lastVisible = layoutManager.findLastVisibleItemPosition() |
|||
val center = (this@JukeboxRecyclerView).getLayoutManagerCenter(layoutManager) |
|||
|
|||
var closestChild: View? = null |
|||
var minDistance = Int.MAX_VALUE |
|||
var closestPosition = RecyclerView.NO_POSITION |
|||
for (i in firstVisible..lastVisible) { |
|||
val child = layoutManager.findViewByPosition(i) ?: continue |
|||
val childCenter = (child.left + child.right) / 2 |
|||
val distance = kotlin.math.abs(childCenter - center) |
|||
if (distance < minDistance) { |
|||
minDistance = distance |
|||
closestChild = child |
|||
closestPosition = i |
|||
} |
|||
} |
|||
|
|||
val flingCount = if (velocityX == 0) 0 else velocityX / 2000 |
|||
var targetPos = closestPosition + flingCount |
|||
val itemCount = layoutManager.itemCount |
|||
targetPos = targetPos.coerceIn(0, itemCount - 1) |
|||
return targetPos |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,409 @@ |
|||
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project |
|||
// SPDX-License-Identifier: GPL-3.0-or-later |
|||
|
|||
package org.yuzu.yuzu_emu.ui |
|||
|
|||
import android.content.Context |
|||
import android.graphics.Rect |
|||
import android.util.AttributeSet |
|||
import android.view.View |
|||
import androidx.recyclerview.widget.LinearLayoutManager |
|||
import androidx.recyclerview.widget.PagerSnapHelper |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import kotlin.math.abs |
|||
import org.yuzu.yuzu_emu.R |
|||
import org.yuzu.yuzu_emu.adapters.GameAdapter |
|||
import androidx.core.view.doOnNextLayout |
|||
import org.yuzu.yuzu_emu.YuzuApplication |
|||
import androidx.preference.PreferenceManager |
|||
|
|||
/** |
|||
* CarouselRecyclerView encapsulates all carousel logic for the games UI. |
|||
* It manages overlapping cards, center snapping, custom drawing order, |
|||
* joypad & fling navigation and mid-screen swipe-to-refresh. |
|||
*/ |
|||
class CarouselRecyclerView @JvmOverloads constructor( |
|||
context: Context, |
|||
attrs: AttributeSet? = null, |
|||
defStyle: Int = 0 |
|||
) : RecyclerView(context, attrs, defStyle) { |
|||
|
|||
private var overlapFactor: Float = 0f |
|||
private var overlapPx: Int = 0 |
|||
private var overlapDecoration: OverlappingDecoration? = null |
|||
private var pagerSnapHelper: PagerSnapHelper? = null |
|||
private var scalingScrollListener: OnScrollListener? = null |
|||
|
|||
companion object { |
|||
private const val CAROUSEL_CARD_SIZE_FACTOR = "CarouselCardSizeMultiplier" |
|||
private const val CAROUSEL_BORDERCARDS_SCALE = "CarouselBorderCardsScale" |
|||
private const val CAROUSEL_BORDERCARDS_ALPHA = "CarouselBorderCardsAlpha" |
|||
private const val CAROUSEL_OVERLAP_FACTOR = "CarouselOverlapFactor" |
|||
private const val CAROUSEL_MAX_FLING_COUNT = "CarouselMaxFlingCount" |
|||
private const val CAROUSEL_FLING_MULTIPLIER = "CarouselFlingMultiplier" |
|||
private const val CAROUSEL_CARDS_SCALING_SHAPE = "CarouselCardsScalingShape" |
|||
private const val CAROUSEL_CARDS_ALPHA_SHAPE = "CarouselCardsAlphaShape" |
|||
const val CAROUSEL_LAST_SCROLL_POSITION = "CarouselLastScrollPosition" |
|||
const val CAROUSEL_VIEW_TYPE_PORTRAIT = "GamesViewTypePortrait" |
|||
const val CAROUSEL_VIEW_TYPE_LANDSCAPE = "GamesViewTypeLandscape" |
|||
} |
|||
|
|||
private val preferences = |
|||
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) |
|||
|
|||
var flingMultiplier: Float = 1f |
|||
|
|||
public var pendingScrollAfterReload: Boolean = false |
|||
|
|||
var useCustomDrawingOrder: Boolean = false |
|||
set(value) { |
|||
field = value |
|||
setChildrenDrawingOrderEnabled(value) |
|||
invalidate() |
|||
} |
|||
|
|||
init { |
|||
setChildrenDrawingOrderEnabled(true) |
|||
} |
|||
|
|||
private fun calculateCenter(width: Int, paddingStart: Int, paddingEnd: Int): Int { |
|||
return paddingStart + (width - paddingStart - paddingEnd) / 2 |
|||
} |
|||
|
|||
private fun getRecyclerViewCenter(): Float { |
|||
return calculateCenter(width, paddingLeft, paddingRight).toFloat() |
|||
} |
|||
|
|||
private fun getLayoutManagerCenter(layoutManager: RecyclerView.LayoutManager): Int { |
|||
return if (layoutManager is LinearLayoutManager) { |
|||
calculateCenter(layoutManager.width, layoutManager.paddingStart, layoutManager.paddingEnd) |
|||
} else { |
|||
width / 2 |
|||
} |
|||
} |
|||
|
|||
private fun getChildDistanceToCenter(view: View): Float { |
|||
return 0.5f * (view.left + view.right) - getRecyclerViewCenter() |
|||
} |
|||
|
|||
fun restoreScrollState(position: Int = 0, attempts: Int = 0) { |
|||
val lm = layoutManager as? LinearLayoutManager ?: return |
|||
if (lm.findLastVisibleItemPosition() == RecyclerView.NO_POSITION && attempts < 10) { |
|||
post { restoreScrollState(position, attempts + 1) } |
|||
return |
|||
} |
|||
scrollToPosition(position) |
|||
} |
|||
|
|||
fun getClosestChildPosition(fullRange: Boolean = false): Int { |
|||
val lm = layoutManager as? LinearLayoutManager ?: return RecyclerView.NO_POSITION |
|||
var minDistance = Int.MAX_VALUE |
|||
var closestPosition = RecyclerView.NO_POSITION |
|||
val start = if (fullRange) 0 else lm.findFirstVisibleItemPosition() |
|||
val end = if (fullRange) lm.childCount - 1 else lm.findLastVisibleItemPosition() |
|||
for (i in start..end) { |
|||
val child = lm.findViewByPosition(i) ?: continue |
|||
val distance = kotlin.math.abs(getChildDistanceToCenter(child).toInt()) |
|||
if (distance < minDistance) { |
|||
minDistance = distance |
|||
closestPosition = i |
|||
} |
|||
} |
|||
return closestPosition |
|||
} |
|||
|
|||
fun updateChildScalesAndAlpha() { |
|||
for (i in 0 until childCount) { |
|||
val child = getChildAt(i) ?: continue |
|||
updateChildScaleAndAlphaForPosition(child) |
|||
} |
|||
} |
|||
|
|||
fun shapingFunction(x: Float, option: Int = 0): Float { |
|||
return when (option) { |
|||
0 -> 1f //Off |
|||
1 -> 1f - x //linear descending |
|||
2 -> (1f - x) * (1f - x) //Ease out |
|||
3 -> if (x < 0.05f) 1f else (1f-x) * 0.8f |
|||
4 -> kotlin.math.cos(x * Math.PI).toFloat() //Cosine |
|||
5 -> kotlin.math.cos( (1.5f * x).coerceIn(0f, 1f) * Math.PI).toFloat() //Cosine 1.5x trimmed |
|||
else -> 1f //Default to Off |
|||
} |
|||
} |
|||
|
|||
fun updateChildScaleAndAlphaForPosition(child: View) { |
|||
val cardSize = (adapter as? GameAdapter ?: return).cardSize |
|||
val position = getChildViewHolder(child).bindingAdapterPosition |
|||
if (position == RecyclerView.NO_POSITION || cardSize <= 0) { |
|||
return // No valid position or card size |
|||
} |
|||
child.layoutParams.width = cardSize |
|||
child.layoutParams.height = cardSize |
|||
|
|||
val center = getRecyclerViewCenter() |
|||
val distance = abs(getChildDistanceToCenter(child)) |
|||
val internalBorderScale = resources.getFraction(R.fraction.carousel_bordercards_scale, 1, 1) |
|||
val borderScale = preferences.getFloat(CAROUSEL_BORDERCARDS_SCALE, internalBorderScale).coerceIn(0f, 1f) |
|||
|
|||
val shapeInput = (distance / center).coerceIn(0f, 1f) |
|||
val internalShapeSetting = resources.getInteger(R.integer.carousel_cards_scaling_shape) |
|||
val scalingShapeSetting = preferences.getInt(CAROUSEL_CARDS_SCALING_SHAPE, internalShapeSetting) |
|||
val shapedScaling = shapingFunction(shapeInput, scalingShapeSetting) |
|||
val scale = (borderScale + (1f - borderScale) * shapedScaling).coerceIn(0f, 1f) |
|||
|
|||
val maxDistance = width / 2f |
|||
val alphaInput = (distance / maxDistance).coerceIn(0f, 1f) |
|||
val internalBordersAlpha = resources.getFraction(R.fraction.carousel_bordercards_alpha, 1, 1) |
|||
val borderAlpha = preferences.getFloat(CAROUSEL_BORDERCARDS_ALPHA, internalBordersAlpha).coerceIn(0f, 1f) |
|||
val internalAlphaShapeSetting = resources.getInteger(R.integer.carousel_cards_alpha_shape) |
|||
val alphaShapeSetting = preferences.getInt(CAROUSEL_CARDS_ALPHA_SHAPE, internalAlphaShapeSetting) |
|||
val shapedAlpha = shapingFunction(alphaInput, alphaShapeSetting) |
|||
val alpha = (borderAlpha + (1f - borderAlpha) * shapedAlpha).coerceIn(0f, 1f) |
|||
|
|||
child.animate().cancel() |
|||
child.alpha = alpha |
|||
child.scaleX = scale |
|||
child.scaleY = scale |
|||
} |
|||
|
|||
fun focusCenteredCard() { |
|||
val centeredPos = getClosestChildPosition() |
|||
if (centeredPos != RecyclerView.NO_POSITION) { |
|||
val vh = findViewHolderForAdapterPosition(centeredPos) |
|||
vh?.itemView?.let { child -> |
|||
child.isFocusable = true |
|||
child.isFocusableInTouchMode = true |
|||
child.requestFocus() |
|||
} |
|||
} |
|||
} |
|||
|
|||
fun setCarouselMode(enabled: Boolean, gameAdapter: GameAdapter? = null) { |
|||
if (enabled) { |
|||
useCustomDrawingOrder = true |
|||
|
|||
val insets = rootWindowInsets |
|||
val bottomInset = insets?.getInsets(android.view.WindowInsets.Type.systemBars())?.bottom ?: 0 |
|||
val internalFactor = resources.getFraction(R.fraction.carousel_card_size_factor, 1, 1) |
|||
val userFactor = preferences.getFloat(CAROUSEL_CARD_SIZE_FACTOR, internalFactor).coerceIn(0f, 1f) |
|||
val cardSize = (userFactor * (height - bottomInset)).toInt() |
|||
gameAdapter?.setCardSize(cardSize) |
|||
|
|||
val internalOverlapFactor = resources.getFraction(R.fraction.carousel_overlap_factor, 1, 1) |
|||
overlapFactor = preferences.getFloat(CAROUSEL_OVERLAP_FACTOR, internalOverlapFactor).coerceIn(0f, 1f) |
|||
overlapPx = (cardSize * overlapFactor).toInt() |
|||
|
|||
val internalFlingMultiplier = resources.getFraction(R.fraction.carousel_fling_multiplier, 1, 1) |
|||
flingMultiplier = preferences.getFloat(CAROUSEL_FLING_MULTIPLIER, internalFlingMultiplier).coerceIn(1f, 5f) |
|||
|
|||
gameAdapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { |
|||
override fun onChanged() { |
|||
if (pendingScrollAfterReload) { |
|||
post { |
|||
jigglyScroll() |
|||
pendingScrollAfterReload = false |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
|
|||
// Detach SnapHelper during setup |
|||
pagerSnapHelper?.attachToRecyclerView(null) |
|||
|
|||
// Add overlap decoration if not present |
|||
if (overlapDecoration == null) { |
|||
overlapDecoration = OverlappingDecoration(overlapPx) |
|||
addItemDecoration(overlapDecoration!!) |
|||
} |
|||
|
|||
// Gradual scalingAdd commentMore actions |
|||
if (scalingScrollListener == null) { |
|||
scalingScrollListener = object : OnScrollListener() { |
|||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { |
|||
super.onScrolled(recyclerView, dx, dy) |
|||
updateChildScalesAndAlpha() |
|||
} |
|||
} |
|||
addOnScrollListener(scalingScrollListener!!) |
|||
} |
|||
|
|||
if (cardSize > 0) { |
|||
val topPadding = ((height - bottomInset - cardSize) / 2).coerceAtLeast(0) // Center vertically |
|||
val sidePadding = (width - cardSize) / 2 // Center first/last card |
|||
setPadding(sidePadding, topPadding, sidePadding, 0) |
|||
clipToPadding = false |
|||
} |
|||
|
|||
if (pagerSnapHelper == null) { |
|||
pagerSnapHelper = CenterPagerSnapHelper() |
|||
pagerSnapHelper!!.attachToRecyclerView(this) |
|||
} |
|||
} else { |
|||
// Remove overlap decoration |
|||
overlapDecoration?.let { removeItemDecoration(it) } |
|||
overlapDecoration = null |
|||
// Remove scaling scroll listener |
|||
scalingScrollListener?.let { removeOnScrollListener(it) } |
|||
scalingScrollListener = null |
|||
// Detach PagerSnapHelper |
|||
pagerSnapHelper?.attachToRecyclerView(null) |
|||
pagerSnapHelper = null |
|||
useCustomDrawingOrder = false |
|||
// Reset padding and fling |
|||
setPadding(0, 0, 0, 0) |
|||
clipToPadding = true |
|||
flingMultiplier = 1f |
|||
// Reset scaling |
|||
for (i in 0 until childCount) { |
|||
val child = getChildAt(i) |
|||
child?.scaleX = 1f |
|||
child?.scaleY = 1f |
|||
child?.alpha = 1f |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onScrollStateChanged(state: Int) { |
|||
super.onScrollStateChanged(state) |
|||
if (state == RecyclerView.SCROLL_STATE_IDLE) { |
|||
focusCenteredCard() |
|||
} |
|||
} |
|||
|
|||
override fun scrollToPosition(position: Int) { |
|||
super.scrollToPosition(position) |
|||
(layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(position, overlapPx) |
|||
doOnNextLayout { |
|||
updateChildScalesAndAlpha() |
|||
focusCenteredCard() |
|||
} |
|||
} |
|||
|
|||
private var lastFocusSearchTime: Long = 0 |
|||
override fun focusSearch(focused: View, direction: Int): View? { |
|||
if (layoutManager !is LinearLayoutManager) return super.focusSearch(focused, direction) |
|||
val vh = findContainingViewHolder(focused) ?: return super.focusSearch(focused, direction) |
|||
val itemCount = adapter?.itemCount ?: return super.focusSearch(focused, direction) |
|||
val position = vh.bindingAdapterPosition |
|||
|
|||
return when (direction) { |
|||
View.FOCUS_LEFT -> { |
|||
if (position > 0) { |
|||
val now = System.currentTimeMillis() |
|||
val repeatDetected = (now - lastFocusSearchTime) < resources.getInteger(R.integer.carousel_focus_search_repeat_threshold_ms) |
|||
lastFocusSearchTime = now |
|||
if (!repeatDetected) { //ensures the first run |
|||
val offset = focused.width - overlapPx |
|||
smoothScrollBy(-offset, 0) |
|||
} |
|||
findViewHolderForAdapterPosition(position - 1)?.itemView ?: super.focusSearch(focused, direction) |
|||
} else { |
|||
focused |
|||
} |
|||
} |
|||
View.FOCUS_RIGHT -> { |
|||
if (position < itemCount - 1) { |
|||
findViewHolderForAdapterPosition(position + 1)?.itemView ?: super.focusSearch(focused, direction) |
|||
} else { |
|||
focused |
|||
} |
|||
} |
|||
else -> super.focusSearch(focused, direction) |
|||
} |
|||
} |
|||
|
|||
// Custom fling multiplier for carousel |
|||
override fun fling(velocityX: Int, velocityY: Int): Boolean { |
|||
val newVelocityX = (velocityX * flingMultiplier).toInt() |
|||
val newVelocityY = (velocityY * flingMultiplier).toInt() |
|||
return super.fling(newVelocityX, newVelocityY) |
|||
} |
|||
|
|||
// Custom drawing order for carousel (for alpha fade) |
|||
override fun getChildDrawingOrder(childCount: Int, i: Int): Int { |
|||
if (!useCustomDrawingOrder || childCount == 0) return i |
|||
val children = (0 until childCount).map { idx -> |
|||
val distance = abs(getChildDistanceToCenter(getChildAt(idx))) |
|||
Pair(idx, distance) |
|||
} |
|||
val sorted = children.sortedWith( |
|||
compareByDescending<Pair<Int, Float>> { it.second } |
|||
.thenBy { it.first } |
|||
) |
|||
return sorted[i].first |
|||
} |
|||
|
|||
fun jigglyScroll() { |
|||
scrollBy(-1, 0) |
|||
scrollBy(1, 0) |
|||
focusCenteredCard() |
|||
} |
|||
|
|||
inner class OverlappingDecoration(private val overlap: Int) : ItemDecoration() { |
|||
override fun getItemOffsets( |
|||
outRect: Rect, view: View, parent: RecyclerView, state: State |
|||
) { |
|||
val position = parent.getChildAdapterPosition(view) |
|||
if (position > 0) { |
|||
outRect.left = -overlap |
|||
} |
|||
} |
|||
} |
|||
|
|||
inner class VerticalCenterDecoration : ItemDecoration() { |
|||
override fun getItemOffsets( |
|||
outRect: android.graphics.Rect, |
|||
view: View, |
|||
parent: RecyclerView, |
|||
state: RecyclerView.State |
|||
) { |
|||
val parentHeight = parent.height |
|||
val childHeight = view.layoutParams.height.takeIf { it > 0 } |
|||
?: view.measuredHeight.takeIf { it > 0 } |
|||
?: view.height |
|||
|
|||
if (parentHeight > 0 && childHeight > 0) { |
|||
val verticalPadding = ((parentHeight - childHeight) / 2).coerceAtLeast(0) |
|||
outRect.top = verticalPadding |
|||
outRect.bottom = verticalPadding |
|||
} |
|||
} |
|||
} |
|||
|
|||
inner class CenterPagerSnapHelper : PagerSnapHelper() { |
|||
|
|||
// NEEDED: fixes center snapping, but introduces ghost movement |
|||
override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? { |
|||
if (layoutManager !is LinearLayoutManager) return null |
|||
return layoutManager.findViewByPosition(getClosestChildPosition()) |
|||
} |
|||
|
|||
//NEEDED: fixes ghost movement when snapping, but breaks inertial scrolling |
|||
override fun calculateDistanceToFinalSnap( |
|||
layoutManager: RecyclerView.LayoutManager, |
|||
targetView: View |
|||
): IntArray? { |
|||
if (layoutManager !is LinearLayoutManager) return super.calculateDistanceToFinalSnap(layoutManager, targetView) |
|||
val out = IntArray(2) |
|||
out[0] = getChildDistanceToCenter(targetView).toInt() |
|||
out[1] = 0 |
|||
return out |
|||
} |
|||
|
|||
// NEEDED: fixes inertial scrolling (broken by calculateDistanceToFinalSnap) |
|||
override fun findTargetSnapPosition( |
|||
layoutManager: RecyclerView.LayoutManager, |
|||
velocityX: Int, |
|||
velocityY: Int |
|||
): Int { |
|||
if (layoutManager !is LinearLayoutManager) return RecyclerView.NO_POSITION |
|||
val closestPosition = this@CarouselRecyclerView.getClosestChildPosition() |
|||
val internalMaxFling = resources.getInteger(R.integer.carousel_max_fling_count) |
|||
val maxFling = preferences.getInt(CAROUSEL_MAX_FLING_COUNT, internalMaxFling).coerceIn(1, 10) |
|||
val rawFlingCount = if (velocityX == 0) 0 else velocityX / 2000 |
|||
val flingCount = rawFlingCount.coerceIn(-maxFling, maxFling) |
|||
var targetPos = (closestPosition + flingCount).coerceIn(0, layoutManager.itemCount - 1) |
|||
return targetPos |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
<com.google.android.material.card.MaterialCardView |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
android:id="@+id/card_game_carousel" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="match_parent" |
|||
app:cardCornerRadius="8dp" |
|||
app:cardElevation="4dp" |
|||
android:layout_margin="0dp" |
|||
app:strokeColor="@android:color/transparent" |
|||
app:strokeWidth="0dp" |
|||
android:alpha="0"> |
|||
|
|||
<androidx.constraintlayout.widget.ConstraintLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:padding="4dp"> |
|||
|
|||
<ImageView |
|||
android:id="@+id/image_game_screen" |
|||
android:layout_width="0dp" |
|||
android:layout_height="0dp" |
|||
android:scaleType="centerCrop" |
|||
android:contentDescription="@string/game_image_desc" |
|||
app:layout_constraintTop_toTopOf="parent" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintBottom_toTopOf="@+id/text_game_title" |
|||
/> |
|||
|
|||
<com.google.android.material.textview.MaterialTextView |
|||
android:id="@+id/text_game_title" |
|||
style="@style/TextAppearance.Material3.TitleMedium" |
|||
android:layout_width="0dp" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="0dp" |
|||
android:requiresFadingEdge="horizontal" |
|||
android:textAlignment="center" |
|||
app:layout_constraintTop_toBottomOf="@+id/image_game_screen" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintBottom_toBottomOf="parent" |
|||
android:text="Game Title" /> |
|||
|
|||
</androidx.constraintlayout.widget.ConstraintLayout> |
|||
</com.google.android.material.card.MaterialCardView> |
|||
@ -1,6 +1,8 @@ |
|||
<resources> |
|||
<fraction name="carousel_min_scale">60%</fraction> |
|||
<fraction name="carousel_min_alpha">60%</fraction> |
|||
<fraction name="carousel_bordercards_scale">60%</fraction> |
|||
<fraction name="carousel_bordercards_alpha">60%</fraction> |
|||
<fraction name="carousel_overlap_factor">60%</fraction> |
|||
<fraction name="carousel_card_size_factor">95%</fraction> |
|||
<fraction name="carousel_fling_multiplier">200%</fraction> |
|||
<fraction name="carousel_card_size_multiplier">100%</fraction> |
|||
<fraction name="carousel_midscreenswipe_width_fraction">20%</fraction> |
|||
</resources> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue