9

Handling search with RxJava2 and Kotlin

 3 years ago
source link: https://www.kotlindevelopment.com/handling-search-rxjava2-kotlin/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
handson

Handling search with RxJava2 and Kotlin

Posted on 17 August 2017 by Karoly Somodi

Presume we have to solve search in an Android app. We just need an EditText and query data based on the input, right? Sounds easy - let's take a look at how would we implement with Kotlin and Reactive Extensions!

Basic solution

Before we jump in and start coding, let's make sure all the necessary libraries are added to the Gradle file. Also, we’ll add a simple EditText to the activity layout, and subscribe to the textChanges observable. Up until this point, this looks like the simplest task ever. 🙂

build.gradle(module.app)

// RxJava2
compile "io.reactivex.rxjava2:rxjava:2.1.2"
compile "io.reactivex.rxjava2:rxandroid:2.0.1"
// RxBindig
compile "com.jakewharton.rxbinding2:rxbinding:2.0.0"
compile "com.jakewharton.rxbinding2:rxbinding-kotlin:2.0.0"

activity_layout.xml

<android.support.v7.widget.AppCompatEditText
  android:id="@+id/mainSearchText"
  android:layout_width="match_parent"
  android:layout_height="match_parent"/>

onCreate subscription

RxTextView.textChanges(mainSearchText)
  .subscribe(
    { Log.d("MainActivity", "$it") },
    { Log.e("MainActivity", "$it") }
  )

Kotlin ❤️ You

Why are we using the RxTextView utility class to observe the text change events? Can't we apply some Kotlin magic to make our life easier? Maybe Jake Wharton asked the same question when he made the RxBinding Kotlin module. It adds extension functions to the view types, making it easier for the IDE to suggest possible functions.

mainSearchText  
  .textChanges()
  .subscribe({
    Log.d("MainActivity", it.toString())
  }, { 
    Log.e("MainActivity", it.toString())
  })

One can easily see how gorgeous the whole stream is without the anonymous classes, thanks to Kotlin language features and lambda syntax. Less code means less time spent on maintenance.

Let's upgrade the UI

We'll add a couple of shiny and good looking features to our layout. Add two drawables into the EditText, configure the keyboard to display a search icon, add a hint inside the input field, and a progress bar above the content. It's super important to indicate progress, because without these UI feedback, the users wouldn't know that they need to be patient.

Extended EditText

<android.support.v7.widget.AppCompatEditText  
  android:id="@+id/mainSearchText"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:layout_marginEnd="16dp"
  android:background="@android:color/transparent"
  android:drawableEnd="@drawable/main_close_white_24dp"
  android:drawableStart="@drawable/main_search_white_24dp"
  android:hint="@string/main_search_hint"
  android:imeOptions="actionSearch"
  android:inputType="text"
  android:lines="1"
  android:maxLines="1"/>

Content body

<FrameLayout  
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <TextView
    android:id="@+id/mainContent"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:gravity="center"
    android:textColor="@color/colorAccent"
    android:textSize="24sp"
    android:textStyle="bold"/>

  <ProgressBar
    android:id="@+id/mainProgressbar"
    android:layout_width="96dp"
    android:layout_height="96dp"
    android:layout_gravity="center"
    android:indeterminate="true"
    android:visibility="gone"/>
</FrameLayout>

As an extra, let's add an extension function to the EditText which will handle the onTouch event of the drawable on the right, so we can clear our input based on a touch of the right drawable if it is clicked.

Extension function for EditText

fun AppCompatEditText.setRightDrawableOnTouchListener(func: AppCompatEditText.() -> Unit) {
  setOnTouchListener { _, event ->
    var consumed = false
    if (event.action == MotionEvent.ACTION_UP) {
      val drawable = compoundDrawables[2]
      if (event.rawX >= (right - drawable.bounds.width())) {
        func()
        consumed = true
      }
    }
    consumed
  }
}

