Browse Source
feature/landscape-carousel (#196)
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>pull/21/head
committed by
crueter
12 changed files with 554 additions and 51 deletions
-
89src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
-
125src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
-
287src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt
-
31src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt
-
10src/android/app/src/main/res/layout-land/fragment_games.xml
-
45src/android/app/src/main/res/layout/card_game_carousel.xml
-
3src/android/app/src/main/res/menu/menu_game_views.xml
-
2src/android/app/src/main/res/values/dimens.xml
-
6src/android/app/src/main/res/values/fractions.xml
-
2src/android/app/src/main/res/values/strings.xml
-
3src/android/gradle.properties
-
2src/tests/video_core/memory_tracker.cpp
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue