Browse Source

[android] layout mode transition issues (corruption/gamepad navigation) fixed (#3212)

fellow mike22 pointed out some vulnerabilities related to layout mode transitions.
let us give it a go!

Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3212
Reviewed-by: Lizzie <lizzie@eden-emu.dev>
Reviewed-by: DraVee <dravee@eden-emu.dev>
Co-authored-by: xbzk <xbzk@eden-emu.dev>
Co-committed-by: xbzk <xbzk@eden-emu.dev>
pull/3219/head
xbzk 3 days ago
committed by crueter
parent
commit
d76edfc683
No known key found for this signature in database GPG Key ID: 425ACD2D4830EBC6
  1. 4
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
  2. 106
      src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt

4
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt

@ -186,6 +186,10 @@ class GamesFragment : Fragment() {
val currentViewType = getCurrentViewType()
val savedViewType = if (isLandscape || currentViewType != GameAdapter.VIEW_TYPE_CAROUSEL) currentViewType else GameAdapter.VIEW_TYPE_GRID
//This prevents Grid/List views from reusing scaled or otherwise modified ViewHolders left over from the carousel.
adapter = null
recycledViewPool.clear()
gameAdapter.setViewType(savedViewType)
currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID)

106
src/android/app/src/main/java/org/yuzu/yuzu_emu/views/CarouselRecyclerView.kt

@ -1,9 +1,6 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.ui
import android.content.Context
@ -55,6 +52,24 @@ class CarouselRecyclerView @JvmOverloads constructor(
private val preferences =
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
private val carouselAdapterObserver = object : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
if (!pendingScrollAfterReload) return
doOnNextLayout {
refreshView()
pendingScrollAfterReload = false
}
}
}
private val isCarouselMode: Boolean
get() {
val lm = layoutManager as? LinearLayoutManager
return lm != null &&
lm.orientation == RecyclerView.HORIZONTAL &&
lm !is androidx.recyclerview.widget.GridLayoutManager
}
var flingMultiplier: Float = 1f
var pendingScrollAfterReload: Boolean = false
@ -70,6 +85,18 @@ class CarouselRecyclerView @JvmOverloads constructor(
setChildrenDrawingOrderEnabled(true)
}
override fun setAdapter(adapter: Adapter<*>?) {
val oldAdapter = this.adapter as? GameAdapter
if (oldAdapter !== adapter) {
oldAdapter?.unregisterAdapterDataObserver(carouselAdapterObserver)
}
super.setAdapter(adapter)
(adapter as? GameAdapter)?.registerAdapterDataObserver(carouselAdapterObserver)
}
private fun calculateCenter(width: Int, paddingStart: Int, paddingEnd: Int): Int {
return paddingStart + (width - paddingStart - paddingEnd) / 2
}
@ -79,14 +106,14 @@ class CarouselRecyclerView @JvmOverloads constructor(
}
private fun getLayoutManagerCenter(layoutManager: RecyclerView.LayoutManager): Int {
return if (layoutManager is LinearLayoutManager) {
calculateCenter(
if (isCarouselMode) {
return calculateCenter(
layoutManager.width,
layoutManager.paddingStart,
layoutManager.paddingEnd
)
} else {
width / 2
return width / 2
}
}
@ -95,6 +122,8 @@ class CarouselRecyclerView @JvmOverloads constructor(
}
fun restoreScrollState(position: Int = 0, attempts: Int = 0) {
if (!isCarouselMode) return
val lm = layoutManager as? LinearLayoutManager ?: return
if (lm.findLastVisibleItemPosition() == RecyclerView.NO_POSITION && attempts < 10) {
post { restoreScrollState(position, attempts + 1) }
@ -104,6 +133,10 @@ class CarouselRecyclerView @JvmOverloads constructor(
}
fun getClosestChildPosition(fullRange: Boolean = false): Int {
if (!isCarouselMode) {
return RecyclerView.NO_POSITION
}
val lm = layoutManager as? LinearLayoutManager ?: return RecyclerView.NO_POSITION
var minDistance = Int.MAX_VALUE
var closestPosition = RecyclerView.NO_POSITION
@ -203,15 +236,22 @@ class CarouselRecyclerView @JvmOverloads constructor(
}
fun refreshView() {
updateChildScalesAndAlpha()
focusCenteredCard()
if (isCarouselMode) {
updateChildScalesAndAlpha()
focusCenteredCard()
}
}
fun notifyInsetsReady(newBottomInset: Int) {
if (bottomInset != newBottomInset) {
bottomInset = newBottomInset
}
setupCarousel(true)
if (isCarouselMode) {
setupCarousel(true)
} else {
setupCarousel(false)
}
}
fun notifyLaidOut(fallBackBottomInset: Int) {
@ -221,7 +261,10 @@ class CarouselRecyclerView @JvmOverloads constructor(
if (gameAdapter.cardSize != newCardSize) {
gameAdapter.setCardSize(newCardSize)
}
setupCarousel(true)
if (isCarouselMode) {
setupCarousel(true)
}
}
fun cardSize(bottomInset: Int): Int {
@ -252,17 +295,6 @@ class CarouselRecyclerView @JvmOverloads constructor(
internalFlingMultiplier
).coerceIn(1f, 5f)
gameAdapter .registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
if (pendingScrollAfterReload) {
doOnNextLayout {
refreshView()
pendingScrollAfterReload = false
}
}
}
})
// Detach SnapHelper during setup
pagerSnapHelper?.attachToRecyclerView(null)
@ -321,22 +353,27 @@ class CarouselRecyclerView @JvmOverloads constructor(
override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)
if (state == RecyclerView.SCROLL_STATE_IDLE) {
if (state == RecyclerView.SCROLL_STATE_IDLE && isCarouselMode) {
focusCenteredCard()
}
}
override fun scrollToPosition(position: Int) {
if (isCarouselMode) {
(layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(position, overlapPx)
doOnNextLayout {
refreshView()
}
} else {
super.scrollToPosition(position)
(layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(position, overlapPx)
doOnNextLayout {
refreshView()
}
}
private var lastFocusSearchTime: Long = 0
override fun focusSearch(focused: View, direction: Int): View? {
if (layoutManager !is LinearLayoutManager) return super.focusSearch(focused, direction)
if (!isCarouselMode) {
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
@ -371,6 +408,8 @@ class CarouselRecyclerView @JvmOverloads constructor(
focused
}
}
// Prevent focus from escaping to external UI elements when forced snapping was removed
View.FOCUS_DOWN -> focused
else -> super.focusSearch(focused, direction)
}
}
@ -434,7 +473,7 @@ class CarouselRecyclerView @JvmOverloads constructor(
// NEEDED: fixes center snapping, but introduces ghost movement
override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? {
if (layoutManager !is LinearLayoutManager) return null
if (!isCarouselMode) return null
return layoutManager.findViewByPosition(getClosestChildPosition())
}
@ -443,12 +482,10 @@ class CarouselRecyclerView @JvmOverloads constructor(
layoutManager: RecyclerView.LayoutManager,
targetView: View
): IntArray? {
if (layoutManager !is LinearLayoutManager) {
return super.calculateDistanceToFinalSnap(
layoutManager,
targetView
)
if (!isCarouselMode) {
return super.calculateDistanceToFinalSnap(layoutManager, targetView)
}
val out = IntArray(2)
out[0] = getChildDistanceToCenter(targetView).toInt()
out[1] = 0
@ -461,8 +498,11 @@ class CarouselRecyclerView @JvmOverloads constructor(
velocityX: Int,
velocityY: Int
): Int {
if (layoutManager !is LinearLayoutManager) return RecyclerView.NO_POSITION
if (!isCarouselMode) return RecyclerView.NO_POSITION
val closestPosition = this@CarouselRecyclerView.getClosestChildPosition()
if (closestPosition == RecyclerView.NO_POSITION) return RecyclerView.NO_POSITION
val internalMaxFling = resources.getInteger(R.integer.carousel_max_fling_count)
val maxFling = preferences.getInt(CAROUSEL_MAX_FLING_COUNT, internalMaxFling).coerceIn(
1,

Loading…
Cancel
Save