mirror of
https://git.eden-emu.dev/eden-emu/eden
synced 2026-10-20 18:59:26 +02:00
[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>
This commit is contained in:
parent
65a3cfd2be
commit
61ffb309a3
3 changed files with 106 additions and 48 deletions
|
|
@ -18,6 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.doOnPreDraw
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.core.widget.doOnTextChanged
|
import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
|
@ -59,6 +60,10 @@ class GamesFragment : Fragment() {
|
||||||
|
|
||||||
private var lastViewType: Int = GameAdapter.VIEW_TYPE_GRID
|
private var lastViewType: Int = GameAdapter.VIEW_TYPE_GRID
|
||||||
private var fallbackBottomInset: Int = 0
|
private var fallbackBottomInset: Int = 0
|
||||||
|
private var pendingPostReloadListSettle = false
|
||||||
|
private var pendingPostReloadListSettleGeneration = 0
|
||||||
|
private var gameListSubmitGeneration = 0
|
||||||
|
private var committedGameListSubmitGeneration = 0
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val SEARCH_TEXT = "SearchText"
|
private const val SEARCH_TEXT = "SearchText"
|
||||||
|
|
@ -168,10 +173,9 @@ class GamesFragment : Fragment() {
|
||||||
|
|
||||||
gamesViewModel.shouldScrollAfterReload.collect(viewLifecycleOwner) { shouldScroll ->
|
gamesViewModel.shouldScrollAfterReload.collect(viewLifecycleOwner) { shouldScroll ->
|
||||||
if (shouldScroll) {
|
if (shouldScroll) {
|
||||||
binding.gridGames.post {
|
pendingPostReloadListSettle = true
|
||||||
(binding.gridGames as? CarouselRecyclerView)?.pendingScrollAfterReload = true
|
pendingPostReloadListSettleGeneration = gameListSubmitGeneration
|
||||||
gameAdapter.notifyDataSetChanged()
|
schedulePostReloadListSettle()
|
||||||
}
|
|
||||||
gamesViewModel.setShouldScrollAfterReload(false)
|
gamesViewModel.setShouldScrollAfterReload(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -273,11 +277,42 @@ class GamesFragment : Fragment() {
|
||||||
lastSearchText = currentSearchText
|
lastSearchText = currentSearchText
|
||||||
lastFilter = currentFilter
|
lastFilter = currentFilter
|
||||||
} else {
|
} else {
|
||||||
((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(games)
|
submitGameList(games)
|
||||||
gamesViewModel.setFilteredGames(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() {
|
private fun setupTopView() {
|
||||||
binding.searchText.doOnTextChanged() { text: CharSequence?, _: Int, _: Int, _: Int ->
|
binding.searchText.doOnTextChanged() { text: CharSequence?, _: Int, _: Int, _: Int ->
|
||||||
if (text.toString().isNotEmpty()) {
|
if (text.toString().isNotEmpty()) {
|
||||||
|
|
@ -414,9 +449,7 @@ class GamesFragment : Fragment() {
|
||||||
|
|
||||||
val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
|
val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
|
||||||
if (searchTerm.isEmpty()) {
|
if (searchTerm.isEmpty()) {
|
||||||
((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(
|
submitGameList(filteredList)
|
||||||
filteredList
|
|
||||||
)
|
|
||||||
gamesViewModel.setFilteredGames(filteredList)
|
gamesViewModel.setFilteredGames(filteredList)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -432,7 +465,7 @@ class GamesFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}.sortedByDescending { it.score }.map { it.item }
|
}.sortedByDescending { it.score }.map { it.item }
|
||||||
|
|
||||||
((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(sortedList)
|
submitGameList(sortedList)
|
||||||
gamesViewModel.setFilteredGames(sortedList)
|
gamesViewModel.setFilteredGames(sortedList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.ui
|
package org.yuzu.yuzu_emu.ui
|
||||||
|
|
@ -11,6 +11,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.PagerSnapHelper
|
import androidx.recyclerview.widget.PagerSnapHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
import org.yuzu.yuzu_emu.R
|
import org.yuzu.yuzu_emu.R
|
||||||
import org.yuzu.yuzu_emu.adapters.GameAdapter
|
import org.yuzu.yuzu_emu.adapters.GameAdapter
|
||||||
import androidx.core.view.doOnNextLayout
|
import androidx.core.view.doOnNextLayout
|
||||||
|
|
@ -34,6 +36,7 @@ class CarouselRecyclerView @JvmOverloads constructor(
|
||||||
private var overlapDecoration: OverlappingDecoration? = null
|
private var overlapDecoration: OverlappingDecoration? = null
|
||||||
private var pagerSnapHelper: PagerSnapHelper? = null
|
private var pagerSnapHelper: PagerSnapHelper? = null
|
||||||
private var scalingScrollListener: OnScrollListener? = null
|
private var scalingScrollListener: OnScrollListener? = null
|
||||||
|
private var savedItemAnimator: RecyclerView.ItemAnimator? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CAROUSEL_CARD_SIZE_FACTOR = "CarouselCardSizeMultiplier"
|
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_OVERLAP_FACTOR = "CarouselOverlapFactor"
|
||||||
private const val CAROUSEL_MAX_FLING_COUNT = "CarouselMaxFlingCount"
|
private const val CAROUSEL_MAX_FLING_COUNT = "CarouselMaxFlingCount"
|
||||||
private const val CAROUSEL_FLING_MULTIPLIER = "CarouselFlingMultiplier"
|
private const val CAROUSEL_FLING_MULTIPLIER = "CarouselFlingMultiplier"
|
||||||
private const val CAROUSEL_CARDS_SCALING_SHAPE = "CarouselCardsScalingShape"
|
private const val CAROUSEL_ARC_ANGLE_STEP_DEGREES = 15.0
|
||||||
private const val CAROUSEL_CARDS_ALPHA_SHAPE = "CarouselCardsAlphaShape"
|
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_LAST_SCROLL_POSITION = "CarouselLastScrollPosition"
|
||||||
const val CAROUSEL_VIEW_TYPE_PORTRAIT = "GamesViewTypePortrait"
|
const val CAROUSEL_VIEW_TYPE_PORTRAIT = "GamesViewTypePortrait"
|
||||||
const val CAROUSEL_VIEW_TYPE_LANDSCAPE = "GamesViewTypeLandscape"
|
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) {
|
fun updateChildScaleAndAlphaForPosition(child: View) {
|
||||||
val cardSize = (adapter as? GameAdapter ?: return).cardSize
|
val cardSize = (adapter as? GameAdapter ?: return).cardSize
|
||||||
val position = getChildViewHolder(child).bindingAdapterPosition
|
val position = getChildViewHolder(child).bindingAdapterPosition
|
||||||
if (position == RecyclerView.NO_POSITION || cardSize <= 0) {
|
if (position == RecyclerView.NO_POSITION || cardSize <= 0) {
|
||||||
return // No valid position or card size
|
return // No valid position or card size
|
||||||
}
|
}
|
||||||
child.layoutParams.width = cardSize
|
val layoutParams = child.layoutParams
|
||||||
child.layoutParams.height = cardSize
|
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 internalBorderScale = resources.getFraction(R.fraction.carousel_bordercards_scale, 1, 1)
|
||||||
val borderScale = preferences.getFloat(CAROUSEL_BORDERCARDS_SCALE, internalBorderScale).coerceIn(
|
val borderScale = preferences.getFloat(CAROUSEL_BORDERCARDS_SCALE, internalBorderScale).coerceIn(
|
||||||
0f,
|
0f,
|
||||||
1f
|
1f
|
||||||
)
|
)
|
||||||
|
|
||||||
val shapeInput = (distance / center).coerceIn(0f, 1f)
|
val shapedScaling = 1f - depthInput
|
||||||
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 scale = (borderScale + (1f - borderScale) * shapedScaling).coerceIn(0f, 1f)
|
||||||
|
|
||||||
val maxDistance = width / 2f
|
|
||||||
val alphaInput = (distance / maxDistance).coerceIn(0f, 1f)
|
|
||||||
val internalBordersAlpha = resources.getFraction(
|
val internalBordersAlpha = resources.getFraction(
|
||||||
R.fraction.carousel_bordercards_alpha,
|
R.fraction.carousel_bordercards_alpha,
|
||||||
1,
|
1,
|
||||||
|
|
@ -209,15 +223,12 @@ class CarouselRecyclerView @JvmOverloads constructor(
|
||||||
0f,
|
0f,
|
||||||
1f
|
1f
|
||||||
)
|
)
|
||||||
val internalAlphaShapeSetting = resources.getInteger(R.integer.carousel_cards_alpha_shape)
|
val shapedAlpha = cos(depthInput * Math.PI).toFloat()
|
||||||
val alphaShapeSetting = preferences.getInt(
|
val baseAlpha = (borderAlpha + (1f - borderAlpha) * shapedAlpha).coerceIn(0f, 1f)
|
||||||
CAROUSEL_CARDS_ALPHA_SHAPE,
|
val rearPresence = (1f - (absoluteAngle - fadeOutStartAngle) /
|
||||||
internalAlphaShapeSetting
|
(fadeOutEndAngle - fadeOutStartAngle)).coerceIn(0f, 1f)
|
||||||
)
|
val alpha = (baseAlpha * rearPresence).coerceIn(0f, 1f)
|
||||||
val shapedAlpha = shapingFunction(alphaInput, alphaShapeSetting)
|
|
||||||
val alpha = (borderAlpha + (1f - borderAlpha) * shapedAlpha).coerceIn(0f, 1f)
|
|
||||||
|
|
||||||
child.animate().cancel()
|
|
||||||
child.alpha = alpha
|
child.alpha = alpha
|
||||||
child.scaleX = scale
|
child.scaleX = scale
|
||||||
child.scaleY = scale
|
child.scaleY = scale
|
||||||
|
|
@ -273,7 +284,9 @@ class CarouselRecyclerView @JvmOverloads constructor(
|
||||||
0f,
|
0f,
|
||||||
1f
|
1f
|
||||||
)
|
)
|
||||||
return (userFactor * (height - bottomInset)).toInt()
|
val scaledHeight = height * userFactor
|
||||||
|
val availableHeight = height - bottomInset
|
||||||
|
return minOf(scaledHeight.toInt(), availableHeight.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setupCarousel(enabled: Boolean) {
|
fun setupCarousel(enabled: Boolean) {
|
||||||
|
|
@ -282,6 +295,13 @@ class CarouselRecyclerView @JvmOverloads constructor(
|
||||||
if (gameAdapter.cardSize == 0) return
|
if (gameAdapter.cardSize == 0) return
|
||||||
if (bottomInset < 0) return
|
if (bottomInset < 0) return
|
||||||
|
|
||||||
|
itemAnimator?.let {
|
||||||
|
if (savedItemAnimator == null) {
|
||||||
|
savedItemAnimator = it
|
||||||
|
}
|
||||||
|
itemAnimator = null
|
||||||
|
}
|
||||||
|
|
||||||
useCustomDrawingOrder = true
|
useCustomDrawingOrder = true
|
||||||
val cardSize = gameAdapter.cardSize
|
val cardSize = gameAdapter.cardSize
|
||||||
|
|
||||||
|
|
@ -336,6 +356,12 @@ class CarouselRecyclerView @JvmOverloads constructor(
|
||||||
// Detach PagerSnapHelper
|
// Detach PagerSnapHelper
|
||||||
pagerSnapHelper?.attachToRecyclerView(null)
|
pagerSnapHelper?.attachToRecyclerView(null)
|
||||||
pagerSnapHelper = null
|
pagerSnapHelper = null
|
||||||
|
savedItemAnimator?.let {
|
||||||
|
if (itemAnimator == null) {
|
||||||
|
itemAnimator = it
|
||||||
|
}
|
||||||
|
savedItemAnimator = null
|
||||||
|
}
|
||||||
useCustomDrawingOrder = false
|
useCustomDrawingOrder = false
|
||||||
// Reset padding and fling
|
// Reset padding and fling
|
||||||
setPadding(0, 0, 0, 0)
|
setPadding(0, 0, 0, 0)
|
||||||
|
|
@ -344,6 +370,7 @@ class CarouselRecyclerView @JvmOverloads constructor(
|
||||||
// Reset scaling
|
// Reset scaling
|
||||||
for (i in 0 until childCount) {
|
for (i in 0 until childCount) {
|
||||||
val child = getChildAt(i)
|
val child = getChildAt(i)
|
||||||
|
child?.translationX = 0f
|
||||||
child?.scaleX = 1f
|
child?.scaleX = 1f
|
||||||
child?.scaleY = 1f
|
child?.scaleY = 1f
|
||||||
child?.alpha = 1f
|
child?.alpha = 1f
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@
|
||||||
<integer name="game_columns_grid">2</integer>
|
<integer name="game_columns_grid">2</integer>
|
||||||
<integer name="carousel_max_fling_count">4</integer>
|
<integer name="carousel_max_fling_count">4</integer>
|
||||||
<integer name="carousel_focus_search_repeat_threshold_ms">100</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 -->
|
<!-- Default SWITCH landscape layout -->
|
||||||
<integer name="BUTTON_A_X">760</integer>
|
<integer name="BUTTON_A_X">760</integer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue