App List: Category "chip" and free text searching of apps
Show a "Chip" in the search box whcih indicates the user is viewing a particular category. This chip: * Gets remtoved when the user presses backspace from in front of it. * Can be re-added by typing the name of a category and then a colon. * Follows the material design guidelines. * Has an accessibility hint that tells screen readers it is a category name.
This commit is contained in:
parent
9bc72ff102
commit
fff7999aac
@ -18,7 +18,7 @@ import org.fdroid.fdroid.R;
|
|||||||
import org.fdroid.fdroid.data.AppProvider;
|
import org.fdroid.fdroid.data.AppProvider;
|
||||||
import org.fdroid.fdroid.data.Schema;
|
import org.fdroid.fdroid.data.Schema;
|
||||||
|
|
||||||
public class AppListActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<Cursor> {
|
public class AppListActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<Cursor>, CategoryTextWatcher.SearchTermsChangedListener {
|
||||||
|
|
||||||
public static final String EXTRA_CATEGORY = "org.fdroid.fdroid.views.apps.AppListActivity.EXTRA_CATEGORY";
|
public static final String EXTRA_CATEGORY = "org.fdroid.fdroid.views.apps.AppListActivity.EXTRA_CATEGORY";
|
||||||
private RecyclerView appView;
|
private RecyclerView appView;
|
||||||
@ -34,6 +34,7 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager.
|
|||||||
setContentView(R.layout.activity_app_list);
|
setContentView(R.layout.activity_app_list);
|
||||||
|
|
||||||
searchInput = (EditText) findViewById(R.id.search);
|
searchInput = (EditText) findViewById(R.id.search);
|
||||||
|
searchInput.addTextChangedListener(new CategoryTextWatcher(this, searchInput, this));
|
||||||
|
|
||||||
View backButton = findViewById(R.id.back);
|
View backButton = findViewById(R.id.back);
|
||||||
backButton.setOnClickListener(new View.OnClickListener() {
|
backButton.setOnClickListener(new View.OnClickListener() {
|
||||||
@ -81,7 +82,7 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager.
|
|||||||
private CharSequence getSearchText(@Nullable String category, @Nullable String searchTerms) {
|
private CharSequence getSearchText(@Nullable String category, @Nullable String searchTerms) {
|
||||||
StringBuilder string = new StringBuilder();
|
StringBuilder string = new StringBuilder();
|
||||||
if (category != null) {
|
if (category != null) {
|
||||||
string.append(category).append(' ');
|
string.append(category).append(":");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchTerms != null) {
|
if (searchTerms != null) {
|
||||||
@ -112,4 +113,11 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager.
|
|||||||
public void onLoaderReset(Loader<Cursor> loader) {
|
public void onLoaderReset(Loader<Cursor> loader) {
|
||||||
appAdapter.setAppCursor(null);
|
appAdapter.setAppCursor(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSearchTermsChanged(@Nullable String category, @NonNull String searchTerms) {
|
||||||
|
this.category = category;
|
||||||
|
this.searchTerms = searchTerms;
|
||||||
|
getSupportLoaderManager().restartLoader(0, null, this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
132
app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java
Normal file
132
app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
package org.fdroid.fdroid.views.apps;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
import android.support.annotation.NonNull;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.text.style.ReplacementSpan;
|
||||||
|
|
||||||
|
import org.fdroid.fdroid.R;
|
||||||
|
import org.fdroid.fdroid.views.categories.CategoryController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This draws a category "chip" in the search text view according to the material design specs
|
||||||
|
* (https://material.google.com/components/chips.html#chips-specs). These contain a circle with an
|
||||||
|
* icon representing "category" on the left, and the name of the category on the right. It also has
|
||||||
|
* a background with curved corners behind the category text.
|
||||||
|
*/
|
||||||
|
public class CategorySpan extends ReplacementSpan {
|
||||||
|
|
||||||
|
private static final int HEIGHT = 32;
|
||||||
|
private static final int CORNER_RADIUS = 16;
|
||||||
|
private static final int ICON_BACKGROUND_SIZE = 32;
|
||||||
|
private static final int ICON_SIZE = 16;
|
||||||
|
private static final int ICON_PADDING = (ICON_BACKGROUND_SIZE - ICON_SIZE) / 2;
|
||||||
|
private static final int TEXT_LEADING_PADDING = 8;
|
||||||
|
private static final int TEXT_TRAILING_PADDING = 12;
|
||||||
|
private static final int TEXT_BELOW_PADDING = 4;
|
||||||
|
private static final int WHITE_SPACE_PADDING_AT_END = 4;
|
||||||
|
private static final float DROP_SHADOW_HEIGHT = 1.5f;
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
|
||||||
|
public CategorySpan(Context context) {
|
||||||
|
super();
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static CharSequence getCategoryName(@Nullable CharSequence text, int start, int end) {
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start + 1 >= end - 1) {
|
||||||
|
// This can happen when the spell checker is trying to underline text within our category
|
||||||
|
// name. It sometimes will ask for sub-lengths of this span.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.subSequence(start, end - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
|
||||||
|
CharSequence categoryName = getCategoryName(text, start, end);
|
||||||
|
if (categoryName == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
float density = context.getResources().getDisplayMetrics().density;
|
||||||
|
|
||||||
|
int iconBackgroundSize = (int) (ICON_BACKGROUND_SIZE * density);
|
||||||
|
int textLeadingPadding = (int) (TEXT_LEADING_PADDING * density);
|
||||||
|
int textWidth = (int) paint.measureText(categoryName.toString());
|
||||||
|
int textTrailingPadding = (int) (TEXT_TRAILING_PADDING * density);
|
||||||
|
int whiteSpacePadding = (int) (WHITE_SPACE_PADDING_AT_END * density);
|
||||||
|
|
||||||
|
return iconBackgroundSize + textLeadingPadding + textWidth + textTrailingPadding + whiteSpacePadding;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
|
||||||
|
CharSequence categoryName = getCategoryName(text, start, end);
|
||||||
|
if (categoryName == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float density = context.getResources().getDisplayMetrics().density;
|
||||||
|
|
||||||
|
int height = (int) (HEIGHT * density);
|
||||||
|
int iconBackgroundSize = (int) (ICON_BACKGROUND_SIZE * density);
|
||||||
|
int cornerRadius = (int) (CORNER_RADIUS * density);
|
||||||
|
int iconSize = (int) (ICON_SIZE * density);
|
||||||
|
int iconPadding = (int) (ICON_PADDING * density);
|
||||||
|
int textWidth = (int) paint.measureText(categoryName.toString());
|
||||||
|
int textLeadingPadding = (int) (TEXT_LEADING_PADDING * density);
|
||||||
|
int textTrailingPadding = (int) (TEXT_TRAILING_PADDING * density);
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
canvas.translate(x, bottom - height + TEXT_BELOW_PADDING * density);
|
||||||
|
|
||||||
|
RectF backgroundRect = new RectF(0, 0, iconBackgroundSize + textLeadingPadding + textWidth + textTrailingPadding, height);
|
||||||
|
|
||||||
|
// The shadow below the entire category chip.
|
||||||
|
canvas.save();
|
||||||
|
canvas.translate(0, DROP_SHADOW_HEIGHT * density);
|
||||||
|
Paint shadowPaint = new Paint();
|
||||||
|
shadowPaint.setColor(0x66000000);
|
||||||
|
shadowPaint.setAntiAlias(true);
|
||||||
|
canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, shadowPaint);
|
||||||
|
canvas.restore();
|
||||||
|
|
||||||
|
// The background which goes behind the text.
|
||||||
|
Paint backgroundPaint = new Paint();
|
||||||
|
backgroundPaint.setColor(CategoryController.getBackgroundColour(categoryName.toString()));
|
||||||
|
backgroundPaint.setAntiAlias(true);
|
||||||
|
canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, backgroundPaint);
|
||||||
|
|
||||||
|
// The background behind the category icon.
|
||||||
|
Paint iconBackgroundPaint = new Paint();
|
||||||
|
iconBackgroundPaint.setColor(0xffd8d8d8);
|
||||||
|
iconBackgroundPaint.setAntiAlias(true);
|
||||||
|
RectF iconBackgroundRect = new RectF(0, 0, iconBackgroundSize, height);
|
||||||
|
canvas.drawRoundRect(iconBackgroundRect, cornerRadius, cornerRadius, iconBackgroundPaint);
|
||||||
|
|
||||||
|
// Category icon on top of the circular background which was just drawn.
|
||||||
|
Drawable icon = context.getResources().getDrawable(R.drawable.ic_category);
|
||||||
|
icon.setBounds(iconPadding, iconPadding, iconPadding + iconSize, iconPadding + iconSize);
|
||||||
|
icon.draw(canvas);
|
||||||
|
|
||||||
|
// The category name drawn to the right of the category name.
|
||||||
|
Paint textPaint = new Paint(paint);
|
||||||
|
textPaint.setColor(Color.WHITE);
|
||||||
|
canvas.drawText(categoryName.toString(), iconBackgroundSize + textLeadingPadding, bottom, textPaint);
|
||||||
|
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,157 @@
|
|||||||
|
package org.fdroid.fdroid.views.apps;
|
||||||
|
|
||||||
|
import android.annotation.TargetApi;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.support.annotation.NonNull;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
|
import android.text.Editable;
|
||||||
|
import android.text.Spanned;
|
||||||
|
import android.text.TextWatcher;
|
||||||
|
import android.text.style.TtsSpan;
|
||||||
|
import android.widget.EditText;
|
||||||
|
|
||||||
|
import org.fdroid.fdroid.R;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search input treats text before the first colon as a category name. Text after this colon
|
||||||
|
* (or all text if there is no colon) is the free text search terms.
|
||||||
|
* The behaviour of this search input is:
|
||||||
|
* * Replacing anything before the first colon with a {@link CategorySpan} that renders a "Chip"
|
||||||
|
* including an icon representing "category" and the name of the category.
|
||||||
|
* * Removing the trailing ":" from a category chip will cause it to remove the entire category
|
||||||
|
* from the input.
|
||||||
|
*/
|
||||||
|
public class CategoryTextWatcher implements TextWatcher {
|
||||||
|
|
||||||
|
interface SearchTermsChangedListener {
|
||||||
|
void onSearchTermsChanged(@Nullable String category, @NonNull String searchTerms);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
private final EditText widget;
|
||||||
|
private final SearchTermsChangedListener listener;
|
||||||
|
|
||||||
|
private int removeTo = -1;
|
||||||
|
private boolean requiresSpanRecalculation = false;
|
||||||
|
|
||||||
|
public CategoryTextWatcher(final Context context, final EditText widget, final SearchTermsChangedListener listener) {
|
||||||
|
this.context = context;
|
||||||
|
this.widget = widget;
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the user removed the first colon in the search text, then request for the entire
|
||||||
|
* block of text representing the category text to be removed when able.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||||
|
removeTo = -1;
|
||||||
|
|
||||||
|
boolean removingOrReplacing = count > 0;
|
||||||
|
|
||||||
|
// Don't bother working out if we need to recalculate spans if we are removing text
|
||||||
|
// right to the start. This could be if we are removing everything (in which case
|
||||||
|
// there is no text to span), or we are removing somewhere from after the category
|
||||||
|
// back to the start (in which case we've removed the category anyway and don't need
|
||||||
|
// to explicilty request it to be removed.
|
||||||
|
if (start == 0 && removingOrReplacing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String string = s.toString();
|
||||||
|
boolean removingColon = removingOrReplacing && string.indexOf(':', start) < (start + count);
|
||||||
|
boolean removingFirstColon = removingColon && string.indexOf(':') >= start;
|
||||||
|
if (removingFirstColon) {
|
||||||
|
removeTo = start + count - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the user added a colon, and there was not previously a colon before the newly added
|
||||||
|
* one, then request for a {@link CategorySpan} to be added when able.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||||
|
boolean addingOrReplacing = count > 0;
|
||||||
|
boolean addingColon = addingOrReplacing && s.subSequence(start, start + count).toString().indexOf(':') >= 0;
|
||||||
|
boolean addingFirstColon = addingColon && s.subSequence(0, start).toString().indexOf(':') == -1;
|
||||||
|
if (addingFirstColon) {
|
||||||
|
requiresSpanRecalculation = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If it was decided that we were removing a category, then ensure that the relevant
|
||||||
|
* characters are removed. If it was deemed we were adding a new category, then ensure
|
||||||
|
* that the relevant {@link CategorySpan} is added to {@param searchText}.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterTextChanged(Editable searchText) {
|
||||||
|
if (removeTo >= 0) {
|
||||||
|
removeLeadingCharacters(searchText, removeTo);
|
||||||
|
removeTo = -1;
|
||||||
|
} else if (requiresSpanRecalculation) {
|
||||||
|
prepareSpans(searchText);
|
||||||
|
requiresSpanRecalculation = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int colonIndex = searchText.toString().indexOf(':');
|
||||||
|
String category = colonIndex == -1 ? null : searchText.subSequence(0, colonIndex).toString();
|
||||||
|
String searchTerms = searchText.subSequence(colonIndex == -1 ? 0 : colonIndex + 1, searchText.length()).toString();
|
||||||
|
listener.onSearchTermsChanged(category, searchTerms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all characters from {@param searchText} up until {@param end}.
|
||||||
|
* Will do so without triggering a further set of callbacks on this {@link TextWatcher},
|
||||||
|
* though if any other {@link TextWatcher}s have been added, they will be notified.
|
||||||
|
*/
|
||||||
|
private void removeLeadingCharacters(Editable searchText, int end) {
|
||||||
|
widget.removeTextChangedListener(this);
|
||||||
|
searchText.replace(0, end, "");
|
||||||
|
widget.addTextChangedListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that a {@link CategorySpan} is in {@param textToSpannify} if required.
|
||||||
|
* Will firstly remove all existing category spans, and then add back one if neccesary.
|
||||||
|
* In addition, also adds a {@link TtsSpan} to indicate to screen readers that the category
|
||||||
|
* span has semantic meaning representing a category.
|
||||||
|
*/
|
||||||
|
@TargetApi(21)
|
||||||
|
private void prepareSpans(Editable textToSpannify) {
|
||||||
|
if (textToSpannify == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSpans(textToSpannify, CategorySpan.class);
|
||||||
|
if (Build.VERSION.SDK_INT >= 21) {
|
||||||
|
removeSpans(textToSpannify, TtsSpan.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
int colonIndex = textToSpannify.toString().indexOf(':');
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
CategorySpan span = new CategorySpan(context);
|
||||||
|
textToSpannify.setSpan(span, 0, colonIndex + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= 21) {
|
||||||
|
// For accessibility reasons, make this more clear to screen readers that the
|
||||||
|
// span we just added semantically represents a category.
|
||||||
|
TtsSpan ttsSpan = new TtsSpan.TextBuilder(context.getString(R.string.category)).build();
|
||||||
|
textToSpannify.setSpan(ttsSpan, 0, 0, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to remove all spans of a certain type from an {@link Editable}.
|
||||||
|
*/
|
||||||
|
private <T> void removeSpans(Editable text, Class<T> clazz) {
|
||||||
|
T[] spans = text.getSpans(0, text.length(), clazz);
|
||||||
|
for (T span : spans) {
|
||||||
|
text.removeSpan(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
app/src/main/res/drawable/category_chip_background.xml
Normal file
10
app/src/main/res/drawable/category_chip_background.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
A category chip displays an icon on the left and the category label on the right. It follows the
|
||||||
|
material design specification at https://material.google.com/components/chips.html#chips-contact-chips.
|
||||||
|
Most of the actual drawing is done in the Java code rather than here.
|
||||||
|
-->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
<solid android:color="#FFCCCCCC" />
|
||||||
|
</shape>
|
@ -444,4 +444,5 @@
|
|||||||
<string name="notification_action_cancel">Cancel</string>
|
<string name="notification_action_cancel">Cancel</string>
|
||||||
<string name="notification_action_install">Install</string>
|
<string name="notification_action_install">Install</string>
|
||||||
|
|
||||||
|
<string name="category">Category</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user