From d77c14a1a42ca61e8d2b42077068669880f6f4b1 Mon Sep 17 00:00:00 2001 From: xbzk Date: Wed, 26 Aug 2026 11:38:34 -0300 Subject: [PATCH] [android, ui] carousel: display wise scaling, snapping fix, bottom insets rework --- .../org/yuzu/yuzu_emu/ui/GamesFragment.kt | 14 +- .../yuzu_emu/views/CarouselRecyclerView.kt | 129 +++++++++++++----- 2 files changed, 97 insertions(+), 46 deletions(-) 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 49034e81c4..a71978b4b8 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 @@ -47,7 +47,6 @@ 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.doOnNextLayout class GamesFragment : Fragment() { private var _binding: FragmentGamesBinding? = null @@ -59,7 +58,6 @@ class GamesFragment : Fragment() { private var originalHeaderLeftMargin: Int? = null private var lastViewType: Int = GameAdapter.VIEW_TYPE_GRID - private var fallbackBottomInset: Int = 0 private var pendingPostReloadListSettle = false private var pendingPostReloadListSettleGeneration = 0 private var gameListSubmitGeneration = 0 @@ -227,12 +225,7 @@ class GamesFragment : Fragment() { } else -> throw IllegalArgumentException("Invalid view type: $savedViewType") } - if (savedViewType == GameAdapter.VIEW_TYPE_CAROUSEL) { - (binding.gridGames as? View)?.let { it -> ViewCompat.requestApplyInsets(it)} - doOnNextLayout { //Carousel: important to avoid overlap issues - (this as? CarouselRecyclerView)?.notifyLaidOut(fallbackBottomInset) - } - } else { + if (savedViewType != GameAdapter.VIEW_TYPE_CAROUSEL) { (this as? CarouselRecyclerView)?.setupCarousel(false) } adapter = gameAdapter @@ -590,11 +583,6 @@ class GamesFragment : Fragment() { qlaunchButton.layoutParams = mlpQLaunch } - val navInsets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) - val gestureInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemGestures()) - val bottomInset = maxOf(navInsets.bottom, gestureInsets.bottom, cutoutInsets.bottom) - fallbackBottomInset = bottomInset - (binding.gridGames as? CarouselRecyclerView)?.notifyInsetsReady(bottomInset) windowInsets } } 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 index 23b2f0be46..0c63f1e121 100644 --- 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 @@ -12,13 +12,16 @@ import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.RecyclerView import kotlin.math.abs import kotlin.math.cos +import kotlin.math.pow import kotlin.math.sin import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.adapters.GameAdapter import androidx.core.view.doOnNextLayout +import androidx.core.view.ViewCompat import org.yuzu.yuzu_emu.YuzuApplication import androidx.preference.PreferenceManager import androidx.core.view.WindowInsetsCompat +import org.yuzu.yuzu_emu.utils.FullscreenHelper /** * CarouselRecyclerView encapsulates all carousel content for the games UI. * It manages overlapping cards, center snapping, custom drawing order, @@ -32,7 +35,9 @@ class CarouselRecyclerView @JvmOverloads constructor( private var overlapFactor: Float = 0f private var overlapPx: Int = 0 - private var bottomInset: Int = -1 + private var bottomInset: Int = 0 + private var latestWindowInsets: WindowInsetsCompat? = null + private var cardGeometryInitialized: Boolean = false private var overlapDecoration: OverlappingDecoration? = null private var pagerSnapHelper: PagerSnapHelper? = null private var scalingScrollListener: OnScrollListener? = null @@ -91,6 +96,38 @@ class CarouselRecyclerView @JvmOverloads constructor( init { setChildrenDrawingOrderEnabled(true) + ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> + latestWindowInsets = insets + updateCardGeometry() + applyCarouselPadding() + insets + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + ViewCompat.requestApplyInsets(this) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (w != oldw || h != oldh) { + updateCardGeometry() + applyCarouselPadding() + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + if (isCarouselMode) updateChildScalesAndAlpha() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + ViewCompat.requestApplyInsets(this) + post { updateCardGeometry() } + } } override fun setAdapter(adapter: Adapter<*>?) { @@ -103,6 +140,8 @@ class CarouselRecyclerView @JvmOverloads constructor( super.setAdapter(adapter) (adapter as? GameAdapter)?.registerAdapterDataObserver(carouselAdapterObserver) + updateCardGeometry() + applyCarouselPadding() } private fun calculateCenter(width: Int, paddingStart: Int, paddingEnd: Int): Int { @@ -253,40 +292,71 @@ class CarouselRecyclerView @JvmOverloads constructor( } } - fun notifyInsetsReady(newBottomInset: Int) { - if (bottomInset != newBottomInset) { - bottomInset = newBottomInset - } - - if (isCarouselMode) { - setupCarousel(true) + private fun resolveBottomInset(windowInsets: WindowInsetsCompat): Int { + val navigationBottom = if (FullscreenHelper.isFullscreenEnabled(context)) { + 0 } else { - setupCarousel(false) + windowInsets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.navigationBars()).bottom } + val gestureInsets = windowInsets.getInsetsIgnoringVisibility( + WindowInsetsCompat.Type.systemGestures() + ) + val cutoutInsets = windowInsets.getInsetsIgnoringVisibility( + WindowInsetsCompat.Type.displayCutout() + ) + return maxOf(navigationBottom, gestureInsets.bottom, cutoutInsets.bottom) } - fun notifyLaidOut(fallBackBottomInset: Int) { - if (bottomInset < 0) bottomInset = fallBackBottomInset - var gameAdapter = adapter as? GameAdapter ?: return - var newCardSize = cardSize(bottomInset) - if (gameAdapter.cardSize != newCardSize) { - gameAdapter.setCardSize(newCardSize) - } + private fun updateCardGeometry() { + if (!isCarouselMode || height <= 0) return - if (isCarouselMode) { - setupCarousel(true) - } - } + val gameAdapter = adapter as? GameAdapter ?: return + val windowInsets = latestWindowInsets ?: ViewCompat.getRootWindowInsets(this) ?: return - fun cardSize(bottomInset: Int): Int { + if (cardGeometryInitialized && !hasWindowFocus()) return + + val newBottomInset = resolveBottomInset(windowInsets).coerceIn(0, height) 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 scaledHeight = height * userFactor - val availableHeight = height - bottomInset - return minOf(scaledHeight.toInt(), availableHeight.toInt()) + val screenWidth = resources.displayMetrics.widthPixels.toFloat() + val screenHeight = resources.displayMetrics.heightPixels.toFloat() + val aspectFactor = ((screenWidth / screenHeight) / (20f / 9f)) + .pow(0.75f) + .coerceIn(0.5f, 1f) + val newCardSize = minOf( + (height * userFactor).toInt(), + height - newBottomInset, + (height * aspectFactor).toInt() + ) + if (newCardSize <= 0) return + val insetChanged = bottomInset != newBottomInset + val cardSizeChanged = gameAdapter.cardSize != newCardSize + + bottomInset = newBottomInset + cardGeometryInitialized = true + + if (cardSizeChanged) gameAdapter.setCardSize(newCardSize) + if (insetChanged || cardSizeChanged) setupCarousel(true) + } + + private fun applyCarouselPadding() { + if (!isCarouselMode) return + + val gameAdapter = adapter as? GameAdapter ?: return + val cardSize = gameAdapter.cardSize + if (cardSize <= 0 || bottomInset < 0) return + + val topPadding = ((height - bottomInset - cardSize) / 2).coerceAtLeast(0) + val sidePadding = (width - cardSize) / 2 + if (paddingLeft != sidePadding || paddingTop != topPadding || + paddingRight != sidePadding || paddingBottom != 0 + ) { + setPadding(sidePadding, topPadding, sidePadding, 0) + } + clipToPadding = false } fun setupCarousel(enabled: Boolean) { @@ -315,9 +385,6 @@ class CarouselRecyclerView @JvmOverloads constructor( internalFlingMultiplier ).coerceIn(1f, 5f) - // Detach SnapHelper during setup - pagerSnapHelper?.attachToRecyclerView(null) - // Add overlap decoration if not present if (overlapDecoration == null) { overlapDecoration = OverlappingDecoration(overlapPx) @@ -335,12 +402,7 @@ class CarouselRecyclerView @JvmOverloads constructor( 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 - } + applyCarouselPadding() if (pagerSnapHelper == null) { pagerSnapHelper = CenterPagerSnapHelper() @@ -362,6 +424,7 @@ class CarouselRecyclerView @JvmOverloads constructor( } savedItemAnimator = null } + cardGeometryInitialized = false useCustomDrawingOrder = false // Reset padding and fling setPadding(0, 0, 0, 0)