Today, we are going to learn an interesting topic in RxJava i.e Instant Search. I would say interesting because it requires your previous knowledge of multiple RxJava concepts such as Debounce, SwitchMap, RxBinding and Retrofit put together.

We’ll consider an example of Contacts Instant Search on a Remote Database. Each time user types a search query, the query will be instantly sent to server and the search results will be displayed in a RecyclerView.

1. Prerequisite

Before getting started, make sure you have gone through the below topics and have basic knowledge.

  • debounce() – Debounce operator is used to collect the search query in timely manner instead of rapidly performing search on every character typed.
  • SwitchMap() – SwitchMap operator is used to consider only the recent Observable and ignoring the previous search results.
  • RxJava Understanding Observables – To know the different types of Observables in RxJava and their purpose.
  • Retrofit – Basics of Retrofit networking library with a live example.
  • ButterKnife – View binding / injection library.
  • Android RecyclerView adding Search Filter – Adding basic Search Filter to RecyclerView.

2. Contacts REST API

I have written a simple Contacts REST API to search contacts by name or mobile number. It lists the contacts from two sources i.e `gmail` and `linkedin`.

https://api.androidhive.info/json/contacts.php (Defaults to gmail source)
https://api.androidhive.info/json/contacts.php?source=gmail
https://api.androidhive.info/json/contacts.php?source=linkedin
https://api.androidhive.info/json/contacts.php?search=tom (Searches for `tom` in both the sources)

3. The Idea

  • Local Search: In Local Search, the contacts will be fetched initially by making the network call. The search will be performed on an array list using RecyclerView’s getFilter() method. In this case the number of HTTP requests will be only one.
  • Remote Search: In Remote Search, an HTTP is call is made every time user enters the search query. The search will be performed on the server and results will be given back.

Now let’s start this by creating a new project in Android Studio. The article seems to be very lengthy, but it is very informative as it combines the concepts you have learned earlier.

4. Creating New Project

> Basic Setup
> Adding Retrofit Network Layer
> Searching Local List
> Searching Remote Database

1. Create a new project in Android Studio from File ⇒ New Project and select Basic Activity from templates.

2. Add RecyclerView, ButterKnife, RxBinding and Retrofit dependencies to your app/build.gradle and Sync the project.

dependencies {
    // ...

    implementation 'com.android.support:recyclerview-v7:26.1.0'

    implementation 'io.reactivex.rxjava2:rxjava:2.1.9'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'

    implementation 'com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0'

    // butter knife
    implementation "com.jakewharton:butterknife:8.8.1"
    annotationProcessor "com.jakewharton:butterknife-compiler:8.8.1"

    implementation "com.squareup.retrofit2:retrofit:2.0.0"
    implementation "com.squareup.retrofit2:converter-gson:2.0.0"
    implementation "com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0"
    implementation "com.squareup.okhttp3:okhttp:3.0.1"
    implementation "com.squareup.okhttp3:okhttp-urlconnection:3.0.1"
    implementation "com.squareup.okhttp3:logging-interceptor:3.4.1"

    // glide image library
    implementation "com.github.bumptech.glide:glide:4.3.1"

    implementation "com.jakewharton.rxbinding2:rxbinding:2.0.0"
}

3. Add the below resources to respective colors.xml, strings.xml and dimens.xml.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#111</color>
    <color name="colorPrimaryDark">#FFF</color>
    <color name="colorAccent">#36D3B2</color>
    <color name="contact_name">#333333</color>
    <color name="contact_number">#8c8c8c</color>
</resources>
<resources>
    <string name="app_name">Instant Search</string>
    <string name="action_settings">Settings</string>
    <string name="activity_local_search">Local Search</string>
    <string name="activity_remote_search">Remote Search</string>
    <string name="hint_search">Type contact name</string>
</resources>
<resources>
    <dimen name="fab_margin">16dp</dimen>
    <dimen name="activity_margin">16dp</dimen>
    <dimen name="thumbnail">40dp</dimen>
    <dimen name="row_padding">10dp</dimen>
    <dimen name="contact_name">15dp</dimen>
    <dimen name="contact_number">12dp</dimen>
    <dimen name="dimen_10">10sp</dimen>
    <dimen name="dimen_20">20sp</dimen>
</resources>

5. Open styles.xml and modify it as below. Here, we are modifying the default theme and applying Light theme to our app.

