Search Function with Custom Search Suggestions

In this tutorial I will show you how you can create a dedicated search view with custom search suggestions („autocomplete“) in combination with a list of recent search queries. The data source for the autocomplete comes from a REST call that is executed every time the user types in a letter in the search field.

SearchView with custom search suggestions (autocomplete) loaded from ArrayList.

Search Functionality in Action

Have a look at this video to see it in action:

Prerequisites / General Info

In this project I am using the ButterKnife library for the binding of the layout and views (i.e. @BindView(R.id.relative_layout)). If you don’t use ButterKnife you have to alter the parts of the code snippets accordingly.

app // build.gradle

minSdkVersion 21
targetSdkVersion 26

implementation "com.jakewharton:butterknife:8.8.1"
implementation "com.zsoltsafrany:needle:1.0.0"

AndroidManifest.xml

Add this part to your AndroidManifest.xml file. Replace AppTheme.RedToolbar with whatever style you have for your toolbar in your project.

<activity
android:name=".SearchActivity"
android:launchMode="singleTop"
android:theme="@style/AppTheme.RedToolbar">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>

<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".MainActivity" />
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
<meta-data
android:name="android.app.default_searchable"
android:value=".SearchActivity" />

</activity>

res/xml/searchable.xml

Create a searchable.xml file and place it in the /res/xml subfolder in your project.

<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
android:hint="TYPE TO SEARCH"
android:label="LABEL"
android:searchSuggestSelection=" ?"
android:voiceSearchMode="showVoiceSearchButton|launchRecognizer" />

SearchActivity

SearchActivity.java is an activity with a toolbar with an active search input field that is initially empty until search results will populate the RecyclerList. While the user is typing search suggestions will be displayed in a menu below the search field. It also saves the last 5 submitted search queries in the SharedPreferences.

The class looks like this:

SearchActivity.java

public class SearchActivity extends AppCompatActivity implements SearchView.OnQueryTextListener, SearchView.OnSuggestionListener, OnSearchResultListItemSelectedListener, OnSearchCompletedListener {
private static String TAG = SearchActivity.class.getSimpleName();

static final int GRID_COLUMN_COUNT = 2;
private static final String COLUMN_ID = "_id";
private static final String COLUMN_SUGGESTION = "suggestion";
private static final String COLUMN_CONTEXT = "context";
private static final String COLUMN_SCORE = "score";
private static final String DEFAULT = "default";

private SearchResultListItemRecyclerViewAdapter searchResultListItemRecyclerViewAdapter;
private MovieSearchSuggestionAdapter movieSearchSuggestionAdapter;
private String queryString;

private SearchManager searchManager;
private SearchView searchView;

private SharedPreferences sharedPreferences;
private SharedPreferences.Editor editor;

public static String[] columns = new String[]{COLUMN_ID, COLUMN_SUGGESTION, COLUMN_CONTEXT, COLUMN_SCORE};

@BindView(R.id.relative_layout)
RelativeLayout relativeLayout;
@BindView(R.id.searchresult_list)
RecyclerView recyclerView;
@BindView(R.id.empty)
View emptyView;
@BindView(R.id.empty_image_view)
ImageView emptyImageView;
@BindView(R.id.empty_label)
TextView emptyLabel;


@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_search);
ButterKnife.bind(this);

getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setHomeButtonEnabled(true);

searchResultListItemRecyclerViewAdapter = new SearchResultListItemRecyclerViewAdapter(new ArrayList<>(), this, this);

GridLayoutManager gridLayoutManager = new GridLayoutManager(this, GRID_COLUMN_COUNT);
recyclerView.setLayoutManager(gridLayoutManager);
gridLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int position) {
switch (searchResultListItemRecyclerViewAdapter.getItemViewType(position)) {
case ListItem.TYPE_HEADER:
// Header uses 2 columns in 2 column grid
return GRID_COLUMN_COUNT;
default:
// Movie uses 1 column in 2 column grid
return 1;
}
}
});

recyclerView.setAdapter(searchResultListItemRecyclerViewAdapter);
recyclerView.addItemDecoration(new MovieGridListItemSpacingDecoration(GRID_COLUMN_COUNT, getResources().getDimensionPixelSize(R.dimen.grid_list_item_spacing), true));

movieSearchSuggestionAdapter = new MovieSearchSuggestionAdapter(getApplicationContext(), null, null, searchView);

sharedPreferences = getSharedPreferences(Preferences.CINEMAN, Context.MODE_PRIVATE);
editor = sharedPreferences.edit();
}

@Override
protected void onNewIntent(Intent intent) {
handleIntent(intent);
}

@Override
public void onStart() {
super.onStart();
if (searchResultListItemRecyclerViewAdapter.getItemCount() == 0) {
emptyView.setVisibility(View.VISIBLE);
recyclerView.setVisibility(View.GONE);
emptyLabel.setText("Universelle Filmsuche");
} else {
emptyView.setVisibility(View.GONE);
recyclerView.setVisibility(View.VISIBLE);
}

}

@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.search_view_options_menu, menu);

searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
searchView = (SearchView) menu.findItem(R.id.search).getActionView();
searchView.setMaxWidth(Integer.MAX_VALUE);
searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName()));
searchView.setOnQueryTextListener(this);
searchView.setIconifiedByDefault(true);
searchView.setQueryHint(getString(R.string.movies_search));
searchView.setElevation(10);
searchView.onActionViewExpanded(); // Activate on launch
searchView.setSuggestionsAdapter(movieSearchSuggestionAdapter);

return true;
}

@OnClick(R.id.empty)
void clickEmptyView() {
searchView.onActionViewExpanded();
}

@Override
public boolean onQueryTextSubmit(String query) {
queryString = query.trim();
if (queryString.length() > 2) {
handleAndUpdateSearchHistoryList(queryString);
searchResultListItemRecyclerViewAdapter.globalMovieSearch(query);
} else {
Handler handler = new Handler();
handler.postDelayed(() -> Snackbar.make(relativeLayout, "Bitte gebe mindestens drei Zeichen für die Suche ein.", Snackbar.LENGTH_LONG).show(), 400);
}

ViewUtils.hideKeyboard(this, searchView);
return true;
}

@Override
public boolean onQueryTextChange(String newText) {
queryString = newText.trim();
autocomplete(queryString);
return true;
}

private void autocomplete(final String query) {
final MatrixCursor matrixCursor = new MatrixCursor(columns);
final Object[] object = new Object[]{0, DEFAULT, DEFAULT, DEFAULT};

// Get the Search History Items
String searchHistoryCommaSeparated = sharedPreferences.getString(Preferences.PREFS_SEARCH_HISTORY_ITEMS, "");
List<String> searchHistoryItemList = new ArrayList<>();
String[] searchHistoryArray = searchHistoryCommaSeparated.split(",");
if (searchHistoryArray.length > 1) {
Collections.addAll(searchHistoryItemList, searchHistoryArray);
}

// Delete duplicate search history items
HashSet<String> hashSet = new HashSet<>();
hashSet.addAll(searchHistoryItemList);
searchHistoryItemList.clear();
searchHistoryItemList.addAll(hashSet);

if ((query != null) && (query.trim().length() > 2)) {
CinemanAPI.movie().autocomplete(query.trim(), new RepositoryEntityListCallback<Autocomplete>() {

@Override
public void onSuccess(List<Autocomplete> tList, boolean isCachedResponse) {

int i = 0;
while (i < tList.size()) {
object[0] = i;
object[1] = tList.get(i).getSuggestion();
object[2] = tList.get(i).getContext();
object[3] = tList.get(i).getScore();

matrixCursor.addRow(object);
i++;
}

for (String historyQuery : searchHistoryItemList) {
Autocomplete ac = new Autocomplete();
ac.setContext("history");
ac.setScore(1000);
ac.setSuggestion(historyQuery);
tList.add(ac);

object[0] = i;
object[1] = ac.getSuggestion();
object[2] = ac.getContext();
object[3] = ac.getScore();

matrixCursor.addRow(object);
i++;
}

updateAdapter(matrixCursor, tList);
}

@Override
public void onFailure(Exception e) {
Log.w(TAG, e.getLocalizedMessage());
}
});
} else if ((query != null) && (query.trim().length() > 0)) {
List<Autocomplete> tList = new ArrayList<>();

int i = 0;
for (String historyQuery : searchHistoryItemList) {
if (historyQuery.length() > 0) {
Autocomplete ac = new Autocomplete();
ac.setContext("history");
ac.setScore(1000);
ac.setSuggestion(historyQuery);
tList.add(ac);

object[0] = i;
object[1] = ac.getSuggestion();
object[2] = ac.getContext();
object[3] = ac.getScore();

matrixCursor.addRow(object);
i++;
}
}

updateAdapter(matrixCursor, tList);
} else {
updateAdapter(matrixCursor, new ArrayList<>());
}
}

private void updateAdapter(MatrixCursor matrixCursor, List<Autocomplete> tList) {
movieSearchSuggestionAdapter = new MovieSearchSuggestionAdapter(getApplicationContext(), matrixCursor, tList, searchView);
searchView.setSuggestionsAdapter(movieSearchSuggestionAdapter);
movieSearchSuggestionAdapter.swapCursor(matrixCursor);
movieSearchSuggestionAdapter.notifyDataSetChanged();
}

private void updateUI() {
if (searchResultListItemRecyclerViewAdapter.getItemCount() == 0) {
emptyView.setVisibility(View.VISIBLE);
recyclerView.setVisibility(View.GONE);
emptyImageView.setImageResource(R.drawable.shrug);
emptyLabel.setText("NOTHING FOUND");
} else {
emptyView.setVisibility(View.GONE);
recyclerView.setVisibility(View.VISIBLE);
}
}

@Override
public void onTheatreListItemSelected(Theatre theatre) {
// TODO kommt später mal...
}

@Override
public void onMovieGridListItemSelected(Movie movie) {
if (movie != null) {
// Intent intent = new Intent(this, MovieDetailActivity.class);
// intent.putExtra(movie);
// startActivity(intent);
}
}

private void handleIntent(Intent intent) {
if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
queryString = intent.getStringExtra(SearchManager.QUERY);
if (queryString.length() > 1) {
searchResultListItemRecyclerViewAdapter.globalMovieSearch(queryString);
searchView.clearFocus();
}
}
}

private void handleAndUpdateSearchHistoryList(String nextSearchQueryAddition) {
String searchHistoryCommaSeparated = sharedPreferences.getString(Preferences.PREFS_SEARCH_HISTORY_ITEMS, "");
List<String> searchHistoryItemList = new ArrayList<>();

String[] searchHistoryArray = searchHistoryCommaSeparated.split(",");
if (searchHistoryArray.length > 0) {
Collections.addAll(searchHistoryItemList, searchHistoryArray);
}

searchHistoryItemList.add(nextSearchQueryAddition);
if (searchHistoryItemList.size() > 5) {
searchHistoryItemList = searchHistoryItemList.subList(1, searchHistoryItemList.size()); // Limit to 5 items
}

StringBuilder csvBuilder = new StringBuilder();
for (String query : searchHistoryItemList) {
csvBuilder.append(query);
csvBuilder.append(",");
}

String historyQueriesString = csvBuilder.toString();
String historyQueriesCommaSeparated = historyQueriesString.substring(0, historyQueriesString.length() - ",".length());

editor.putString(Preferences.PREFS_SEARCH_HISTORY_ITEMS, historyQueriesCommaSeparated);
editor.apply();
}

@Override
public void onOnSearchCompleted() {
updateUI();

// Hide the autocomplete dropdown list
onQueryTextChange("");
}

@Override
public boolean onSuggestionSelect(int position) {
searchView.clearFocus();
return false;
}

@Override
public boolean onSuggestionClick(int position) {
searchView.clearFocus();
return false;
}
}

OnSearchResultListItemSelectedListener.java

interface OnSearchResultListItemSelectedListener {
void onMovieGridListItemSelected(Movie movie);
}

RepositoryEntityListCallback.java

public abstract class RepositoryEntityListCallback<T> {
public abstract void onSuccess(List<T> entityList, boolean isCachedResponse);

public abstract void onFailure(Exception e);
}

Autocomplete.java

public class Autocomplete {
private int score;
private String suggestion;
private String context;

public int getScore() {
return score;
}

public void setScore(int score) {
this.score = score;
}

public String getSuggestion() {
return suggestion;
}

public void setSuggestion(String suggestion) {
this.suggestion = suggestion;
}

public String getContext() {
return context;
}

public void setContext(String context) {
this.context = context;
}

}

activity_search.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/relative_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorBackground"
tools:context=".SearchActivity">

<android.support.v7.widget.RecyclerView xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/searchresult_list"
android:name=".SearchActivity"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="GridLayoutManager"
tools:context=".SearchActivity"
tools:listitem="@layout/movie_grid_list_item" />

<RelativeLayout
android:id="@+id/empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorBackground"
android:clickable="true"
android:visibility="gone">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:orientation="vertical"
android:paddingBottom="33dp">

<ImageView
android:id="@+id/empty_image_view"
app:srcCompat="@drawable/ic_onboarding_map"
android:layout_width="match_parent"
android:layout_height="67dp"
android:scaleType="fitCenter" />

<TextView
android:id="@+id/empty_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingLeft="48dp"
android:paddingTop="16dp"
android:paddingRight="48dp"
android:text="@string/settings_regions_empty_alert"
android:textAppearance="@style/TextAppearance.primaryText" />

</LinearLayout>

</RelativeLayout>
</RelativeLayout>

search_view_options_menu.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">

<item
android:id="@+id/search"
android:icon="@drawable/ic_search"
android:showAsAction="always"
android:title="@string/movies_search"
app:actionViewClass="android.support.v7.widget.SearchView"
app:showAsAction="always"
tools:ignore="AppCompatResource" />

</menu>

movie_grid_list_item.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<ImageView
android:id="@+id/moviePoster"
android:layout_width="match_parent"
android:layout_height="@dimen/grid_list_item_poster_height"
android:scaleType="centerCrop" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/colorGridItemBackground"
android:orientation="horizontal">

<TextView
android:id="@+id/movieNameLabel"
android:gravity="center_vertical|left"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_marginTop="6dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="2"
android:paddingLeft="@dimen/grid_list_item_label_padding_left_right"
android:textAppearance="@style/TextAppearance.posterText" />

<LinearLayout
android:id="@+id/watchlistButton"
android:layout_width="48dp"
android:layout_height="56dp"
android:clickable="true">

<ImageView
android:id="@+id/watchlistButtonImageView"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="8dp"
android:layout_marginTop="14dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_add_circle_outline_black" />

</LinearLayout>

</LinearLayout>

</LinearLayout>

MovieSearchSuggestionAdapter.java

public class MovieSearchSuggestionAdapter extends CursorAdapter implements View.OnClickListener {
private final static String TAG = MovieSearchSuggestionAdapter.class.getSimpleName();

private Context context;
private final LayoutInflater mInflater;
private ImageView imageView;
private TextView suggestionTextView;
private SearchView searchView;

private final List<Autocomplete> autocompleteList;

public MovieSearchSuggestionAdapter(final Context context, final Cursor cursor, final List<Autocomplete> autocompleteList, SearchView searchView) {
super(context, cursor, 0);

this.context = context;
this.searchView = searchView;
this.autocompleteList = autocompleteList;
this.mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
}

@Override
public View newView(Context context, Cursor cursor, ViewGroup parent) {
final View view = mInflater.inflate(R.layout.search_result_item_layout, parent, false);

imageView = view.findViewById(R.id.icon);
suggestionTextView = view.findViewById(R.id.suggestion);

return view;
}

@Override
public void bindView(View view, Context context, Cursor cursor) {
final int position = cursor.getPosition();
if (cursorInBounds(position)) {

final Autocomplete auto = autocompleteList.get(position);
suggestionTextView.setText(auto.getSuggestion());

if (auto.getContext().equalsIgnoreCase("movie")) {
imageView.setImageResource(R.drawable.ic_search);
} else if (auto.getContext().equalsIgnoreCase("history")) {
imageView.setImageResource(R.drawable.ic_time);
}

view.setTag(position);
view.setOnClickListener(this);
} else {
// Something went wrong
}
}

private boolean cursorInBounds(final int position) {
return position < autocompleteList.size();
}

@Override
public void onClick(final View view) {
final int position = (Integer) view.getTag();

if (cursorInBounds(position)) {
final Autocomplete selected = autocompleteList.get(position);

ViewUtils.hideKeyboard(context, searchView);
searchView.setQuery(selected.getSuggestion(), true);
} else {
// Something went wrong
}
}


}

sarch_result.item.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/colorBackground">

<ImageView
android:id="@+id/icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:layout_margin="10dp"
android:padding="5dp"
android:src="@drawable/ic_search" />

<TextView
android:id="@+id/suggestion"
android:layout_width="wrap_content"
android:layout_height="23dp"
android:layout_centerVertical="true"
android:layout_toRightOf="@id/icon" />

</RelativeLayout>

SearchResultListItemRecyclerViewAdapter.java

class SearchResultListItemRecyclerViewAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private final static String TAG = SearchResultListItemRecyclerViewAdapter.class.getSimpleName();

private List<Movie> movieList;
private List<ListItem> listItems;
private final OnSearchCompletedListener onSearchCompletedListener;
private final OnSearchResultListItemSelectedListener onSearchResultListItemSelectedListener;

SearchResultListItemRecyclerViewAdapter(List<Movie> items, OnSearchResultListItemSelectedListener onSearchResultListItemSelectedListener, OnSearchCompletedListener onSearchCompletedListener) {
listItems = new ArrayList<>();
this.onSearchResultListItemSelectedListener = onSearchResultListItemSelectedListener;
this.onSearchCompletedListener = onSearchCompletedListener;
setMovieList(items);

globalMovieSearch(null);
}

@Override
public int getItemViewType(int position) {
return listItems.get(position).getType();
}

@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
if (viewType == ListItem.TYPE_HEADER) {
View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.generic_list_header_item, parent, false);
return new GenericListHeaderViewHolder(itemView);
}

View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.movie_search_result_grid_list_item, parent, false);
return new MovieSearchResultGridListItemViewHolder(itemView);
}

@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
int type = getItemViewType(position);

if (type == ListItem.TYPE_HEADER) {
GenericListHeaderItem genericListHeaderItem = (GenericListHeaderItem) listItems.get(position);
GenericListHeaderViewHolder genericListHeaderItemViewHolder = (GenericListHeaderViewHolder) holder;
genericListHeaderItemViewHolder.setLabelText(genericListHeaderItem.getLabel());
return;
} else {
final MovieGridListItem movieGridListItem = (MovieGridListItem) listItems.get(position);
MovieSearchResultGridListItemViewHolder movieSearchResultGridListItemViewHolder = (MovieSearchResultGridListItemViewHolder) holder;
movieSearchResultGridListItemViewHolder.setMoviePosition(movieGridListItem.getMoviePosition());
movieSearchResultGridListItemViewHolder.setMovie(movieGridListItem.getMovie(), CinemanApplication.getCurrentAppInstance().getApplicationContext());
movieSearchResultGridListItemViewHolder.view.setOnClickListener(v -> onSearchResultListItemSelectedListener.onMovieGridListItemSelected(movieGridListItem.getMovie()));
}
}


@Override
public int getItemCount() {
return listItems.size();
}

void setMovieList(List<Movie> movieList) {
this.movieList = movieList;
}

/**
* Replace "
CinemanAPI.movie().searchMovies()" with your own REST call / data access with the search query from the parameter for the search. The return type in this example is an ArrayList with Movie objects which gets forwarded to the formatMovieList() method where we format the list to head- and body-type of recycler list (or grid for that matter) items. This way we could group the search results in various sections i.e. movies, theatres etc.
*
*
@param query
*/
public void globalMovieSearch(final String query) {
if ((query != null) && (query.trim().length() > 2)) {
CinemanAPI.movie().searchMovies(query.trim(), new RepositoryEntityListCallback<Movie>() {

@Override
public void onSuccess(List<Movie> mList, boolean isCachedResponse) {
movieList.clear();
movieList.addAll(mList);

formatMovieList(mList);
}

@Override
public void onFailure(Exception e) {
Log.e(TAG, e.getLocalizedMessage());
formatMovieList(new ArrayList<>());
}
});
} else {
movieList.clear();
}
}

