aboutsummaryrefslogtreecommitdiff
path: root/src/android
diff options
context:
space:
mode:
authorxbzk <xbzk@noreply.localhost>2025-06-26 20:52:54 +0000
committercrueter <crueter@noreply.localhost>2025-06-26 20:52:54 +0000
commit03794d4773aa952ca1cc68ddcca2b6c4fe423c33 (patch)
treeda7d919e509dd2ca3eebf0d780eee7d3b19f85aa /src/android
parent37f890ec1662dd8980dbbe79d7c45444a1a4f4fa (diff)
feature/landscape-carousel (#196)
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>
Diffstat (limited to 'src/android')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt89
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt125
-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/ui/MidScreenSwipeRefreshLayout.kt31
-rw-r--r--src/android/app/src/main/res/layout-land/fragment_games.xml10
-rw-r--r--src/android/app/src/main/res/layout/card_game_carousel.xml45
-rw-r--r--src/android/app/src/main/res/menu/menu_game_views.xml3
-rw-r--r--src/android/app/src/main/res/values/dimens.xml2
-rw-r--r--src/android/app/src/main/res/values/fractions.xml6
-rw-r--r--src/android/app/src/main/res/values/strings.xml2
-rw-r--r--src/android/gradle.properties3
11 files changed, 553 insertions, 50 deletions
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 2596ab0e93..c5fc6ff168 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
@@ -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()
}
- override fun getItemViewType(position: Int): Int = viewType
+ 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) {
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 9785e35a5e..ce5960c546 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
@@ -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)
)
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
new file mode 100644
index 0000000000..130e10dacf
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt
@@ -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
+ }
+ }
+} \ No newline at end of file
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/ui/MidScreenSwipeRefreshLayout.kt
new file mode 100644
index 0000000000..74de15205f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt
@@ -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
+ }
+} \ No newline at end of file
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 68ccb86b57..087a943497 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
@@ -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"
diff --git a/src/android/app/src/main/res/layout/card_game_carousel.xml b/src/android/app/src/main/res/layout/card_game_carousel.xml
new file mode 100644
index 0000000000..43e2875306
--- /dev/null
+++ b/src/android/app/src/main/res/layout/card_game_carousel.xml
@@ -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>
diff --git a/src/android/app/src/main/res/menu/menu_game_views.xml b/src/android/app/src/main/res/menu/menu_game_views.xml
index 18e95bc5ad..241cdcb4b5 100644
--- a/src/android/app/src/main/res/menu/menu_game_views.xml
+++ b/src/android/app/src/main/res/menu/menu_game_views.xml
@@ -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> \ No newline at end of file
diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml
index bf733637fe..1533dd2831 100644
--- a/src/android/app/src/main/res/values/dimens.xml
+++ b/src/android/app/src/main/res/values/dimens.xml
@@ -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>
diff --git a/src/android/app/src/main/res/values/fractions.xml b/src/android/app/src/main/res/values/fractions.xml
new file mode 100644
index 0000000000..5c5b0b289c
--- /dev/null
+++ b/src/android/app/src/main/res/values/fractions.xml
@@ -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>
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 10094a5b5f..5c41f6650a 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -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>
diff --git a/src/android/gradle.properties b/src/android/gradle.properties
index 1efd91043f..ef5fafba03 100644
--- a/src/android/gradle.properties
+++ b/src/android/gradle.properties
@@ -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*