<resources>
    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.AppCompat.Light">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <style name="AppTheme.NoActionBar">
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>
    </style>

    <style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Light" />
    <style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
</resources>

6. Open AndroidManifest.xml and add INTERNET permission as we are going to make HTTP calls. You can also notice LocalSearchActivity and RemoteSearchActivity activities added, we’ll create them shortly.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="info.androidhive.rxjavasearch">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity
            android:name=".view.MainActivity"
            android:label="@string/app_name"
            android:theme="@style/AppTheme.NoActionBar">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity
            android:name=".view.LocalSearchActivity"
            android:label="@string/activity_local_search"
            android:windowSoftInputMode="stateHidden"
            android:theme="@style/AppTheme.NoActionBar" />
        <activity
            android:name=".view.RemoteSearchActivity"
            android:label="@string/activity_remote_search"
            android:windowSoftInputMode="stateHidden"
            android:theme="@style/AppTheme.NoActionBar" />
    </application>

</manifest>

11. Open the layout files of main activity i.e activity_main.xml and content_main.xml and add two Buttons. These buttons are just to launch LocalSearchActivity and RemoteSearchActivity activities.

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="info.androidhive.rxjavasearch.view.MainActivity">

    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="@android:color/white"
            app:popupTheme="@style/AppTheme.PopupOverlay" />

    </android.support.design.widget.AppBarLayout>

    <include layout="@layout/content_main" />

</android.support.design.widget.CoordinatorLayout>
<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="@dimen/activity_margin"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="info.androidhive.rxjavasearch.view.MainActivity"
    tools:showIn="@layout/activity_main">

    <TextView
        android:layout_width="match_parent"
        android:layout_marginTop="30dp"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:text="Performs local search on array list. First it fetches all contacts and performs local search" />

    <Button
        android:id="@+id/btn_local_search"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:background="@color/colorPrimary"
        android:text="Local Search"
        android:textColor="@android:color/white" />


    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="50dp"
        android:gravity="center_horizontal"
        android:text="Performs Remote Search by sending HTTP call every time search query is entered" />

    <Button
        android:id="@+id/btn_remote_search"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:background="@color/colorPrimary"
        android:text="Remote Search"
        android:textColor="@android:color/white" />
</LinearLayout>

4. Open MainActivity.java and add the button click events to launch the activities.

import android.content.Intent;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.view.View;
import android.view.Menu;
import android.view.MenuItem;

import butterknife.ButterKnife;
import butterknife.OnClick;
import info.androidhive.rxjavasearch.R;

public class MainActivity extends AppCompatActivity {

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

        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        // white background notification bar
        whiteNotificationBar(toolbar);
    }

    @OnClick(R.id.btn_local_search)
    public void openLocalSearch() {
        // launching local search activity
        startActivity(new Intent(MainActivity.this, LocalSearchActivity.class));
    }

    @OnClick(R.id.btn_remote_search)
    public void openRemoteSearch() {
        // launch remote search activity
        startActivity(new Intent(MainActivity.this, RemoteSearchActivity.class));
    }

    private void whiteNotificationBar(View view) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            int flags = view.getSystemUiVisibility();
            flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
            view.setSystemUiVisibility(flags);
            getWindow().setStatusBarColor(Color.WHITE);
        }
    }
}
android-rxjava-search

7. Create the packages named app, adapter, network, network/model, and view. These packages keeps the project organised.

Below is the final project structure and files we are aiming to create.

rxjava-instant-search-project-structure

4.1 Adding Retrofit Network Layout

At this stage we are good with basic project setup. Now let’s add the necessary classes required for Retrofit library.

12. Under network/model package, create a model named Contact.java. This POJO class is required to serialize the JSON.

import com.google.gson.annotations.SerializedName;

/**
 * Created by ravi on 31/01/18.
 */

public class Contact {
    String name;

    @SerializedName("image")
    String profileImage;

    String phone;
    String email;

    public String getName() {
        return name;
    }

    public String getProfileImage() {
        return profileImage;
    }

    public String getPhone() {
        return phone;
    }

    public String getEmail() {
        return email;
    }

    /**
     * Checking contact equality against email
     */
    @Override
    public boolean equals(Object obj) {
        if (obj != null && (obj instanceof Contact)) {
            return ((Contact) obj).getEmail().equalsIgnoreCase(email);
        }
        return false;
    }
}

11. Under app package, create a call named Const.java. This class contains the base URL of the REST API.

