In my fragment I have a RecyclerView, which displays results from the query I enter in options menu. It is an API from which I receive TV-shows list.
The query needs string with a len of 3 at least. When it's 1 or 2 the adapter is cleared.
override fun onQueryTextChange(newText: String?): Boolean {
if (newText != null && newText.length > 2) {
if (!newText.isNullOrBlank() && newText.length > 2)
viewModel.searchMovies(newText)
}
else {
adapter.setMoviesList(emptyList())
}
return true
}
However, I encountered an issue after entering e.g. "cat" twice. I received a list of shows having cat in it. After removing query from optionmenu and taping it again the adapter was empty. And there was no same search. For me -> because the flow value didn't change.
In ViewModel I have:
private val _moviesStateFlow = MutableStateFlow<List<TvMazeShowResponse>>(emptyList())
val moviesStateFlow = _moviesStateFlow as StateFlow<List<TvMazeShowResponse>>
fun searchMovies(query: String) {
viewModelScope.launch {
val response = api.getApiResponse(query)
_moviesStateFlow.emit(response)
}
}
And this StateFlow I collect in fragment.
lifecycleScope.launch {
viewModel.moviesStateFlow.collect {
adapter.setMoviesList(it)
}
}
To fix the problem I added another function in VM
fun clearFlow() {
viewModelScope.launch {
_moviesStateFlow.emit(emptyList())
}
}
And now in the fragment in onQueryTextChange in else I added.
else {
adapter.setMoviesList(emptyList())
viewModel.clearFlow()
}
Now it works as expected. But is there a better way to achieve that?
To make your code less convoluted, avoid doing logic in your UI classes (Fragment/Activity/Adapter) and make your ViewModel provide the single source of truth.
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.searchMovies(newText.orEmpty())
return true
}
// In ViewModel
fun searchMovies(query: String) {
val trimmedQuery = query.trim()
viewModelScope.launch {
val response = if (trimmedQuery.length <= 2) emptyList() else api.getApiResponse(trimmedQuery)
_moviesStateFlow.emit(response)
}
}
To avoid running multiple obsolete queries if the user is typing quickly, I suggest cancelling previous searches when starting new ones.
private val searchJob? = null
fun searchMovies(query: String) {
val trimmedQuery = query.trim()
searchJob?.cancel()
searchJob = viewModelScope.launch {
val response = if (trimmedQuery.length <= 2) emptyList() else api.getApiResponse(trimmedQuery)
_moviesStateFlow.emit(response)
}
}
Related
I am getting data from an API with editText search. At first search it works as expected but on second and so on, it will not show the only new response, instead it keeps old one and adds new one to end of it. It acts like it's caching previous ones. How can i fix that to show only last search word results?
Fragment:
var job: Job? = null
binding.etSearchNews.addTextChangedListener { editable ->
job?.cancel()
job = MainScope().launch {
delay(Constants.SEARCH_DELAY)
editable?.let {
if (editable.toString().isNotEmpty()) {
viewModel.searchNews(editable.toString())
}
}
}
}
viewModel.searchNews.observe(viewLifecycleOwner) {
when (it) {
is Resource.Success -> {
hideProgressBar()
it.data?.let { response ->
newsAdapter.differ.submitList(response.articles.toList())
}
}
is Resource.Error -> {}
is Resource.Loading -> {}
}
}
Adapter:
private val differCallback = object: DiffUtil.ItemCallback<Article>() {
override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean {
return oldItem.url == newItem.url
}
override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean {
return oldItem == newItem
}
}
val differ = AsyncListDiffer(this, differCallback)
API:
#GET("v2/everything")
suspend fun searchNews(
#Query("q") query: String,
#Query("page") number: Int = 1,
#Query("pageSize") size: Int = Constants.PAGE_SIZE,
#Query("apiKey") key: String = Constants.API_KEY
): Response<NewsResponse>
I've tried to add but no luck:
#Headers("Cache-Control: no-cache")
After spending a day to solve this, I've found the answer finally. It was because of i haven't properly clear the response before. It needs to be cleaned in addTextChangedListener so every new try will start on fresh response.
Also for leaving the fragment and coming back to it data refresh and scroll position changes problem, i added hasFocus to avoid it:
So, SearchFragment looks like this:
var job: Job? = null
binding.etSearchNews.addTextChangedListener { editable ->
job?.cancel()
job = MainScope().launch {
delay(Constants.SEARCH_DELAY)
editable?.let {
if (editable.toString().isNotEmpty()) {
if (binding.etSearchNews.hasFocus()) {
viewModel.searchNewsResponse = null
viewModel.searchNewsPage = 1
viewModel.searchNews(editable.toString())
}
} else {
newsAdapter.differ.submitList(listOf())
}
}
}
}
P.S. I didn't see that code in the tutorial, watched twice that part. I hope it helps to others.
I'm trying to insert separators to my list using the paging 3 compose library however, insertSeparators doesn't seem to indicate when we are at the beginning or end. My expectations are that before will be null at the beginning while after will be null at the end of the list. But it's never null thus hard to know when we are at the beginning or end. Here is the code:
private val filterPreferences =
MutableStateFlow(HomePreferences.FilterPreferences())
val games: Flow<PagingData<GameModel>> = filterPreferences.flatMapLatest {
useCase.execute(it)
}.map { pagingData ->
pagingData.map { GameModel.GameItem(it) }
}.map {
it.insertSeparators {before,after->
if (after == null) {
return#insertSeparators null
}
if (before == null) {
Log.i(TAG, "before is null: ") // never reach here
return#insertSeparators GameModel.SeparatorItem("title")
}
if(condition) {
GameModel.SeparatorItem("title")
}
else null
}
}
.cachedIn(viewModelScope)
GamesUseCase
class GamesUseCase #Inject constructor(
private val executionThread: PostExecutionThread,
private val repo: GamesRepo,
) : FlowUseCase<HomePreferences, PagingData<Game>>() {
override val dispatcher: CoroutineDispatcher
get() = executionThread.io
override fun execute(params: HomePreferences?): Flow<PagingData<Game>> {
val preferences = params as HomePreferences.FilterPreferences
preferences.apply {
return repo.fetchGames(query,
parentPlatforms,
platforms,
stores,
developers,
genres,
tags)
}
}
}
FlowUseCase
abstract class FlowUseCase<in Params, out T>() {
abstract val dispatcher: CoroutineDispatcher
abstract fun execute(params: Params? = null): Flow<T>
operator fun invoke(params: Params? = null) = execute(params).flowOn(dispatcher)
}
Here is the dependency :
object Pagination {
object Version {
const val pagingCompose = "1.0.0-alpha14"
}
const val pagingCompose = "androidx.paging:paging-compose:${Version.pagingCompose}"
}
I'm assuming that filterPreferences gives you Flow of some preference and useCase.execute returns Flow<PagingData<Model>>, correct?
I believe that the problem is in usage of flatMapLatest - it mixes page events of multiple useCase.execute calls together.
You should do something like this:
val games: Flow<Flow<PagingData<GameModel>>> = filterPreferences.mapLatest {
useCase.execute(it)
}.mapLatest {
it.map { pagingData -> pagingData.map { GameModel.GameItem(it) } }
}.mapLatest {
it.map { pagingData ->
pagingData.insertSeparators { before, after -> ... }
} // .cachedIn(viewModelScope)
}
This same structure works for us very well. I'm only not sure how cachedIn will work here, we are using a different caching mechanism, but you can try.
So, I have a pager setup using kotlin flows in android. I have a string that I get from the user and whenever that changes I use the flatmaplatest operator to get the latest item from the Pager object. The pager object is as follows.
fun searchAedItem(search: String) {
viewModelScope.launch {
mutableSearchData.emit(search)
}
}
val searchFlow = mutableSearchData.flatMapLatest {
IOLog.d(TAG, "search Flow happening..$it")
val jsonObject = JSONObject()
jsonObject.put("keyword", it)
val gson = getGsonObject(jsonObject)
Pager(PagingConfig(pageSize = 1)) { AedSearchSource(aedApi, gson) }.flow
}
the above is inside my viewmodel and I'm observing these inside my activity as follows:
lifecycleScope.launchWhenStarted {
viewmodel.searchFlow.collect {
IOLog.d("mutableSearchData", it.toString())
adapter?.submitData(it)
}
}
My adapter seems to be running fine since there was no problem when I displayed it with a different list
AedSearchSource class looks like this.
class AedSearchSource(val aedApi: AedApi, val searchObje: JsonObject) :
PagingSource<Int, AedDevicesListItem>() {
override fun getRefreshKey(state: PagingState<Int, AedDevicesListItem>): Int? {
TODO("Not yet implemented")
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AedDevicesListItem> {
return try {
val nextPage = params.key ?: 1
val response = aedApi.serachAed(searchObje, nextPage).await()
val list = response.body()!!
return if (list.isEmpty()) {
LoadResult.Error(Throwable("End of list reached."))
} else
LoadResult.Page(
data = list,
prevKey = if (nextPage == 1) null else nextPage - 1,
nextKey = nextPage.inc()
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
so, the problem is that it only works the first time api gets called and executes fine. and then subsequent times when I have a different text in searchAedItem() Pager(PagingConfig(pageSize = 1)) { AedSearchSource(aedApi, gson) }.flow does not get executed. but the flatMapLatest executes just fine.but pager object doesnot execute that api that I want to search.
Any ideas of what I am doing wrong?
Make sure to use .collectLatest from your Flow<PagingData> in order to cancel the previous generation once it gets superseded by a new search term.
In my app, I have this flow:
ClickListender in my fragment:
search_button.setOnClickListener {
if(search_input.text.isNullOrEmpty())
Toast.makeText(activity, "Input Error", Toast.LENGTH_LONG).show()
else
viewModel.onSearchButtonClicked(search_input.text.toString())
}
onSearchButtonClicked inside viewModel:
fun onSearchButtonClicked(input: String) {
coroutineScope.launch {
repo.insertToDatabase(input)
}
}
insertToDatabase inside Repository:
suspend fun insertToDatabase(string: String) {
withContext(Dispatchers.IO) {
val dataList =
ExternalApi.retrofitCall.getData(string).await()
if (dataList.intialDataResult < 1) {
//show error
} else {
//all good
database.myDataBase.insertAll(dataList)
}
}
}
I need to show error message if intialDataResult is less then one.
I thought about create MutableLiveData inside my repository with initial value of false and listen from the fragment through the viewModel, but it's not good approach because I have no way to set the LiveData to "false" again after I show error message.
I also tried to return bool from the insertToDatabase function and decide if to show error or not, with no success.
Any ideas how can I solve this?
Why not create a LiveData to manage your work's result state?
Create a class to store result of work why sealed class?
sealed class ResultState{
object Success: ResultState() // this is object because I added no params
data class Failure(val message: String): ResultState()
}
Create a LiveData to report this result
val stateLiveData = MutableLiveData<ResultState>()
Make insertToDatabase() return a result
suspend fun insertToDatabase(input: String): ResultState {
return withContext<ResultState>(Dispatchers.IO) {
val dataList =
ExternalApi.retrofitCall.getData(string).await()
if (dataList.intialDataResult < 1) {
return#withContext ResultState.Failure("Reason of error...")
} else {
database.myDataBase.insertAll(dataList)
return#withContext ResultState.Success
}
}
}
Now, report result to UI
fun onSearchButtonClicked(input: String) {
coroutineScope.launch {
val resultState = repo.insertToDatabase(input)
stateLiveData.value = resultState
}
}
In UI,
viewModel.stateLiveData.observe(viewLifeCycleOwner, Observer { state ->
when (state) {
is ResultState.Success -> { /* show success in UI */ }
is ResultState.Failure -> { /* show error in UI with state.message variable */ }
}
})
Similarly, you can add a ResultState.PROGRESS to show that a task is running in the UI.
If you have any queries, please add a comment.
I'm having a hard time making a call to my api. I'm using Reactivex with kotlin and Flowables. My API returns a list of items if the date I passed by the "If-Modified_since" header is less than the last update.
If there is no update I get as an app return android app a 304 error.
I need to do the following procedure.
1-> I make a call to the api
2-> If the call is successful, save the list in Realm and return to the viewmodel
3-> If the error is 304, I perform a cache search (Realm) of the items
4-> If it is another error, I return the error normally for the ViewModel
Here is the code below, but I'm not sure if it's that way.
override fun getTickets(eventId: String): Flowable<List<Ticket>> {
return factory
.retrieveRemoteDataStore()
.getTickets(eventId)
.map {
saveTickets(it)
it
}.onErrorResumeNext { t: Throwable ->
if (t is HttpException && t.response().code() == 304) {
factory.retrieveCacheDataStore().getTickets(eventId)
} else
//Should return error
}
The question is, what is the best way to do this?
Thank you.
I'm going to assume, that you're using Retrofit. If that's the case, then you could wrap your getTickets call in Single<Response<SomeModel>>. This way, on first map you can check the errorcode, something among the lines of:
...getTickets(id)
.map{ response ->
when {
response.isSuccessful && response.body!=null -> {
saveTickets(it)
it
}
!response.isSuccessful && response.errorCode() == 304 -> {
factory.retrieveCacheDataStore().getTickets(eventId)
}
else -> throw IOException()
}
}
This could of course be made pretty using standard/extension functions but wanted to keep it simple for readability purposes.
Hope this helps!
Most of my comments are my explanations.
data class Ticket(val id:Int) {
companion object {
fun toListFrom(jsonObject: JSONObject): TICKETS {
/**do your parsing of data transformation here */
return emptyList()
}
}
}
typealias TICKETS = List<Ticket>
class ExampleViewModel(): ViewModel() {
private var error: BehaviorSubject<Throwable> = BehaviorSubject.create()
private var tickets: BehaviorSubject<TICKETS> = BehaviorSubject.create()
/**public interfaces that your activity or fragment talk to*/
fun error(): Observable<Throwable> = this.error
fun tickets(): Observable<TICKETS> = this.tickets
fun start() {
fetch("http://api.something.com/v1/tickets/")
.subscribeOn(Schedulers.io())
.onErrorResumeNext { t: Throwable ->
if (t.message == "304") {
get(3)
} else {
this.error.onNext(t)
/** this makes the chain completed gracefuly without executing flatMap or any other operations*/
Observable.empty()
}
}
.flatMap(this::insertToRealm)
.subscribe(this.tickets)
}
private fun insertToRealm(tickets: TICKETS) : Observable<TICKETS> {
/**any logic here is mainly to help you save into Realm**/
/** I think realm has the option to ignore items that are already in the db*/
return Observable.empty()
}
private fun get(id: Int): Observable<TICKETS> {
/**any logic here is mainly to help you fetch from your cache**/
return Observable.empty()
}
private fun fetch(apiRoute: String): Observable<TICKETS> {
/**
* boilerplate code
wether you're using Retrofit or Okhttp, that's the logic you
should try to have
* */
val status: Int = 0
val rawResponse = ""
val error: Throwable? = null
val jsonResponse = JSONObject(rawResponse)
return Observable.defer {
if (status == 200) {
Observable.just(Ticket.toListFrom(jsonResponse))
}
else if (status == 304) {
Observable.error<TICKETS>(Throwable("304"))
}
else {
Observable.error<TICKETS>(error)
}
}
}
override fun onCleared() {
super.onCleared()
this.error = BehaviorSubject.create()
this.tickets = BehaviorSubject.create()
}
}