aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAleksandr Popovich <alekpopo@pm.me>2025-07-04 21:37:15 +0000
committerCamilleLaVey <camillelavey@noreply.localhost>2025-07-04 21:37:15 +0000
commit39a46d755f103579a1ef19cd5fbda2916fd334c1 (patch)
tree8d33635e1fe5617e7a27cc46e95d673fae61b2c6 /src
parentb60d0aabf0bddcc0866577d0de2d6a39efdd896b (diff)
[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>
Diffstat (limited to 'src')
-rw-r--r--src/android/app/build.gradle.kts2
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt26
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/MidScreenSwipeRefreshLayout.kt (renamed from src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt)6
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt10
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt88
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt287
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt409
-rw-r--r--src/android/app/src/main/res/layout-land/card_game_carousel.xml46
-rw-r--r--src/android/app/src/main/res/layout-land/fragment_games.xml3
-rw-r--r--src/android/app/src/main/res/values/dimens.xml1
-rw-r--r--src/android/app/src/main/res/values/fractions.xml8
-rw-r--r--src/android/app/src/main/res/values/integers.xml4
12 files changed, 544 insertions, 346 deletions
diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts
index b235698b22..5a65a28baf 100644
--- a/src/android/app/build.gradle.kts
+++ b/src/android/app/build.gradle.kts
@@ -127,7 +127,7 @@ android {
applicationIdSuffix = ".relWithDebInfo"
isJniDebuggable = true
}
-
+
// Signed by debug key disallowing distribution on Play Store.
// Attaches 'debug' suffix to version and package name, allowing installation alongside the release build.
debug {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
index c5fc6ff168..750e8f4729 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
@@ -32,6 +32,7 @@ import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.GameIconUtils
import org.yuzu.yuzu_emu.utils.ViewUtils.marquee
import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
+import androidx.recyclerview.widget.RecyclerView
class GameAdapter(private val activity: AppCompatActivity) :
AbstractDiffAdapter<Game, GameAdapter.GameViewHolder>(exact = false) {
@@ -49,7 +50,7 @@ class GameAdapter(private val activity: AppCompatActivity) :
notifyDataSetChanged()
}
- var cardSize: Int = 0
+ public var cardSize: Int = 0
private set
fun setCardSize(size: Int) {
@@ -63,7 +64,6 @@ class GameAdapter(private val activity: AppCompatActivity) :
override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
- // Always reset scale/alpha for recycled views
when (getItemViewType(position)) {
VIEW_TYPE_LIST -> {
val listBinding = holder.binding as CardGameListBinding
@@ -85,14 +85,9 @@ class GameAdapter(private val activity: AppCompatActivity) :
}
VIEW_TYPE_CAROUSEL -> {
val carouselBinding = holder.binding as CardGameCarouselBinding
- carouselBinding.cardGameCarousel.scaleX = 1f
- carouselBinding.cardGameCarousel.scaleY = 1f
+ //soothens transient flickering
+ carouselBinding.cardGameCarousel.scaleY = 0f
carouselBinding.cardGameCarousel.alpha = 0f
- // Set square size for carousel
- if (cardSize > 0) {
- carouselBinding.root.layoutParams.width = cardSize
- carouselBinding.root.layoutParams.height = cardSize
- }
}
}
}
@@ -158,16 +153,6 @@ class GameAdapter(private val activity: AppCompatActivity) :
private fun bindCarouselView(model: Game) {
val carouselBinding = binding as CardGameCarouselBinding
- // Remove padding from the root LinearLayout
- (carouselBinding.root.getChildAt(0) as? LinearLayout)?.setPadding(0, 0, 0, 0)
-
- // Always set square size and remove margins for carousel
- val params = carouselBinding.root.layoutParams
- params.width = cardSize
- params.height = cardSize
- if (params is ViewGroup.MarginLayoutParams) params.setMargins(0, 0, 0, 0)
- carouselBinding.root.layoutParams = params
-
carouselBinding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
GameIconUtils.loadGameIcon(model, carouselBinding.imageGameScreen)
@@ -178,6 +163,9 @@ class GameAdapter(private val activity: AppCompatActivity) :
carouselBinding.imageGameScreen.contentDescription =
binding.root.context.getString(R.string.game_image_desc, model.title)
+
+ // Ensure zero-heighted-full-width cards for carousel
+ carouselBinding.root.layoutParams.width = cardSize
}
fun onClick(game: Game) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/MidScreenSwipeRefreshLayout.kt
index 74de15205f..649bea9d54 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/MidScreenSwipeRefreshLayout.kt
@@ -3,6 +3,7 @@
package org.yuzu.yuzu_emu.ui
+import org.yuzu.yuzu_emu.R
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
@@ -21,8 +22,9 @@ class MidScreenSwipeRefreshLayout @JvmOverloads constructor(
MotionEvent.ACTION_DOWN -> {
startX = ev.x
val width = width
- val leftBound = width / 3
- val rightBound = width * 2 / 3
+ val center_fraction = resources.getFraction(R.fraction.carousel_midscreenswipe_width_fraction, 1, 1).coerceIn(0f, 1f)
+ val leftBound = ((1 - center_fraction) / 2) * width
+ val rightBound = leftBound + (width * center_fraction)
allowRefresh = startX >= leftBound && startX <= rightBound
}
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
index 8e4b566766..66f012d1af 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
@@ -42,11 +42,16 @@ class GamesViewModel : ViewModel() {
val searchFocused: StateFlow<Boolean> get() = _searchFocused
private val _searchFocused = MutableStateFlow(false)
+ val shouldScrollAfterReload: StateFlow<Boolean> get() = _shouldScrollAfterReload
+ private val _shouldScrollAfterReload = MutableStateFlow(false)
+
private val _folders = MutableStateFlow(mutableListOf<GameDir>())
val folders = _folders.asStateFlow()
private val _filteredGames = MutableStateFlow<List<Game>>(emptyList())
+ var lastScrollPosition: Int = 0
+
init {
// Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys()
@@ -74,6 +79,10 @@ class GamesViewModel : ViewModel() {
_shouldScrollToTop.value = shouldScroll
}
+ fun setShouldScrollAfterReload(shouldScroll: Boolean) {
+ _shouldScrollAfterReload.value = shouldScroll
+ }
+
fun setSearchFocused(searchFocused: Boolean) {
_searchFocused.value = searchFocused
}
@@ -123,6 +132,7 @@ class GamesViewModel : ViewModel() {
setGames(GameHelper.getGames())
reloading.set(false)
_isReloading.value = false
+ _shouldScrollAfterReload.value = true
if (directoriesChanged) {
setShouldSwapData(true)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
index 38d6ebc7d3..1a74057569 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
@@ -11,6 +11,7 @@ import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.ViewTreeObserver
import android.view.inputmethod.InputMethodManager
import android.widget.ImageButton
import android.widget.PopupMenu
@@ -48,6 +49,8 @@ import java.util.Locale
import androidx.core.content.edit
import androidx.core.view.updateLayoutParams
import org.yuzu.yuzu_emu.features.settings.model.Settings
+import android.view.ViewParent
+import androidx.core.view.doOnNextLayout
class GamesFragment : Fragment() {
private var _binding: FragmentGamesBinding? = null
@@ -62,8 +65,6 @@ class GamesFragment : Fragment() {
companion object {
private const val SEARCH_TEXT = "SearchText"
- private const val PREF_VIEW_TYPE_PORTRAIT = "GamesViewTypePortrait"
- private const val PREF_VIEW_TYPE_LANDSCAPE = "GamesViewTypeLandscape"
private const val PREF_SORT_TYPE = "GamesSortType"
}
@@ -84,14 +85,14 @@ class GamesFragment : Fragment() {
private fun getCurrentViewType(): Int {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
- val key = if (isLandscape) PREF_VIEW_TYPE_LANDSCAPE else PREF_VIEW_TYPE_PORTRAIT
+ val key = if (isLandscape) CarouselRecyclerView.CAROUSEL_VIEW_TYPE_LANDSCAPE else CarouselRecyclerView.CAROUSEL_VIEW_TYPE_PORTRAIT
val fallback = if (isLandscape) GameAdapter.VIEW_TYPE_CAROUSEL else GameAdapter.VIEW_TYPE_GRID
return preferences.getInt(key, fallback)
}
private fun setCurrentViewType(type: Int) {
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
- val key = if (isLandscape) PREF_VIEW_TYPE_LANDSCAPE else PREF_VIEW_TYPE_PORTRAIT
+ val key = if (isLandscape) CarouselRecyclerView.CAROUSEL_VIEW_TYPE_LANDSCAPE else CarouselRecyclerView.CAROUSEL_VIEW_TYPE_PORTRAIT
preferences.edit { putInt(key, type) }
}
override fun onCreateView(
@@ -150,7 +151,9 @@ class GamesFragment : Fragment() {
)
}
gamesViewModel.games.collect(viewLifecycleOwner) {
- setAdapter(it)
+ if (it.size > 0) {
+ setAdapter(it)
+ }
}
gamesViewModel.shouldSwapData.collect(
viewLifecycleOwner,
@@ -165,6 +168,16 @@ class GamesFragment : Fragment() {
resetState = { gamesViewModel.setShouldScrollToTop(false) }
) { if (it) scrollToTop() }
+ gamesViewModel.shouldScrollAfterReload.collect(viewLifecycleOwner) { shouldScroll ->
+ if (shouldScroll) {
+ binding.gridGames.post {
+ (binding.gridGames as? CarouselRecyclerView)?.pendingScrollAfterReload = true
+ gameAdapter.notifyDataSetChanged()
+ }
+ gamesViewModel.setShouldScrollAfterReload(false)
+ }
+ }
+
setupTopView()
binding.addDirectory.setOnClickListener {
@@ -176,17 +189,12 @@ class GamesFragment : Fragment() {
val applyGridGamesBinding = {
(binding.gridGames as? RecyclerView)?.apply {
- val savedViewType = getCurrentViewType()
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
- val effectiveViewType = if (!isLandscape && savedViewType == GameAdapter.VIEW_TYPE_CAROUSEL) {
- GameAdapter.VIEW_TYPE_GRID
- } else {
- savedViewType
- }
- gameAdapter.setViewType(effectiveViewType)
+ val currentViewType = getCurrentViewType()
+ val savedViewType = if (isLandscape || currentViewType != GameAdapter.VIEW_TYPE_CAROUSEL) currentViewType else GameAdapter.VIEW_TYPE_GRID
+
gameAdapter.setViewType(savedViewType)
currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID)
- val overlapPx = resources.getDimensionPixelSize(R.dimen.carousel_overlap)
// Set the correct layout manager
layoutManager = when (savedViewType) {
@@ -203,23 +211,14 @@ class GamesFragment : Fragment() {
}
else -> throw IllegalArgumentException("Invalid view type: $savedViewType")
}
-
- // Carousel mode: wait for layout, then set card size and enable carousel features
if (savedViewType == GameAdapter.VIEW_TYPE_CAROUSEL) {
- post {
- val insets = ViewCompat.getRootWindowInsets(this)
- val bottomInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0
- val size = (resources.getFraction(R.fraction.carousel_card_size_multiplier, 1, 1) * (height - bottomInset)).toInt()
- if (size > 0) {
- gameAdapter.setCardSize(size)
- (this as? JukeboxRecyclerView)?.setCarouselMode(true, overlapPx, size)
- }
+ doOnNextLayout {
+ (this as? CarouselRecyclerView)?.setCarouselMode(true, gameAdapter)
+ adapter = gameAdapter
}
} else {
- // Disable carousel features in other modes
- (this as? JukeboxRecyclerView)?.setCarouselMode(false, overlapPx, 0)
+ (this as? CarouselRecyclerView)?.setCarouselMode(false)
}
-
adapter = gameAdapter
lastViewType = savedViewType
}
@@ -232,12 +231,34 @@ class GamesFragment : Fragment() {
}
}
+ override fun onPause() {
+ super.onPause()
+ if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) {
+ gamesViewModel.lastScrollPosition = (binding.gridGames as? CarouselRecyclerView)?.getClosestChildPosition() ?: 0
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) {
+ (binding.gridGames as? CarouselRecyclerView)?.restoreScrollState(gamesViewModel.lastScrollPosition)
+ }
+ }
+
+ private var lastSearchText: String = ""
+ private var lastFilter: Int = preferences.getInt(PREF_SORT_TYPE, View.NO_ID)
+
private fun setAdapter(games: List<Game>) {
val currentSearchText = binding.searchText.text.toString()
val currentFilter = binding.filterButton.id
- if (currentSearchText.isNotEmpty() || currentFilter != View.NO_ID) {
+ val searchChanged = currentSearchText != lastSearchText
+ val filterChanged = currentFilter != lastFilter
+
+ if (searchChanged || filterChanged) {
filterAndSearch(games)
+ lastSearchText = currentSearchText
+ lastFilter = currentFilter
} else {
((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(games)
gamesViewModel.setFilteredGames(games)
@@ -292,6 +313,7 @@ class GamesFragment : Fragment() {
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.view_grid -> {
+ if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) onPause()
setCurrentViewType(GameAdapter.VIEW_TYPE_GRID)
applyGridGamesBinding()
item.isChecked = true
@@ -299,6 +321,7 @@ class GamesFragment : Fragment() {
}
R.id.view_list -> {
+ if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) onPause()
setCurrentViewType(GameAdapter.VIEW_TYPE_LIST)
applyGridGamesBinding()
item.isChecked = true
@@ -306,9 +329,12 @@ class GamesFragment : Fragment() {
}
R.id.view_carousel -> {
- setCurrentViewType(GameAdapter.VIEW_TYPE_CAROUSEL)
- applyGridGamesBinding()
- item.isChecked = true
+ if (!item.isChecked || getCurrentViewType() != GameAdapter.VIEW_TYPE_CAROUSEL) {
+ setCurrentViewType(GameAdapter.VIEW_TYPE_CAROUSEL)
+ applyGridGamesBinding()
+ item.isChecked = true
+ onResume()
+ }
true
}
@@ -402,7 +428,7 @@ class GamesFragment : Fragment() {
private fun scrollToTop() {
if (_binding != null) {
- (binding.gridGames as? JukeboxRecyclerView)?.smoothScrollToPosition(0)
+ (binding.gridGames as? CarouselRecyclerView)?.smoothScrollToPosition(0)
}
}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt
deleted file mode 100644
index 130e10dacf..0000000000
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt
+++ /dev/null
@@ -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
- }
- }
-} \ No newline at end of file
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt
new file mode 100644
index 0000000000..ae40fcb498
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt
@@ -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
+ }
+ }
+} \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout-land/card_game_carousel.xml b/src/android/app/src/main/res/layout-land/card_game_carousel.xml
new file mode 100644
index 0000000000..fcc9397a13
--- /dev/null
+++ b/src/android/app/src/main/res/layout-land/card_game_carousel.xml
@@ -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>
diff --git a/src/android/app/src/main/res/layout-land/fragment_games.xml b/src/android/app/src/main/res/layout-land/fragment_games.xml
index 087a943497..14e9d5358c 100644
--- a/src/android/app/src/main/res/layout-land/fragment_games.xml
+++ b/src/android/app/src/main/res/layout-land/fragment_games.xml
@@ -185,7 +185,7 @@
android:visibility="gone"
/>
- <org.yuzu.yuzu_emu.ui.JukeboxRecyclerView
+ <org.yuzu.yuzu_emu.ui.CarouselRecyclerView
android:id="@+id/grid_games"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -196,7 +196,6 @@
android:fadeScrollbars="true"
/>
-
</RelativeLayout>
</org.yuzu.yuzu_emu.ui.MidScreenSwipeRefreshLayout>
diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml
index 1533dd2831..555f8c1e97 100644
--- a/src/android/app/src/main/res/values/dimens.xml
+++ b/src/android/app/src/main/res/values/dimens.xml
@@ -15,7 +15,6 @@
<dimen name="icon_inset">24dp</dimen>
<dimen name="spacing_bottom_list_fab">96dp</dimen>
<dimen name="spacing_fab">24dp</dimen>
- <dimen name="carousel_overlap">150dp</dimen>
<dimen name="dialog_margin">20dp</dimen>
<dimen name="elevated_app_bar">3dp</dimen>
diff --git a/src/android/app/src/main/res/values/fractions.xml b/src/android/app/src/main/res/values/fractions.xml
index 5c5b0b289c..deb8ac3bea 100644
--- a/src/android/app/src/main/res/values/fractions.xml
+++ b/src/android/app/src/main/res/values/fractions.xml
@@ -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>
diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml
index 5ddc913c41..cc05478c21 100644
--- a/src/android/app/src/main/res/values/integers.xml
+++ b/src/android/app/src/main/res/values/integers.xml
@@ -3,6 +3,10 @@
<integer name="grid_columns">1</integer>
<integer name="game_columns_list">1</integer>
<integer name="game_columns_grid">2</integer>
+ <integer name="carousel_max_fling_count">4</integer>
+ <integer name="carousel_focus_search_repeat_threshold_ms">100</integer>
+ <integer name="carousel_cards_scaling_shape">1</integer>
+ <integer name="carousel_cards_alpha_shape">4</integer>
<!-- Default SWITCH landscape layout -->
<integer name="BUTTON_A_X">760</integer>