From 03794d4773aa952ca1cc68ddcca2b6c4fe423c33 Mon Sep 17 00:00:00 2001 From: xbzk Date: Thu, 26 Jun 2025 20:52:54 +0000 Subject: [PATCH] feature/landscape-carousel (#196) second try. all known visual resizing bugs fixed. Co-authored-by: Allison Cunha Co-authored-by: crueter Co-authored-by: Aleksandr Popovich Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/196 Co-authored-by: xbzk Co-committed-by: xbzk --- .../org/yuzu/yuzu_emu/adapters/GameAdapter.kt | 89 +++++- .../org/yuzu/yuzu_emu/ui/GamesFragment.kt | 125 +++++--- .../yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt | 287 ++++++++++++++++++ .../ui/MidScreenSwipeRefreshLayout.kt | 31 ++ .../main/res/layout-land/fragment_games.xml | 10 +- .../main/res/layout/card_game_carousel.xml | 45 +++ .../app/src/main/res/menu/menu_game_views.xml | 3 + .../app/src/main/res/values/dimens.xml | 2 +- .../app/src/main/res/values/fractions.xml | 6 + .../app/src/main/res/values/strings.xml | 2 + src/android/gradle.properties | 3 - src/tests/video_core/memory_tracker.cpp | 2 +- 12 files changed, 554 insertions(+), 51 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt create mode 100644 src/android/app/src/main/res/layout/card_game_carousel.xml create mode 100644 src/android/app/src/main/res/values/fractions.xml diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt index 2596ab0e93..c5fc6ff168 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later package org.yuzu.yuzu_emu.adapters @@ -6,6 +6,7 @@ package org.yuzu.yuzu_emu.adapters import android.net.Uri import android.view.LayoutInflater import android.view.ViewGroup +import android.widget.LinearLayout import android.widget.ImageView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity @@ -25,6 +26,7 @@ import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.databinding.CardGameListBinding import org.yuzu.yuzu_emu.databinding.CardGameGridBinding +import org.yuzu.yuzu_emu.databinding.CardGameCarouselBinding import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.model.GamesViewModel import org.yuzu.yuzu_emu.utils.GameIconUtils @@ -37,6 +39,7 @@ class GameAdapter(private val activity: AppCompatActivity) : companion object { const val VIEW_TYPE_GRID = 0 const val VIEW_TYPE_LIST = 1 + const val VIEW_TYPE_CAROUSEL = 2 } private var viewType = 0 @@ -46,29 +49,74 @@ class GameAdapter(private val activity: AppCompatActivity) : notifyDataSetChanged() } + var cardSize: Int = 0 + private set + + fun setCardSize(size: Int) { + if (cardSize != size && size > 0) { + cardSize = size + notifyDataSetChanged() + } + } + override fun getItemViewType(position: Int): Int = viewType - + override fun onBindViewHolder(holder: GameViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + // Always reset scale/alpha for recycled views + when (getItemViewType(position)) { + VIEW_TYPE_LIST -> { + val listBinding = holder.binding as CardGameListBinding + listBinding.cardGameList.scaleX = 1f + listBinding.cardGameList.scaleY = 1f + listBinding.cardGameList.alpha = 1f + // Reset layout params to XML defaults + listBinding.root.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + listBinding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + } + VIEW_TYPE_GRID -> { + val gridBinding = holder.binding as CardGameGridBinding + gridBinding.cardGameGrid.scaleX = 1f + gridBinding.cardGameGrid.scaleY = 1f + gridBinding.cardGameGrid.alpha = 1f + // Reset layout params to XML defaults + gridBinding.root.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + gridBinding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + } + VIEW_TYPE_CAROUSEL -> { + val carouselBinding = holder.binding as CardGameCarouselBinding + carouselBinding.cardGameCarousel.scaleX = 1f + carouselBinding.cardGameCarousel.scaleY = 1f + carouselBinding.cardGameCarousel.alpha = 0f + // Set square size for carousel + if (cardSize > 0) { + carouselBinding.root.layoutParams.width = cardSize + carouselBinding.root.layoutParams.height = cardSize + } + } + } + } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { val binding = when (viewType) { VIEW_TYPE_LIST -> CardGameListBinding.inflate(LayoutInflater.from(parent.context), parent, false) VIEW_TYPE_GRID -> CardGameGridBinding.inflate(LayoutInflater.from(parent.context), parent, false) + VIEW_TYPE_CAROUSEL -> CardGameCarouselBinding.inflate(LayoutInflater.from(parent.context), parent, false) else -> throw IllegalArgumentException("Invalid view type") } return GameViewHolder(binding, viewType) } inner class GameViewHolder( - private val binding: ViewBinding, + internal val binding: ViewBinding, private val viewType: Int ) : AbstractViewHolder(binding) { - override fun bind(model: Game) { when (viewType) { VIEW_TYPE_LIST -> bindListView(model) VIEW_TYPE_GRID -> bindGridView(model) + VIEW_TYPE_CAROUSEL -> bindCarouselView(model) } } @@ -84,6 +132,10 @@ class GameAdapter(private val activity: AppCompatActivity) : listBinding.textGameTitle.marquee() listBinding.cardGameList.setOnClickListener { onClick(model) } listBinding.cardGameList.setOnLongClickListener { onLongClick(model) } + + // Reset layout params to XML defaults + listBinding.root.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + listBinding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT } private fun bindGridView(model: Game) { @@ -97,6 +149,35 @@ class GameAdapter(private val activity: AppCompatActivity) : gridBinding.textGameTitle.marquee() gridBinding.cardGameGrid.setOnClickListener { onClick(model) } gridBinding.cardGameGrid.setOnLongClickListener { onLongClick(model) } + + // Reset layout params to XML defaults + gridBinding.root.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + gridBinding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + } + + private fun bindCarouselView(model: Game) { + val carouselBinding = binding as CardGameCarouselBinding + + // Remove padding from the root LinearLayout + (carouselBinding.root.getChildAt(0) as? LinearLayout)?.setPadding(0, 0, 0, 0) + + // Always set square size and remove margins for carousel + val params = carouselBinding.root.layoutParams + params.width = cardSize + params.height = cardSize + if (params is ViewGroup.MarginLayoutParams) params.setMargins(0, 0, 0, 0) + carouselBinding.root.layoutParams = params + + carouselBinding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP + GameIconUtils.loadGameIcon(model, carouselBinding.imageGameScreen) + + carouselBinding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ") + carouselBinding.textGameTitle.marquee() + carouselBinding.cardGameCarousel.setOnClickListener { onClick(model) } + carouselBinding.cardGameCarousel.setOnLongClickListener { onLongClick(model) } + + carouselBinding.imageGameScreen.contentDescription = + binding.root.context.getString(R.string.game_image_desc, model.title) } fun onClick(game: Game) { 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 9785e35a5e..ce5960c546 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 @@ -28,11 +28,10 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.color.MaterialColors -import info.debatty.java.stringsimilarity.Jaccard -import info.debatty.java.stringsimilarity.JaroWinkler +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.YuzuApplication import org.yuzu.yuzu_emu.adapters.GameAdapter @@ -43,6 +42,8 @@ import org.yuzu.yuzu_emu.model.HomeViewModel import org.yuzu.yuzu_emu.ui.main.MainActivity import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible import org.yuzu.yuzu_emu.utils.collect +import info.debatty.java.stringsimilarity.Jaccard +import info.debatty.java.stringsimilarity.JaroWinkler import java.util.Locale import androidx.core.content.edit import androidx.core.view.updateLayoutParams @@ -57,9 +58,12 @@ class GamesFragment : Fragment() { private var originalHeaderRightMargin: Int? = null private var originalHeaderLeftMargin: Int? = null + private var lastViewType: Int = GameAdapter.VIEW_TYPE_GRID + companion object { private const val SEARCH_TEXT = "SearchText" - private const val PREF_VIEW_TYPE = "GamesViewType" + private const val PREF_VIEW_TYPE_PORTRAIT = "GamesViewTypePortrait" + private const val PREF_VIEW_TYPE_LANDSCAPE = "GamesViewTypeLandscape" private const val PREF_SORT_TYPE = "GamesSortType" } @@ -78,7 +82,18 @@ class GamesFragment : Fragment() { } } + private fun getCurrentViewType(): Int { + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val key = if (isLandscape) PREF_VIEW_TYPE_LANDSCAPE else PREF_VIEW_TYPE_PORTRAIT + val fallback = if (isLandscape) GameAdapter.VIEW_TYPE_CAROUSEL else GameAdapter.VIEW_TYPE_GRID + return preferences.getInt(key, fallback) + } + private fun setCurrentViewType(type: Int) { + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val key = if (isLandscape) PREF_VIEW_TYPE_LANDSCAPE else PREF_VIEW_TYPE_PORTRAIT + preferences.edit { putInt(key, type) } + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -104,36 +119,31 @@ class GamesFragment : Fragment() { applyGridGamesBinding() binding.swipeRefresh.apply { - // Add swipe down to refresh gesture - setOnRefreshListener { + (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setOnRefreshListener { gamesViewModel.reloadGames(false) } - - // Set theme color to the refresh animation's background - setProgressBackgroundColorSchemeColor( - MaterialColors.getColor( + (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setProgressBackgroundColorSchemeColor( + com.google.android.material.color.MaterialColors.getColor( binding.swipeRefresh, com.google.android.material.R.attr.colorPrimary ) ) - setColorSchemeColors( - MaterialColors.getColor( + (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setColorSchemeColors( + com.google.android.material.color.MaterialColors.getColor( binding.swipeRefresh, com.google.android.material.R.attr.colorOnPrimary ) ) - - // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn post { if (_binding == null) { return@post } - binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value + (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.isRefreshing = gamesViewModel.isReloading.value } } gamesViewModel.isReloading.collect(viewLifecycleOwner) { - binding.swipeRefresh.isRefreshing = it + (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.isRefreshing = it binding.noticeText.setVisible( visible = gamesViewModel.games.value.isEmpty() && !it, gone = false @@ -165,20 +175,53 @@ class GamesFragment : Fragment() { } val applyGridGamesBinding = { - binding.gridGames.apply { - val savedViewType = preferences.getInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID) + (binding.gridGames as? RecyclerView)?.apply { + val savedViewType = getCurrentViewType() + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val effectiveViewType = if (!isLandscape && savedViewType == GameAdapter.VIEW_TYPE_CAROUSEL) { + GameAdapter.VIEW_TYPE_GRID + } else { + savedViewType + } + gameAdapter.setViewType(effectiveViewType) gameAdapter.setViewType(savedViewType) currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID) - adapter = gameAdapter + val overlapPx = resources.getDimensionPixelSize(R.dimen.carousel_overlap) - val gameGrid = when (savedViewType) { - GameAdapter.VIEW_TYPE_LIST -> R.integer.game_columns_list - GameAdapter.VIEW_TYPE_GRID -> R.integer.game_columns_grid - else -> 0 + // Set the correct layout manager + layoutManager = when (savedViewType) { + GameAdapter.VIEW_TYPE_GRID -> { + val columns = resources.getInteger(R.integer.game_columns_grid) + GridLayoutManager(context, columns) + } + GameAdapter.VIEW_TYPE_LIST -> { + val columns = resources.getInteger(R.integer.game_columns_list) + GridLayoutManager(context, columns) + } + GameAdapter.VIEW_TYPE_CAROUSEL -> { + LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + } + else -> throw IllegalArgumentException("Invalid view type: $savedViewType") } - layoutManager = GridLayoutManager(requireContext(), resources.getInteger(gameGrid)) + // Carousel mode: wait for layout, then set card size and enable carousel features + if (savedViewType == GameAdapter.VIEW_TYPE_CAROUSEL) { + post { + val insets = ViewCompat.getRootWindowInsets(this) + val bottomInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0 + val size = (resources.getFraction(R.fraction.carousel_card_size_multiplier, 1, 1) * (height - bottomInset)).toInt() + if (size > 0) { + gameAdapter.setCardSize(size) + (this as? JukeboxRecyclerView)?.setCarouselMode(true, overlapPx, size) + } + } + } else { + // Disable carousel features in other modes + (this as? JukeboxRecyclerView)?.setCarouselMode(false, overlapPx, 0) + } + adapter = gameAdapter + lastViewType = savedViewType } } @@ -193,11 +236,10 @@ class GamesFragment : Fragment() { val currentSearchText = binding.searchText.text.toString() val currentFilter = binding.filterButton.id - if (currentSearchText.isNotEmpty() || currentFilter != View.NO_ID) { filterAndSearch(games) } else { - (binding.gridGames.adapter as GameAdapter).submitList(games) + ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(games) gamesViewModel.setFilteredGames(games) } } @@ -235,24 +277,36 @@ class GamesFragment : Fragment() { private fun showViewMenu(anchor: View) { val popup = PopupMenu(requireContext(), anchor) popup.menuInflater.inflate(R.menu.menu_game_views, popup.menu) + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + if (!isLandscape) { + popup.menu.findItem(R.id.view_carousel)?.isVisible = false + } - val currentViewType = (preferences.getInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID)) + val currentViewType = getCurrentViewType() when (currentViewType) { GameAdapter.VIEW_TYPE_LIST -> popup.menu.findItem(R.id.view_list).isChecked = true GameAdapter.VIEW_TYPE_GRID -> popup.menu.findItem(R.id.view_grid).isChecked = true + GameAdapter.VIEW_TYPE_CAROUSEL -> popup.menu.findItem(R.id.view_carousel).isChecked = true } popup.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.view_grid -> { - preferences.edit() { putInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID) } + setCurrentViewType(GameAdapter.VIEW_TYPE_GRID) applyGridGamesBinding() item.isChecked = true true } R.id.view_list -> { - preferences.edit() { putInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_LIST) } + setCurrentViewType(GameAdapter.VIEW_TYPE_LIST) + applyGridGamesBinding() + item.isChecked = true + true + } + + R.id.view_carousel -> { + setCurrentViewType(GameAdapter.VIEW_TYPE_CAROUSEL) applyGridGamesBinding() item.isChecked = true true @@ -301,20 +355,18 @@ class GamesFragment : Fragment() { lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } } - R.id.filter_recently_added -> { baseList.filter { val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } } - else -> baseList } val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) if (searchTerm.isEmpty()) { - (binding.gridGames.adapter as GameAdapter).submitList(filteredList) + ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(filteredList) gamesViewModel.setFilteredGames(filteredList) return } @@ -330,7 +382,7 @@ class GamesFragment : Fragment() { } }.sortedByDescending { it.score }.map { it.item } - (binding.gridGames.adapter as GameAdapter).submitList(sortedList) + ((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(sortedList) gamesViewModel.setFilteredGames(sortedList) } @@ -343,7 +395,6 @@ class GamesFragment : Fragment() { imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) } - override fun onDestroyView() { super.onDestroyView() _binding = null @@ -351,7 +402,7 @@ class GamesFragment : Fragment() { private fun scrollToTop() { if (_binding != null) { - binding.gridGames.smoothScrollToPosition(0) + (binding.gridGames as? JukeboxRecyclerView)?.smoothScrollToPosition(0) } } @@ -364,7 +415,7 @@ class GamesFragment : Fragment() { val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) - binding.swipeRefresh.setProgressViewEndTarget( + (binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setProgressViewEndTarget( false, barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) ) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt new file mode 100644 index 0000000000..130e10dacf --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/JukeboxRecyclerView.kt @@ -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> { 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 + } + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt new file mode 100644 index 0000000000..74de15205f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/MidScreenSwipeRefreshLayout.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/android/app/src/main/res/layout-land/fragment_games.xml b/src/android/app/src/main/res/layout-land/fragment_games.xml index 68ccb86b57..087a943497 100644 --- a/src/android/app/src/main/res/layout-land/fragment_games.xml +++ b/src/android/app/src/main/res/layout-land/fragment_games.xml @@ -5,6 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="?attr/colorSurface" + android:clipChildren="false" > - - - + + + + + + + + + + diff --git a/src/android/app/src/main/res/menu/menu_game_views.xml b/src/android/app/src/main/res/menu/menu_game_views.xml index 18e95bc5ad..241cdcb4b5 100644 --- a/src/android/app/src/main/res/menu/menu_game_views.xml +++ b/src/android/app/src/main/res/menu/menu_game_views.xml @@ -8,5 +8,8 @@ android:id="@+id/view_list" android:title="@string/view_list" android:checked="true"/> + \ No newline at end of file diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml index bf733637fe..1533dd2831 100644 --- a/src/android/app/src/main/res/values/dimens.xml +++ b/src/android/app/src/main/res/values/dimens.xml @@ -15,7 +15,7 @@ 24dp 96dp 24dp - + 150dp 20dp 3dp diff --git a/src/android/app/src/main/res/values/fractions.xml b/src/android/app/src/main/res/values/fractions.xml new file mode 100644 index 0000000000..5c5b0b289c --- /dev/null +++ b/src/android/app/src/main/res/values/fractions.xml @@ -0,0 +1,6 @@ + + 60% + 60% + 200% + 100% + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 10094a5b5f..5c41f6650a 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -232,6 +232,8 @@ Alphabetical List Grid + Carousel + Screenshot for %1$s Folder Pre-Alpha Software "WARNING: This software is in the pre-alpha stage and may have bugs and incomplete feature implementations." diff --git a/src/android/gradle.properties b/src/android/gradle.properties index 1efd91043f..ef5fafba03 100644 --- a/src/android/gradle.properties +++ b/src/android/gradle.properties @@ -1,9 +1,6 @@ # SPDX-FileCopyrightText: Copyright 2025 yuzu Emulator Project # SPDX-License-Identifier: GPL-3.0-or-later -# SPDX-FileCopyrightText: 2023 yuzu Emulator Project -# SPDX-License-Identifier: GPL-3.0-or-later - # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* diff --git a/src/tests/video_core/memory_tracker.cpp b/src/tests/video_core/memory_tracker.cpp index fbfcae62dd..da7e88ea03 100644 --- a/src/tests/video_core/memory_tracker.cpp +++ b/src/tests/video_core/memory_tracker.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #include