feature/landscape-carousel (#196)
Some checks failed
eden-build / linux (push) Waiting to run
eden-build / android (push) Has been cancelled
eden-build / windows (msvc) (push) Has been cancelled
eden-build / source (push) Has been cancelled

second try. all known visual resizing bugs fixed.

Co-authored-by: Allison Cunha <allisonbzk@gmail.com>
Co-authored-by: crueter <swurl@swurl.xyz>
Co-authored-by: Aleksandr Popovich <alekpopo@pm.me>
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/196
Co-authored-by: xbzk <xbzk@noreply.localhost>
Co-committed-by: xbzk <xbzk@noreply.localhost>
This commit is contained in:
xbzk 2025-06-26 20:52:54 +00:00 committed by crueter
parent 37f890ec16
commit 03794d4773
12 changed files with 554 additions and 51 deletions

View file

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2025 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.adapters
@ -6,6 +6,7 @@ package org.yuzu.yuzu_emu.adapters
import android.net.Uri
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
@ -25,6 +26,7 @@ import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.databinding.CardGameListBinding
import org.yuzu.yuzu_emu.databinding.CardGameGridBinding
import org.yuzu.yuzu_emu.databinding.CardGameCarouselBinding
import org.yuzu.yuzu_emu.model.Game
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.GameIconUtils
@ -37,6 +39,7 @@ class GameAdapter(private val activity: AppCompatActivity) :
companion object {
const val VIEW_TYPE_GRID = 0
const val VIEW_TYPE_LIST = 1
const val VIEW_TYPE_CAROUSEL = 2
}
private var viewType = 0
@ -46,29 +49,74 @@ class GameAdapter(private val activity: AppCompatActivity) :
notifyDataSetChanged()
}
var cardSize: Int = 0
private set
fun setCardSize(size: Int) {
if (cardSize != size && size > 0) {
cardSize = size
notifyDataSetChanged()
}
}
override fun getItemViewType(position: Int): Int = viewType
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
listBinding.cardGameList.scaleX = 1f
listBinding.cardGameList.scaleY = 1f
listBinding.cardGameList.alpha = 1f
// Reset layout params to XML defaults
listBinding.root.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
listBinding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
}
VIEW_TYPE_GRID -> {
val gridBinding = holder.binding as CardGameGridBinding
gridBinding.cardGameGrid.scaleX = 1f
gridBinding.cardGameGrid.scaleY = 1f
gridBinding.cardGameGrid.alpha = 1f
// Reset layout params to XML defaults
gridBinding.root.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
gridBinding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
}
VIEW_TYPE_CAROUSEL -> {
val carouselBinding = holder.binding as CardGameCarouselBinding
carouselBinding.cardGameCarousel.scaleX = 1f
carouselBinding.cardGameCarousel.scaleY = 1f
carouselBinding.cardGameCarousel.alpha = 0f
// Set square size for carousel
if (cardSize > 0) {
carouselBinding.root.layoutParams.width = cardSize
carouselBinding.root.layoutParams.height = cardSize
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
val binding = when (viewType) {
VIEW_TYPE_LIST -> CardGameListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
VIEW_TYPE_GRID -> CardGameGridBinding.inflate(LayoutInflater.from(parent.context), parent, false)
VIEW_TYPE_CAROUSEL -> CardGameCarouselBinding.inflate(LayoutInflater.from(parent.context), parent, false)
else -> throw IllegalArgumentException("Invalid view type")
}
return GameViewHolder(binding, viewType)
}
inner class GameViewHolder(
private val binding: ViewBinding,
internal val binding: ViewBinding,
private val viewType: Int
) : AbstractViewHolder<Game>(binding) {
override fun bind(model: Game) {
when (viewType) {
VIEW_TYPE_LIST -> bindListView(model)
VIEW_TYPE_GRID -> bindGridView(model)
VIEW_TYPE_CAROUSEL -> bindCarouselView(model)
}
}
@ -84,6 +132,10 @@ class GameAdapter(private val activity: AppCompatActivity) :
listBinding.textGameTitle.marquee()
listBinding.cardGameList.setOnClickListener { onClick(model) }
listBinding.cardGameList.setOnLongClickListener { onLongClick(model) }
// Reset layout params to XML defaults
listBinding.root.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
listBinding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
}
private fun bindGridView(model: Game) {
@ -97,6 +149,35 @@ class GameAdapter(private val activity: AppCompatActivity) :
gridBinding.textGameTitle.marquee()
gridBinding.cardGameGrid.setOnClickListener { onClick(model) }
gridBinding.cardGameGrid.setOnLongClickListener { onLongClick(model) }
// Reset layout params to XML defaults
gridBinding.root.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
gridBinding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
}
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)
carouselBinding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ")
carouselBinding.textGameTitle.marquee()
carouselBinding.cardGameCarousel.setOnClickListener { onClick(model) }
carouselBinding.cardGameCarousel.setOnLongClickListener { onLongClick(model) }
carouselBinding.imageGameScreen.contentDescription =
binding.root.context.getString(R.string.game_image_desc, model.title)
}
fun onClick(game: Game) {

View file

@ -28,11 +28,10 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import info.debatty.java.stringsimilarity.Jaccard
import info.debatty.java.stringsimilarity.JaroWinkler
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.adapters.GameAdapter
@ -43,6 +42,8 @@ import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.ui.main.MainActivity
import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible
import org.yuzu.yuzu_emu.utils.collect
import info.debatty.java.stringsimilarity.Jaccard
import info.debatty.java.stringsimilarity.JaroWinkler
import java.util.Locale
import androidx.core.content.edit
import androidx.core.view.updateLayoutParams
@ -57,9 +58,12 @@ class GamesFragment : Fragment() {
private var originalHeaderRightMargin: Int? = null
private var originalHeaderLeftMargin: Int? = null
private var lastViewType: Int = GameAdapter.VIEW_TYPE_GRID
companion object {
private const val SEARCH_TEXT = "SearchText"
private const val PREF_VIEW_TYPE = "GamesViewType"
private const val PREF_VIEW_TYPE_PORTRAIT = "GamesViewTypePortrait"
private const val PREF_VIEW_TYPE_LANDSCAPE = "GamesViewTypeLandscape"
private const val PREF_SORT_TYPE = "GamesSortType"
}
@ -78,7 +82,18 @@ 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 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
preferences.edit { putInt(key, type) }
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -104,36 +119,31 @@ class GamesFragment : Fragment() {
applyGridGamesBinding()
binding.swipeRefresh.apply {
// Add swipe down to refresh gesture
setOnRefreshListener {
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setOnRefreshListener {
gamesViewModel.reloadGames(false)
}
// Set theme color to the refresh animation's background
setProgressBackgroundColorSchemeColor(
MaterialColors.getColor(
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setProgressBackgroundColorSchemeColor(
com.google.android.material.color.MaterialColors.getColor(
binding.swipeRefresh,
com.google.android.material.R.attr.colorPrimary
)
)
setColorSchemeColors(
MaterialColors.getColor(
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setColorSchemeColors(
com.google.android.material.color.MaterialColors.getColor(
binding.swipeRefresh,
com.google.android.material.R.attr.colorOnPrimary
)
)
// Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn
post {
if (_binding == null) {
return@post
}
binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.isRefreshing = gamesViewModel.isReloading.value
}
}
gamesViewModel.isReloading.collect(viewLifecycleOwner) {
binding.swipeRefresh.isRefreshing = it
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.isRefreshing = it
binding.noticeText.setVisible(
visible = gamesViewModel.games.value.isEmpty() && !it,
gone = false
@ -165,20 +175,53 @@ class GamesFragment : Fragment() {
}
val applyGridGamesBinding = {
binding.gridGames.apply {
val savedViewType = preferences.getInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID)
(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)
gameAdapter.setViewType(savedViewType)
currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID)
adapter = gameAdapter
val overlapPx = resources.getDimensionPixelSize(R.dimen.carousel_overlap)
val gameGrid = when (savedViewType) {
GameAdapter.VIEW_TYPE_LIST -> R.integer.game_columns_list
GameAdapter.VIEW_TYPE_GRID -> R.integer.game_columns_grid
else -> 0
// Set the correct layout manager
layoutManager = when (savedViewType) {
GameAdapter.VIEW_TYPE_GRID -> {
val columns = resources.getInteger(R.integer.game_columns_grid)
GridLayoutManager(context, columns)
}
GameAdapter.VIEW_TYPE_LIST -> {
val columns = resources.getInteger(R.integer.game_columns_list)
GridLayoutManager(context, columns)
}
GameAdapter.VIEW_TYPE_CAROUSEL -> {
LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
}
else -> throw IllegalArgumentException("Invalid view type: $savedViewType")
}
layoutManager = GridLayoutManager(requireContext(), resources.getInteger(gameGrid))
// 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)
}
}
} else {
// Disable carousel features in other modes
(this as? JukeboxRecyclerView)?.setCarouselMode(false, overlapPx, 0)
}
adapter = gameAdapter
lastViewType = savedViewType
}
}
@ -193,11 +236,10 @@ class GamesFragment : Fragment() {
val currentSearchText = binding.searchText.text.toString()
val currentFilter = binding.filterButton.id
if (currentSearchText.isNotEmpty() || currentFilter != View.NO_ID) {
filterAndSearch(games)
} else {
(binding.gridGames.adapter as GameAdapter).submitList(games)
((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(games)
gamesViewModel.setFilteredGames(games)
}
}
@ -235,24 +277,36 @@ class GamesFragment : Fragment() {
private fun showViewMenu(anchor: View) {
val popup = PopupMenu(requireContext(), anchor)
popup.menuInflater.inflate(R.menu.menu_game_views, popup.menu)
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
if (!isLandscape) {
popup.menu.findItem(R.id.view_carousel)?.isVisible = false
}
val currentViewType = (preferences.getInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID))
val currentViewType = getCurrentViewType()
when (currentViewType) {
GameAdapter.VIEW_TYPE_LIST -> popup.menu.findItem(R.id.view_list).isChecked = true
GameAdapter.VIEW_TYPE_GRID -> popup.menu.findItem(R.id.view_grid).isChecked = true
GameAdapter.VIEW_TYPE_CAROUSEL -> popup.menu.findItem(R.id.view_carousel).isChecked = true
}
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.view_grid -> {
preferences.edit() { putInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID) }
setCurrentViewType(GameAdapter.VIEW_TYPE_GRID)
applyGridGamesBinding()
item.isChecked = true
true
}
R.id.view_list -> {
preferences.edit() { putInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_LIST) }
setCurrentViewType(GameAdapter.VIEW_TYPE_LIST)
applyGridGamesBinding()
item.isChecked = true
true
}
R.id.view_carousel -> {
setCurrentViewType(GameAdapter.VIEW_TYPE_CAROUSEL)
applyGridGamesBinding()
item.isChecked = true
true
@ -301,20 +355,18 @@ class GamesFragment : Fragment() {
lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
}.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) }
}
R.id.filter_recently_added -> {
baseList.filter {
val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
}.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) }
}
else -> baseList
}
val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
if (searchTerm.isEmpty()) {
(binding.gridGames.adapter as GameAdapter).submitList(filteredList)
((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(filteredList)
gamesViewModel.setFilteredGames(filteredList)
return
}
@ -330,7 +382,7 @@ class GamesFragment : Fragment() {
}
}.sortedByDescending { it.score }.map { it.item }
(binding.gridGames.adapter as GameAdapter).submitList(sortedList)
((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(sortedList)
gamesViewModel.setFilteredGames(sortedList)
}
@ -343,7 +395,6 @@ class GamesFragment : Fragment() {
imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
@ -351,7 +402,7 @@ class GamesFragment : Fragment() {
private fun scrollToTop() {
if (_binding != null) {
binding.gridGames.smoothScrollToPosition(0)
(binding.gridGames as? JukeboxRecyclerView)?.smoothScrollToPosition(0)
}
}
@ -364,7 +415,7 @@ class GamesFragment : Fragment() {
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
binding.swipeRefresh.setProgressViewEndTarget(
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setProgressViewEndTarget(
false,
barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
)

View file

@ -0,0 +1,287 @@
// 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
}
}
}

View file

@ -0,0 +1,31 @@
// 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.util.AttributeSet
import android.view.MotionEvent
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
class MidScreenSwipeRefreshLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : SwipeRefreshLayout(context, attrs) {
private var startX = 0f
private var allowRefresh = false
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
startX = ev.x
val width = width
val leftBound = width / 3
val rightBound = width * 2 / 3
allowRefresh = startX >= leftBound && startX <= rightBound
}
}
return if (allowRefresh) super.onInterceptTouchEvent(ev) else false
}
}

View file

@ -5,6 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:clipChildren="false"
>
<LinearLayout
@ -159,7 +160,7 @@
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
<org.yuzu.yuzu_emu.ui.MidScreenSwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="0dp"
@ -184,22 +185,21 @@
android:visibility="gone"
/>
<androidx.recyclerview.widget.RecyclerView
<org.yuzu.yuzu_emu.ui.JukeboxRecyclerView
android:id="@+id/grid_games"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:clipChildren="false"
android:scrollbarStyle="outsideOverlay"
android:scrollbars="vertical"
android:fadeScrollbars="true"
android:paddingHorizontal="4dp"
android:paddingVertical="4dp"
/>
</RelativeLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</org.yuzu.yuzu_emu.ui.MidScreenSwipeRefreshLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/add_directory"

View file

@ -0,0 +1,45 @@
<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="4dp"
app:strokeColor="@android:color/transparent"
app:strokeWidth="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp">
<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="8dp"
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>

View file

@ -8,5 +8,8 @@
android:id="@+id/view_list"
android:title="@string/view_list"
android:checked="true"/>
<item
android:id="@+id/view_carousel"
android:title="@string/view_carousel" />
</group>
</menu>

View file

@ -15,7 +15,7 @@
<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>

View file

@ -0,0 +1,6 @@
<resources>
<fraction name="carousel_min_scale">60%</fraction>
<fraction name="carousel_min_alpha">60%</fraction>
<fraction name="carousel_fling_multiplier">200%</fraction>
<fraction name="carousel_card_size_multiplier">100%</fraction>
</resources>

View file

@ -232,6 +232,8 @@
<string name="alphabetical">Alphabetical</string>
<string name="view_list">List</string>
<string name="view_grid">Grid</string>
<string name="view_carousel">Carousel</string>
<string name="game_image_desc">Screenshot for %1$s</string>
<string name="folder">Folder</string>
<string name="pre_alpha_warning_title">Pre-Alpha Software</string>
<string name="pre_alpha_warning_description">"WARNING: This software is in the pre-alpha stage and may have bugs and incomplete feature implementations."</string>

View file

@ -1,9 +1,6 @@
# SPDX-FileCopyrightText: Copyright 2025 yuzu Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*

View file

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include <memory>