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.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";
|
||||
private RecyclerView appView;
|
||||
@ -34,6 +34,7 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager.
|
||||
setContentView(R.layout.activity_app_list);
|
||||
|
||||
searchInput = (EditText) findViewById(R.id.search);
|
||||
searchInput.addTextChangedListener(new CategoryTextWatcher(this, searchInput, this));
|
||||
|
||||
View backButton = findViewById(R.id.back);
|
||||
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) {
|
||||
StringBuilder string = new StringBuilder();
|
||||
if (category != null) {
|
||||
string.append(category).append(' ');
|
||||
string.append(category).append(":");
|
||||
}
|
||||
|
||||
if (searchTerms != null) {
|
||||
@ -112,4 +113,11 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager.
|
||||
public void onLoaderReset(Loader<Cursor> loader) {
|
||||
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_install">Install</string>
|
||||
|
||||
<string name="category">Category</string>
|
||||
</resources>
|
||||
|
Loading…
x
Reference in New Issue
Block a user