Android: Add game grid view

This commit is contained in:
Connor McLaughlin 2021-03-06 14:02:07 +10:00
parent 2106197418
commit 5bd39bc2c7
15 changed files with 460 additions and 58 deletions

View file

@ -0,0 +1,159 @@
package com.github.stenzek.duckstation;
import android.content.Context;
import android.content.res.Configuration;
import android.os.AsyncTask;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.PopupMenu;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
public class GameGridFragment extends Fragment implements GameList.OnRefreshListener {
private static final int SPACING_DIPS = 25;
private static final int WIDTH_DIPS = 160;
private MainActivity mParent;
private RecyclerView mRecyclerView;
private ViewAdapter mAdapter;
private GridAutofitLayoutManager mLayoutManager;
public GameGridFragment(MainActivity parent) {
super(R.layout.fragment_game_grid);
this.mParent = parent;
}
private GameList getGameList() {
return mParent.getGameList();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
mAdapter = new ViewAdapter(mParent, getGameList());
getGameList().addRefreshListener(this);
mRecyclerView = view.findViewById(R.id.game_list_view);
mRecyclerView.setAdapter(mAdapter);
final int columnWidth = Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
WIDTH_DIPS + SPACING_DIPS, getResources().getDisplayMetrics()));
mLayoutManager = new GridAutofitLayoutManager(getContext(), columnWidth);
mRecyclerView.setLayoutManager(mLayoutManager);
}
@Override
public void onDestroyView() {
super.onDestroyView();
getGameList().removeRefreshListener(this);
}
@Override
public void onGameListRefresh() {
mAdapter.notifyDataSetChanged();
}
private static class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener {
private MainActivity mParent;
private ImageView mImageView;
private GameListEntry mEntry;
public ViewHolder(@NonNull MainActivity parent, @NonNull View itemView) {
super(itemView);
mParent = parent;
mImageView = itemView.findViewById(R.id.imageView);
mImageView.setOnClickListener(this);
mImageView.setOnLongClickListener(this);
}
public void bindToEntry(GameListEntry entry) {
mEntry = entry;
final String coverPath = entry.getCoverPath();
if (coverPath == null) {
mImageView.setImageDrawable(ContextCompat.getDrawable(mParent, R.drawable.ic_media_cdrom));
return;
}
new ImageLoadTask(mImageView).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, coverPath);
}
@Override
public void onClick(View v) {
mParent.startEmulation(mEntry.getPath(), mParent.shouldResumeStateByDefault());
}
@Override
public boolean onLongClick(View v) {
PopupMenu menu = new PopupMenu(mParent, v, Gravity.RIGHT | Gravity.TOP);
menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu());
menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
int id = item.getItemId();
if (id == R.id.game_list_entry_menu_start_game) {
mParent.startEmulation(mEntry.getPath(), false);
return true;
} else if (id == R.id.game_list_entry_menu_resume_game) {
mParent.startEmulation(mEntry.getPath(), true);
return true;
} else if (id == R.id.game_list_entry_menu_properties) {
mParent.openGameProperties(mEntry.getPath());
return true;
}
return false;
}
});
menu.show();
return true;
}
}
private static class ViewAdapter extends RecyclerView.Adapter<ViewHolder> {
private MainActivity mParent;
private LayoutInflater mInflater;
private GameList mGameList;
public ViewAdapter(@NonNull MainActivity parent, @NonNull GameList gameList) {
mParent = parent;
mInflater = LayoutInflater.from(parent);
mGameList = gameList;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ViewHolder(mParent, mInflater.inflate(R.layout.layout_game_grid_entry, parent, false));
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
GameListEntry entry = mGameList.getEntry(position);
holder.bindToEntry(entry);
}
@Override
public int getItemCount() {
return mGameList.getEntryCount();
}
@Override
public int getItemViewType(int position) {
return R.layout.layout_game_grid_entry;
}
}
}

View file

