From fff7999aacb9ec4eea317a7dfa0597cffd8f3f1f Mon Sep 17 00:00:00 2001 From: Peter Serwylo Date: Thu, 24 Nov 2016 11:50:36 +1100 Subject: [PATCH] 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. --- .../fdroid/views/apps/AppListActivity.java | 12 +- .../fdroid/views/apps/CategorySpan.java | 132 +++++++++++++++ .../views/apps/CategoryTextWatcher.java | 157 ++++++++++++++++++ .../res/drawable/category_chip_background.xml | 10 ++ app/src/main/res/values/strings.xml | 1 + 5 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java create mode 100644 app/src/main/java/org/fdroid/fdroid/views/apps/CategoryTextWatcher.java create mode 100644 app/src/main/res/drawable/category_chip_background.xml diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java index e9294eaa6..4b68022f7 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java @@ -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 { +public class AppListActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks, 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 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); + } } diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java b/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java new file mode 100644 index 000000000..c97acb0f4 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/CategorySpan.java @@ -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(); + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/CategoryTextWatcher.java b/app/src/main/java/org/fdroid/fdroid/views/apps/CategoryTextWatcher.java new file mode 100644 index 000000000..daad4a208 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/CategoryTextWatcher.java @@ -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 void removeSpans(Editable text, Class clazz) { + T[] spans = text.getSpans(0, text.length(), clazz); + for (T span : spans) { + text.removeSpan(span); + } + } +} diff --git a/app/src/main/res/drawable/category_chip_background.xml b/app/src/main/res/drawable/category_chip_background.xml new file mode 100644 index 000000000..fd4ed2ac8 --- /dev/null +++ b/app/src/main/res/drawable/category_chip_background.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4acac57c2..bd5c70f67 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -444,4 +444,5 @@ Cancel Install + Category