public class Const {
    public static final String BASE_URL = "https://api.androidhive.info/json/";
}

12. Create a class named ApiClient.java and add the below code. This class initializes the Retrofit client with necessary configuration.

import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

import info.androidhive.rxjavasearch.app.Const;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;

/**
 * Created by ravi on 31/01/18.
 */

public class ApiClient {
    private static String TAG = ApiClient.class.getSimpleName();
    private static Retrofit retrofit = null;
    private static int REQUEST_TIMEOUT = 60;
    private static OkHttpClient okHttpClient;


    public static Retrofit getClient() {

        if (okHttpClient == null)
            initOkHttp();

        if (retrofit == null) {
            retrofit = new Retrofit.Builder()
                    .baseUrl(Const.BASE_URL)
                    .client(okHttpClient)
                    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                    .addConverterFactory(GsonConverterFactory.create())
                    .build();
        }
        return retrofit;
    }

    private static void initOkHttp() {
        OkHttpClient.Builder httpClient = new OkHttpClient().newBuilder()
                .connectTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS)
                .readTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS)
                .writeTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS);

        HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor();
        interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);

        httpClient.addInterceptor(interceptor);

        httpClient.addInterceptor(new Interceptor() {
            @Override
            public Response intercept(Chain chain) throws IOException {
                Request original = chain.request();
                Request.Builder requestBuilder = original.newBuilder()
                        .addHeader("Accept", "application/json")
                        .addHeader("Request-Type", "Android")
                        .addHeader("Content-Type", "application/json");

                Request request = requestBuilder.build();
                return chain.proceed(request);
            }
        });

        okHttpClient = httpClient.build();
    }

    public static void resetApiClient() {
        retrofit = null;
        okHttpClient = null;
    }
}

13. Create another class named ApiService.java. In this class, we define the REST API endpoints with proper Observable and request parameters.

  • Single Observable is used here as list of contacts will be fetched at once.
  • getContacts() takes two parameters. `source` would be either gmail or linkedin and `query` will be actual search query.
import java.util.List;

import info.androidhive.rxjavasearch.network.model.Contact;
import io.reactivex.Single;
import retrofit2.http.GET;
import retrofit2.http.Query;

public interface ApiService {

    @GET("contacts.php")
    Single<List<Contact>> getContacts(@Query("source") String source, @Query("search") String query);
}

To test the local search, I am creating a new activity (LocalSearchActivity.java) and an adapter class (ContactsAdapterFilterable.java).

1. Create new activity by Right Clicking on view ⇒ New ⇒ Activity ⇒ Basic Activity and name the activity as LocalSearchActivity.

12. Create a layout named contact_row_item.xml and paste the below layout. This layout holds the view of single row item in RecyclerView. Here, we are declaring an ImageView for contact thumbnail image and couple TextViews to display name and mobile number.

<?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="?attr/selectableItemBackground"
    android:clickable="true"
    android:paddingBottom="@dimen/row_padding"
    android:paddingLeft="@dimen/activity_margin"
    android:paddingRight="@dimen/activity_margin"
    android:paddingTop="@dimen/row_padding">

    <ImageView
        android:id="@+id/thumbnail"
        android:layout_width="@dimen/thumbnail"
        android:layout_height="@dimen/thumbnail"
        android:layout_centerVertical="true"
        android:layout_marginRight="@dimen/row_padding" />

    <TextView
        android:id="@+id/name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_toRightOf="@id/thumbnail"
        android:fontFamily="sans-serif-medium"
        android:textColor="@color/contact_name"
        android:textSize="@dimen/contact_name" />

    <TextView
        android:id="@+id/phone"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/name"
        android:layout_toRightOf="@id/thumbnail"
        android:textColor="@color/contact_number"
        android:textSize="@dimen/contact_number" />

</RelativeLayout>

11. Create a class named ContactsAdapterFilterable.java and implement the class from Filterable.

  • In this adapter, getFilter() holds the actual logic to search the array list for matched contact name or phone number.
import android.content.Context;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;

import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;

import java.util.ArrayList;
import java.util.List;

import info.androidhive.rxjavasearch.R;
import info.androidhive.rxjavasearch.network.model.Contact;