@ -8,13 +8,19 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
public class GameList {
public interface OnRefreshListener {
void onGameListRefresh();
}
private Activity mContext;
private GameListEntry[] mEntries;
private ListViewAdapter mAdapter;
private ArrayList<OnRefreshListener> mRefreshListeners = new ArrayList<>();
public GameList(Activity context) {
mContext = context;
@ -22,6 +28,13 @@ public class GameList {
mEntries = new GameListEntry[0];
}
public void addRefreshListener(OnRefreshListener listener) {
mRefreshListeners.add(listener);
}
public void removeRefreshListener(OnRefreshListener listener) {
mRefreshListeners.remove(listener);
}
private class GameListEntryComparator implements Comparator<GameListEntry> {
@Override
public int compare(GameListEntry left, GameListEntry right) {
@ -29,7 +42,6 @@ public class GameList {
}
}
public void refresh(boolean invalidateCache, boolean invalidateDatabase, Activity parentActivity) {
// Search and get entries from native code
AndroidProgressCallback progressCallback = new AndroidProgressCallback(mContext);
@ -47,6 +59,8 @@ public class GameList {
}
mEntries = newEntries;
mAdapter.notifyDataSetChanged();
for (OnRefreshListener listener : mRefreshListeners)
listener.onGameListRefresh();
});
});
}

View file

@ -96,6 +96,8 @@ public class GameListEntry {
return mCompatibilityRating;
}
public String getCoverPath() { return mCoverPath; }
public static String getFileNameForPath(String path) {
int lastSlash = path.lastIndexOf('/');
if (lastSlash > 0 && lastSlash < path.length() - 1)

View file

@ -0,0 +1,68 @@
package com.github.stenzek.duckstation;
import android.os.Bundle;
import android.view.Gravity;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.PopupMenu;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
public class GameListFragment extends Fragment {
private MainActivity mParent;
private ListView mGameListView;
public GameListFragment(MainActivity parent) {
super(R.layout.fragment_game_list);
this.mParent = parent;
}
private GameList getGameList() {
return mParent.getGameList();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
mGameListView = view.findViewById(R.id.game_list_view);
mGameListView.setAdapter(getGameList().getListViewAdapter());
mGameListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
mParent.startEmulation(getGameList().getEntry(position).getPath(), mParent.shouldResumeStateByDefault());
}
});
mGameListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
@Override
public boolean onItemLongClick(AdapterView<?> parent, View view, int position,
long id) {
PopupMenu menu = new PopupMenu(getContext(), view, Gravity.RIGHT | Gravity.TOP);
menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu());
menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
int id = item.getItemId();
if (id == R.id.game_list_entry_menu_start_game) {
mParent.startEmulation(getGameList().getEntry(position).getPath(), false);
return true;
} else if (id == R.id.game_list_entry_menu_resume_game) {
mParent.startEmulation(getGameList().getEntry(position).getPath(), true);
return true;
} else if (id == R.id.game_list_entry_menu_properties) {
mParent.openGameProperties(getGameList().getEntry(position).getPath());
return true;
}
return false;
}
});
menu.show();
return true;
}
});
}
}

View file

@ -0,0 +1,73 @@
package com.github.stenzek.duckstation;
// https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count
import android.content.Context;
import android.util.TypedValue;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
public class GridAutofitLayoutManager extends GridLayoutManager
{
private int columnWidth;
private boolean isColumnWidthChanged = true;
private int lastWidth;
private int lastHeight;
public GridAutofitLayoutManager(@NonNull final Context context, final int columnWidth) {
/* Initially set spanCount to 1, will be changed automatically later. */
super(context, 1);
setColumnWidth(checkedColumnWidth(context, columnWidth));
}
public GridAutofitLayoutManager(
@NonNull final Context context,
final int columnWidth,
final int orientation,
final boolean reverseLayout) {
/* Initially set spanCount to 1, will be changed automatically later. */
super(context, 1, orientation, reverseLayout);
setColumnWidth(checkedColumnWidth(context, columnWidth));
}
private int checkedColumnWidth(@NonNull final Context context, int columnWidth) {
if (columnWidth <= 0) {
/* Set default columnWidth value (48dp here). It is better to move this constant
to static constant on top, but we need context to convert it to dp, so can't really
do so. */
columnWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48,
context.getResources().getDisplayMetrics());
}
return columnWidth;
}
public void setColumnWidth(final int newColumnWidth) {
if (newColumnWidth > 0 && newColumnWidth != columnWidth) {
columnWidth = newColumnWidth;
isColumnWidthChanged = true;
}
}
@Override
public void onLayoutChildren(@NonNull final RecyclerView.Recycler recycler, @NonNull final RecyclerView.State state) {
final int width = getWidth();
final int height = getHeight();
if (columnWidth > 0 && width > 0 && height > 0 && (isColumnWidthChanged || lastWidth != width || lastHeight != height)) {
final int totalSpace;
if (getOrientation() == VERTICAL) {
totalSpace = width - getPaddingRight() - getPaddingLeft();
} else {
totalSpace = height - getPaddingTop() - getPaddingBottom();
}
final int spanCount = Math.max(1, totalSpace / columnWidth);
setSpanCount(spanCount);
isColumnWidthChanged = false;
}
lastWidth = width;
lastHeight = height;
super.onLayoutChildren(recycler, state);
}
}

