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 // SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.adapters package org.yuzu.yuzu_emu.adapters
@ -6,6 +6,7 @@ package org.yuzu.yuzu_emu.adapters
import android.net.Uri import android.net.Uri
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity 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.YuzuApplication
import org.yuzu.yuzu_emu.databinding.CardGameListBinding import org.yuzu.yuzu_emu.databinding.CardGameListBinding
import org.yuzu.yuzu_emu.databinding.CardGameGridBinding 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.Game
import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.utils.GameIconUtils import org.yuzu.yuzu_emu.utils.GameIconUtils
@ -37,6 +39,7 @@ class GameAdapter(private val activity: AppCompatActivity) :
companion object { companion object {
const val VIEW_TYPE_GRID = 0 const val VIEW_TYPE_GRID = 0
const val VIEW_TYPE_LIST = 1 const val VIEW_TYPE_LIST = 1
const val VIEW_TYPE_CAROUSEL = 2
} }
private var viewType = 0 private var viewType = 0
@ -46,29 +49,74 @@ class GameAdapter(private val activity: AppCompatActivity) :
notifyDataSetChanged() 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 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
val binding = when (viewType) { val binding = when (viewType) {
VIEW_TYPE_LIST -> CardGameListBinding.inflate(LayoutInflater.from(parent.context), parent, false) VIEW_TYPE_LIST -> CardGameListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
VIEW_TYPE_GRID -> CardGameGridBinding.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") else -> throw IllegalArgumentException("Invalid view type")
} }
return GameViewHolder(binding, viewType) return GameViewHolder(binding, viewType)
} }
inner class GameViewHolder( inner class GameViewHolder(
private val binding: ViewBinding, internal val binding: ViewBinding,
private val viewType: Int private val viewType: Int
) : AbstractViewHolder<Game>(binding) { ) : AbstractViewHolder<Game>(binding) {
override fun bind(model: Game) { override fun bind(model: Game) {
when (viewType) { when (viewType) {
VIEW_TYPE_LIST -> bindListView(model) VIEW_TYPE_LIST -> bindListView(model)
VIEW_TYPE_GRID -> bindGridView(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.textGameTitle.marquee()
listBinding.cardGameList.setOnClickListener { onClick(model) } listBinding.cardGameList.setOnClickListener { onClick(model) }
listBinding.cardGameList.setOnLongClickListener { onLongClick(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) { private fun bindGridView(model: Game) {
@ -97,6 +149,35 @@ class GameAdapter(private val activity: AppCompatActivity) :
gridBinding.textGameTitle.marquee() gridBinding.textGameTitle.marquee()
gridBinding.cardGameGrid.setOnClickListener { onClick(model) } gridBinding.cardGameGrid.setOnClickListener { onClick(model) }
gridBinding.cardGameGrid.setOnLongClickListener { onLongClick(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) { fun onClick(game: Game) {

View file

@ -28,11 +28,10 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors import androidx.recyclerview.widget.GridLayoutManager
import info.debatty.java.stringsimilarity.Jaccard import androidx.recyclerview.widget.LinearLayoutManager
import info.debatty.java.stringsimilarity.JaroWinkler import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.adapters.GameAdapter 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.ui.main.MainActivity
import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible
import org.yuzu.yuzu_emu.utils.collect import org.yuzu.yuzu_emu.utils.collect
import info.debatty.java.stringsimilarity.Jaccard
import info.debatty.java.stringsimilarity.JaroWinkler
import java.util.Locale import java.util.Locale
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@ -57,9 +58,12 @@ class GamesFragment : Fragment() {
private var originalHeaderRightMargin: Int? = null private var originalHeaderRightMargin: Int? = null
private var originalHeaderLeftMargin: Int? = null private var originalHeaderLeftMargin: Int? = null
private var lastViewType: Int = GameAdapter.VIEW_TYPE_GRID
companion object { companion object {
private const val SEARCH_TEXT = "SearchText" 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" 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( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -104,36 +119,31 @@ class GamesFragment : Fragment() {
applyGridGamesBinding() applyGridGamesBinding()
binding.swipeRefresh.apply { binding.swipeRefresh.apply {
// Add swipe down to refresh gesture (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setOnRefreshListener {
setOnRefreshListener {
gamesViewModel.reloadGames(false) gamesViewModel.reloadGames(false)
} }
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setProgressBackgroundColorSchemeColor(
// Set theme color to the refresh animation's background com.google.android.material.color.MaterialColors.getColor(
setProgressBackgroundColorSchemeColor(
MaterialColors.getColor(
binding.swipeRefresh, binding.swipeRefresh,
com.google.android.material.R.attr.colorPrimary com.google.android.material.R.attr.colorPrimary
) )
) )
setColorSchemeColors( (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setColorSchemeColors(
MaterialColors.getColor( com.google.android.material.color.MaterialColors.getColor(
binding.swipeRefresh, binding.swipeRefresh,
com.google.android.material.R.attr.colorOnPrimary 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 { post {
if (_binding == null) { if (_binding == null) {
return@post return@post
} }
binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.isRefreshing = gamesViewModel.isReloading.value
} }
} }
gamesViewModel.isReloading.collect(viewLifecycleOwner) { gamesViewModel.isReloading.collect(viewLifecycleOwner) {
binding.swipeRefresh.isRefreshing = it (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.isRefreshing = it
binding.noticeText.setVisible( binding.noticeText.setVisible(
visible = gamesViewModel.games.value.isEmpty() && !it, visible = gamesViewModel.games.value.isEmpty() && !it,
gone = false gone = false
@ -165,20 +175,53 @@ class GamesFragment : Fragment() {
} }
val applyGridGamesBinding = { val applyGridGamesBinding = {
binding.gridGames.apply { (binding.gridGames as? RecyclerView)?.apply {
val savedViewType = preferences.getInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID) 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) gameAdapter.setViewType(savedViewType)
currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID) currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID)
adapter = gameAdapter val overlapPx = resources.getDimensionPixelSize(R.dimen.carousel_overlap)
val gameGrid = when (savedViewType) { // Set the correct layout manager
GameAdapter.VIEW_TYPE_LIST -> R.integer.game_columns_list layoutManager = when (savedViewType) {
GameAdapter.VIEW_TYPE_GRID -> R.integer.game_columns_grid GameAdapter.VIEW_TYPE_GRID -> {
else -> 0 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 currentSearchText = binding.searchText.text.toString()
val currentFilter = binding.filterButton.id val currentFilter = binding.filterButton.id
if (currentSearchText.isNotEmpty() || currentFilter != View.NO_ID) { if (currentSearchText.isNotEmpty() || currentFilter != View.NO_ID) {
filterAndSearch(games) filterAndSearch(games)
} else { } else {
(binding.gridGames.adapter as GameAdapter).submitList(games) ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(games)
gamesViewModel.setFilteredGames(games) gamesViewModel.setFilteredGames(games)
} }
} }
@ -235,24 +277,36 @@ class GamesFragment : Fragment() {
private fun showViewMenu(anchor: View) { private fun showViewMenu(anchor: View) {
val popup = PopupMenu(requireContext(), anchor) val popup = PopupMenu(requireContext(), anchor)
popup.menuInflater.inflate(R.menu.menu_game_views, popup.menu) 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) { when (currentViewType) {
GameAdapter.VIEW_TYPE_LIST -> popup.menu.findItem(R.id.view_list).isChecked = true 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_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 -> popup.setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.view_grid -> { R.id.view_grid -> {
preferences.edit() { putInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID) } setCurrentViewType(GameAdapter.VIEW_TYPE_GRID)
applyGridGamesBinding() applyGridGamesBinding()
item.isChecked = true item.isChecked = true
true true
} }
R.id.view_list -> { 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() applyGridGamesBinding()
item.isChecked = true item.isChecked = true
true true
@ -301,20 +355,18 @@ class GamesFragment : Fragment() {
lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
}.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) }
} }
R.id.filter_recently_added -> { R.id.filter_recently_added -> {
baseList.filter { baseList.filter {
val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
}.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) }
} }
else -> baseList else -> baseList
} }
val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
if (searchTerm.isEmpty()) { if (searchTerm.isEmpty()) {
(binding.gridGames.adapter as GameAdapter).submitList(filteredList) ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(filteredList)
gamesViewModel.setFilteredGames(filteredList) gamesViewModel.setFilteredGames(filteredList)
return return
} }
@ -330,7 +382,7 @@ class GamesFragment : Fragment() {
} }
}.sortedByDescending { it.score }.map { it.item } }.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) gamesViewModel.setFilteredGames(sortedList)
} }
@ -343,7 +395,6 @@ class GamesFragment : Fragment() {
imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
} }
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
@ -351,7 +402,7 @@ class GamesFragment : Fragment() {
private fun scrollToTop() { private fun scrollToTop() {
if (_binding != null) { 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) val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
binding.swipeRefresh.setProgressViewEndTarget( (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setProgressViewEndTarget(
false, false,
barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) 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_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:clipChildren="false"
> >
<LinearLayout <LinearLayout
@ -159,7 +160,7 @@
</LinearLayout> </LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <org.yuzu.yuzu_emu.ui.MidScreenSwipeRefreshLayout
android:id="@+id/swipe_refresh" android:id="@+id/swipe_refresh"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
@ -184,22 +185,21 @@
android:visibility="gone" android:visibility="gone"
/> />
<androidx.recyclerview.widget.RecyclerView <org.yuzu.yuzu_emu.ui.JukeboxRecyclerView
android:id="@+id/grid_games" android:id="@+id/grid_games"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipToPadding="false" android:clipToPadding="false"
android:clipChildren="false"
android:scrollbarStyle="outsideOverlay" android:scrollbarStyle="outsideOverlay"
android:scrollbars="vertical" android:scrollbars="vertical"
android:fadeScrollbars="true" android:fadeScrollbars="true"
android:paddingHorizontal="4dp"
android:paddingVertical="4dp"
/> />
</RelativeLayout> </RelativeLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </org.yuzu.yuzu_emu.ui.MidScreenSwipeRefreshLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/add_directory" 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:id="@+id/view_list"
android:title="@string/view_list" android:title="@string/view_list"
android:checked="true"/> android:checked="true"/>
<item
android:id="@+id/view_carousel"
android:title="@string/view_carousel" />
</group> </group>
</menu> </menu>

View file

@ -15,7 +15,7 @@
<dimen name="icon_inset">24dp</dimen> <dimen name="icon_inset">24dp</dimen>
<dimen name="spacing_bottom_list_fab">96dp</dimen> <dimen name="spacing_bottom_list_fab">96dp</dimen>
<dimen name="spacing_fab">24dp</dimen> <dimen name="spacing_fab">24dp</dimen>
<dimen name="carousel_overlap">150dp</dimen>
<dimen name="dialog_margin">20dp</dimen> <dimen name="dialog_margin">20dp</dimen>
<dimen name="elevated_app_bar">3dp</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="alphabetical">Alphabetical</string>
<string name="view_list">List</string> <string name="view_list">List</string>
<string name="view_grid">Grid</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="folder">Folder</string>
<string name="pre_alpha_warning_title">Pre-Alpha Software</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> <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-FileCopyrightText: Copyright 2025 yuzu Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later # 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. # Project-wide Gradle settings.
# IDE (e.g. Android Studio) users: # IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override* # 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 // SPDX-License-Identifier: GPL-3.0-or-later
#include <memory> #include <memory>