public class ContactsAdapterFilterable extends RecyclerView.Adapter<ContactsAdapterFilterable.MyViewHolder>
        implements Filterable {
    private Context context;
    private List<Contact> contactList;
    private List<Contact> contactListFiltered;
    private ContactsAdapterListener listener;

    public class MyViewHolder extends RecyclerView.ViewHolder {
        public TextView name, phone;
        public ImageView thumbnail;

        public MyViewHolder(View view) {
            super(view);
            name = view.findViewById(R.id.name);
            phone = view.findViewById(R.id.phone);
            thumbnail = view.findViewById(R.id.thumbnail);

            view.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    // send selected contact in callback
                    listener.onContactSelected(contactListFiltered.get(getAdapterPosition()));
                }
            });
        }
    }


    public ContactsAdapterFilterable(Context context, List<Contact> contactList, ContactsAdapterListener listener) {
        this.context = context;
        this.listener = listener;
        this.contactList = contactList;
        this.contactListFiltered = contactList;
    }

    @Override
    public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View itemView = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.contact_row_item, parent, false);

        return new MyViewHolder(itemView);
    }

    @Override
    public void onBindViewHolder(MyViewHolder holder, final int position) {
        final Contact contact = contactListFiltered.get(position);
        holder.name.setText(contact.getName());
        holder.phone.setText(contact.getPhone());

        Glide.with(context)
                .load(contact.getProfileImage())
                .apply(RequestOptions.circleCropTransform())
                .into(holder.thumbnail);
    }

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

    @Override
    public Filter getFilter() {
        return new Filter() {
            @Override
            protected FilterResults performFiltering(CharSequence charSequence) {
                String charString = charSequence.toString();
                if (charString.isEmpty()) {
                    contactListFiltered = contactList;
                } else {
                    List<Contact> filteredList = new ArrayList<>();
                    for (Contact row : contactList) {

                        // name match condition. this might differ depending on your requirement
                        // here we are looking for name or phone number match
                        if (row.getName().toLowerCase().contains(charString.toLowerCase()) || row.getPhone().contains(charSequence)) {
                            filteredList.add(row);
                        }
                    }

                    contactListFiltered = filteredList;
                }

                FilterResults filterResults = new FilterResults();
                filterResults.values = contactListFiltered;
                return filterResults;
            }

            @Override
            protected void publishResults(CharSequence charSequence, FilterResults filterResults) {
                contactListFiltered = (ArrayList<Contact>) filterResults.values;
                notifyDataSetChanged();
            }
        };
    }

    public interface ContactsAdapterListener {
        void onContactSelected(Contact contact);
    }
}

12. Open the layout file of LocalSearchActivity and add RecyclerView widget. We are also adding an EditText to input the search query.

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".view.LocalSearchActivity">

    <EditText
        android:id="@+id/input_search"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="@dimen/activity_margin"
        android:layout_marginRight="@dimen/activity_margin"
        android:hint="@string/hint_search"
        android:paddingBottom="@dimen/dimen_20" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/input_search" />

</RelativeLayout>

12. Open LocalSearchActivity.java and do the necessary changes as shown below.

  • CompositeDisposable is used to dispose the subscriptions in onDestroy() method.
  • RxTextView.textChangeEvents Triggers an event whenever the text is changed in search EditText.
  • debounce(300, TimeUnit.MILLISECONDS) – Emits the search query every 300 milliseconds.
  • distinctUntilChanged() avoids making same search request again
  • fetchContacts() fetches all contacts by making Retrofit HTTP call
  • searchContacts() – Is an Observer that will be called when search query is emitted. By calling mAdapter.getFilter().filter(), the search query will filter the data on ArrayList.
import android.graphics.Color;
import android.os.Build;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.support.v7.widget.DefaultItemAnimator;
import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
import android.widget.EditText;

import com.jakewharton.rxbinding2.widget.RxTextView;
import com.jakewharton.rxbinding2.widget.TextViewTextChangeEvent;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.Unbinder;
import info.androidhive.rxjavasearch.R;
import info.androidhive.rxjavasearch.adapter.ContactsAdapterFilterable;
import info.androidhive.rxjavasearch.network.ApiClient;
import info.androidhive.rxjavasearch.network.ApiService;
import info.androidhive.rxjavasearch.network.model.Contact;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.observers.DisposableObserver;
import io.reactivex.observers.DisposableSingleObserver;
import io.reactivex.schedulers.Schedulers;

public class LocalSearchActivity extends AppCompatActivity implements ContactsAdapterFilterable.ContactsAdapterListener {

    private static final String TAG = LocalSearchActivity.class.getSimpleName();

    private CompositeDisposable disposable = new CompositeDisposable();
    private ApiService apiService;
    private ContactsAdapterFilterable mAdapter;
    private List<Contact> contactsList = new ArrayList<>();

    @BindView(R.id.input_search)
    EditText inputSearch;

    @BindView(R.id.recycler_view)
    RecyclerView recyclerView;

    private Unbinder unbinder;

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

        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        getSupportActionBar().setDisplayHomeAsUpEnabled(true);

        mAdapter = new ContactsAdapterFilterable(this, contactsList, this);

        RecyclerView.LayoutManager mLayoutManager = new LinearLayoutManager(getApplicationContext());
        recyclerView.setLayoutManager(mLayoutManager);
        recyclerView.setItemAnimator(new DefaultItemAnimator());
        recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
        recyclerView.setAdapter(mAdapter);

        whiteNotificationBar(recyclerView);

        apiService = ApiClient.getClient().create(ApiService.class);

        disposable.add(RxTextView.textChangeEvents(inputSearch)
                .skipInitialValue()
                .debounce(300, TimeUnit.MILLISECONDS)
                /*.filter(new Predicate<TextViewTextChangeEvent>() {
                    @Override
                    public boolean test(TextViewTextChangeEvent textViewTextChangeEvent) throws Exception {
                        return TextUtils.isEmpty(textViewTextChangeEvent.text().toString()) || textViewTextChangeEvent.text().toString().length() > 2;
                    }
                })*/
                .distinctUntilChanged()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeWith(searchContacts()));


        // source: `gmail` or `linkedin`
        // fetching all contacts on app launch
        // only gmail will be fetched
        fetchContacts("gmail");
    }

    private DisposableObserver<TextViewTextChangeEvent> searchContacts() {
        return new DisposableObserver<TextViewTextChangeEvent>() {
            @Override
            public void onNext(TextViewTextChangeEvent textViewTextChangeEvent) {
                Log.d(TAG, "Search query: " + textViewTextChangeEvent.text());
                mAdapter.getFilter().filter(textViewTextChangeEvent.text());
            }

            @Override
            public void onError(Throwable e) {
                Log.e(TAG, "onError: " + e.getMessage());
            }

            @Override
            public void onComplete() {

            }
        };
    }

    /**
     * Fetching all contacts
     */
    private void fetchContacts(String source) {
        disposable.add(apiService
                .getContacts(source, null)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeWith(new DisposableSingleObserver<List<Contact>>() {
                    @Override
                    public void onSuccess(List<Contact> contacts) {
                        contactsList.clear();
                        contactsList.addAll(contacts);
                        mAdapter.notifyDataSetChanged();
                    }

                    @Override
                    public void onError(Throwable e) {

                    }
                }));
    }

    @Override
    protected void onDestroy() {
        disposable.clear();
        unbinder.unbind();
        super.onDestroy();
    }

    @Override
    public void onContactSelected(Contact contact) {

    }

    private void whiteNotificationBar(View view) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            int flags = view.getSystemUiVisibility();
            flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
            view.setSystemUiVisibility(flags);
            getWindow().setStatusBarColor(Color.WHITE);
        }
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        if (item.getItemId() == android.R.id.home) {
            finish();
        }

        return super.onOptionsItemSelected(item);
    }
}

If you run the app and launch the Local Search activity, you can see the search working just fine.

Now let’s move on to remote database search. The different between local and remote search is, in Remote Search the search query will be sent to server and search will be performed on the server, so the logic needs to be changed.

11. We need another RecyclerView adapter class that doesn’t require getFilter() method. Under adapter package, create a class named ContactsAdapter.java

import android.content.Context;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;

import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;

import java.util.List;

import info.androidhive.rxjavasearch.R;
import info.androidhive.rxjavasearch.network.model.Contact;

public class ContactsAdapter extends RecyclerView.Adapter<ContactsAdapter.MyViewHolder> {
    private Context context;
    private List<Contact> contactList;
    private ContactsAdapterListener listener;

    public class MyViewHolder extends RecyclerView.ViewHolder {
        public TextView name, phone;
        public ImageView thumbnail;