/**
* Loop through the movie list and create head- and movie items
*
*
@param mList
*/
private void formatMovieList(List<Movie> mList) {
final List<ListItem> newListItems = new ArrayList<>();

Needle.onBackgroundThread().withTaskType("globalMovieSearch").serially().execute(() -> {
GenericListHeaderItem genericListHeaderItem = new GenericListHeaderItem();
genericListHeaderItem.setLabel(CinemanApplication.getCurrentAppInstance().getString(R.string.movies_title));
newListItems.add(genericListHeaderItem);

int i = 2;
for (Movie movie : mList) {
MovieGridListItem movieGridListItem = new MovieGridListItem();
movieGridListItem.setMoviePosition(++i);
movieGridListItem.setMovie(movie);
newListItems.add(movieGridListItem);
}

if (mList.size() == 0) {
newListItems.clear();
}

Needle.onMainThread().execute(() -> {
listItems.clear();
listItems.addAll(newListItems);
notifyDataSetChanged();
onSearchCompletedListener.onOnSearchCompleted();
});
});
}

}

generic_list_header_item.xml

<?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="wrap_content"
android:orientation="vertical">

<TextView
android:id="@+id/label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/activity_horizontal_margin"
android:layout_marginTop="@dimen/activity_vertical_margin"
android:layout_marginRight="@dimen/activity_horizontal_margin"
android:layout_marginBottom="@dimen/activity_vertical_margin"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.subHeader" />
</LinearLayout>

movie_search_result_grid_list_item.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/ticketingHighlightColor">

<ImageView
android:id="@+id/movie_poster"
android:layout_width="match_parent"
android:layout_height="@dimen/grid_list_item_poster_height"
android:scaleType="centerCrop" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/movie_poster"
android:layout_alignParentBottom="true"
android:background="@color/colorGridItemBackground"
android:orientation="horizontal">

<TextView
android:id="@+id/movie_title_label"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginLeft="@dimen/grid_list_item_label_padding_left_right"
android:layout_marginTop="6dp"
android:layout_weight="1"
android:ellipsize="end"
android:gravity="center_vertical|left"
android:maxLines="2"
android:text="Movie Title"
android:textAppearance="@style/TextAppearance.posterText" />

</LinearLayout>

</RelativeLayout>

GenericListHeaderViewHolder.java

class GenericListHeaderViewHolder extends RecyclerView.ViewHolder {
final View view;
private String labelText;

@BindView(R.id.label)
TextView labelView;

String getLabelText() {
return labelText;
}

void setLabelText(String labelText) {
this.labelText = labelText;
labelView.setText(labelText);
}

GenericListHeaderViewHolder(View view) {
super(view);
this.view = view;
ButterKnife.bind(this, view);
}

@Override
public String toString() {
return super.toString() + " '" + labelView.getText() + "'";
}
}

MovieSearchResultGridListItemViewHolder.java

class MovieSearchResultGridListItemViewHolder extends RecyclerView.ViewHolder {
private final static double posterRatio = 1.3825;
final View view;
private Movie movie;
private int moviePosition = -1;
private ColorDrawable placeHolderColorDrawable;

@BindView(R.id.movie_poster)
ImageView moviePoster;
@BindView(R.id.movie_title_label)
TextView movieNameLabel;


Movie getMovie() {
return movie;
}

void setMovie(Movie movie, Context context) {
this.movie = movie;
movieNameLabel.setText(movie.getTitle());
if (movie.getColorMeta() != null && movie.getColorMeta().getPosterBest() != null && movie.getColorMeta().getPosterBest().size() > 0) {
String hexColor = String.format("#%06X", (0xFFFFFF & movie.getColorMeta().getPosterBest().get(0)));
placeHolderColorDrawable.setColor(Color.parseColor(hexColor));
Picasso.with(context).load(movie.getPoster().getUrl2x()).placeholder(placeHolderColorDrawable).into(moviePoster);
} else {
Picasso.with(context).load(movie.getPoster().getUrl2x()).into(moviePoster);
}

public int getMoviePosition() {
return moviePosition;
}

public void setMoviePosition(int moviePosition) {
this.moviePosition = moviePosition;
}

MovieSearchResultGridListItemViewHolder(View view) {
super(view);
this.view = view;
ButterKnife.bind(this, view);

placeHolderColorDrawable = new ColorDrawable();
int imageWidth = (CinemanApplication.getCurrentAppInstance().getScreenWidth() - ((int) view.getResources().getDimension(R.dimen.grid_list_item_spacing) * (MovieGridListFragment.GRID_COLUMN_COUNT + 1))) / MovieGridListFragment.GRID_COLUMN_COUNT;
int desiredHeight = (int) (imageWidth * posterRatio);
if (moviePoster.getLayoutParams().height != desiredHeight) {
moviePoster.getLayoutParams().height = desiredHeight;
moviePoster.requestLayout();
}
}

@Override
public String toString() {
return super.toString() + " '" + movieNameLabel.getText() + "'";
}
}

Launching the Search

To start the search add this code block to any other activity or button click event.

Intent intent = new Intent(getActivity(), SearchActivity.class);
startActivity(intent);

Got improvements or questions? Or is anything missing? Let me know in the comments section below.

Open external Android app from within Android app

You can open any external Android application from within your Android application by pressing a button (or any other event). All you need to know is the namespace of the application you want to launch.

For this functionality use following code snippet:

public class MainActivity extends Activity {</p>
<p>private Button openAppButton;</p>
<p>@Override<br />
protected void onCreate(Bundle savedInstanceState) {<br />
super.onCreate(savedInstanceState);<br />
setContentView(R.layout.activity_main);</p>
<p>openAppButton = (Button) findViewById(R.id.openApp);<br />
openAppButton.setOnClickListener(new OnClickListener() {</p>
<p>@Override<br />
public void onClick(View arg0) {</p>
<p>Intent i = new Intent();<br />
PackageManager manager = getPackageManager();<br />
i = manager.getLaunchIntentForPackage("launching.app.namespace");<br />
i.addCategory(Intent.CATEGORY_LAUNCHER);<br />
startActivity(i);<br />
}</p>
<p>});<br />
}</p>
<p>}<br />

In this code replace „launching.app.namespace“ with the namespace of the app you want to launch. If you don’t know the namespace of the app you can go to Google Play and search for the app. Inside the URL the namespace is mentioned as the „id“. For example, Google Maps has following URL:

