From 39a46d755f103579a1ef19cd5fbda2916fd334c1 Mon Sep 17 00:00:00 2001 From: Aleksandr Popovich Date: Fri, 4 Jul 2025 21:37:15 +0000 Subject: [PATCH] [android] Update carousel view (#254) - Cherry picked the patches from xbzk PR. Signed-off-by: Aleksandr Popovich Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/254 Co-authored-by: Aleksandr Popovich Co-committed-by: Aleksandr Popovich --- src/android/app/build.gradle.kts | 2 +- .../org/yuzu/yuzu_emu/adapters/GameAdapter.kt | 26 +- .../MidScreenSwipeRefreshLayout.kt | 6 +- .../org/yuzu/yuzu_emu/model/GamesViewModel.kt | 10 + .../org/yuzu/yuzu_emu/ui/GamesFragment.kt | 88 ++-- .../yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt | 287 ------------ .../yuzu_emu/views/CarouselRecyclerView.kt | 409 ++++++++++++++++++ .../res/layout-land/card_game_carousel.xml | 46 ++ .../main/res/layout-land/fragment_games.xml | 3 +- .../app/src/main/res/values/dimens.xml | 1 - .../app/src/main/res/values/fractions.xml | 8 +- .../app/src/main/res/values/integers.xml | 4 + 12 files changed, 544 insertions(+), 346 deletions(-) rename src/android/app/src/main/java/org/yuzu/yuzu_emu/{ui => layout}/MidScreenSwipeRefreshLayout.kt (75%) delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt create mode 100644 src/android/app/src/main/res/layout-land/card_game_carousel.xml diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index b235698b22..5a65a28baf 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -127,7 +127,7 @@ android { applicationIdSuffix = ".relWithDebInfo" isJniDebuggable = true } - + // Signed by debug key disallowing distribution on Play Store. // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. debug { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt index c5fc6ff168..750e8f4729 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt @@ -32,6 +32,7 @@ import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.utils.GameIconUtils import org.yuzu.yuzu_emu.utils.ViewUtils.marquee import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder +import androidx.recyclerview.widget.RecyclerView class GameAdapter(private val activity: AppCompatActivity) : AbstractDiffAdapter(exact = false) { @@ -49,7 +50,7 @@ class GameAdapter(private val activity: AppCompatActivity) : notifyDataSetChanged() } - var cardSize: Int = 0 + public var cardSize: Int = 0 private set fun setCardSize(size: Int) { @@ -63,7 +64,6 @@ class GameAdapter(private val activity: AppCompatActivity) : override fun onBindViewHolder(holder: GameViewHolder, position: Int) { super.onBindViewHolder(holder, position) - // Always reset scale/alpha for recycled views when (getItemViewType(position)) { VIEW_TYPE_LIST -> { val listBinding = holder.binding as CardGameListBinding @@ -85,14 +85,9 @@ class GameAdapter(private val activity: AppCompatActivity) : } VIEW_TYPE_CAROUSEL -> { val carouselBinding = holder.binding as CardGameCarouselBinding - carouselBinding.cardGameCarousel.scaleX = 1f - carouselBinding.cardGameCarousel.scaleY = 1f + //soothens transient flickering + carouselBinding.cardGameCarousel.scaleY = 0f carouselBinding.cardGameCarousel.alpha = 0f - // Set square size for carousel - if (cardSize > 0) { - carouselBinding.root.layoutParams.width = cardSize - carouselBinding.root.layoutParams.height = cardSize - } } } } @@ -158,16 +153,6 @@ class GameAdapter(private val activity: AppCompatActivity) : private fun bindCarouselView(model: Game) { val carouselBinding = binding as CardGameCarouselBinding - // Remove padding from the root LinearLayout - (carouselBinding.root.getChildAt(0) as? LinearLayout)?.setPadding(0, 0, 0, 0) - - // Always set square size and remove margins for carousel - val params = carouselBinding.root.layoutParams - params.width = cardSize - params.height = cardSize - if (params is ViewGroup.MarginLayoutParams) params.setMargins(0, 0, 0, 0) - carouselBinding.root.layoutParams = params - carouselBinding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP GameIconUtils.loadGameIcon(model, carouselBinding.imageGameScreen) @@ -178,6 +163,9 @@ class GameAdapter(private val activity: AppCompatActivity) : carouselBinding.imageGameScreen.contentDescription = binding.root.context.getString(R.string.game_image_desc, model.title) + + // Ensure zero-heighted-full-width cards for carousel + carouselBinding.root.layoutParams.width = cardSize } fun onClick(game: Game) { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/MidScreenSwipeRefreshLayout.kt similarity index 75% rename from src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt rename to src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/MidScreenSwipeRefreshLayout.kt index 74de15205f..649bea9d54 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/MidScreenSwipeRefreshLayout.kt @@ -3,6 +3,7 @@ package org.yuzu.yuzu_emu.ui +import org.yuzu.yuzu_emu.R import android.content.Context import android.util.AttributeSet import android.view.MotionEvent @@ -21,8 +22,9 @@ class MidScreenSwipeRefreshLayout @JvmOverloads constructor( MotionEvent.ACTION_DOWN -> { startX = ev.x val width = width - val leftBound = width / 3 - val rightBound = width * 2 / 3 + val center_fraction = resources.getFraction(R.fraction.carousel_midscreenswipe_width_fraction, 1, 1).coerceIn(0f, 1f) + val leftBound = ((1 - center_fraction) / 2) * width + val rightBound = leftBound + (width * center_fraction) allowRefresh = startX >= leftBound && startX <= rightBound } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt index 8e4b566766..66f012d1af 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt @@ -42,11 +42,16 @@ class GamesViewModel : ViewModel() { val searchFocused: StateFlow get() = _searchFocused private val _searchFocused = MutableStateFlow(false) + val shouldScrollAfterReload: StateFlow get() = _shouldScrollAfterReload + private val _shouldScrollAfterReload = MutableStateFlow(false) + private val _folders = MutableStateFlow(mutableListOf()) val folders = _folders.asStateFlow() private val _filteredGames = MutableStateFlow>(emptyList()) + var lastScrollPosition: Int = 0 + init { // Ensure keys are loaded so that ROM metadata can be decrypted. NativeLibrary.reloadKeys() @@ -74,6 +79,10 @@ class GamesViewModel : ViewModel() { _shouldScrollToTop.value = shouldScroll } + fun setShouldScrollAfterReload(shouldScroll: Boolean) { + _shouldScrollAfterReload.value = shouldScroll + } + fun setSearchFocused(searchFocused: Boolean) { _searchFocused.value = searchFocused } @@ -123,6 +132,7 @@ class GamesViewModel : ViewModel() { setGames(GameHelper.getGames()) reloading.set(false) _isReloading.value = false + _shouldScrollAfterReload.value = true if (directoriesChanged) { setShouldSwapData(true) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt index 38d6ebc7d3..1a74057569 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt @@ -11,6 +11,7 @@ import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewTreeObserver import android.view.inputmethod.InputMethodManager import android.widget.ImageButton import android.widget.PopupMenu @@ -48,6 +49,8 @@ import java.util.Locale import androidx.core.content.edit import androidx.core.view.updateLayoutParams import org.yuzu.yuzu_emu.features.settings.model.Settings +import android.view.ViewParent +import androidx.core.view.doOnNextLayout class GamesFragment : Fragment() { private var _binding: FragmentGamesBinding? = null @@ -62,8 +65,6 @@ class GamesFragment : Fragment() { companion object { private const val SEARCH_TEXT = "SearchText" - private const val PREF_VIEW_TYPE_PORTRAIT = "GamesViewTypePortrait" - private const val PREF_VIEW_TYPE_LANDSCAPE = "GamesViewTypeLandscape" private const val PREF_SORT_TYPE = "GamesSortType" } @@ -84,14 +85,14 @@ class GamesFragment : Fragment() { private fun getCurrentViewType(): Int { val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val key = if (isLandscape) PREF_VIEW_TYPE_LANDSCAPE else PREF_VIEW_TYPE_PORTRAIT + val key = if (isLandscape) CarouselRecyclerView.CAROUSEL_VIEW_TYPE_LANDSCAPE else CarouselRecyclerView.CAROUSEL_VIEW_TYPE_PORTRAIT val fallback = if (isLandscape) GameAdapter.VIEW_TYPE_CAROUSEL else GameAdapter.VIEW_TYPE_GRID return preferences.getInt(key, fallback) } private fun setCurrentViewType(type: Int) { val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val key = if (isLandscape) PREF_VIEW_TYPE_LANDSCAPE else PREF_VIEW_TYPE_PORTRAIT + val key = if (isLandscape) CarouselRecyclerView.CAROUSEL_VIEW_TYPE_LANDSCAPE else CarouselRecyclerView.CAROUSEL_VIEW_TYPE_PORTRAIT preferences.edit { putInt(key, type) } } override fun onCreateView( @@ -150,7 +151,9 @@ class GamesFragment : Fragment() { ) } gamesViewModel.games.collect(viewLifecycleOwner) { - setAdapter(it) + if (it.size > 0) { + setAdapter(it) + } } gamesViewModel.shouldSwapData.collect( viewLifecycleOwner, @@ -165,6 +168,16 @@ class GamesFragment : Fragment() { resetState = { gamesViewModel.setShouldScrollToTop(false) } ) { if (it) scrollToTop() } + gamesViewModel.shouldScrollAfterReload.collect(viewLifecycleOwner) { shouldScroll -> + if (shouldScroll) { + binding.gridGames.post { + (binding.gridGames as? CarouselRecyclerView)?.pendingScrollAfterReload = true + gameAdapter.notifyDataSetChanged() + } + gamesViewModel.setShouldScrollAfterReload(false) + } + } + setupTopView() binding.addDirectory.setOnClickListener { @@ -176,17 +189,12 @@ class GamesFragment : Fragment() { val applyGridGamesBinding = { (binding.gridGames as? RecyclerView)?.apply { - val savedViewType = getCurrentViewType() val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val effectiveViewType = if (!isLandscape && savedViewType == GameAdapter.VIEW_TYPE_CAROUSEL) { - GameAdapter.VIEW_TYPE_GRID - } else { - savedViewType - } - gameAdapter.setViewType(effectiveViewType) + val currentViewType = getCurrentViewType() + val savedViewType = if (isLandscape || currentViewType != GameAdapter.VIEW_TYPE_CAROUSEL) currentViewType else GameAdapter.VIEW_TYPE_GRID + gameAdapter.setViewType(savedViewType) currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID) - val overlapPx = resources.getDimensionPixelSize(R.dimen.carousel_overlap) // Set the correct layout manager layoutManager = when (savedViewType) { @@ -203,23 +211,14 @@ class GamesFragment : Fragment() { } else -> throw IllegalArgumentException("Invalid view type: $savedViewType") } - - // Carousel mode: wait for layout, then set card size and enable carousel features if (savedViewType == GameAdapter.VIEW_TYPE_CAROUSEL) { - post { - val insets = ViewCompat.getRootWindowInsets(this) - val bottomInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0 - val size = (resources.getFraction(R.fraction.carousel_card_size_multiplier, 1, 1) * (height - bottomInset)).toInt() - if (size > 0) { - gameAdapter.setCardSize(size) - (this as? JukeboxRecyclerView)?.setCarouselMode(true, overlapPx, size) - } + doOnNextLayout { + (this as? CarouselRecyclerView)?.setCarouselMode(true, gameAdapter) + adapter = gameAdapter } } else { - // Disable carousel features in other modes - (this as? JukeboxRecyclerView)?.setCarouselMode(false, overlapPx, 0) + (this as? CarouselRecyclerView)?.setCarouselMode(false) } - adapter = gameAdapter lastViewType = savedViewType } @@ -232,12 +231,34 @@ class GamesFragment : Fragment() { } } + override fun onPause() { + super.onPause() + if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) { + gamesViewModel.lastScrollPosition = (binding.gridGames as? CarouselRecyclerView)?.getClosestChildPosition() ?: 0 + } + } + + override fun onResume() { + super.onResume() + if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) { + (binding.gridGames as? CarouselRecyclerView)?.restoreScrollState(gamesViewModel.lastScrollPosition) + } + } + + private var lastSearchText: String = "" + private var lastFilter: Int = preferences.getInt(PREF_SORT_TYPE, View.NO_ID) + private fun setAdapter(games: List) { val currentSearchText = binding.searchText.text.toString() val currentFilter = binding.filterButton.id - if (currentSearchText.isNotEmpty() || currentFilter != View.NO_ID) { + val searchChanged = currentSearchText != lastSearchText + val filterChanged = currentFilter != lastFilter + + if (searchChanged || filterChanged) { filterAndSearch(games) + lastSearchText = currentSearchText + lastFilter = currentFilter } else { ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(games) gamesViewModel.setFilteredGames(games) @@ -292,6 +313,7 @@ class GamesFragment : Fragment() { popup.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.view_grid -> { + if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) onPause() setCurrentViewType(GameAdapter.VIEW_TYPE_GRID) applyGridGamesBinding() item.isChecked = true @@ -299,6 +321,7 @@ class GamesFragment : Fragment() { } R.id.view_list -> { + if (getCurrentViewType() == GameAdapter.VIEW_TYPE_CAROUSEL) onPause() setCurrentViewType(GameAdapter.VIEW_TYPE_LIST) applyGridGamesBinding() item.isChecked = true @@ -306,9 +329,12 @@ class GamesFragment : Fragment() { } R.id.view_carousel -> { - setCurrentViewType(GameAdapter.VIEW_TYPE_CAROUSEL) - applyGridGamesBinding() - item.isChecked = true + if (!item.isChecked || getCurrentViewType() != GameAdapter.VIEW_TYPE_CAROUSEL) { + setCurrentViewType(GameAdapter.VIEW_TYPE_CAROUSEL) + applyGridGamesBinding() + item.isChecked = true + onResume() + } true } @@ -402,7 +428,7 @@ class GamesFragment : Fragment() { private fun scrollToTop() { if (_binding != null) { - (binding.gridGames as? JukeboxRecyclerView)?.smoothScrollToPosition(0) + (binding.gridGames as? CarouselRecyclerView)?.smoothScrollToPosition(0) } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt deleted file mode 100644 index 130e10dacf..0000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt +++ /dev/null @@ -1,287 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project -// SPDX-License-Identifier: GPL-3.0-or-later - -package org.yuzu.yuzu_emu.ui - -import android.content.Context -import android.graphics.Rect -import android.util.AttributeSet -import android.view.View -import android.view.KeyEvent -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.PagerSnapHelper -import androidx.recyclerview.widget.RecyclerView -import kotlin.math.abs -import org.yuzu.yuzu_emu.R - -/** - * JukeboxRecyclerView encapsulates all carousel/grid/list logic for the games UI. - * It manages overlapping cards, center snapping, custom drawing order, and mid-screen swipe-to-refresh. - * Use setCarouselMode(enabled, overlapPx) to toggle carousel features. - */ -class JukeboxRecyclerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0 -) : RecyclerView(context, attrs, defStyle) { - - // Carousel/overlap/snap state - private var overlapPx: Int = 0 - private var overlapDecoration: OverlappingDecoration? = null - private var pagerSnapHelper: PagerSnapHelper? = null - - var flingMultiplier: Float = resources.getFraction(R.fraction.carousel_fling_multiplier, 1, 1) - - var useCustomDrawingOrder: Boolean = false - set(value) { - field = value - setChildrenDrawingOrderEnabled(value) - invalidate() - } - - init { - setChildrenDrawingOrderEnabled(true) - } - - /** - * Returns the horizontal center given width and paddings. - */ - private fun calculateCenter(width: Int, paddingStart: Int, paddingEnd: Int): Int { - return paddingStart + (width - paddingStart - paddingEnd) / 2 - } - - /** - * Returns the horizontal center of this RecyclerView, accounting for padding. - */ - private fun getRecyclerViewCenter(): Float { - return calculateCenter(width, paddingLeft, paddingRight).toFloat() - } - - /** - * Returns the horizontal center of a LayoutManager, accounting for padding. - */ - private fun getLayoutManagerCenter(layoutManager: RecyclerView.LayoutManager): Int { - return if (layoutManager is LinearLayoutManager) { - calculateCenter(layoutManager.width, layoutManager.paddingStart, layoutManager.paddingEnd) - } else { - width / 2 - } - } - - private fun updateChildScalesAndAlpha() { - val center = getRecyclerViewCenter() - - for (i in 0 until childCount) { - val child = getChildAt(i) - val childCenter = (child.left + child.right) / 2f - val distance = abs(center - childCenter) - val minScale = resources.getFraction(R.fraction.carousel_min_scale, 1, 1) - val scale = minScale + (1f - minScale) * (1f - distance / center).coerceAtMost(1f) - child.scaleX = scale - child.scaleY = scale - - val maxDistance = width / 2f - val norm = (distance / maxDistance).coerceIn(0f, 1f) - val minAlpha = resources.getFraction(R.fraction.carousel_min_alpha, 1, 1) - val alpha = minAlpha + (1f - minAlpha) * kotlin.math.cos(norm * Math.PI).toFloat() - child.alpha = alpha - } - } - - /** - * Enable or disable carousel mode. - * When enabled, applies overlap, snap, and custom drawing order. - */ - fun setCarouselMode(enabled: Boolean, overlapPx: Int = 0, cardSize: Int = 0) { - this.overlapPx = overlapPx - if (enabled) { - // Add overlap decoration if not present - if (overlapDecoration == null) { - overlapDecoration = OverlappingDecoration(overlapPx) - addItemDecoration(overlapDecoration!!) - } - // Attach PagerSnapHelper - if (pagerSnapHelper == null) { - pagerSnapHelper = CenterPagerSnapHelper() - pagerSnapHelper!!.attachToRecyclerView(this) - } - useCustomDrawingOrder = true - flingMultiplier = resources.getFraction(R.fraction.carousel_fling_multiplier, 1, 1) - - // Center first/last card - post { - if (cardSize > 0) { - val sidePadding = (width - cardSize) / 2 - setPadding(sidePadding, 0, sidePadding, 0) - clipToPadding = false - } - } - // Handle bottom insets for keyboard/navigation bar only - androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> - val imeInset = insets.getInsets(androidx.core.view.WindowInsetsCompat.Type.ime()).bottom - val navInset = insets.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom - // Only adjust bottom padding, keep top at 0 - view.setPadding(view.paddingLeft, 0, view.paddingRight, maxOf(imeInset, navInset)) - insets - } - } else { - // Remove overlap decoration - overlapDecoration?.let { removeItemDecoration(it) } - overlapDecoration = null - // Detach PagerSnapHelper - pagerSnapHelper?.attachToRecyclerView(null) - pagerSnapHelper = null - useCustomDrawingOrder = false - // Reset padding and fling - setPadding(0, 0, 0, 0) - clipToPadding = true - flingMultiplier = 1.0f - // Reset scaling - for (i in 0 until childCount) { - val child = getChildAt(i) - child?.scaleX = 1f - child?.scaleY = 1f - } - } - } - - // trap past boundaries navigation - override fun focusSearch(focused: View, direction: Int): View? { - val lm = layoutManager as? LinearLayoutManager ?: return super.focusSearch(focused, direction) - val vh = findContainingViewHolder(focused) ?: return super.focusSearch(focused, direction) - val position = vh.bindingAdapterPosition - val itemCount = adapter?.itemCount ?: return super.focusSearch(focused, direction) - - return when (direction) { - View.FOCUS_LEFT -> { - if (position > 0) { - findViewHolderForAdapterPosition(position - 1)?.itemView ?: super.focusSearch(focused, direction) - } else { - focused - } - } - View.FOCUS_RIGHT -> { - if (position < itemCount - 1) { - findViewHolderForAdapterPosition(position + 1)?.itemView ?: super.focusSearch(focused, direction) - } else { - focused - } - } - else -> super.focusSearch(focused, direction) - } - } - - // Custom fling multiplier for carousel - override fun fling(velocityX: Int, velocityY: Int): Boolean { - val newVelocityX = (velocityX * flingMultiplier).toInt() - val newVelocityY = (velocityY * flingMultiplier).toInt() - return super.fling(newVelocityX, newVelocityY) - } - - private var scaleUpdatePosted = false - // Custom drawing order for carousel (for alpha fade) - override fun getChildDrawingOrder(childCount: Int, i: Int): Int { - if (!useCustomDrawingOrder || childCount == 0) return i - val center = getRecyclerViewCenter() - val children = (0 until childCount).map { idx -> - val child = getChildAt(idx) - val childCenter = (child.left + child.right) / 2f - val distance = abs(childCenter - center) - Pair(idx, distance) - } - val sorted = children.sortedWith( - compareByDescending> { it.second } - .thenBy { it.first } - ) - // Post scale update once per frame - if (!scaleUpdatePosted && i == childCount - 1) { - scaleUpdatePosted = true - post { - updateChildScalesAndAlpha() - scaleUpdatePosted = false - } - } - //Log.d("JukeboxRecyclerView", "Child $i got order ${sorted[i].first} at distance ${sorted[i].second} from center $center") - return sorted[i].first - } - - // --- OverlappingDecoration (inner class) --- - inner class OverlappingDecoration(private val overlapPx: Int) : ItemDecoration() { - override fun getItemOffsets( - outRect: Rect, view: View, parent: RecyclerView, state: State - ) { - val position = parent.getChildAdapterPosition(view) - if (position > 0) { - outRect.left = -overlapPx - } - } - } - - // Enable proper center snapping - inner class CenterPagerSnapHelper : PagerSnapHelper() { - - // NEEDED: fixes center snapping, but introduces ghost movement - override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? { - if (layoutManager !is LinearLayoutManager) return null - val center = (this@JukeboxRecyclerView).getLayoutManagerCenter(layoutManager) - var minDistance = Int.MAX_VALUE - var closestChild: View? = null - for (i in 0 until layoutManager.childCount) { - val child = layoutManager.getChildAt(i) ?: continue - val childCenter = (child.left + child.right) / 2 - val distance = kotlin.math.abs(childCenter - center) - if (distance < minDistance) { - minDistance = distance - closestChild = child - } - } - return closestChild - } - - //NEEDED: fixes ghost movement when snapping, but breaks inertial scrolling - override fun calculateDistanceToFinalSnap( - layoutManager: RecyclerView.LayoutManager, - targetView: View - ): IntArray? { - if (layoutManager !is LinearLayoutManager) return super.calculateDistanceToFinalSnap(layoutManager, targetView) - val out = IntArray(2) - val center = (this@JukeboxRecyclerView).getLayoutManagerCenter(layoutManager) - val childCenter = (targetView.left + targetView.right) / 2 - out[0] = childCenter - center - out[1] = 0 - return out - } - - // NEEDED: fixes inertial scrolling (broken by calculateDistanceToFinalSnap) - override fun findTargetSnapPosition( - layoutManager: RecyclerView.LayoutManager, - velocityX: Int, - velocityY: Int - ): Int { - if (layoutManager !is LinearLayoutManager) return RecyclerView.NO_POSITION - val firstVisible = layoutManager.findFirstVisibleItemPosition() - val lastVisible = layoutManager.findLastVisibleItemPosition() - val center = (this@JukeboxRecyclerView).getLayoutManagerCenter(layoutManager) - - var closestChild: View? = null - var minDistance = Int.MAX_VALUE - var closestPosition = RecyclerView.NO_POSITION - for (i in firstVisible..lastVisible) { - val child = layoutManager.findViewByPosition(i) ?: continue - val childCenter = (child.left + child.right) / 2 - val distance = kotlin.math.abs(childCenter - center) - if (distance < minDistance) { - minDistance = distance - closestChild = child - closestPosition = i - } - } - - val flingCount = if (velocityX == 0) 0 else velocityX / 2000 - var targetPos = closestPosition + flingCount - val itemCount = layoutManager.itemCount - targetPos = targetPos.coerceIn(0, itemCount - 1) - return targetPos - } - } -} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt new file mode 100644 index 0000000000..ae40fcb498 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt @@ -0,0 +1,409 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.ui + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.PagerSnapHelper +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.abs +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.GameAdapter +import androidx.core.view.doOnNextLayout +import org.yuzu.yuzu_emu.YuzuApplication +import androidx.preference.PreferenceManager + +/** + * CarouselRecyclerView encapsulates all carousel logic for the games UI. + * It manages overlapping cards, center snapping, custom drawing order, + * joypad & fling navigation and mid-screen swipe-to-refresh. + */ +class CarouselRecyclerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : RecyclerView(context, attrs, defStyle) { + + private var overlapFactor: Float = 0f + private var overlapPx: Int = 0 + private var overlapDecoration: OverlappingDecoration? = null + private var pagerSnapHelper: PagerSnapHelper? = null + private var scalingScrollListener: OnScrollListener? = null + + companion object { + private const val CAROUSEL_CARD_SIZE_FACTOR = "CarouselCardSizeMultiplier" + private const val CAROUSEL_BORDERCARDS_SCALE = "CarouselBorderCardsScale" + private const val CAROUSEL_BORDERCARDS_ALPHA = "CarouselBorderCardsAlpha" + private const val CAROUSEL_OVERLAP_FACTOR = "CarouselOverlapFactor" + private const val CAROUSEL_MAX_FLING_COUNT = "CarouselMaxFlingCount" + private const val CAROUSEL_FLING_MULTIPLIER = "CarouselFlingMultiplier" + private const val CAROUSEL_CARDS_SCALING_SHAPE = "CarouselCardsScalingShape" + private const val CAROUSEL_CARDS_ALPHA_SHAPE = "CarouselCardsAlphaShape" + const val CAROUSEL_LAST_SCROLL_POSITION = "CarouselLastScrollPosition" + const val CAROUSEL_VIEW_TYPE_PORTRAIT = "GamesViewTypePortrait" + const val CAROUSEL_VIEW_TYPE_LANDSCAPE = "GamesViewTypeLandscape" + } + + private val preferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + var flingMultiplier: Float = 1f + + public var pendingScrollAfterReload: Boolean = false + + var useCustomDrawingOrder: Boolean = false + set(value) { + field = value + setChildrenDrawingOrderEnabled(value) + invalidate() + } + + init { + setChildrenDrawingOrderEnabled(true) + } + + private fun calculateCenter(width: Int, paddingStart: Int, paddingEnd: Int): Int { + return paddingStart + (width - paddingStart - paddingEnd) / 2 + } + + private fun getRecyclerViewCenter(): Float { + return calculateCenter(width, paddingLeft, paddingRight).toFloat() + } + + private fun getLayoutManagerCenter(layoutManager: RecyclerView.LayoutManager): Int { + return if (layoutManager is LinearLayoutManager) { + calculateCenter(layoutManager.width, layoutManager.paddingStart, layoutManager.paddingEnd) + } else { + width / 2 + } + } + + private fun getChildDistanceToCenter(view: View): Float { + return 0.5f * (view.left + view.right) - getRecyclerViewCenter() + } + + fun restoreScrollState(position: Int = 0, attempts: Int = 0) { + val lm = layoutManager as? LinearLayoutManager ?: return + if (lm.findLastVisibleItemPosition() == RecyclerView.NO_POSITION && attempts < 10) { + post { restoreScrollState(position, attempts + 1) } + return + } + scrollToPosition(position) + } + + fun getClosestChildPosition(fullRange: Boolean = false): Int { + val lm = layoutManager as? LinearLayoutManager ?: return RecyclerView.NO_POSITION + var minDistance = Int.MAX_VALUE + var closestPosition = RecyclerView.NO_POSITION + val start = if (fullRange) 0 else lm.findFirstVisibleItemPosition() + val end = if (fullRange) lm.childCount - 1 else lm.findLastVisibleItemPosition() + for (i in start..end) { + val child = lm.findViewByPosition(i) ?: continue + val distance = kotlin.math.abs(getChildDistanceToCenter(child).toInt()) + if (distance < minDistance) { + minDistance = distance + closestPosition = i + } + } + return closestPosition + } + + fun updateChildScalesAndAlpha() { + for (i in 0 until childCount) { + val child = getChildAt(i) ?: continue + updateChildScaleAndAlphaForPosition(child) + } + } + + fun shapingFunction(x: Float, option: Int = 0): Float { + return when (option) { + 0 -> 1f //Off + 1 -> 1f - x //linear descending + 2 -> (1f - x) * (1f - x) //Ease out + 3 -> if (x < 0.05f) 1f else (1f-x) * 0.8f + 4 -> kotlin.math.cos(x * Math.PI).toFloat() //Cosine + 5 -> kotlin.math.cos( (1.5f * x).coerceIn(0f, 1f) * Math.PI).toFloat() //Cosine 1.5x trimmed + else -> 1f //Default to Off + } + } + + fun updateChildScaleAndAlphaForPosition(child: View) { + val cardSize = (adapter as? GameAdapter ?: return).cardSize + val position = getChildViewHolder(child).bindingAdapterPosition + if (position == RecyclerView.NO_POSITION || cardSize <= 0) { + return // No valid position or card size + } + child.layoutParams.width = cardSize + child.layoutParams.height = cardSize + + val center = getRecyclerViewCenter() + val distance = abs(getChildDistanceToCenter(child)) + val internalBorderScale = resources.getFraction(R.fraction.carousel_bordercards_scale, 1, 1) + val borderScale = preferences.getFloat(CAROUSEL_BORDERCARDS_SCALE, internalBorderScale).coerceIn(0f, 1f) + + val shapeInput = (distance / center).coerceIn(0f, 1f) + val internalShapeSetting = resources.getInteger(R.integer.carousel_cards_scaling_shape) + val scalingShapeSetting = preferences.getInt(CAROUSEL_CARDS_SCALING_SHAPE, internalShapeSetting) + val shapedScaling = shapingFunction(shapeInput, scalingShapeSetting) + val scale = (borderScale + (1f - borderScale) * shapedScaling).coerceIn(0f, 1f) + + val maxDistance = width / 2f + val alphaInput = (distance / maxDistance).coerceIn(0f, 1f) + val internalBordersAlpha = resources.getFraction(R.fraction.carousel_bordercards_alpha, 1, 1) + val borderAlpha = preferences.getFloat(CAROUSEL_BORDERCARDS_ALPHA, internalBordersAlpha).coerceIn(0f, 1f) + val internalAlphaShapeSetting = resources.getInteger(R.integer.carousel_cards_alpha_shape) + val alphaShapeSetting = preferences.getInt(CAROUSEL_CARDS_ALPHA_SHAPE, internalAlphaShapeSetting) + val shapedAlpha = shapingFunction(alphaInput, alphaShapeSetting) + val alpha = (borderAlpha + (1f - borderAlpha) * shapedAlpha).coerceIn(0f, 1f) + + child.animate().cancel() + child.alpha = alpha + child.scaleX = scale + child.scaleY = scale + } + + fun focusCenteredCard() { + val centeredPos = getClosestChildPosition() + if (centeredPos != RecyclerView.NO_POSITION) { + val vh = findViewHolderForAdapterPosition(centeredPos) + vh?.itemView?.let { child -> + child.isFocusable = true + child.isFocusableInTouchMode = true + child.requestFocus() + } + } + } + + fun setCarouselMode(enabled: Boolean, gameAdapter: GameAdapter? = null) { + if (enabled) { + useCustomDrawingOrder = true + + val insets = rootWindowInsets + val bottomInset = insets?.getInsets(android.view.WindowInsets.Type.systemBars())?.bottom ?: 0 + val internalFactor = resources.getFraction(R.fraction.carousel_card_size_factor, 1, 1) + val userFactor = preferences.getFloat(CAROUSEL_CARD_SIZE_FACTOR, internalFactor).coerceIn(0f, 1f) + val cardSize = (userFactor * (height - bottomInset)).toInt() + gameAdapter?.setCardSize(cardSize) + + val internalOverlapFactor = resources.getFraction(R.fraction.carousel_overlap_factor, 1, 1) + overlapFactor = preferences.getFloat(CAROUSEL_OVERLAP_FACTOR, internalOverlapFactor).coerceIn(0f, 1f) + overlapPx = (cardSize * overlapFactor).toInt() + + val internalFlingMultiplier = resources.getFraction(R.fraction.carousel_fling_multiplier, 1, 1) + flingMultiplier = preferences.getFloat(CAROUSEL_FLING_MULTIPLIER, internalFlingMultiplier).coerceIn(1f, 5f) + + gameAdapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onChanged() { + if (pendingScrollAfterReload) { + post { + jigglyScroll() + pendingScrollAfterReload = false + } + } + } + }) + + // Detach SnapHelper during setup + pagerSnapHelper?.attachToRecyclerView(null) + + // Add overlap decoration if not present + if (overlapDecoration == null) { + overlapDecoration = OverlappingDecoration(overlapPx) + addItemDecoration(overlapDecoration!!) + } + + // Gradual scalingAdd commentMore actions + if (scalingScrollListener == null) { + scalingScrollListener = object : OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + updateChildScalesAndAlpha() + } + } + addOnScrollListener(scalingScrollListener!!) + } + + if (cardSize > 0) { + val topPadding = ((height - bottomInset - cardSize) / 2).coerceAtLeast(0) // Center vertically + val sidePadding = (width - cardSize) / 2 // Center first/last card + setPadding(sidePadding, topPadding, sidePadding, 0) + clipToPadding = false + } + + if (pagerSnapHelper == null) { + pagerSnapHelper = CenterPagerSnapHelper() + pagerSnapHelper!!.attachToRecyclerView(this) + } + } else { + // Remove overlap decoration + overlapDecoration?.let { removeItemDecoration(it) } + overlapDecoration = null + // Remove scaling scroll listener + scalingScrollListener?.let { removeOnScrollListener(it) } + scalingScrollListener = null + // Detach PagerSnapHelper + pagerSnapHelper?.attachToRecyclerView(null) + pagerSnapHelper = null + useCustomDrawingOrder = false + // Reset padding and fling + setPadding(0, 0, 0, 0) + clipToPadding = true + flingMultiplier = 1f + // Reset scaling + for (i in 0 until childCount) { + val child = getChildAt(i) + child?.scaleX = 1f + child?.scaleY = 1f + child?.alpha = 1f + } + } + } + + override fun onScrollStateChanged(state: Int) { + super.onScrollStateChanged(state) + if (state == RecyclerView.SCROLL_STATE_IDLE) { + focusCenteredCard() + } + } + + override fun scrollToPosition(position: Int) { + super.scrollToPosition(position) + (layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(position, overlapPx) + doOnNextLayout { + updateChildScalesAndAlpha() + focusCenteredCard() + } + } + + private var lastFocusSearchTime: Long = 0 + override fun focusSearch(focused: View, direction: Int): View? { + if (layoutManager !is LinearLayoutManager) return super.focusSearch(focused, direction) + val vh = findContainingViewHolder(focused) ?: return super.focusSearch(focused, direction) + val itemCount = adapter?.itemCount ?: return super.focusSearch(focused, direction) + val position = vh.bindingAdapterPosition + + return when (direction) { + View.FOCUS_LEFT -> { + if (position > 0) { + val now = System.currentTimeMillis() + val repeatDetected = (now - lastFocusSearchTime) < resources.getInteger(R.integer.carousel_focus_search_repeat_threshold_ms) + lastFocusSearchTime = now + if (!repeatDetected) { //ensures the first run + val offset = focused.width - overlapPx + smoothScrollBy(-offset, 0) + } + findViewHolderForAdapterPosition(position - 1)?.itemView ?: super.focusSearch(focused, direction) + } else { + focused + } + } + View.FOCUS_RIGHT -> { + if (position < itemCount - 1) { + findViewHolderForAdapterPosition(position + 1)?.itemView ?: super.focusSearch(focused, direction) + } else { + focused + } + } + else -> super.focusSearch(focused, direction) + } + } + + // Custom fling multiplier for carousel + override fun fling(velocityX: Int, velocityY: Int): Boolean { + val newVelocityX = (velocityX * flingMultiplier).toInt() + val newVelocityY = (velocityY * flingMultiplier).toInt() + return super.fling(newVelocityX, newVelocityY) + } + + // Custom drawing order for carousel (for alpha fade) + override fun getChildDrawingOrder(childCount: Int, i: Int): Int { + if (!useCustomDrawingOrder || childCount == 0) return i + val children = (0 until childCount).map { idx -> + val distance = abs(getChildDistanceToCenter(getChildAt(idx))) + Pair(idx, distance) + } + val sorted = children.sortedWith( + compareByDescending> { it.second } + .thenBy { it.first } + ) + return sorted[i].first + } + + fun jigglyScroll() { + scrollBy(-1, 0) + scrollBy(1, 0) + focusCenteredCard() + } + + inner class OverlappingDecoration(private val overlap: Int) : ItemDecoration() { + override fun getItemOffsets( + outRect: Rect, view: View, parent: RecyclerView, state: State + ) { + val position = parent.getChildAdapterPosition(view) + if (position > 0) { + outRect.left = -overlap + } + } + } + + inner class VerticalCenterDecoration : ItemDecoration() { + override fun getItemOffsets( + outRect: android.graphics.Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val parentHeight = parent.height + val childHeight = view.layoutParams.height.takeIf { it > 0 } + ?: view.measuredHeight.takeIf { it > 0 } + ?: view.height + + if (parentHeight > 0 && childHeight > 0) { + val verticalPadding = ((parentHeight - childHeight) / 2).coerceAtLeast(0) + outRect.top = verticalPadding + outRect.bottom = verticalPadding + } + } + } + + inner class CenterPagerSnapHelper : PagerSnapHelper() { + + // NEEDED: fixes center snapping, but introduces ghost movement + override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? { + if (layoutManager !is LinearLayoutManager) return null + return layoutManager.findViewByPosition(getClosestChildPosition()) + } + + //NEEDED: fixes ghost movement when snapping, but breaks inertial scrolling + override fun calculateDistanceToFinalSnap( + layoutManager: RecyclerView.LayoutManager, + targetView: View + ): IntArray? { + if (layoutManager !is LinearLayoutManager) return super.calculateDistanceToFinalSnap(layoutManager, targetView) + val out = IntArray(2) + out[0] = getChildDistanceToCenter(targetView).toInt() + out[1] = 0 + return out + } + + // NEEDED: fixes inertial scrolling (broken by calculateDistanceToFinalSnap) + override fun findTargetSnapPosition( + layoutManager: RecyclerView.LayoutManager, + velocityX: Int, + velocityY: Int + ): Int { + if (layoutManager !is LinearLayoutManager) return RecyclerView.NO_POSITION + val closestPosition = this@CarouselRecyclerView.getClosestChildPosition() + val internalMaxFling = resources.getInteger(R.integer.carousel_max_fling_count) + val maxFling = preferences.getInt(CAROUSEL_MAX_FLING_COUNT, internalMaxFling).coerceIn(1, 10) + val rawFlingCount = if (velocityX == 0) 0 else velocityX / 2000 + val flingCount = rawFlingCount.coerceIn(-maxFling, maxFling) + var targetPos = (closestPosition + flingCount).coerceIn(0, layoutManager.itemCount - 1) + return targetPos + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/res/layout-land/card_game_carousel.xml b/src/android/app/src/main/res/layout-land/card_game_carousel.xml new file mode 100644 index 0000000000..fcc9397a13 --- /dev/null +++ b/src/android/app/src/main/res/layout-land/card_game_carousel.xml @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout-land/fragment_games.xml b/src/android/app/src/main/res/layout-land/fragment_games.xml index 087a943497..14e9d5358c 100644 --- a/src/android/app/src/main/res/layout-land/fragment_games.xml +++ b/src/android/app/src/main/res/layout-land/fragment_games.xml @@ -185,7 +185,7 @@ android:visibility="gone" /> - - diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml index 1533dd2831..555f8c1e97 100644 --- a/src/android/app/src/main/res/values/dimens.xml +++ b/src/android/app/src/main/res/values/dimens.xml @@ -15,7 +15,6 @@ 24dp 96dp 24dp - 150dp 20dp 3dp diff --git a/src/android/app/src/main/res/values/fractions.xml b/src/android/app/src/main/res/values/fractions.xml index 5c5b0b289c..deb8ac3bea 100644 --- a/src/android/app/src/main/res/values/fractions.xml +++ b/src/android/app/src/main/res/values/fractions.xml @@ -1,6 +1,8 @@ - 60% - 60% + 60% + 60% + 60% + 95% 200% - 100% + 20% diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml index 5ddc913c41..cc05478c21 100644 --- a/src/android/app/src/main/res/values/integers.xml +++ b/src/android/app/src/main/res/values/integers.xml @@ -3,6 +3,10 @@ 1 1 2 + 4 + 100 + 1 + 4 760