@@ -7,8 +7,15 @@ import android.arch.paging.LivePagedListBuilder
77import android.arch.paging.PagedList
88import android.arch.paging.PagedList.BoundaryCallback
99import kotlinx.coroutines.experimental.CancellationException
10+ import kotlinx.coroutines.experimental.Job
11+ import kotlinx.coroutines.experimental.channels.Channel
12+ import kotlinx.coroutines.experimental.channels.ReceiveChannel
13+ import kotlinx.coroutines.experimental.channels.consumeEach
14+ import kotlinx.coroutines.experimental.channels.produce
15+ import kotlinx.coroutines.experimental.delay
1016import kotlinx.coroutines.experimental.launch
1117import org.wordpress.android.R
18+ import org.wordpress.android.analytics.AnalyticsTracker
1219import org.wordpress.android.fluxc.model.MediaModel
1320import org.wordpress.android.fluxc.model.SiteModel
1421import org.wordpress.android.util.getDistinct
@@ -120,6 +127,14 @@ class GiphyPickerViewModel @Inject constructor(
120127 val selectionBarIsVisible: LiveData <Boolean > =
121128 Transformations .map(selectedMediaViewModelList) { it.isNotEmpty() }.getDistinct()
122129
130+ private val _isPerformingInitialLoad = MutableLiveData <Boolean >()
131+ /* *
132+ * Returns `true` if we are (or going to) perform an initial load due to a [search] call.
133+ *
134+ * This will be `false` when the initial load has been executed and completed.
135+ */
136+ val isPerformingInitialLoad: LiveData <Boolean > = _isPerformingInitialLoad
137+
123138 /* *
124139 * The [PagedList] that should be displayed in the RecyclerView
125140 */
@@ -133,6 +148,8 @@ class GiphyPickerViewModel @Inject constructor(
133148 */
134149 private val pagedListBoundaryCallback = object : BoundaryCallback <GiphyMediaViewModel >() {
135150 override fun onZeroItemsLoaded () {
151+ _isPerformingInitialLoad .postValue(false )
152+
136153 val displayMode = when {
137154 dataSourceFactory.initialLoadError != null -> EmptyDisplayMode .VISIBLE_NETWORK_ERROR
138155 dataSourceFactory.searchQuery.isBlank() -> EmptyDisplayMode .VISIBLE_NO_SEARCH_QUERY
@@ -143,11 +160,26 @@ class GiphyPickerViewModel @Inject constructor(
143160 }
144161
145162 override fun onItemAtFrontLoaded (itemAtFront : GiphyMediaViewModel ) {
163+ _isPerformingInitialLoad .postValue(false )
146164 _emptyDisplayMode .postValue(EmptyDisplayMode .HIDDEN )
147165 super .onItemAtFrontLoaded(itemAtFront)
148166 }
149167 }
150168
169+ /* *
170+ * Receives the query strings submitted in [search]
171+ *
172+ * This is the [ReceiveChannel] used to [debounce] on.
173+ */
174+ private val searchQueryChannel = Channel <String >()
175+
176+ init {
177+ // Register a [searchQueryChannel] callback which gets called after a set number of time has elapsed.
178+ launch {
179+ searchQueryChannel.debounce().consumeEach { search(query = it, immediately = true ) }
180+ }
181+ }
182+
151183 /* *
152184 * Perform additional initialization for this ViewModel
153185 *
@@ -158,22 +190,42 @@ class GiphyPickerViewModel @Inject constructor(
158190 }
159191
160192 /* *
161- * Set the current search query
193+ * Set the current search query.
194+ *
195+ * The search will not be executed until a short amount of time has elapsed. This enables us to keep receiving
196+ * queries from a text field without unnecessarily launching API requests. API requests will only be executed
197+ * when, presumably, the user has stopped typing.
162198 *
163199 * This also clears the [selectedMediaViewModelList]. This makes sense because the user will not be seeing the
164200 * currently selected [GiphyMediaViewModel] if the new search query results are different.
201+ *
202+ * Searching is disabled if downloading or the [query] is the same as the last one.
203+ *
204+ * @param immediately If `true`, bypasses the timeout and immediately executes API requests
165205 */
166- fun search (searchQuery : String ) {
167- if (_state .value != State .IDLE ) {
168- return
169- }
206+ fun search (query : String , immediately : Boolean = false) = launch {
207+ if (immediately) {
208+ if (_state .value != State .IDLE ) {
209+ return @launch
210+ }
211+ // Do not search if the same. This prevents searching to be re-executed after configuration changes.
212+ if (dataSourceFactory.searchQuery == query) {
213+ return @launch
214+ }
215+
216+ _isPerformingInitialLoad .postValue(true )
170217
171- _selectedMediaViewModelList .postValue(LinkedHashMap ())
218+ _selectedMediaViewModelList .postValue(LinkedHashMap ())
172219
173- // The empty view should be hidden while the user is searching
174- _emptyDisplayMode .postValue(EmptyDisplayMode .HIDDEN )
220+ // The empty view should be hidden while the user is searching
221+ _emptyDisplayMode .postValue(EmptyDisplayMode .HIDDEN )
222+
223+ dataSourceFactory.searchQuery = query
175224
176- dataSourceFactory.searchQuery = searchQuery
225+ AnalyticsTracker .track(AnalyticsTracker .Stat .GIPHY_PICKER_SEARCHED )
226+ } else {
227+ searchQueryChannel.send(query)
228+ }
177229 }
178230
179231 /* *
@@ -257,6 +309,25 @@ class GiphyPickerViewModel @Inject constructor(
257309 }
258310 }
259311
312+ /* *
313+ * Creates a new [ReceiveChannel] which only produces values after the [timeout] has elapsed and no new values
314+ * have been received from self ([ReceiveChannel]).
315+ *
316+ * This works like Rx's [Debounce operator](http://reactivex.io/documentation/operators/debounce.html).
317+ */
318+ private fun <T > ReceiveChannel<T>.debounce (timeout : Int = 300): ReceiveChannel <T > = produce {
319+ var job: Job ? = null
320+
321+ consumeEach {
322+ job?.cancel()
323+
324+ job = launch {
325+ delay(timeout)
326+ send(it)
327+ }
328+ }
329+ }
330+
260331 /* *
261332 * Retries all previously failed page loads.
262333 *
0 commit comments