 https://play.google.com/store/apps/details?id=com.google.android.apps.maps

As you can see, the value of id is „com.google.android.apps.maps“ – this is the app’s namespace.

 

Wrapping a MGWT Webapp with PhoneGap into a Native Android App

Here is an example how to make a simple MGWT Webapp and wrap it with PhoneGap into a native Android application. With MGWT to you can create fantastic mobile web applications with the look and feel of native mobile Android or iOS apps.

  • Install Eclipse IDE
  • Install GWT plugin into Eclipse
  • Download the MGWT jar file
  • Download the PhoneGap (Cordova) jar file
  • Create a new GWT project („HelloWorld“) with Eclipse (the official „Getting started“ documentation for GWT can be found here).
  • Add following jar files into the /libs folder of the newly created GWT project:
    mgwt.jar
    gwtphonegap.jar
  • Add these lines of code into the HelloWorld.gwt.xml file:
    <br />
    <!-- Other module inherits --><br />
    <inherits name="com.googlecode.mgwt.MGWT"/><br />
    <inherits name='com.googlecode.gwtphonegap.PhoneGap' /><set-property name="user.agent" value="safari" /><br />
    <set-configuration-property name="mgwt.css" value="pretty" /><br />
    
  • Develop your GWT app as you wish using the MGWT library (the MGWT „Getting started“ documentation to be found here). Here is a simple and easy example:
    <br />
    public class HelloWorld implements EntryPoint {public void onModuleLoad() {<br />
    // set viewport and other settings for mobile<br />
    MGWT.applySettings(MGWTSettings.getAppSetting());// build animation helper and attach it<br />
    AnimationHelper animationHelper = new AnimationHelper();<br />
    RootPanel.get().add(animationHelper);// build some UI<br />
    LayoutPanel layoutPanel = new LayoutPanel();<br />
    Button button = new Button("Hello World! Woohoo!");<br />
    layoutPanel.add(button);// animate<br />
    animationHelper.goTo(layoutPanel, Animation.SLIDE);}}<br />
    
  • Create new native Android project with Eclipse. With this we are going to wrap the MGWT project into a native app.
  • Add the PhoneGap (or Cordova) jar file into the newly created Android project’s /libs folder.
  • Create subfolders /assets/www in that project. Make sure to add the PhoneGap.js (JavaScript) file into the /assets/www folder!
  • Create a simple index.html file in the /assets/www folder. Here a simple example of the html file:
    <br />
    <head><br />
    <title>Cordova</title><br />
    <script type="text/javascript" charset="utf-8" src="cordova-2.3.0.js"></script><br />
    </head><br />
    <body><br />
    <h1>Hello World</h1><br />
    <h2>PhoneGap works!</h2><br />
    </body><br />
    </html><br />
    
  • Add following permissions to the Android project’s manifest file:
    <br />
    <uses-permission android:name="android.permission.CAMERA" /><br />
    <uses-permission android:name="android.permission.VIBRATE" /><br />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /><br />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /><br />
    <uses-permission android:name="android.permission.ACCESS_LOCATION_EXTRA_COMMANDS" /><br />
    <uses-permission android:name="android.permission.READ_PHONE_STATE" /><br />
    <uses-permission android:name="android.permission.INTERNET" /><br />
    <uses-permission android:name="android.permission.RECEIVE_SMS" /><br />
    <uses-permission android:name="android.permission.RECORD_AUDIO" /><br />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /><br />
    <uses-permission android:name="android.permission.READ_CONTACTS" /><br />
    <uses-permission android:name="android.permission.WRITE_CONTACTS" /><br />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /><br />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /><br />
    <uses-permission android:name="android.permission.GET_ACCOUNTS" /><br />
    <uses-permission android:name="android.permission.BROADCAST_STICKY" /><br />
    
