Introduction
A basic need required in a wide variety of applications is the search feature. In our case we have a restaurant application and need the user to be able to easily and quickly search the menu for what they want. In this article, I will explain how I added local search capabilities to our existing restaurant application while maintaining UI continuity. I will go into detail about the UI choices I made and why, in addition to going over how to add a GestureOverlayView to the activity.
Figure 1: Screenshot of the search view in the restaurant application
Search
For search, there are a number of design considerations to make before we even start coding. What do you want to search for? We want to search the title and the description for the user to get the maximum results and as titles are not always telling as to what the dish actually is. Additionally you could add some hidden metadata for each dish to search as well. As for the layout of the search activity, how do you want the search results to appear? I started out with a list view to display the results, just like the cart view activity. However, the dishes didn’t look appealing to tap on as the image was small and then when I made the image larger there was less space for results on the page. So I decided to stick with the grid view that the main part of the menu uses, but instead of having a larger detail view on one side, the grid view will take up the whole screen to make it easily differentiable from the normal menu. To see the detail view of the item now, the user taps the item and it appears as a dialogue fragment hovering above the page (see Figure 2). That way the user can quickly tap away to close it and tap on another item to view instead. Search needs to work quickly and smoothly for the user as they want to find what they are looking for as fast as possible, otherwise they might not be able to find the item or get frustrated looking for it and leave. And finally, how are we going to handle the user’s privacy? We could design a search that would offer suggestions based on recent queries or might have a search that requires personal information being entered. This brings up concerns about other people seeing what you are searching for and where your personal data is going. While in our case, it is just a restaurant application so you shouldn’t be too concerned with people knowing you love pie, there are applications where you will need to take privacy into account. For our application, it does not require any personal information, does not log any of the search terms, and there is no history of the search terms.
The first step to implement it in our restaurant app is to look at our database class and add a method to build a new table of search results for use to display. You can read more about the restaurant’s database setup here: Using a Database With Your Android* App. Using a SQLite query we can easily search our database with just a few lines of code to find our items. Here we are searching the name and description for anything that contains the search term plus any additional text after it. We are also returning all the columns as we will need that information later to display it in the detail view. Note that if your database is very large, there could be a delay and you will want to display a progress bar or loading spinner to the user.
/** * Builds a table of items matching the searchTerm in their name or description */ public Cursor searchMenuItems(String searchTerm) { SQLiteDatabase db = getReadableDatabase(); SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); qb.setTables(TABLES.MENU); Cursor c = qb.query(db, null, "("+MenuColumns.NAME+" LIKE '%"+searchTerm+"%') " +"OR ("+MenuColumns.DESCRIPTION+" LIKE '%" + searchTerm+"%')", null, null, null, null); return c; }
Code Example 1: Method for searching our database
Next, our main activity needs to be set up with the search option in the action bar. For more about how to set up the action bar read this article: Building Dynamic UI for Android* Devices. The search functionality will be handled entirely within our application; we don’t want the search to start listing applications installed on the device or send an intent for another search application to handle.
Add this string variable to the MainActivity class. We will be using this to send the query string to the search intent:
/* Search string label */ public final static String SEARCH_MESSAGE= "com.example.restaurant.MESSAGE";
Code Example 2: Class variable to add extended data to the search intent
Update the MainActivity’s onCreateOptionsMenu method:
/** * Initialize the action menu on action bar */ public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.action_bar, menu); //set up the search MenuItem searchItem = menu.findItem(R.id.action_search); SearchView mSearchView = (SearchView) searchItem.getActionView(); searchItem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW); //set up the query listener mSearchView.setOnQueryTextListener(new OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String query) { //start the search intent Intent searchIntent = new Intent(MainActivity.this, SearchResultsActivity.class); searchIntent.putExtra(SEARCH_MESSAGE, query); startActivity(searchIntent); return false; } @Override public boolean onQueryTextChange(String query) { //do nothing in our case return true; } }); return super.onCreateOptionsMenu(menu); }
Code Example 3: Action bar initialization code
And the SearchResultsActivity class:
public class SearchResultsActivity extends Activity{ TextView mQueryText; GridView searchListResults; SearchAdapter adapter; Vector<com.example.restaurant.MenuFactory.MenuItem> searchList; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.search_query_grid_results); mQueryText = (TextView) findViewById(R.id.txt_query); //setup the grid view searchListResults = (GridView)findViewById(R.id.search_results); searchList= new Vector<com.example.restaurant.MenuFactory.MenuItem>(); //get and process search query here final Intent queryIntent = getIntent(); doSearchQuery(queryIntent); adapter= new SearchAdapter(this,searchList); searchListResults.setAdapter(adapter); //Listener for grid view searchListResults.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View v, int position, long id){ FragmentTransaction ft = getFragmentManager().beginTransaction(); Fragment prev = getFragmentManager().findFragmentByTag("dialog"); if (prev != null) { ft.remove(prev); } ft.addToBackStack(null); DialogFragment newFragment = SearchResultsDialogFragment.newInstance(searchList.elementAt(position)); newFragment.show(ft, "dialog"); } }); }
Code Example 4: The main search results class (continued below)
When we build the list, we will also handle the case of what to do when we don’t find any matching results as well. If there are no matches, we display a dialogue message to the viewer to let them know and close the search activity so they don’t see a blank page.
/** * Builds the found item list. */ private void doSearchQuery(final Intent queryIntent) { //Get the query text String message= queryIntent.getStringExtra(MainActivity.SEARCH_MESSAGE); //Set the UI field mQueryText.setText(message); RestaurantDatabase dB= new RestaurantDatabase(this); MenuFactory mMF= MenuFactory.getInstance(); Cursor c= dB.searchMenuItems(message); Set<String> categories = new HashSet<String>(); while (c.moveToNext()) { String category = c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.CATEGORY)); categories.add(category); //build a new menu item and add it to the list MenuItem item= mMF.new MenuItem(); item.setCategory(category); item.setName(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.NAME))); item.setDescription(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.DESCRIPTION))); item.setNutrition(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.NUTRITION))); item.setPrice(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.PRICE))); item.setImageName(c.getString(c.getColumnIndexOrThrow(RestaurantDatabase.MenuColumns.IMAGENAME))); searchList.add(item); } c.close(); //Handle the case of not finding anything if(searchList.size()==0){ Intent intent = new Intent(SearchResultsActivity.this, OrderViewDialogue.class); intent.putExtra(OrderViewActivity.DIALOGUE_MESSAGE, "Sorry, no matching items found."); startActivity(intent); SearchResultsActivity.this.finish(); } }
Code Example 4 continued
In this part of the class is the adapter for the grid view, which we were able to reuse from the main menu code itself with some minor edits. We are also able to adapt the layout files as well, so keeping the UI consistent visually has the other benefit of easily recycling code and not having to start from scratch. You might have noticed above, that I also reused OrderViewDialogue, a class I wrote for the cart to use but will also work here.
/** * SearchAdapter to handle the grid view of found items. Each grid item contains * a view_grid_item which includes a image, name, and price. */ class SearchAdapter extends BaseAdapter { private Vector<com.example.restaurant.MenuFactory.MenuItem> mFoundList; private LayoutInflater inflater; public SearchAdapter(Context c, Vector<com.example.restaurant.MenuFactory.MenuItem> list) { mFoundList= list; inflater = LayoutInflater.from(c); } public int getCount() { return mFoundList.size(); } public Object getItem(int position) { return mFoundList.get(position); } public long getItemId(int position) { return 0; } // create a new ItemView for each item referenced by the Adapter public View getView(int position, View convertView, ViewGroup parent) { View v = convertView; ImageView picture; TextView name; TextView price; if(v == null) { v = inflater.inflate(R.layout.view_grid_item, parent, false); v.setTag(R.id.picture, v.findViewById(R.id.picture)); v.setTag(R.id.grid_name, v.findViewById(R.id.grid_name)); v.setTag(R.id.grid_price, v.findViewById(R.id.grid_price)); } picture= (ImageView) v.getTag(R.id.picture); name= (TextView) v.getTag(R.id.grid_name); price= (TextView) v.getTag(R.id.grid_price); final MenuItem foundItem = (MenuItem) mFoundList.get(position); InputStream inputStream = null; AssetManager assetManager = null; try { assetManager = getAssets(); inputStream = assetManager.open(foundItem.imageName); picture.setImageBitmap(BitmapFactory.decodeStream(inputStream)); } catch (Exception e) { Log.d("ActionBarLog", e.getMessage()); } finally { } name.setText(foundItem.name); price.setText(foundItem.price); return v; } } }
Code Example 4 continued
Another consideration on the layout of the UI is landscape versus portrait mode. Below is the search_query_grid_results.xml in the res/layout-land folder. Here you can see that numColumns is set to four, the res/layout-port file is identical except that the field has a value of two.
<?xml version="1.0" encoding="utf-8"?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="5dp" android:paddingRight="5dp" android:paddingBottom="5dp" android:paddingTop="5dp" android:orientation="vertical"><LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"><TextView android:layout_width="wrap_content" android:layout_height="wrap_content" style="@style/FragmentTitle" android:text="Results For: " /><TextView android:id="@+id/txt_query" android:layout_width="wrap_content" android:layout_height="wrap_content" style="@style/OrderTitle"/></LinearLayout><GridView android:id="@+id/search_results" android:layout_width="fill_parent" android:layout_height="0dp" android:paddingTop="10dp" android:numColumns="4" android:verticalSpacing="10dp" android:horizontalSpacing="10dp" android:layout_weight="1" android:stretchMode="columnWidth" android:gravity="center"/></LinearLayout>
Code Example 5: Search view landscape layout xml
Figure 2: Detail view of an item that shows when user taps on it
Gesture Overlay
To exit the search view, we would like it to slide left or right with a swipe, similar to the view pager scrolling in the rest of the menu. A GestureDetector works brilliantly over a list view for this, but has no effect when used in conjunction with a grid view. Hence we have to switch to using a GestureOverlayView. You will need to build a gesture library using the GestureBuilder application that you can find in the SDK samples (eg. android\sdk\samples\android-19\legacy\GestureBuilder). Build and launch the app on your device device and use it to name and create gestures. Once you have added all the gestures you need (in our case, left swipe and right swipe), copy the ‘gestures’ file from your device to res/raw folder. The app will tell you where it is saving the gesture file. In my case, all I had to do was plug in my device via USB and it was there in the root folder.
Figure 3: Screen shot of the gesture builder application and the gestures we added
One you have your file in place update the SearchResultsActivity class with:
GestureLibrary gestureLibrary; GestureOverlayView gestureOverlayView;
Code Example 6: Variable declaration for the GestureOverlayView
In the onCreate method, initialize the view, load the library, and set up the listener for what to do when the user performs a matching gesture. Make sure to match the name to what you created in the library. For the animation we are going to perform it with the overridePendingTransition call. We have a value of 0 for the incoming animation to specify no animation. You could make a blank animation xml file and reference it, but a good percentage of the time the system will get confused and the outgoing animation will execute extremely quickly.
gestureOverlayView = (GestureOverlayView)findViewById(R.id.gestures); //initialize the gesture library and set up the gesture listener gestureLibrary = GestureLibraries.fromRawResource(this, R.raw.gestures); gestureLibrary.load(); gestureOverlayView.addOnGesturePerformedListener(new OnGesturePerformedListener(){ @Override public void onGesturePerformed(GestureOverlayView view, Gesture gesture) { ArrayList<Prediction> prediction = gestureLibrary.recognize(gesture); if(prediction.size() > 0){ String action= prediction.get(0).name; //our gesture library contains "left swipe" and "right swipe" gestures if("left swipe".equals(action)){ //slide out to the left SearchResultsActivity.this.finish(); overridePendingTransition(0, R.anim.move_left); } else if("right swipe".equals(action)){ //slide out to the right SearchResultsActivity.this.finish(); overridePendingTransition(0, R.anim.move_right); } } }}); //gesture is transparent (no longer a yellow line) gestureOverlayView.setGestureVisible(false);
Code Example 7: Initialize the GestureOverlayView in the onCreate
The animation file move_left.xml: (move_right.xml is the same except toXDelta is positive)
<?xml version="1.0" encoding="utf-8"?><translate xmlns:android="http://schemas.android.com/apk/res/android" android:duration="500" android:fromXDelta="0" android:toXDelta="-100%" android:interpolator="@android:anim/decelerate_interpolator" />
Code Example 8: Move left animation code
Note that your GridView cannot have a layout_height of 0dp when inside a GestureOverlayView, as it really will only get 0dp instead of expanding as needed like in a linear layout. To accommodate for this in our case, we set the layout_height to fill_parent. We also don’t want our gesture to be visible and we don’t want a delay while we wait for our invisible gesture to fade, so you will see fadeOffset and fadeDuration set to 0.
<android.gesture.GestureOverlayView android:id="@+id/gestures" android:layout_width="fill_parent" android:layout_height="fill_parent" android:fadeOffset="0" android:fadeDuration="0" android:eventsInterceptionEnabled="true"><GridView android:id="@+id/search_results" android:layout_width="fill_parent" android:layout_height="fill_parent" android:paddingTop="10dp" android:numColumns="4" android:verticalSpacing="10dp" android:horizontalSpacing="10dp" android:layout_weight="1" android:stretchMode="columnWidth" android:gravity="center"/></android.gesture.GestureOverlayView>
Code Example 9:Updated GridView piece with the GestureOverlayView for the layout xml
Summary
You have now seen how local search can be added to an Android application in addition to learning why some key UI choices were made. I also pointed out some of the challenges that came up and how to avoid them. You should now be able to incorporate search into your own application while taking the user experience into account.
References
https://developer.android.com/training/search/index.html
About the Author
Whitney Foster is a software engineer at Intel in the Software Solutions Group working on scale enabling projects for Android applications.
*Other names and brands may be claimed as the property of others.
**This sample source code is released under the Intel Sample Source Code License Agreement.