        public MyViewHolder(View view) {
            super(view);
            name = view.findViewById(R.id.name);
            phone = view.findViewById(R.id.phone);
            thumbnail = view.findViewById(R.id.thumbnail);

            view.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    // send selected contact in callback
                    listener.onContactSelected(contactList.get(getAdapterPosition()));
                }
            });
        }
    }

    public ContactsAdapter(Context context, List<Contact> contactList, ContactsAdapterListener listener) {
        this.context = context;
        this.listener = listener;
        this.contactList = contactList;
    }

    @Override
    public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View itemView = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.contact_row_item, parent, false);

        return new MyViewHolder(itemView);
    }

    @Override
    public void onBindViewHolder(MyViewHolder holder, final int position) {
        final Contact contact = contactList.get(position);
        holder.name.setText(contact.getName());
        holder.phone.setText(contact.getPhone());

        Glide.with(context)
                .load(contact.getProfileImage())
                .apply(RequestOptions.circleCropTransform())
                .into(holder.thumbnail);
    }

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

    public interface ContactsAdapterListener {
        void onContactSelected(Contact contact);
    }
}

11. As well, we need another activity to test the remote search. Create another activity named RemoteSearchActivity.java

10. Open the layout file of RemoteSearchActivity and add RecyclerView widget. We also need an EditText to input the search query.

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".view.RemoteSearchActivity">

    <EditText
        android:id="@+id/input_search"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="@dimen/activity_margin"
        android:layout_marginRight="@dimen/activity_margin"
        android:hint="@string/hint_search"
        android:paddingBottom="@dimen/dimen_20" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/input_search" />

</RelativeLayout>

13. Open RemoteSearchActivity.java and add the below code.

  • PublishSubject – You can notice PublishSubject introduced in this activity. PublishSubject emits the events at the time of subscription. In our case, calling publishSubject.onNext() invokes the emission of Observable again thus making newer network call.
  • apiService.getContacts() Makes the network call with search query string.
  • switchMapSingle() plays very important role here. When there are multiple search requests in the queue, SwitchMap() ignores the previous emission and considers only the current search query. So the list will always displays the latest search results.
import android.graphics.Color;
import android.os.Build;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.support.v7.widget.DefaultItemAnimator;
import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
import android.widget.EditText;

import com.jakewharton.rxbinding2.widget.RxTextView;
import com.jakewharton.rxbinding2.widget.TextViewTextChangeEvent;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.Unbinder;
import info.androidhive.rxjavasearch.R;
import info.androidhive.rxjavasearch.adapter.ContactsAdapter;
import info.androidhive.rxjavasearch.network.ApiClient;
import info.androidhive.rxjavasearch.network.ApiService;
import info.androidhive.rxjavasearch.network.model.Contact;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.functions.Function;
import io.reactivex.observers.DisposableObserver;
import io.reactivex.schedulers.Schedulers;
import io.reactivex.subjects.PublishSubject;

public class RemoteSearchActivity extends AppCompatActivity implements ContactsAdapter.ContactsAdapterListener {

    private static final String TAG = RemoteSearchActivity.class.getSimpleName();

    private CompositeDisposable disposable = new CompositeDisposable();
    private PublishSubject<String> publishSubject = PublishSubject.create();
    private ApiService apiService;
    private ContactsAdapter mAdapter;
    private List<Contact> contactsList = new ArrayList<>();

    @BindView(R.id.input_search)
    EditText inputSearch;


    @BindView(R.id.recycler_view)
    RecyclerView recyclerView;

    private Unbinder unbinder;

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

        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        getSupportActionBar().setDisplayHomeAsUpEnabled(true);

        mAdapter = new ContactsAdapter(this, contactsList, this);

        RecyclerView.LayoutManager mLayoutManager = new LinearLayoutManager(getApplicationContext());
        recyclerView.setLayoutManager(mLayoutManager);
        recyclerView.setItemAnimator(new DefaultItemAnimator());
        recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
        recyclerView.setAdapter(mAdapter);

        whiteNotificationBar(recyclerView);

        apiService = ApiClient.getClient().create(ApiService.class);

        DisposableObserver<List<Contact>> observer = getSearchObserver();

        disposable.add(publishSubject.debounce(300, TimeUnit.MILLISECONDS)
                .distinctUntilChanged()
                .switchMapSingle(new Function<String, Single<List<Contact>>>() {
                    @Override
                    public Single<List<Contact>> apply(String s) throws Exception {
                        return apiService.getContacts(null, s)
                                .subscribeOn(Schedulers.io())
                                .observeOn(AndroidSchedulers.mainThread());
                    }
                })
                .subscribeWith(observer));