  • Change the MainActivity class of the Android project that it looks like this:
    <br />
    public class MainActivity extends DroidGap {@Override<br />
    public void onCreate(Bundle savedInstanceState) {<br />
    super.onCreate(savedInstanceState);<br />
    // setContentView(R.layout.activity_main);<br />
    super.loadUrl("file:///android_asset/www/index.html");<br />
    }@Override<br />
    public boolean onCreateOptionsMenu(Menu menu) {<br />
    // Inflate the menu; this adds items to the action bar if it is present.<br />
    getMenuInflater().inflate(R.menu.activity_main, menu);<br />
    return true;<br />
    }}<br />
    
  • Build the Android project and see if the index.html file is being displayed after launching the app.
  • Compile (Google ->GWT Compile) the MGWT project. The compiled code can be found inside of the /war folder of the MGWT project.
  • Copy all the content of the /war folder of the MGWT project into the /assets/www folder in the Android project.
  • In the Android project in the MainActivity class change this line of code:
    <br />
    super.loadUrl("file:///android_asset/www/index.html");<br />
    

    to this

    <br />
    super.loadUrl("file:///android_asset/www/HelloWorld.html");<br />
    

    (or however the main html file of the MGWT project is named)

  • Run the Android project again. Now you should see the content of the MGWT app displayed acting like a native Android application. If that looks okay you can export the project to an APK file! Done!

Unity 3D: Debugging with the Unity logfile when testing an APK file on an actual device

Normally, if you test your Unity game on your Android phone you are able to see the Unity debug logfile in the Android logcat with Eclipse.

<br />
Debug.Log("info");<br />
Debug.LogWarning("warning");<br />
Debug.LogError("error");

However, in case this is not working you can output the game’s logfile to a flat file. Do as follows:

  • Connect to the device using ADB.
  • Use the logcat command:
    adb logcat -d > mylogfile.txt

That will create a text file (mylogfile.txt) which should include any statements you logged (like „information“ from Debug.Log).

Android debugging: Force app to be installed on SD card

If you are working on a big application which uses a lot of memory you might get the error „No space left on device“ when trying to debug. With a Android Debug Bridge (ADB) command you can force the application to be installed on the SD card directly.

You have following options to chose when it comes to where the app shall be installed by default:

<br />
The setInstallLocation command changes the default install location<br />
0 [auto]: Let system decide the best location<br />
1 [internal]: Install on internal device storage<br />
2 [external]: Install on external media<br />

Do as described:

  1. Connect your phone to your computer via USB. Make sure it is in debug mode.
  2. Open Windows CMD Command (cmd.exe)
  3. Type following command if you want the default location to be your SD card:
    adb shell pm setInstallLocation 2
That’s about it. From now on when testing your app it will automatically be installed on the SD card instead of the internal memory.

How to convert an Activity class to use with FragmentPagerAdapter

This article shows how you have to alter your Activity classes to make them usable as Fragments for a horizontal slider.

You can find a tutorial how to create a smooth horizontal slider here:
Simple tutorial for a “smooth horizontal view slider” with Android

If you already have Activity classes and you want to combine them into one horizontal slider (FragmentPagerAdapter) you have to make some alterations in order to make it work. Here is how:

Let’s say you have following Activity classes in your project and want to combine them with a FragmentPagerAdapter:

