mirror of
https://git.eden-emu.dev/eden-emu/eden.git
synced 2025-07-20 07:05:46 +00:00
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>
This commit is contained in:
parent
37f890ec16
commit
03794d4773
12 changed files with 554 additions and 51 deletions
|
@ -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
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
package org.yuzu.yuzu_emu.adapters
|
package org.yuzu.yuzu_emu.adapters
|
||||||
|
@ -6,6 +6,7 @@ package org.yuzu.yuzu_emu.adapters
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.YuzuApplication
|
||||||
import org.yuzu.yuzu_emu.databinding.CardGameListBinding
|
import org.yuzu.yuzu_emu.databinding.CardGameListBinding
|
||||||
import org.yuzu.yuzu_emu.databinding.CardGameGridBinding
|
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.Game
|
||||||
import org.yuzu.yuzu_emu.model.GamesViewModel
|
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||||
import org.yuzu.yuzu_emu.utils.GameIconUtils
|
import org.yuzu.yuzu_emu.utils.GameIconUtils
|
||||||
|
@ -37,6 +39,7 @@ class GameAdapter(private val activity: AppCompatActivity) :
|
||||||
companion object {
|
companion object {
|
||||||
const val VIEW_TYPE_GRID = 0
|
const val VIEW_TYPE_GRID = 0
|
||||||
const val VIEW_TYPE_LIST = 1
|
const val VIEW_TYPE_LIST = 1
|
||||||
|
const val VIEW_TYPE_CAROUSEL = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
private var viewType = 0
|
private var viewType = 0
|
||||||
|
@ -46,29 +49,74 @@ class GameAdapter(private val activity: AppCompatActivity) :
|
||||||
notifyDataSetChanged()
|
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 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 {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
|
||||||
val binding = when (viewType) {
|
val binding = when (viewType) {
|
||||||
VIEW_TYPE_LIST -> CardGameListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
VIEW_TYPE_LIST -> CardGameListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
VIEW_TYPE_GRID -> CardGameGridBinding.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")
|
else -> throw IllegalArgumentException("Invalid view type")
|
||||||
}
|
}
|
||||||
return GameViewHolder(binding, viewType)
|
return GameViewHolder(binding, viewType)
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class GameViewHolder(
|
inner class GameViewHolder(
|
||||||
private val binding: ViewBinding,
|
internal val binding: ViewBinding,
|
||||||
private val viewType: Int
|
private val viewType: Int
|
||||||
) : AbstractViewHolder<Game>(binding) {
|
) : AbstractViewHolder<Game>(binding) {
|
||||||
|
|
||||||
|
|
||||||
override fun bind(model: Game) {
|
override fun bind(model: Game) {
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
VIEW_TYPE_LIST -> bindListView(model)
|
VIEW_TYPE_LIST -> bindListView(model)
|
||||||
VIEW_TYPE_GRID -> bindGridView(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.textGameTitle.marquee()
|
||||||
listBinding.cardGameList.setOnClickListener { onClick(model) }
|
listBinding.cardGameList.setOnClickListener { onClick(model) }
|
||||||
listBinding.cardGameList.setOnLongClickListener { onLongClick(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) {
|
private fun bindGridView(model: Game) {
|
||||||
|
@ -97,6 +149,35 @@ class GameAdapter(private val activity: AppCompatActivity) :
|
||||||
gridBinding.textGameTitle.marquee()
|
gridBinding.textGameTitle.marquee()
|
||||||
gridBinding.cardGameGrid.setOnClickListener { onClick(model) }
|
gridBinding.cardGameGrid.setOnClickListener { onClick(model) }
|
||||||
gridBinding.cardGameGrid.setOnLongClickListener { onLongClick(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) {
|
fun onClick(game: Game) {
|
||||||
|
|
|
@ -28,11 +28,10 @@ import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.color.MaterialColors
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import info.debatty.java.stringsimilarity.Jaccard
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import info.debatty.java.stringsimilarity.JaroWinkler
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import org.yuzu.yuzu_emu.R
|
import org.yuzu.yuzu_emu.R
|
||||||
import org.yuzu.yuzu_emu.YuzuApplication
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
import org.yuzu.yuzu_emu.adapters.GameAdapter
|
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.ui.main.MainActivity
|
||||||
import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible
|
import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible
|
||||||
import org.yuzu.yuzu_emu.utils.collect
|
import org.yuzu.yuzu_emu.utils.collect
|
||||||
|
import info.debatty.java.stringsimilarity.Jaccard
|
||||||
|
import info.debatty.java.stringsimilarity.JaroWinkler
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
|
@ -57,9 +58,12 @@ class GamesFragment : Fragment() {
|
||||||
private var originalHeaderRightMargin: Int? = null
|
private var originalHeaderRightMargin: Int? = null
|
||||||
private var originalHeaderLeftMargin: Int? = null
|
private var originalHeaderLeftMargin: Int? = null
|
||||||
|
|
||||||
|
private var lastViewType: Int = GameAdapter.VIEW_TYPE_GRID
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val SEARCH_TEXT = "SearchText"
|
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"
|
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(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
@ -104,36 +119,31 @@ class GamesFragment : Fragment() {
|
||||||
applyGridGamesBinding()
|
applyGridGamesBinding()
|
||||||
|
|
||||||
binding.swipeRefresh.apply {
|
binding.swipeRefresh.apply {
|
||||||
// Add swipe down to refresh gesture
|
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setOnRefreshListener {
|
||||||
setOnRefreshListener {
|
|
||||||
gamesViewModel.reloadGames(false)
|
gamesViewModel.reloadGames(false)
|
||||||
}
|
}
|
||||||
|
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setProgressBackgroundColorSchemeColor(
|
||||||
// Set theme color to the refresh animation's background
|
com.google.android.material.color.MaterialColors.getColor(
|
||||||
setProgressBackgroundColorSchemeColor(
|
|
||||||
MaterialColors.getColor(
|
|
||||||
binding.swipeRefresh,
|
binding.swipeRefresh,
|
||||||
com.google.android.material.R.attr.colorPrimary
|
com.google.android.material.R.attr.colorPrimary
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
setColorSchemeColors(
|
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setColorSchemeColors(
|
||||||
MaterialColors.getColor(
|
com.google.android.material.color.MaterialColors.getColor(
|
||||||
binding.swipeRefresh,
|
binding.swipeRefresh,
|
||||||
com.google.android.material.R.attr.colorOnPrimary
|
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 {
|
post {
|
||||||
if (_binding == null) {
|
if (_binding == null) {
|
||||||
return@post
|
return@post
|
||||||
}
|
}
|
||||||
binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value
|
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.isRefreshing = gamesViewModel.isReloading.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gamesViewModel.isReloading.collect(viewLifecycleOwner) {
|
gamesViewModel.isReloading.collect(viewLifecycleOwner) {
|
||||||
binding.swipeRefresh.isRefreshing = it
|
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.isRefreshing = it
|
||||||
binding.noticeText.setVisible(
|
binding.noticeText.setVisible(
|
||||||
visible = gamesViewModel.games.value.isEmpty() && !it,
|
visible = gamesViewModel.games.value.isEmpty() && !it,
|
||||||
gone = false
|
gone = false
|
||||||
|
@ -165,20 +175,53 @@ class GamesFragment : Fragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val applyGridGamesBinding = {
|
val applyGridGamesBinding = {
|
||||||
binding.gridGames.apply {
|
(binding.gridGames as? RecyclerView)?.apply {
|
||||||
val savedViewType = preferences.getInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID)
|
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)
|
gameAdapter.setViewType(savedViewType)
|
||||||
currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID)
|
currentFilter = preferences.getInt(PREF_SORT_TYPE, View.NO_ID)
|
||||||
adapter = gameAdapter
|
val overlapPx = resources.getDimensionPixelSize(R.dimen.carousel_overlap)
|
||||||
|
|
||||||
val gameGrid = when (savedViewType) {
|
// Set the correct layout manager
|
||||||
GameAdapter.VIEW_TYPE_LIST -> R.integer.game_columns_list
|
layoutManager = when (savedViewType) {
|
||||||
GameAdapter.VIEW_TYPE_GRID -> R.integer.game_columns_grid
|
GameAdapter.VIEW_TYPE_GRID -> {
|
||||||
else -> 0
|
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 currentSearchText = binding.searchText.text.toString()
|
||||||
val currentFilter = binding.filterButton.id
|
val currentFilter = binding.filterButton.id
|
||||||
|
|
||||||
|
|
||||||
if (currentSearchText.isNotEmpty() || currentFilter != View.NO_ID) {
|
if (currentSearchText.isNotEmpty() || currentFilter != View.NO_ID) {
|
||||||
filterAndSearch(games)
|
filterAndSearch(games)
|
||||||
} else {
|
} else {
|
||||||
(binding.gridGames.adapter as GameAdapter).submitList(games)
|
((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(games)
|
||||||
gamesViewModel.setFilteredGames(games)
|
gamesViewModel.setFilteredGames(games)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -235,24 +277,36 @@ class GamesFragment : Fragment() {
|
||||||
private fun showViewMenu(anchor: View) {
|
private fun showViewMenu(anchor: View) {
|
||||||
val popup = PopupMenu(requireContext(), anchor)
|
val popup = PopupMenu(requireContext(), anchor)
|
||||||
popup.menuInflater.inflate(R.menu.menu_game_views, popup.menu)
|
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) {
|
when (currentViewType) {
|
||||||
GameAdapter.VIEW_TYPE_LIST -> popup.menu.findItem(R.id.view_list).isChecked = true
|
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_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 ->
|
popup.setOnMenuItemClickListener { item ->
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.view_grid -> {
|
R.id.view_grid -> {
|
||||||
preferences.edit() { putInt(PREF_VIEW_TYPE, GameAdapter.VIEW_TYPE_GRID) }
|
setCurrentViewType(GameAdapter.VIEW_TYPE_GRID)
|
||||||
applyGridGamesBinding()
|
applyGridGamesBinding()
|
||||||
item.isChecked = true
|
item.isChecked = true
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.view_list -> {
|
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()
|
applyGridGamesBinding()
|
||||||
item.isChecked = true
|
item.isChecked = true
|
||||||
true
|
true
|
||||||
|
@ -301,20 +355,18 @@ class GamesFragment : Fragment() {
|
||||||
lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
||||||
}.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) }
|
}.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) }
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.filter_recently_added -> {
|
R.id.filter_recently_added -> {
|
||||||
baseList.filter {
|
baseList.filter {
|
||||||
val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
|
val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
|
||||||
addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
||||||
}.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) }
|
}.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) }
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> baseList
|
else -> baseList
|
||||||
}
|
}
|
||||||
|
|
||||||
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.adapter as GameAdapter).submitList(filteredList)
|
((binding.gridGames as? RecyclerView)?.adapter as? GameAdapter)?.submitList(filteredList)
|
||||||
gamesViewModel.setFilteredGames(filteredList)
|
gamesViewModel.setFilteredGames(filteredList)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -330,7 +382,7 @@ class GamesFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}.sortedByDescending { it.score }.map { it.item }
|
}.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)
|
gamesViewModel.setFilteredGames(sortedList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -343,7 +395,6 @@ class GamesFragment : Fragment() {
|
||||||
imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
|
imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = null
|
||||||
|
@ -351,7 +402,7 @@ class GamesFragment : Fragment() {
|
||||||
|
|
||||||
private fun scrollToTop() {
|
private fun scrollToTop() {
|
||||||
if (_binding != null) {
|
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)
|
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
|
||||||
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
|
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
|
||||||
|
|
||||||
binding.swipeRefresh.setProgressViewEndTarget(
|
(binding.swipeRefresh as? MidScreenSwipeRefreshLayout)?.setProgressViewEndTarget(
|
||||||
false,
|
false,
|
||||||
barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
|
barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="?attr/colorSurface"
|
android:background="?attr/colorSurface"
|
||||||
|
android:clipChildren="false"
|
||||||
>
|
>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
@ -159,7 +160,7 @@
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<org.yuzu.yuzu_emu.ui.MidScreenSwipeRefreshLayout
|
||||||
android:id="@+id/swipe_refresh"
|
android:id="@+id/swipe_refresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
|
@ -184,22 +185,21 @@
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<org.yuzu.yuzu_emu.ui.JukeboxRecyclerView
|
||||||
android:id="@+id/grid_games"
|
android:id="@+id/grid_games"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
|
android:clipChildren="false"
|
||||||
android:scrollbarStyle="outsideOverlay"
|
android:scrollbarStyle="outsideOverlay"
|
||||||
android:scrollbars="vertical"
|
android:scrollbars="vertical"
|
||||||
android:fadeScrollbars="true"
|
android:fadeScrollbars="true"
|
||||||
android:paddingHorizontal="4dp"
|
|
||||||
android:paddingVertical="4dp"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
</org.yuzu.yuzu_emu.ui.MidScreenSwipeRefreshLayout>
|
||||||
|
|
||||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||||
android:id="@+id/add_directory"
|
android:id="@+id/add_directory"
|
||||||
|
|
45
src/android/app/src/main/res/layout/card_game_carousel.xml
Normal file
45
src/android/app/src/main/res/layout/card_game_carousel.xml
Normal file
|
@ -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>
|
|
@ -8,5 +8,8 @@
|
||||||
android:id="@+id/view_list"
|
android:id="@+id/view_list"
|
||||||
android:title="@string/view_list"
|
android:title="@string/view_list"
|
||||||
android:checked="true"/>
|
android:checked="true"/>
|
||||||
|
<item
|
||||||
|
android:id="@+id/view_carousel"
|
||||||
|
android:title="@string/view_carousel" />
|
||||||
</group>
|
</group>
|
||||||
</menu>
|
</menu>
|
|
@ -15,7 +15,7 @@
|
||||||
<dimen name="icon_inset">24dp</dimen>
|
<dimen name="icon_inset">24dp</dimen>
|
||||||
<dimen name="spacing_bottom_list_fab">96dp</dimen>
|
<dimen name="spacing_bottom_list_fab">96dp</dimen>
|
||||||
<dimen name="spacing_fab">24dp</dimen>
|
<dimen name="spacing_fab">24dp</dimen>
|
||||||
|
<dimen name="carousel_overlap">150dp</dimen>
|
||||||
<dimen name="dialog_margin">20dp</dimen>
|
<dimen name="dialog_margin">20dp</dimen>
|
||||||
<dimen name="elevated_app_bar">3dp</dimen>
|
<dimen name="elevated_app_bar">3dp</dimen>
|
||||||
|
|
||||||
|
|
6
src/android/app/src/main/res/values/fractions.xml
Normal file
6
src/android/app/src/main/res/values/fractions.xml
Normal file
|
@ -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>
|
|
@ -232,6 +232,8 @@
|
||||||
<string name="alphabetical">Alphabetical</string>
|
<string name="alphabetical">Alphabetical</string>
|
||||||
<string name="view_list">List</string>
|
<string name="view_list">List</string>
|
||||||
<string name="view_grid">Grid</string>
|
<string name="view_grid">Grid</string>
|
||||||
|
<string name="view_carousel">Carousel</string>
|
||||||
|
<string name="game_image_desc">Screenshot for %1$s</string>
|
||||||
<string name="folder">Folder</string>
|
<string name="folder">Folder</string>
|
||||||
<string name="pre_alpha_warning_title">Pre-Alpha Software</string>
|
<string name="pre_alpha_warning_title">Pre-Alpha Software</string>
|
||||||
<string name="pre_alpha_warning_description">"WARNING: This software is in the pre-alpha stage and may have bugs and incomplete feature implementations."</string>
|
<string name="pre_alpha_warning_description">"WARNING: This software is in the pre-alpha stage and may have bugs and incomplete feature implementations."</string>
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
# SPDX-FileCopyrightText: Copyright 2025 yuzu Emulator Project
|
# SPDX-FileCopyrightText: Copyright 2025 yuzu Emulator Project
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# 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.
|
# Project-wide Gradle settings.
|
||||||
# IDE (e.g. Android Studio) users:
|
# IDE (e.g. Android Studio) users:
|
||||||
# Gradle settings configured through the IDE *will override*
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
|
|
@ -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
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue