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> |
<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_fling_multiplier">200%</fraction> |
||||
<fraction name="carousel_card_size_multiplier">100%</fraction> |
|
||||
|
<fraction name="carousel_midscreenswipe_width_fraction">20%</fraction> |
||||
</resources> |
</resources> |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue