@ -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 ( !is CarouselMode ) 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 ( !is CarouselMode ) {
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 ( !is CarouselMode ) {
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 ( !is CarouselMode ) 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 ( !is CarouselMode ) {
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 ( !is CarouselMode ) 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 ,