Search code examples
androidrx-java2rx-androidrx-binding

How to implement Periodic processing of user input?


My current Android application allows users to search for content remotely.

e.g. The user is presented with an EditText which accepts their search strings and triggers a remote API call that returns results that match the entered text.

Worse case is that I simply add a TextWatcher and trigger an API call each time onTextChanged is called. This could be improved by forcing the user to enter at least N characters to search for before making the first API call.

The "Perfect" solution would have the following features:-

Once the user starts entering search string(s)

Periodically (every M milliseconds) consume the entire string(s) entered. Trigger an API call each time the period expires and the current user input is different to the previous user input.

[Is it possible to have a dynamic timeout related to the entered texts length? e.g while the text is "short" the API response size will be large and take longer to return and parse; As the search text gets longer the API response size will reduce along with "inflight" and parsing time]

When the user restarts typing into the EditText field restart the Periodic consumption of text.

Whenever the user presses the ENTER key trigger "final" API call, and stop monitoring user input into the EditText field.

Set a minimum length of text the user has to enter before an API call is triggered but combine this minimum length with an overriding Timeout value so that when the user wishes to search for a "short" text string they can.

I am sure that RxJava and or RxBindings can support the above requirements however so far I have failed to realise a workable solution.

My attempts include

private PublishSubject<String> publishSubject;

  publishSubject = PublishSubject.create();
        publishSubject.filter(text -> text.length() > 2)
                .debounce(300, TimeUnit.MILLISECONDS)
                .toFlowable(BackpressureStrategy.LATEST)
                .subscribe(new Consumer<String>() {
                    @Override
                    public void accept(final String s) throws Exception {
                        Log.d(TAG, "accept() called with: s = [" + s + "]");
                    }
                });


       mEditText.addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) {

            }

            @Override
            public void onTextChanged(final CharSequence s, final int start, final int before, final int count) {
                publishSubject.onNext(s.toString());
            }

            @Override
            public void afterTextChanged(final Editable s) {

            }
        });

And this with RxBinding

 RxTextView.textChanges(mEditText)
                .debounce(500, TimeUnit.MILLISECONDS)
                .subscribe(new Consumer<CharSequence>(){
                    @Override
                    public void accept(final CharSequence charSequence) throws Exception {
                        Log.d(TAG, "accept() called with: charSequence = [" + charSequence + "]");
                    }
                });

Neither of which give me a conditional filter that combines entered text length and a Timeout value.

I've also replaced debounce with throttleLast and sample neither of which furnished the required solution.

Is it possible to achieve my required functionality?

DYNAMIC TIMEOUT

An acceptable solution would cope with the following three scenarios

i). The user wishes to search for the any word beginning with "P"

ii). The user wishes to search for any word beginning with "Pneumo"

iii). The user wishes to search for the word "Pneumonoultramicroscopicsilicovolcanoconiosis"

In all three scenarios as soon as the user types the letter "P" I will display a progress spinner (however no API call will be executed at this point). I would like to balance the need to give the user search feedback within a responsive UI against making "wasted" API calls over the network.

If I could rely on the user entering their search text then clicking the "Done" (or "Enter") key I could initiate the final API call immediately.

Scenario One

As the text entered by the user is short in length (e.g. 1 character long) My timeout value will be at its maximum value, This gives the user the opportunity to enter additional characters and saves "wasted API calls".

As the user wishes to search for the letter "P" alone, once the Max Timeout expires I will execute the API call and display the results. This scenario gives the user the worst user experience as they have to wait for my Dynamic Timeout to expire and then wait for a Large API response to be returned and displayed. They will not see any intermediary search results.

Scenario Two

This scenario combines scenario one as I have no idea what the user is going to search for (or the search strings final length) if they type all 6 characters "quickly" I can execute one API call, however the slower they are entering the 6 characters will increase the chance of executing wasted API calls.

This scenario gives the user an improved user experience as they have to wait for my Dynamic Timeout to expire however they do have a chance of seeing intermediary search results. The API responses will be smaller than scenario one.

Scenario Three

This scenario combines scenario one and two as I have no idea what the user is going to search for (or the search strings final length) if they type all 45 characters "quickly" I can execute one API call (maybe!), however the slower they type the 45 characters will increase the chance of executing wasted API calls.

I'am not tied to any technology that delivers my desired solution. I believe Rx is the best approach I've identified so far.


Solution

  • Something like this should work (didn't really try it)

     Single<String> firstTypeOnlyStream = RxTextView.textChanges(mEditText)
                .skipInitialValue()
                .map(CharSequence::toString)
                .firstOrError();
    
        Observable<CharSequence> restartTypingStream = RxTextView.textChanges(mEditText)
                .filter(charSequence -> charSequence.length() == 0);
    
        Single<String> latestTextStream = RxTextView.textChanges(mEditText)
                .map(CharSequence::toString)
                .firstOrError();
    
        Observable<TextViewEditorActionEvent> enterStream =
                RxTextView.editorActionEvents(mEditText, actionEvent -> actionEvent.actionId() == EditorInfo.IME_ACTION_DONE);
    
        firstTypeOnlyStream
                .flatMapObservable(__ ->
                        latestTextStream
                                .toObservable()
                                .doOnNext(text -> nextDelay = delayByLength(text.length()))
                                .repeatWhen(objectObservable -> objectObservable
                                        .flatMap(o -> Observable.timer(nextDelay, TimeUnit.MILLISECONDS)))
                                .distinctUntilChanged()
                                .flatMap(text -> {
                                    if (text.length() > MINIMUM_TEXT_LENGTH) {
                                        return apiRequest(text);
                                    } else {
                                        return Observable.empty();
                                    }
                                })
                )
                .takeUntil(restartTypingStream)
                .repeat()
                .takeUntil(enterStream)
                .mergeWith(enterStream.flatMap(__ ->
                        latestTextStream.flatMapObservable(this::apiRequest)
                ))
                .subscribe(requestResult -> {
                    //do your thing with each request result
                });
    

    The idea is to construct the stream based on sampling rather then the text changed events itself, based on your requirement to sample each X time.

    The way I did it here, is to construct one stream (firstTypeOnlyStream for the initial triggering of the events (the first time user input text), this stream will start the entire processing stream with the first typing of the user, next, when this first trigger arrives, we will basically sample the edit text periodically using the latestTextStream. latestTextStream is not really a stream over time, but rather a sampling of the current state of the EditText using the InitialValueObservable property of RxBinding (it simply emits on subscription the current text on the EditText) in other words it's a fancy way to get current text on subscription, and it's equivalent to:
    Observable.fromCallable(() -> mEditText.getText().toString());
    next, for dynamic timeout/delay, we update the nextDelay based on the text length and using repeatWhen with timer to wait for the desired time. together with distinctUntilChanged, it should give the desired sampling based on text length. further on, we'll fire the request based on the text (if long enough).

    Stop by Enter - use takeUntil with enterStream which will be triggered on Enter and it also will trigger the final query.

    Restarting - when the user 'restarts' typing - i.e. text is empty, .takeUntil(restartTypingStream) + repeat() will stop the stream when empty string enter, and restarts it (resubscribe).