Skip to content

Commit aac1809

Browse files
authored
Merge pull request #8 from shiki/issue/8627-giphy-search-while-typing
Giphy Picker: Search While Typing
2 parents f0b8add + 2c3f2fd commit aac1809

File tree

6 files changed

+102
-12
lines changed

6 files changed

+102
-12
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- #8627 Add Importing from Giphy in Editor and Media Library

WordPress/src/main/java/org/wordpress/android/ui/giphy/GiphyPickerActivity.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ class GiphyPickerActivity : AppCompatActivity() {
7272
initializeToolbar()
7373
initializeRecyclerView()
7474
initializeSearchView()
75+
initializeSearchProgressBar()
7576
initializeSelectionBar()
7677
initializeEmptyView()
7778
initializeRangeLoadErrorEventHandlers()
@@ -127,16 +128,26 @@ class GiphyPickerActivity : AppCompatActivity() {
127128
search_view.setOnQueryTextListener(object : OnQueryTextListener {
128129
override fun onQueryTextSubmit(query: String): Boolean {
129130
search_view.clearFocus()
130-
viewModel.search(query)
131131
return true
132132
}
133133

134134
override fun onQueryTextChange(newText: String): Boolean {
135+
viewModel.search(newText)
135136
return true
136137
}
137138
})
138139
}
139140

141+
/**
142+
* Show the progress bar in the center of the page if we are performing an initial page load.
143+
*/
144+
private fun initializeSearchProgressBar() {
145+
viewModel.isPerformingInitialLoad.getDistinct().observe(this, Observer {
146+
val isPerformingInitialLoad = it ?: return@Observer
147+
progress.visibility = if (isPerformingInitialLoad) View.VISIBLE else View.GONE
148+
})
149+
}
150+
140151
/**
141152
* Configure the selection bar and its labels when the [GiphyPickerViewModel] selected items change
142153
*/

WordPress/src/main/java/org/wordpress/android/viewmodel/giphy/GiphyPickerViewModel.kt

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@ import android.arch.paging.LivePagedListBuilder
77
import android.arch.paging.PagedList
88
import android.arch.paging.PagedList.BoundaryCallback
99
import 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
1016
import kotlinx.coroutines.experimental.launch
1117
import org.wordpress.android.R
18+
import org.wordpress.android.analytics.AnalyticsTracker
1219
import org.wordpress.android.fluxc.model.MediaModel
1320
import org.wordpress.android.fluxc.model.SiteModel
1421
import 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
*

WordPress/src/test/java/org/wordpress/android/viewmodel/giphy/GiphyPickerViewModelTest.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ class GiphyPickerViewModelTest {
110110
viewModel.toggleSelected(mediaViewModel)
111111

112112
// Act
113-
viewModel.search("dummy")
113+
runBlocking {
114+
viewModel.search(query = "dummy", immediately = true).join()
115+
}
114116

115117
// Assert
116118
assertThat(viewModel.selectedMediaViewModelList.value).isEmpty()
@@ -122,7 +124,9 @@ class GiphyPickerViewModelTest {
122124
assertThat(viewModel.emptyDisplayMode.value).isEqualTo(EmptyDisplayMode.VISIBLE_NO_SEARCH_QUERY)
123125

124126
// Act
125-
viewModel.search("dummy")
127+
runBlocking {
128+
viewModel.search("dummy", immediately = true).join()
129+
}
126130

127131
// Assert
128132
assertThat(viewModel.emptyDisplayMode.value).isEqualTo(EmptyDisplayMode.HIDDEN)

libs/analytics/WordPressAnalytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ public enum Stat {
413413
STOCK_MEDIA_ACCESSED,
414414
STOCK_MEDIA_SEARCHED,
415415
STOCK_MEDIA_UPLOADED,
416+
GIPHY_PICKER_SEARCHED,
416417
GIPHY_PICKER_ACCESSED,
417418
GIPHY_PICKER_DOWNLOADED,
418419
SHORTCUT_STATS_CLICKED,

libs/analytics/WordPressAnalytics/src/main/java/org/wordpress/android/analytics/AnalyticsTrackerNosara.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,6 +1229,8 @@ public static String getEventNameForStat(AnalyticsTracker.Stat stat) {
12291229
return "stock_media_searched";
12301230
case STOCK_MEDIA_UPLOADED:
12311231
return "stock_media_uploaded";
1232+
case GIPHY_PICKER_SEARCHED:
1233+
return "giphy_picker_searched";
12321234
case GIPHY_PICKER_ACCESSED:
12331235
return "giphy_picker_accessed";
12341236
case GIPHY_PICKER_DOWNLOADED:

0 commit comments

Comments
 (0)