I just noticed problem earlier in my app, I see the ViewModel inside fragment doesn't save/keep recycler view when I rotate the device, I don't want to use the old method like save data in bundle onSaveInstanceState
and restore it, I tried to figure why this problem by printing some logs on each method in fragment lifecycle but I didn't succeed
GIF showing the problem
the ViewModel
@HiltViewModel
class PostViewModel @Inject constructor(
private val mainRepository: MainRepository,
private val dataStoreRepository: DataStoreRepository,
application: Application
) :
AndroidViewModel(application) {
/** ROOM DATABASE */
val readAllPosts: LiveData<List<Item>> = mainRepository.localDataSource.getAllItems().asLiveData()
val postsBySearchInDB: MutableLiveData<List<Item>> = MutableLiveData()
/** RETROFIT **/
var postsResponse: MutableLiveData<NetworkResult<PostList>> = MutableLiveData()
var searchedPostsResponse: MutableLiveData<NetworkResult<PostList>> = MutableLiveData()
var postListResponse: PostList? = null
var postListByLabelResponse: PostList? = null
var searchPostListResponse: PostList? = null
val label = MutableLiveData<String>()
var finalURL: MutableLiveData<String?> = MutableLiveData()
val token = MutableLiveData<String?>()
val currentDestination = MutableLiveData<Int>()
fun getCurrentDestination() {
viewModelScope.launch {
dataStoreRepository.readCurrentDestination.collect {
currentDestination.value = it
}
}
}
val errorCode = MutableLiveData<Int>()
val searchError = MutableLiveData<Boolean>()
var networkStats = false
var backOnline = false
val recyclerViewLayout = dataStoreRepository.readRecyclerViewLayout.asLiveData()
val readBackOnline = dataStoreRepository.readBackOnline.asLiveData()
override fun onCleared() {
super.onCleared()
finalURL.value = null
token.value = null
}
private fun saveBackOnline(backOnline: Boolean) = viewModelScope.launch {
dataStoreRepository.saveBackOnline(backOnline)
}
fun saveCurrentDestination(currentDestination: Int) {
viewModelScope.launch {
dataStoreRepository.saveCurrentDestination(currentDestination)
}
}
fun saveRecyclerViewLayout(layout: String) {
viewModelScope.launch {
dataStoreRepository.saveRecyclerViewLayout(layout)
}
}
fun getPosts() = viewModelScope.launch {
getPostsSafeCall()
}
fun getPostListByLabel() = viewModelScope.launch {
getPostsByLabelSafeCall()
}
fun getItemsBySearch() = viewModelScope.launch {
getItemsBySearchSafeCall()
}
private suspend fun getPostsByLabelSafeCall() {
postsResponse.value = NetworkResult.Loading()
if (hasInternetConnection()) {
try {
val response = mainRepository.remoteDataSource.getPostListByLabel(finalURL.value!!)
postsResponse.value = handlePostsByLabelResponse(response)
} catch (ex: HttpException) {
Log.e(TAG, ex.message ex.cause)
postsResponse.value = NetworkResult.Error(ex.message.toString())
errorCode.value = ex.code()
} catch (ex: NullPointerException) {
postsResponse.value = NetworkResult.Error("There's no items")
}
} else {
postsResponse.value = NetworkResult.Error("No Internet Connection.")
}
}
private suspend fun getPostsSafeCall() {
postsResponse.value = NetworkResult.Loading()
if (hasInternetConnection()) {
try {
if (finalURL.value.isNullOrEmpty()) {
finalURL.value = "$BASE_URL?key=$API_KEY"
}
val response = mainRepository.remoteDataSource.getPostList(finalURL.value!!)
postsResponse.value = handlePostsResponse(response)
} catch (e: Exception) {
postsResponse.value = NetworkResult.Error(e.message.toString())
if (e is HttpException) {
errorCode.value = e.code()
Log.e(TAG, "getPostsSafeCall: errorCode $errorCode")
Log.e(TAG, "getPostsSafeCall: ${e.message.toString()}")
}
}
} else {
postsResponse.value = NetworkResult.Error("No Internet Connection.")
}
}
private fun handlePostsResponse(response: Response<PostList>): NetworkResult<PostList> {
if (response.isSuccessful) {
if (!(token.value.equals(response.body()?.nextPageToken))) {
token.value = response.body()?.nextPageToken
response.body()?.let { resultResponse ->
Log.d(
TAG, "handlePostsResponse: old token is: ${token.value} "
"new token is: ${resultResponse.nextPageToken}"
)
finalURL.value = "${BASE_URL}?pageToken=${token.value}&key=${API_KEY}"
Log.e(TAG, "handlePostsResponse finalURL is ${finalURL.value!!}")
for (item in resultResponse.items) {
insertItem(item)
}
return NetworkResult.Success(resultResponse)
}
}
}
if (token.value == null) {
errorCode.value = 400
} else {
errorCode.value = response.code()
}
return NetworkResult.Error(
"network results of handlePostsResponse ${
response.body().toString()
}"
)
}
private fun handlePostsByLabelResponse(response: Response<PostList>): NetworkResult<PostList> {
if (response.isSuccessful) {
response.body()?.let { resultResponse ->
Log.d(
TAG, "handlePostsByLabelResponse: old token is: ${token.value} "
"new token is: ${resultResponse.nextPageToken}"
)
finalURL.postValue(
(BASE_URL_POSTS_BY_LABEL "posts?labels=${label.value}"
"&maxResults=20"
"&pageToken=")
token.value
"&key=" API_KEY
)
if (postListByLabelResponse == null) {
postListByLabelResponse = resultResponse
} else {
val oldPosts = postListByLabelResponse?.items
val newPosts = resultResponse.items
oldPosts?.addAll(newPosts)
}
return NetworkResult.Success(postListByLabelResponse?:resultResponse)
}
}
if (token.value == null) {
errorCode.value = 400
} else {
errorCode.value = response.code()
}
Log.e(TAG, "handlePostsByLabelResponse: final URL ${finalURL.value}")
return NetworkResult.Error(
"network results of handlePostsByLabelResponse ${
response.body().toString()
}"
)
}
private fun hasInternetConnection(): Boolean {
val connectivityManager = getApplication<Application>().getSystemService(
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val activeNetwork = connectivityManager.activeNetwork ?: return false
val capabilities =
connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> true
else -> false
}
} else {
val networkInfo = connectivityManager.activeNetworkInfo
return networkInfo != null && networkInfo.isConnectedOrConnecting
}
}
fun showNetworkStats() {
if (!networkStats) {
Toast.makeText(getApplication(), "No Internet connection", Toast.LENGTH_SHORT).show()
saveBackOnline(true)
} else if (networkStats) {
if (backOnline) {
Toast.makeText(getApplication(), "We're back online", Toast.LENGTH_SHORT).show()
saveBackOnline(false)
}
}
}
private fun insertItem(item: Item) {
viewModelScope.launch(Dispatchers.IO) {
mainRepository.localDataSource.insertItem(item)
}
}
private suspend fun getItemsBySearchSafeCall() {
searchedPostsResponse.value = NetworkResult.Loading()
if (!label.value.isNullOrEmpty()) {
finalURL.value = "${BASE_URL}?labels=${label.value}&maxResults=500&key=$API_KEY"
}
Log.e(TAG, "getItemsBySearch: ${finalURL.value}")
if (hasInternetConnection()) {
try {
val response = mainRepository.remoteDataSource.getPostListBySearch(finalURL.value!!)
searchedPostsResponse.value = handlePostsBySearchResponse(response)
} catch (e: Exception) {
searchedPostsResponse.value = NetworkResult.Error(e.message.toString())
}
} else {
searchedPostsResponse.value = NetworkResult.Error("No Internet Connection.")
}
}
private fun handlePostsBySearchResponse(response: Response<PostList>): NetworkResult<PostList> {
return if (response.isSuccessful) {
val postListResponse = response.body()
NetworkResult.Success(postListResponse!!)
} else {
errorCode.value = response.code()
NetworkResult.Error(response.errorBody().toString())
}
}
fun getItemsBySearchInDB(keyword: String) {
Log.d(TAG, "getItemsBySearchInDB: called")
viewModelScope.launch {
val items = mainRepository.localDataSource.getItemsBySearch(keyword)
if (items.isNotEmpty()) {
postsBySearchInDB.value = items
} else {
searchError.value = true
Log.e(TAG, "list is empty")
}
}
}
}
the fragment
@AndroidEntryPoint
class AccessoryFragment : Fragment(), MenuProvider, TitleAndGridLayout {
private var _binding: FragmentAccessoryBinding? = null
private val binding get() = _binding!!
private var searchItemList = arrayListOf<Item>()
private lateinit var postViewModel: PostViewModel
private val titleLayoutManager: GridLayoutManager by lazy { GridLayoutManager(context, 2) }
private val gridLayoutManager: GridLayoutManager by lazy { GridLayoutManager(context, 3) }
private var linearLayoutManager: LinearLayoutManager? = null
private val KEY_RECYCLER_STATE = "recycler_state"
private val mBundleRecyclerViewState by lazy { Bundle() }
private lateinit var adapter: PostAdapter
private var isScrolling = false
var currentItems = 0
var totalItems: Int = 0
var scrollOutItems: Int = 0
private var postsAPiFlag = false
private var keyword: String? = null
private lateinit var networkListener: NetworkListener
private var networkStats = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
postViewModel = ViewModelProvider(this)[PostViewModel::class.java]
adapter = PostAdapter(this)
postViewModel.finalURL.value =
BASE_URL_POSTS_BY_LABEL "posts?labels=Accessory&maxResults=20&key=$API_KEY"
networkListener = NetworkListener()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentAccessoryBinding.inflate(inflater, container, false)
val menuHost: MenuHost = requireActivity()
menuHost.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.CREATED)
postViewModel.label.value = "Accessory"
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Log.d(TAG, "onViewCreated: called")
postViewModel.recyclerViewLayout.observe(viewLifecycleOwner) { layout ->
linearLayoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
Log.w(TAG, "onViewCreated getSavedLayout called")
when (layout) {
"cardLayout" -> {
binding.accessoryRecyclerView.layoutManager = linearLayoutManager
adapter.viewType = 0
binding.accessoryRecyclerView.adapter = adapter
}
"cardMagazineLayout" -> {
binding.accessoryRecyclerView.layoutManager = linearLayoutManager
adapter.viewType = 1
binding.accessoryRecyclerView.adapter = adapter
}
"titleLayout" -> {
binding.accessoryRecyclerView.layoutManager = titleLayoutManager
adapter.viewType = 2
binding.accessoryRecyclerView.adapter = adapter
}
"gridLayout" -> {
binding.accessoryRecyclerView.layoutManager = gridLayoutManager
adapter.viewType = 3
binding.accessoryRecyclerView.adapter = adapter
}
}
}
lifecycleScope.launchWhenStarted {
networkListener.checkNetworkAvailability(requireContext()).collect { stats ->
Log.d(TAG, "networkListener: $stats")
postViewModel.networkStats = stats
postViewModel.showNetworkStats()
[email protected] = stats
if (stats ) {
if (binding.accessoryRecyclerView.visibility == View.GONE) {
binding.accessoryRecyclerView.visibility = View.VISIBLE
}
requestApiData()
} else {
// Log.d(TAG, "onViewCreated: savedInstanceState $savedInstanceState")
noInternetConnectionLayout()
}
}
}
binding.accessoryRecyclerView.onItemClick { _, position, _ ->
val postItem = adapter.differ.currentList[position]
findNavController().navigate(
AccessoryFragmentDirections.actionNavAccessoryToDetailsActivity(
postItem
)
)
}
binding.accessoryRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
if (newState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
isScrolling = true
}
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
currentItems = linearLayoutManager!!.childCount
totalItems = adapter.itemCount
scrollOutItems = linearLayoutManager!!.findFirstVisibleItemPosition()
if ((!recyclerView.canScrollVertically(1) && dy > 0) &&
(isScrolling && currentItems scrollOutItems >= totalItems && postsAPiFlag)
) {
hideShimmerEffect()
postViewModel.getPostListByLabel()
isScrolling = false
}
}
})
postViewModel.errorCode.observe(viewLifecycleOwner) { errorCode ->
if (errorCode == 400) {
binding.accessoryRecyclerView.setPadding(0, 0, 0, 0)
Toast.makeText(requireContext(), R.string.lastPost, Toast.LENGTH_LONG).show()
binding.progressBar.visibility = View.GONE
} else {
Log.e(TAG, "onViewCreated: ${postViewModel.errorCode.value.toString()} ")
noInternetConnectionLayout()
}
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
Log.d(TAG, "onConfigurationChanged: ${newConfig.orientation}")
Log.d(TAG, "onConfigurationChanged: ${adapter.differ.currentList.toString()}")
Log.d(
TAG,
"onConfigurationChanged: "
binding.accessoryRecyclerView.layoutManager?.itemCount.toString()
)
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
Log.d(TAG, "onViewStateRestored: called")
}
private fun requestApiData() {
showShimmerEffect()
Log.d(TAG, "requestApiData: called")
postViewModel.getPostListByLabel()
postViewModel.postsResponse.observe(viewLifecycleOwner) { response ->
postsAPiFlag = true
when (response) {
is NetworkResult.Success -> {
hideShimmerEffect()
response.data?.let {
binding.progressBar.visibility = View.GONE
// itemArrayList.addAll(it.items)
adapter.differ.submitList(it.items.toList())
}
}
is NetworkResult.Error -> {
hideShimmerEffect()
binding.progressBar.visibility = View.GONE
Log.e(TAG, response.data.toString())
Log.e(TAG, response.message.toString())
}
is NetworkResult.Loading -> {
binding.progressBar.visibility = View.VISIBLE
}
}
}
}
private fun showShimmerEffect() {
binding.apply {
shimmerLayout.visibility = View.VISIBLE
accessoryRecyclerView.visibility = View.INVISIBLE
}
}
private fun hideShimmerEffect() {
binding.apply {
shimmerLayout.stopShimmer()
shimmerLayout.visibility = View.GONE
accessoryRecyclerView.visibility = View.VISIBLE
}
}
private fun changeAndSaveLayout() {
Log.w(TAG, "changeAndSaveLayout: called")
val builder = AlertDialog.Builder(requireContext())
builder.setTitle(getString(R.string.choose_layout))
val recyclerViewLayouts = resources.getStringArray(R.array.RecyclerViewLayouts)
// SharedPreferences.Editor editor = sharedPreferences.edit();
builder.setItems(
recyclerViewLayouts
) { _: DialogInterface?, index: Int ->
try {
when (index) {
0 -> {
adapter.viewType = 0
binding.accessoryRecyclerView.layoutManager = linearLayoutManager
binding.accessoryRecyclerView.adapter = adapter
postViewModel.saveRecyclerViewLayout("cardLayout")
}
1 -> {
adapter.viewType = 1
binding.accessoryRecyclerView.layoutManager = linearLayoutManager
binding.accessoryRecyclerView.adapter = adapter
postViewModel.saveRecyclerViewLayout("cardMagazineLayout")
}
2 -> {
adapter.viewType = 2
binding.accessoryRecyclerView.layoutManager = titleLayoutManager
binding.accessoryRecyclerView.adapter = adapter
postViewModel.saveRecyclerViewLayout("titleLayout")
}
3 -> {
adapter.viewType = 3
binding.accessoryRecyclerView.layoutManager = gridLayoutManager
binding.accessoryRecyclerView.adapter = adapter
postViewModel.saveRecyclerViewLayout("gridLayout")
}
}
} catch (e: Exception) {
Log.e(TAG, "changeAndSaveLayout: " e.message)
Log.e(TAG, "changeAndSaveLayout: " e.cause)
}
}
val alertDialog = builder.create()
alertDialog.show()
}
private fun noInternetConnectionLayout() {
binding.apply {
// accessoryRecyclerView.removeAllViews()
Log.d(TAG, "noInternetConnectionLayout: called")
shimmerLayout.stopShimmer()
shimmerLayout.visibility = View.GONE
accessoryRecyclerView.visibility = View.GONE
}
binding.noInternetConnectionLayout.inflate()
binding.noInternetConnectionLayout.let {
if (networkStats) {
it.visibility = View.GONE
}
}
}
override fun onDestroyView() {
super.onDestroyView()
// adapter.isDestroyed = true
linearLayoutManager?.removeAllViews()
// adView.destroy()
linearLayoutManager = null
_binding = null
}
override fun onDetach() {
super.onDetach()
if(linearLayoutManager != null){
linearLayoutManager = null
}
}
override fun tellFragmentToGetItems() {
if (postViewModel.recyclerViewLayout.value.equals("titleLayout")
|| postViewModel.recyclerViewLayout.value.equals("gridLayout")
) {
hideShimmerEffect()
postViewModel.getPostListByLabel()
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.main, menu)
val searchManager =
requireContext().getSystemService(Context.SEARCH_SERVICE) as SearchManager
val searchView = menu.findItem(R.id.app_bar_search).actionView as SearchView
searchView.setSearchableInfo(searchManager.getSearchableInfo(requireActivity().componentName))
searchView.queryHint = resources.getString(R.string.searchForPosts)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(keyword: String): Boolean {
if (keyword.isEmpty()) {
Snackbar.make(
requireView(),
"please enter keyword to search",
Snackbar.LENGTH_SHORT
).show()
}
// itemArrayList.clear()
[email protected] = keyword
requestSearchApi(keyword)
return false
}
override fun onQueryTextChange(newText: String): Boolean {
return false
}
})
searchView.setOnCloseListener {
if (keyword.isNullOrEmpty()) {
hideShimmerEffect()
return@setOnCloseListener false
}
if (Utils.hasInternetConnection(requireContext())) {
showShimmerEffect()
postViewModel.postListByLabelResponse = null
searchItemList.clear()
// adapter.differ.submitList(ArrayList())
linearLayoutManager?.removeAllViews()
binding.accessoryRecyclerView.removeAllViews()
// itemArrayList.clear()
adapter.differ.submitList(null)
postViewModel.finalURL.value =
BASE_URL_POSTS_BY_LABEL "posts?labels=Accessory&maxResults=20&key=$API_KEY"
requestApiData()
// itemArrayList.clear()
// adapter.submitList(itemArrayList)
//====> Here I call the request api method again
Log.d(
TAG,
"setOnCloseListener: called ${adapter.differ.currentList.size.toString()}"
)
// adapter.notifyDataSetChanged()
// binding.progressBar.visibility = View.GONE
//
Log.d(TAG, "setOnCloseListener: ${postViewModel.finalURL.value.toString()}")
//
// adapter.notifyDataSetChanged()
// }
} else {
Log.d(TAG, "setOnCloseListener: called")
adapter.differ.submitList(null)
searchItemList.clear()
noInternetConnectionLayout()
}
false
}
postViewModel.searchError.observe(viewLifecycleOwner) { searchError ->
if (searchError) {
Toast.makeText(
requireContext(),
"There's no posts with this keyword", Toast.LENGTH_LONG
).show()
}
}
}
private fun requestSearchApi(keyword: String) {
if (Utils.hasInternetConnection(requireContext())) {
showShimmerEffect()
postViewModel.finalURL.value =
"${BASE_URL}?labels=Accessory&maxResults=500&key=$API_KEY"
postViewModel.getItemsBySearch()
postViewModel.searchedPostsResponse.observe(viewLifecycleOwner) { response ->
when (response) {
is NetworkResult.Success -> {
postsAPiFlag = false
// adapter.differ.currentList.clear()
if (searchItemList.isNotEmpty()) {
searchItemList.clear()
}
binding.progressBar.visibility = View.GONE
lifecycleScope.launch {
withContext(Dispatchers.Default) {
searchItemList.addAll(response.data?.items?.filter {
it.title.contains(keyword) || it.content.contains(keyword)
} as ArrayList<Item>)
}
}
Log.d(TAG, "requestSearchApi: test size ${searchItemList.size}")
if (searchItemList.isEmpty()) {
// adapter.differ.submitList(null)
Toast.makeText(
requireContext(),
"The search word was not found in any post",
Toast.LENGTH_SHORT
).show()
hideShimmerEffect()
return@observe
} else {
postsAPiFlag = false
// itemArrayList.clear()
adapter.differ.submitList(null)
hideShimmerEffect()
// Log.d(
//// TAG, "requestSearchApi: searchItemList ${searchItemList[0].title}"
// )
adapter.differ.submitList(searchItemList)
// binding.accessoryRecyclerView.scrollToPosition(0)
}
}
is NetworkResult.Error -> {
hideShimmerEffect()
binding.progressBar.visibility = View.GONE
Toast.makeText(
requireContext(),
response.message.toString(),
Toast.LENGTH_SHORT
).show()
Log.e(TAG, "onQueryTextSubmit: $response")
}
is NetworkResult.Loading -> {
binding.progressBar.visibility = View.VISIBLE
}
}
}
} else {
noInternetConnectionLayout()
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return if (menuItem.itemId == R.id.change_layout) {
changeAndSaveLayout()
true
} else false
}
}
CodePudding user response:
Unless I'm missing something (that's a lot of code to go through!) you don't set any data on your adapter until this bit:
private fun requestApiData() {
postViewModel.getPostListByLabel()
postViewModel.postsResponse.observe(viewLifecycleOwner) {
...
adapter.differ.submitList(it.items.toList())
}
And getPostListByLabel()
clears the current data in postsResponse
fun getPostListByLabel() = viewModelScope.launch {
getPostsByLabelSafeCall()
}
private suspend fun getPostsByLabelSafeCall() {
postsResponse.value = NetworkResult.Loading()
// fetch data over network and update postsResponse with it later
...
}
So when you first observe
it, it's in the NetworkResult.Loading
state - any posts you had stored have been wiped.
Your Fragment
gets recreated when the Activity
is rotated and destroyed - so if you're initialising the ViewModel
data contents as part of that Fragment
setup (like you're doing here) it's going to get reinitialised every time the Fragment
is recreated, and you'll lose the current data.
You'll need to work out a way to avoid that happening - you don't actually want to do that clear-and-fetch whenever a Fragment
is created, so you'll have to decide when it should happen. Maybe when the ViewModel
is first created (i.e. through the init
block), maybe the first time a Fragment
calls it (e.g. create an initialised boolean in the VM set to false, check it in the call, set true when it runs). Or maybe just when postsResponse
has no value yet (postsResponse.value == null
). I don't know the flow of your application so you'll have to work out when to force a fetch and when to keep the data that's already there