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:
Peter Serwylo 2016-11-24 11:50:36 +11:00
parent 9bc72ff102
commit fff7999aac
5 changed files with 310 additions and 2 deletions

View File

@ -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);
}
}

View 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();
}
}

View File

@ -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);
}
}
}

View 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>

View File

@ -444,4 +444,5 @@
<string name="notification_action_cancel">Cancel</string>
<string name="notification_action_install">Install</string>
<string name="category">Category</string>
</resources>