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
	 Peter Serwylo
						Peter Serwylo