View file

@ -12,30 +12,28 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.PopupMenu;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentFactory;
import androidx.preference.PreferenceManager;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
public class MainActivity extends AppCompatActivity {
private static final int REQUEST_EXTERNAL_STORAGE_PERMISSIONS = 1;
@ -47,7 +45,32 @@ public class MainActivity extends AppCompatActivity {
private GameList mGameList;
private ListView mGameListView;
private GameListFragment mGameListFragment;
private GameGridFragment mGameGridFragment;
private boolean mHasExternalStoragePermissions = false;
private boolean mIsShowingGameGrid = false;
public MainActivity() {
getSupportFragmentManager().setFragmentFactory(createFragmentFactory());
}
private static String getTitleString() {
String scmVersion = AndroidHostInterface.getScmVersion();
final int gitHashPos = scmVersion.indexOf("-g");
if (gitHashPos > 0)
scmVersion = scmVersion.substring(0, gitHashPos);
return String.format("DuckStation %s", scmVersion);
}
public GameList getGameList() {
return mGameList;
}
public boolean shouldResumeStateByDefault() {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
return prefs.getBoolean("Main/SaveStateOnExit", true);
}
private void setLanguage() {
String language = PreferenceManager.getDefaultSharedPreferences(this).getString("Main/Language", "none");
@ -86,20 +109,42 @@ public class MainActivity extends AppCompatActivity {
private void loadSettings() {
setLanguage();
setTheme();
mIsShowingGameGrid = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("Main/GameGridView", false);
}
private boolean shouldResumeStateByDefault() {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
return prefs.getBoolean("Main/SaveStateOnExit", true);
private FragmentFactory createFragmentFactory() {
return new FragmentFactory() {
@NonNull
@Override
public Fragment instantiate(@NonNull ClassLoader classLoader, @NonNull String className) {
if (className == GameListFragment.class.getName())
return new GameListFragment(MainActivity.this);
else if (className == GameGridFragment.class.getName())
return new GameGridFragment(MainActivity.this);
return super.instantiate(classLoader, className);
}
};
}
private static String getTitleString() {
String scmVersion = AndroidHostInterface.getScmVersion();
final int gitHashPos = scmVersion.indexOf("-g");
if (gitHashPos > 0)
scmVersion = scmVersion.substring(0, gitHashPos);
private void switchGameListView() {
mIsShowingGameGrid = !mIsShowingGameGrid;
PreferenceManager.getDefaultSharedPreferences(this)
.edit()
.putBoolean("Main/GameGridView", mIsShowingGameGrid)
.commit();
return String.format("DuckStation %s", scmVersion);
updateGameListFragment();
invalidateOptionsMenu();
}
private void updateGameListFragment() {
getSupportFragmentManager()
.beginTransaction()
.setReorderingAllowed(true).
replace(R.id.content_fragment, mIsShowingGameGrid ? mGameGridFragment : mGameListFragment)
.commit();
}
@Override
@ -127,42 +172,9 @@ public class MainActivity extends AppCompatActivity {
// Set up game list view.
mGameList = new GameList(this);
mGameListView = findViewById(R.id.game_list_view);
mGameListView.setAdapter(mGameList.getListViewAdapter());
mGameListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
startEmulation(mGameList.getEntry(position).getPath(), shouldResumeStateByDefault());
}
});
mGameListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
@Override
public boolean onItemLongClick(AdapterView<?> parent, View view, int position,
long id) {
PopupMenu menu = new PopupMenu(MainActivity.this, view,
Gravity.RIGHT | Gravity.TOP);
menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu());
menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
int id = item.getItemId();
if (id == R.id.game_list_entry_menu_start_game) {
startEmulation(mGameList.getEntry(position).getPath(), false);
return true;
} else if (id == R.id.game_list_entry_menu_resume_game) {
startEmulation(mGameList.getEntry(position).getPath(), true);
return true;
} else if (id == R.id.game_list_entry_menu_properties) {
openGameProperties(mGameList.getEntry(position).getPath());
return true;
}
return false;
}
});
menu.show();
return true;
}
});
mGameListFragment = new GameListFragment(this);
mGameGridFragment = new GameGridFragment(this);
updateGameListFragment();
mHasExternalStoragePermissions = checkForExternalStoragePermissions();
if (mHasExternalStoragePermissions)
@ -194,6 +206,13 @@ public class MainActivity extends AppCompatActivity {
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu; this adds items to the action bar if it is present.
getMenuInflater().inflate(R.menu.menu_main, menu);
final MenuItem switchViewItem = menu.findItem(R.id.action_switch_view);
if (switchViewItem != null) {
switchViewItem.setTitle(mIsShowingGameGrid ? R.string.action_show_game_list : R.string.action_show_game_grid);
switchViewItem.setIcon(mIsShowingGameGrid ? R.drawable.ic_baseline_view_list_24 : R.drawable.ic_baseline_grid_view_24);
}
return true;
}
@ -227,6 +246,9 @@ public class MainActivity extends AppCompatActivity {
Intent intent = new Intent(this, ControllerMappingActivity.class);
startActivity(intent);
return true;
} else if (id == R.id.action_switch_view) {
switchGameListView();
return true;
} else if (id == R.id.action_show_version) {
showVersion();
return true;
@ -325,14 +347,14 @@ public class MainActivity extends AppCompatActivity {
}
}
private boolean openGameProperties(String path) {
public boolean openGameProperties(String path) {
Intent intent = new Intent(this, GamePropertiesActivity.class);
intent.putExtra("path", path);
startActivity(intent);
return true;
}
private boolean startEmulation(String bootPath, boolean resumeState) {
public boolean startEmulation(String bootPath, boolean resumeState) {
if (!doBIOSCheck())
return false;

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,3v8h8L11,3L3,3zM9,9L5,9L5,5h4v4zM3,13v8h8v-8L3,13zM9,19L5,19v-4h4v4zM13,3v8h8L21,3h-8zM19,9h-4L15,5h4v4zM13,13v8h8v-8h-8zM19,19h-4v-4h4v4z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M4,14h4v-4L4,10v4zM4,19h4v-4L4,15v4zM4,9h4L8,5L4,5v4zM9,14h12v-4L9,10v4zM9,19h12v-4L9,15v4zM9,5v4h12L21,5L9,5z"/>
</vector>

View file

@ -33,7 +33,12 @@
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/content_main" />
<FrameLayout
android:id="@+id/content_fragment"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_weight="1"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_resume"

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/game_list_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -3,10 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:context=".MainActivity"
tools:showIn="@layout/activity_main">
android:layout_height="match_parent">
<ListView
android:id="@+id/game_list_view"

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_margin="25dp">
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foreground="?android:attr/selectableItemBackground"
app:srcCompat="@drawable/ic_media_cdrom"
tools:layout_editor_absoluteX="1dp"
tools:layout_editor_absoluteY="1dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -14,6 +14,12 @@
<item
android:id="@+id/action_settings"
android:icon="@drawable/ic_baseline_settings_24"
android:orderInCategory="101"
android:title="@string/action_settings"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_switch_view"
android:icon="@drawable/ic_baseline_settings_24"
android:orderInCategory="100"
android:title="@string/action_settings"
app:showAsAction="ifRoom" />

View file

@ -227,4 +227,6 @@
<string name="dialog_touchscreen_controller_buttons">Touchscreen Controller Buttons</string>
<string name="dialog_touchscreen_controller_settings">Touchscreen Controller Settings</string>
<string name="settings_summary_console_tty_output">Logs debug messages printed by games.</string>
<string name="action_show_game_list">List View</string>
<string name="action_show_game_grid">Grid View</string>
</resources>