        // skipInitialValue() - skip for the first time when EditText empty
        disposable.add(RxTextView.textChangeEvents(inputSearch)
                .skipInitialValue()
                .debounce(300, TimeUnit.MILLISECONDS)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeWith(searchContactsTextWatcher()));

        disposable.add(observer);

        // passing empty string fetches all the contacts
        publishSubject.onNext("");
    }

    private DisposableObserver<List<Contact>> getSearchObserver() {
        return new DisposableObserver<List<Contact>>() {
            @Override
            public void onNext(List<Contact> contacts) {
                contactsList.clear();
                contactsList.addAll(contacts);
                mAdapter.notifyDataSetChanged();
            }

            @Override
            public void onError(Throwable e) {
                Log.e(TAG, "onError: " + e.getMessage());
            }

            @Override
            public void onComplete() {

            }
        };
    }

    private DisposableObserver<TextViewTextChangeEvent> searchContactsTextWatcher() {
        return new DisposableObserver<TextViewTextChangeEvent>() {
            @Override
            public void onNext(TextViewTextChangeEvent textViewTextChangeEvent) {
                Log.d(TAG, "Search query: " + textViewTextChangeEvent.text());
                publishSubject.onNext(textViewTextChangeEvent.text().toString());
            }

            @Override
            public void onError(Throwable e) {
                Log.e(TAG, "onError: " + e.getMessage());
            }

            @Override
            public void onComplete() {

            }
        };
    }

    @Override
    protected void onDestroy() {
        disposable.clear();
        unbinder.unbind();
        super.onDestroy();
    }

    @Override
    public void onContactSelected(Contact contact) {

    }

    private void whiteNotificationBar(View view) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            int flags = view.getSystemUiVisibility();
            flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
            view.setSystemUiVisibility(flags);
            getWindow().setStatusBarColor(Color.WHITE);
        }
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        if (item.getItemId() == android.R.id.home) {
            finish();
        }

        return super.onOptionsItemSelected(item);
    }
}

If you launch the Remote Search activity, you can see the result fetched from a remote source. The visual representation of both local and remote searches is same but the logic differs completely from one another.

Rxjava-instant-search-with-local-and-remote-databases

I am hoping few queries / suggestions on this article. If you have any, please post them in the comment section below.

Happy Coding 🙂

Author

  • amit sharma

    Hey Ravi Thanks for this post, I have a question How can we implement search while we have paging on RecyclerView and we have to select multiple items with the help of checkbox on list Item.
    I am trying to do the same but facing issues
    1. I have more than one page on Recyclerview (100 pages but page 1 is loaded on first open rest will be loaded while scroll) and search happen only 1 page or already loaded pages.
    2. If I implement remote search how can mix the selected item with previous loaded items
    for example – Page one have 10 records I have selected 3 of them now I want to search remotely and select 2 items, now how would I show 3+2 items selected along with all items on Recyclerview.

    I hope you understand my problem. Thanks in Advance 🙂

    • Hi Amit

      Can moving the search part completely to a different activity solve your problem? like we do in Gmail app.

      • amit sharma

        I was thinking the same way but not with different activity I can do in same activity by using two different Recyclerviews and combine the result of selected Items, Thanks Ravi let me try. 🙂

        • Cool. Consider testability of the code too.

  • Jimmy Alejandro Alvarez Calder

    In terms of performance, which is less problematic. having a list of items that will get bigger or making several request.

    I think also it will depends on data structure you use and the filtering algorithm.

  • Malik Ghulam Murtza

    Thanks Ravi, this was awesome as compared to previously searching manually using filters or bulk of API calls. . (Y)

  • Nermeen Ahmed (‫نرمين عبد المق

    Thanks Ravi for these precious tutorials, I’m confused about PublishSubject. Is it used to pass change event from an Observer to another? Is that right?
    Another question about this line on Remote Search Example:
    disposable.add(observer);
    Why you added this observer to disposable?

  • pradeepkumar reddy

    If i have to make the server call or start local search only after typing minimum 3 characters. how can i do it ?
    Is there any operator to wait for 3 characters before observable emits the data ??

    I want to make the server call after typing minimum 3 characters. And for local search i want the user to type minimum of 3 characters. Is there any operator to wait for 3 characters and then emit ??

  • pradeepkumar reddy

    what does skipInitialValue() do ?

  • Saloni Garg

    Hi ravi, can you please explain the use of publishsubject.