  • MainArticle
  • AuthorInformation
  • CommentSection

First of all you change extends Activity to extends Fragment in those classes.

Before:

<br />
public class MainArticle extends Activity {...}<br />

After:

<br />
public class MainArticle extends Fragment {...}<br />

As with any Activity class there is the mandatory onCreate(…) method. Change this method to public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {…}.

Before:

<br />
protected void onCreate(Bundle savedInstanceState) {<br />
super.onCreate(savedInstanceState);<br />
setContentView(R.layout.outer_layout);<br />
...<br />
}<br />

After:

<br />
private LinearLayout ll;<br />
private FragmentActivity fa;</p>
<p>public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {</p>
<p>fa = super.getActivity();<br />
ll = (LinearLayout) inflater.inflate(R.layout.outer_layout, container, false);<br />
...</p>
<p>return ll;<br />
}</p>
<p>

The difference now is that we have to use the FragmentActivity object „fa“ and get the Activity from its parent class. Otherwise we won’t be able to access the Layout XML’s elements. You now also have to return a View object which should be your most outer element in the Layout XML file. In this example’s case it would be the LinearLayout called R.layout.outer_layout.

If you used an Intent object to pass variables to that view you will have to change the line of code from:

intent = getIntent();

to

fa = super.getActivity();<br />
intent = fa.getIntent();<br />

All the elements you are accessing from the XML Layout file can still be accessed. However, you have to alter the lines of code from

TextView myTextView = (TextView) findViewById(R.id.myTextView);

to

TextView myTextView = (TextView) ll.findViewById(R.id.myTextView);

Also if you have any Toast messages in that Activity you have to change the line of code to the following:

Toast.makeText(fa, message, Toast.LENGTH_LONG).show();

If you have any links which forward the user to another Activity you have to change the source parameter to „fa“. This will look like this:

<br />
Intent explicitIntent = new Intent(fa, AuthorInformation.class);<br />
startActivity(explicitIntent);<br />

Once you’ve done all that you can add those newly converted Fragment classes to the FragmentPagerAdapter class. If you have any questions feel free to use the comment section below.

Simple tutorial for a „smooth horizontal view slider“ with Android

Screenshot of a horizontal sliderSurely you have seen some Android apps which have this cool feature to scroll through different Views smoothly with a horizontal swipe. If you have some Views and you want them to be „slide-able“ you can do this quite easily actually.

Because this feature is not in Android 2.x you will need to use the compatibility pack from Android. It is a library with classes from Android 3. It is called „android-support-v13.jar“. You can download this JAR file from developer.android.com or just download the sample project from this site which also contains this JAR file.

Create a new project and add android-support-v13.jar to its build path. First we need a FragmentPagerAdapter. Create a class according to the code below:

MyPagerAdapter.java

<br />
public class MyPagerAdapter extends FragmentPagerAdapter {</p>
<p>private final List<Fragment> fragments;</p>
<p>/**<br />
* @param fm<br />
* @param fragments<br />
*/<br />
public MyPagerAdapter(FragmentManager fm, List<Fragment> fragments) {<br />
super(fm);<br />
this.fragments = fragments;<br />
}</p>
<p>/*<br />
* (non-Javadoc)<br />
*<br />
* @see android.support.v4.app.FragmentPagerAdapter#getItem(int)<br />
*/<br />
@Override<br />
public Fragment getItem(int position) {<br />
return this.fragments.get(position);<br />
}</p>
<p>/*<br />
* (non-Javadoc)<br />
*<br />
* @see android.support.v4.view.PagerAdapter#getCount()<br />
*/<br />
@Override<br />
public int getCount() {<br />
return this.fragments.size();<br />
}<br />
}<br />

Next you have to create a class which handles the different Fragments (i.e. the separate „Views“). This class is going to be your Activity class.

ViewPagerFragmentActivity.java

<br />
public class ViewPagerFragmentActivity extends FragmentActivity {</p>
<p>private PagerAdapter mPagerAdapter;</p>
<p>@Override<br />
protected void onCreate(Bundle savedInstanceState) {<br />
super.onCreate(savedInstanceState);<br />
super.setContentView(R.layout.viewpager_layout);</p>
<p>// initialize the pager<br />
this.initialisePaging();<br />
}</p>
<p>/**<br />
* Initialize the fragments to be paged<br />
*/<br />
private void initialisePaging() {</p>
<p>List<Fragment> fragments = new Vector<Fragment>();<br />
fragments.add(Fragment.instantiate(this, Fragment0.class.getName()));<br />
fragments.add(Fragment.instantiate(this, Fragment1.class.getName()));<br />
fragments.add(Fragment.instantiate(this, Fragment2.class.getName()));<br />
this.mPagerAdapter = new MyPagerAdapter(super.getSupportFragmentManager(), fragments);</p>
<p>ViewPager pager = (ViewPager) super.findViewById(R.id.viewpager);<br />
pager.setAdapter(this.mPagerAdapter);<br />
}<br />
}<br />

viewpager_layout.xml

<br />
<?xml version="1.0" encoding="utf-8"?></p>
<p><android.support.v4.view.ViewPager<br />
xmlns:android="http://schemas.android.com/apk/res/android"<br />
android:layout_width="fill_parent"<br />
android:layout_height="fill_parent"<br />
android:id="@+id/awesomepager"></p>
<p></android.support.v4.view.ViewPager><br />

Eclipse will mark some errors. This is because the Fragment classes have not created yet. But this is what we are going to do right now. Create as many classes as you wish to have in your slider. In my example we use the classes Fragment0, Fragment1 and Fragment2. If you want to use any other classes make sure to include them in the code in the ViewPagerFragmentActivity.java class.

Fragment0.java, Fragment1.java, Fragment2.java

<br />
public class Fragment0 extends Fragment {<br />
/**<br />
* (non-Javadoc)<br />
*<br />
* @see android.support.v4.app.Fragment#onCreateView(android.view.LayoutInflater,<br />
* android.view.ViewGroup, android.os.Bundle)<br />
*/<br />
@Override<br />
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {<br />
return (LinearLayout) inflater.inflate(R.layout.fragment0_layout, container, false);<br />
}<br />
}<br />

As for the XML Layouts you can design them however you like.

Lastly add the classes ViewPagerFragmentActivity, Fragment0, Fragment1 and Fragment2 to the manifest file.

That’s all. The app should work already allowing you to slide through the Fragment classes smoothly with a horizontal swipe.

If you’re lazy you can as well just download the whole demo project here:
HorizontalSmoothSliderExample

If you already have a FragmentPagerAdapter and want to use your already existing Activity classes als Fragments you can use my other tutorial here:
How to convert an Activity class to use with FragmentPagerAdapter