Handling the 'clear' touch event

mainSearchText.setRightDrawableOnTouchListener {  
  text.clear()
}

To the internet and beyond

Until now we did nothing of the extreme, but here comes true magic. Let's connect our search EditText to a query service - it can be anything from a database to a server, depending on the requirements.

.flatMap { searchService.search(it).subscribeOn(Schedulers.io()) }
.observeOn(AndroidSchedulers.mainThread())

We're doing work on the IO thread as specified by subscribeOn, but at the end of the day we need to mutate the state of views based on the results. This is what the observeOn method specifies, we select the thread which will deal with the subscription and the side effects.

Showing that the app is working in the background is always a good idea, so we're displaying a progress bar until our work is finished, with the doOnNext and doOnEach functions.

before flatMap

.doOnNext {
  mainProgressbar.visibility = View.VISIBLE
  mainContent.visibility = View.GONE
}

after observeOn

.doOnEach {
  mainProgressbar.visibility = View.GONE
  mainContent.visibility = View.VISIBLE
}

As a finishing touch, we’ll add an extra line to our subscription mainContent.text = it.text and the result will be displayed on the UI. Beautiful, isn't it?

Where we are going, we don't need error handling

If something can go wrong, it will - a good developer always keeps an eye out for possible errors.

We handled visibility in doOnEach, so no matter if the stream throws an exception, or finishes correctly, the progress bar will be hidden, and the content will be visible.

Every time something subscribes to the EditText, the content will be emitted immediately. To avoid this behavior, simply skip the first element:

.skip(1)

If you look at the server/database log, you will see that we are doing a query each time the user hits a character, meaning we're burning CPU and network resources when we wouldn't have to. Add a little debounce timer between doOnNext and flatMap to avoid this.

.debounce(800, TimeUnit.MILLISECONDS)

Moving on to the query, what if it fails? What if our service can't process the search phrase? onError will be triggered, making the app unsubscribe from textChanges(), and the EditText will no longer be emitting anything. Sounds bad, right? Just because a request is not finished properly, I don't want to lose my UI logic inside my activity. We’ll inform our users about errors with doOnError, and use theretry to resubscribe to the textChanges observable.

.doOnError {
  Snackbar.make(main_coordinator, "Error while searching", Snackbar.LENGTH_SHORT).show()
}
.retry()

RxBinding knowledge

If we've read the documentations before implementing anything - what we always do before starting to write code - we would saw the warning for the textChange event:

Create an observable of character sequences for text changes on View
Warning: Values emitted by this observable are mutable and owned by the host

That means every value passed down the stream can be changed by the system if the value is delayed or debounced on an another thread. Which we definitely do, so to avoid mutability simply map the value to a new object after skip

.map { it.toString() }

Final code

See the final version here:

searchSubscription = mainSearchText
  .textChanges()
  .skip(1)
  .map { it.toString() }
  .doOnNext {
    mainProgressbar.visibility = View.VISIBLE
    mainContent.visibility = View.GONE
  }
  .debounce(800, TimeUnit.MILLISECONDS)
  .flatMap {
    if (it.isNotBlank()) {
      searchService.search(it).subscribeOn(Schedulers.io())
    } else {
      Observable.just(SearchResult.empty())
    }
  }
  .observeOn(AndroidSchedulers.mainThread())
  .doOnEach {
    mainProgressbar.visibility = View.GONE
    mainContent.visibility = View.VISIBLE
  }
  .doOnError { Snackbar.make(main_coordinator, "Error while searching", Snackbar.LENGTH_SHORT).show() }
  .retry()
  .subscribe({
    mainContent.text = it.text
    Log.d("MainActivity", it.text)
  }, { 
    Log.e("MainActivity", it.toString())
  })

Quite a journey, right? We avoided a couple of harmful bugs in our code and solved plenty of time-consuming mistakes. I hope it helped you.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK