aboutsummaryrefslogtreecommitdiff
path: root/src/android
diff options
context:
space:
mode:
authorxbzk <xbzk@eden-emu.dev>2026-08-21 23:29:04 +0200
committercrueter <crueter@eden-emu.dev>2026-08-21 23:29:04 +0200
commit61ffb309a39a42301b75627ddb23239c0e320e4c (patch)
tree78aed26802525de33d36e6f9585d629a1d61829b /src/android
parent65a3cfd2be02e1e59bcfcf09fe3edff5ed48a18a (diff)
[android,ui] carousel view improvements (#4277)
- [x] I have read and followed the [Contribution Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/CONTRIBUTING.md#code-contributions). - [x] I have read and followed the [AI Policy](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/AI.md) - [x] I have read and followed the [Coding Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/Coding.md) to the best of my ability. ------------------- One major change to make on carousel, one yoozoo era bug fixed, and some other minor adjustments. Commits squashed: [android, ui] Fixed carousel card size scaling over bottom insets [android, ui] Removed old shapingFunctions [android, ui] Implemented carousel true elliptic movement model [android, ui] Fixed overheighted flicker on cards after refresh [android, ui] Fixed full opaque secondary cards after refresh [android, ui] Fixed flickers due to doubled list redraw after refreshes Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/4277 Reviewed-by: CamilleLaVey <camillelavey99@gmail.com> Reviewed-by: Samuel <lizzie@eden-emu.dev>
Diffstat (limited to 'src/android')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt51
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt101
-rw-r--r--src/android/app/src/main/res/values/integers.xml2
3 files changed, 106 insertions, 48 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 6931a1f9d5..49034e81c4 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
@@ -18,6 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.doOnPreDraw
import androidx.core.view.updatePadding
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment
@@ -59,6 +60,10 @@ class GamesFragment : Fragment() {
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
+ private var committedGameListSubmitGeneration = 0
companion object {
private const val SEARCH_TEXT = "SearchText"
@@ -168,10 +173,9 @@ class GamesFragment : Fragment() {
gamesViewModel.shouldScrollAfterReload.collect(viewLifecycleOwner) { shouldScroll ->
if (shouldScroll) {
- binding.gridGames.post {
- (binding.gridGames as? CarouselRecyclerView)?.pendingScrollAfterReload = true
- gameAdapter.notifyDataSetChanged()
- }
+ pendingPostReloadListSettle = true
+ pendingPostReloadListSettleGeneration = gameListSubmitGeneration
+ schedulePostReloadListSettle()
gamesViewModel.setShouldScrollAfterReload(false)
}
}
@@ -273,11 +277,42 @@ class GamesFragment : Fragment() {
lastSearchText = currentSearchText
lastFilter = currentFilter
} else {
- ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(games)
+ submitGameList(games)
gamesViewModel.setFilteredGames(games)
}
}
+ private fun submitGameList(games: List<Game>) {
+ val adapter = (binding.gridGames as? RecyclerView)?.adapter as? GameAdapter
+ if (adapter == null) {
+ schedulePostReloadListSettle()
+ return
+ }
+
+ val submitGeneration = ++gameListSubmitGeneration
+ adapter.submitList(games) {
+ if (committedGameListSubmitGeneration < submitGeneration) {
+ committedGameListSubmitGeneration = submitGeneration
+ }
+ schedulePostReloadListSettle()
+ }
+ }
+
+
+ private fun schedulePostReloadListSettle() {
+ if (!pendingPostReloadListSettle || _binding == null) return
+
+ binding.gridGames.doOnPreDraw {
+ if (!pendingPostReloadListSettle || _binding == null) return@doOnPreDraw
+ if (committedGameListSubmitGeneration < pendingPostReloadListSettleGeneration) {
+ schedulePostReloadListSettle()
+ return@doOnPreDraw
+ }
+ pendingPostReloadListSettle = false
+
+ (binding.gridGames as? CarouselRecyclerView)?.refreshView()
+ }
+ }
private fun setupTopView() {
binding.searchText.doOnTextChanged() { text: CharSequence?, _: Int, _: Int, _: Int ->
if (text.toString().isNotEmpty()) {
@@ -414,9 +449,7 @@ class GamesFragment : Fragment() {
val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
if (searchTerm.isEmpty()) {
- ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(
- filteredList
- )
+ submitGameList(filteredList)
gamesViewModel.setFilteredGames(filteredList)
return
}
@@ -432,7 +465,7 @@ class GamesFragment : Fragment() {
}
}.sortedByDescending { it.score }.map { it.item }
- ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(sortedList)
+ submitGameList(sortedList)
gamesViewModel.setFilteredGames(sortedList)
}
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 24dafbcd32..23b2f0be46 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
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.ui
@@ -11,6 +11,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
+import kotlin.math.cos
+import kotlin.math.sin
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.GameAdapter
import androidx.core.view.doOnNextLayout
@@ -34,6 +36,7 @@ class CarouselRecyclerView @JvmOverloads constructor(
private var overlapDecoration: OverlappingDecoration? = null
private var pagerSnapHelper: PagerSnapHelper? = null
private var scalingScrollListener: OnScrollListener? = null
+ private var savedItemAnimator: RecyclerView.ItemAnimator? = null
companion object {
private const val CAROUSEL_CARD_SIZE_FACTOR = "CarouselCardSizeMultiplier"
@@ -42,8 +45,13 @@ class CarouselRecyclerView @JvmOverloads constructor(
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"
+ private const val CAROUSEL_ARC_ANGLE_STEP_DEGREES = 15.0
+ private const val CAROUSEL_ARC_MAX_ANGLE_DEGREES = 165.0
+ private const val CAROUSEL_ARC_DEPTH_MAX_ANGLE_DEGREES = 85.0
+ private const val CAROUSEL_ARC_DEPTH_STRETCH = 5.0f
+ private const val CAROUSEL_ARC_X_DEPTH_FACTOR = 0.55f
+ private const val CAROUSEL_ARC_FADE_OUT_START_DEGREES = 60.0
+ private const val CAROUSEL_ARC_FADE_OUT_END_DEGREES = 95.0
const val CAROUSEL_LAST_SCROLL_POSITION = "CarouselLastScrollPosition"
const val CAROUSEL_VIEW_TYPE_PORTRAIT = "GamesViewTypePortrait"
const val CAROUSEL_VIEW_TYPE_LANDSCAPE = "GamesViewTypeLandscape"
@@ -160,46 +168,52 @@ class CarouselRecyclerView @JvmOverloads constructor(
}
}
- 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 layoutParams = child.layoutParams
+ if (layoutParams.width != cardSize || layoutParams.height != cardSize) {
+ child.layoutParams = layoutParams.apply {
+ width = cardSize
+ height = cardSize
+ }
+ }
+
+ val signedDistance = getChildDistanceToCenter(child)
+ val itemStep = (cardSize - overlapPx).toFloat().coerceAtLeast(1f)
+ val angleStep = Math.toRadians(CAROUSEL_ARC_ANGLE_STEP_DEGREES).toFloat()
+ val maxAngle = Math.toRadians(CAROUSEL_ARC_MAX_ANGLE_DEGREES).toFloat()
+ val depthMaxAngle = Math.toRadians(CAROUSEL_ARC_DEPTH_MAX_ANGLE_DEGREES).toFloat()
+ val fadeOutStartAngle = Math.toRadians(CAROUSEL_ARC_FADE_OUT_START_DEGREES).toFloat()
+ val fadeOutEndAngle = Math.toRadians(CAROUSEL_ARC_FADE_OUT_END_DEGREES).toFloat()
+ val angle = (signedDistance / itemStep * angleStep).coerceIn(-maxAngle, maxAngle)
+ val arcRadius = itemStep / angleStep
+ val arcX = sin(angle) * arcRadius
+ val absoluteAngle = abs(angle)
+ val rawDepthInput = ((1f - cos(absoluteAngle)) / (1f - cos(depthMaxAngle)))
+ .coerceIn(0f, 1f)
+ val easedDepthTail = Math.pow(
+ (1f - rawDepthInput).toDouble(),
+ CAROUSEL_ARC_DEPTH_STRETCH.toDouble()
+ ).toFloat()
+ val depthInput = (1f - easedDepthTail).coerceIn(0f, 1f)
+ val projectedArcX = arcX * (1f - rawDepthInput * CAROUSEL_ARC_X_DEPTH_FACTOR)
+
+ child.animate().cancel()
+ child.translationX = projectedArcX - signedDistance
- 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 shapedScaling = 1f - depthInput
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,
@@ -209,15 +223,12 @@ class CarouselRecyclerView @JvmOverloads constructor(
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)
+ val shapedAlpha = cos(depthInput * Math.PI).toFloat()
+ val baseAlpha = (borderAlpha + (1f - borderAlpha) * shapedAlpha).coerceIn(0f, 1f)
+ val rearPresence = (1f - (absoluteAngle - fadeOutStartAngle) /
+ (fadeOutEndAngle - fadeOutStartAngle)).coerceIn(0f, 1f)
+ val alpha = (baseAlpha * rearPresence).coerceIn(0f, 1f)
- child.animate().cancel()
child.alpha = alpha
child.scaleX = scale
child.scaleY = scale
@@ -273,7 +284,9 @@ class CarouselRecyclerView @JvmOverloads constructor(
0f,
1f
)
- return (userFactor * (height - bottomInset)).toInt()
+ val scaledHeight = height * userFactor
+ val availableHeight = height - bottomInset
+ return minOf(scaledHeight.toInt(), availableHeight.toInt())
}
fun setupCarousel(enabled: Boolean) {
@@ -282,6 +295,13 @@ class CarouselRecyclerView @JvmOverloads constructor(
if (gameAdapter.cardSize == 0) return
if (bottomInset < 0) return
+ itemAnimator?.let {
+ if (savedItemAnimator == null) {
+ savedItemAnimator = it
+ }
+ itemAnimator = null
+ }
+
useCustomDrawingOrder = true
val cardSize = gameAdapter.cardSize
@@ -336,6 +356,12 @@ class CarouselRecyclerView @JvmOverloads constructor(
// Detach PagerSnapHelper
pagerSnapHelper?.attachToRecyclerView(null)
pagerSnapHelper = null
+ savedItemAnimator?.let {
+ if (itemAnimator == null) {
+ itemAnimator = it
+ }
+ savedItemAnimator = null
+ }
useCustomDrawingOrder = false
// Reset padding and fling
setPadding(0, 0, 0, 0)
@@ -344,6 +370,7 @@ class CarouselRecyclerView @JvmOverloads constructor(
// Reset scaling
for (i in 0 until childCount) {
val child = getChildAt(i)
+ child?.translationX = 0f
child?.scaleX = 1f
child?.scaleY = 1f
child?.alpha = 1f
diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml
index cc05478c21..55e885e42e 100644
--- a/src/android/app/src/main/res/values/integers.xml
+++ b/src/android/app/src/main/res/values/integers.xml
@@ -5,8 +5,6 @@
<integer name="game_columns_grid">2</integer>
<integer name="carousel_max_fling_count">4</integer>
<integer name="carousel_focus_search_repeat_threshold_ms">100</integer>
- <integer name="carousel_cards_scaling_shape">1</integer>
- <integer name="carousel_cards_alpha_shape">4</integer>
<!-- Default SWITCH landscape layout -->
<integer name="BUTTON_A_X">760</integer>