ViewPager with custom pages - kotlin.UninitializedPropertyAccessException - android

I'm using custom Pages in my ViewPager and entire App is based on those screens.
There are 2 main abstract functions in those Pages.
First one is getScreen() which suppose to copy function of onCreateView from Fragment. Its called inside ViewPagerAdapters function to initialize layout for that screen.
Example from adapter:
override fun instantiateItem(container: ViewGroup, position: Int): View {
val page = pageList[position]
val layout = page.getScreen(container)
App.log("flowSwitch: instantiateItem: getScreen: ${page::class.java}")
container.addView(layout)
page.screenLayoutWasInitialized = true
return layout
}
Another function is onScreenSwitched(). This one suppose to be called only if I switch to the screen manually by swiping/clicking on tab/calling it in code to get into next screen.
There is initializing some values for Views, sometimes based on payload provided from previous screens.
I call this onScreenSwitched() function inside switchScreen() function which is part of my Navigation class. I just pass there screen name and payload. Its always called after mainViewPager.setCurrentItem(index, useAnim).
Example:
fun switchScreen(
screen: Class<out FlowScreen>,
payload: List<ScreenPayload>? = null,
action: (() -> Unit)? = null,
useAnim: Boolean = true,
){
App.log("AppNavigationFlow: MainTabActivity: switchScreen: $screen")
try {
val index = mainFlowList.indexOfFirst { item -> item::class.java == screen }
App.log("AppNavigationFlow: MainTabActivity: switchScreen: index: $index")
if (index >= 0){
delayedScreenSelection {
mainTabLayout.getTabAt(index)?.select()
App.log("AppNavigationFlow: MainTabActivity: switchScreen: pageNameAtPos: ${mainViewPager.adapter?.getPageTitle(index)}")
mainViewPager.setCurrentItem(index, useAnim)
mainPagerAdapter.getPageAtPos(index)?.apply {
App.log("AppNavigationFlow: MainTabActivity: switchScreen: page: ${this::class.java}")
mainCurrentPage?.apply {
setScreenVisibleState(false)
resetToDefault()
removeBottomSheet(false)
this#MainTabActivity.removeBottomSheet(false)
}
mainCurrentPage = this
mainCurrentPage?.apply {
setScreenVisibleState(true)
clearPayload()
clearAction()
}
payload?.let { mainCurrentPage?.sendPayload(payload) }
action?.let { mainCurrentPage?.setAction(action) }
onScreenSwitched()
onPageChanged()
}?:kotlin.run {
setTabsEnabled(true)
}
}
} else {
setTabsEnabled(true)
}
}catch (e: IndexOutOfBoundsException){
App.log("AppNavigationFlow: MainTabActivity: switchScreen: $e")
setTabsEnabled(true)
}
}
98% of users are getting always called getScreen function before onScreenSwitched function, therefore my layout is completely initialized by that time onScreenSwitched is called.
But for 2% of users, they are getting kotlin.UninitializedPropertyAccessException because for example I'm trying to setup text for Button which was not initialized yet in getScreen function.
How to prevent this? I'm not sure if ViewPager should allow that to happen. How can setContentView ignore instantiateItem call if layout was not initialized yet for that screen I'm switching to? I ditched Fragments because of this bug happening in Fragments too and its happening again with fully customized logic. How can I build something functional when I cant even rely on basic native components to work as it suppose to at first place? There is possibly something I'm missing but 98% of time its working and I personally cant simulate those crashes but I want to fix it for those 2% of users.
Example usage in Page:
private lateinit var toolbarTitle: TextView
private lateinit var acceptButton: LoadingButton
override fun getScreen(collection: ViewGroup): View {
val layout = CustomResources.inflateLayout(inflater, l, collection, false) as ViewGroup
toolbarTitle = layout.findText(R.id.actionbarTitle)
acceptButton = layout.findViewById(R.id.acceptButton)
return layout
}
override fun onScreenSwitched() {
super.onScreenSwitched()
acceptButton.setText(if(payload.ok) "Yes" else "No")
}

Related

ViewPager (RecyclerView) with custom item count doesn't update data properly

I've created an adapter (extending ListAdapter with DiffUtil.ItemCallback) for my RecyclerView. It's an ordinary adapter with several itemViewTypes, but it should be smth like cyclic, if API sends flag and dataset size is > 1 (made by overriding getItemCount() to return 1000 when conditions == true).
When I change app locale through app settings, my fragment recreates, data loads asynchronously (reactively, several times in a row, from different requests, depending on several rx fields, which causes data set to be a combination of data on different languages just after locale is changed (in the end all dataset is correctly translated btw) (make it more like synchronous is not possible because of feature specifics)), posting its values to LiveData, which triggers updates of recycler view, the problem appears:
After last data set update some of the views (nearest to currently displayed and currently displayed) appear not to be translated.
Final data set, which is posted to LiveData is translated correctly, it even has correct locale tag in its id. Also after views are recycled and we return back to them - they are also correct.
DiffUtil is computed correctly also (I've tried to return only false in item callbacks and recycler view still didn't update its view holders correctly).
When itemCount == list.size everything works fine.
When adapter is pretending to be cyclic and itemCount == 1000 - no.
Can somebody explain this behaviour and help to figure out how to solve this?
Adapter Code Sample:
private const val TYPE_0 = 0
private const val TYPE_1 = 1
class CyclicAdapter(
val onClickedCallback: (id: String) -> Unit,
val onCloseClickedCallback: (id: String) -> Unit,
) : ListAdapter<IViewData, RecyclerView.ViewHolder>(DataDiffCallback()) {
var isCyclic: Boolean = false
set(value) {
if (field != value) {
field = value
}
}
override fun getItemCount(): Int {
return if (isCyclic) {
AdapterUtils.MAX_ITEMS // 1000
} else {
currentList.size
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
TYPE_0 -> Type0.from(parent)
TYPE_1 -> Type1.from(parent)
else -> throw ClassCastException("View Holder for ${viewType} is not specified")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is Type0 -> {
val item = getItem(
AdapterUtils.actualPosition(
position,
currentList.size
)
) as ViewData.Type0
holder.setData(item, onClickedCallback)
}
is Type1 -> {
val item = getItem(
AdapterUtils.actualPosition(
position,
currentList.size
)
) as ViewData.Type1
holder.setData(item, onClickedCallback, onCloseClickedCallback)
}
}
}
override fun getItemViewType(position: Int): Int {
return when (val item = getItem(AdapterUtils.actualPosition(position, currentList.size))) {
is ViewData.Type0 -> TYPE_0
is ViewData.Type1 -> TYPE_1
else -> throw ClassCastException("View Type for ${item.javaClass} is not specified")
}
}
class Type0 private constructor(itemView: View) :
RecyclerView.ViewHolder(itemView) {
fun setData(
viewData: ViewData.Type0,
onClickedCallback: (id: String) -> Unit
) {
(itemView as Type0View).apply {
acceptData(viewData)
setOnClickedCallback { url ->
onClickedCallback(viewData.id,)
}
}
}
companion object {
fun from(parent: ViewGroup): Type0 {
val view = Type0View(parent.context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
return Type0(view)
}
}
}
class Type1 private constructor(itemView: View) :
RecyclerView.ViewHolder(itemView) {
fun setData(
viewData: ViewData.Type1,
onClickedCallback: (id: String) -> Unit,
onCloseClickedCallback: (id: String) -> Unit
) {
(itemView as Type1View).apply {
acceptData(viewData)
setOnClickedCallback { url ->
onClickedCallback(viewData.id)
}
setOnCloseClickedCallback(onCloseClickedCallback)
}
}
companion object {
fun from(parent: ViewGroup): Type1 {
val view = Type1View(parent.context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
return Type1(view)
}
}
}
}
ViewPager Code Sample:
class CyclicViewPager #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
ICyclicViewPager {
private val cyclicViewPager: ViewPager2
private lateinit var onClickedCallback: (id: String) -> Unit
private lateinit var onCloseClickedCallback: (id: String) -> Unit
private lateinit var adapter: CyclicAdapter
init {
LayoutInflater
.from(context)
.inflate(R.layout.v_cyclic_view_pager, this, true)
cyclicViewPager = findViewById(R.id.cyclic_view_pager)
(cyclicViewPager.getChildAt(0) as RecyclerView).apply {
addItemDecoration(SpacingDecorator().apply {
dpBetweenItems = 12
})
clipToPadding = false
clipChildren = false
overScrollMode = RecyclerView.OVER_SCROLL_NEVER
}
cyclicViewPager.offscreenPageLimit = 3
}
override fun initialize(
onClickedCallback: (id: String) -> Unit,
onCloseClickedCallback: (id: String) -> Unit
) {
this.onClickedCallback = onClickedCallback
this.onCloseClickedCallback = onCloseClickedCallback
adapter = CyclicAdapter(
onClickedCallback,
onCloseClickedCallback,
).apply {
stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
cyclicViewPager.adapter = adapter
}
override fun setState(viewPagerState: CyclicViewPagerState) {
when (viewPagerState.cyclicityState) {
is CyclicViewPagerState.CyclicityState.Enabled -> {
adapter.submitList(viewPagerState.pages) {
adapter.isCyclic = true
cyclicViewPager.post {
cyclicViewPager.setCurrentItem(
// Setting view pager item to +- 500
AdapterUtils.getCyclicInitialPosition(
adapter.currentList.size
), false
)
}
}
}
is CyclicViewPagerState.CyclicityState.Disabled -> {
if (viewPagerState.pages.size == 1 && adapter.isCyclic) {
cyclicViewPager.setCurrentItem(0, false)
adapter.isCyclic = false
}
adapter.submitList(viewPagerState.pages)
}
}
}
}
Adapter Utils Code:
object AdapterUtils {
const val MAX_ITEMS = 1000
fun actualPosition(position: Int, listSize: Int): Int {
return if (listSize == 0) {
0
} else {
(position + listSize) % listSize
}
}
fun getCyclicInitialPosition(listSize: Int): Int {
return if (listSize > 0) {
MAX_ITEMS / 2 - ((MAX_ITEMS / 2) % listSize)
} else {
0
}
}
}
Have tried not to use default itemView variable of RecyclerView (became even worse).
Tried to make diff utils always return false, to check if it calculates diff correctly (yes, correctly)
Tried to add locale tags to ids of data set items (didn't help to solve)
Tried to post empty dataset on locale change before setting new data to it (shame on me, shouldn't even think about it)
Tried do add debounce to rx to make it wait a bit before update (didn't help)
UPD: When I call adapter.notifyDatasetChanged() manually, which is not the preferred way, everything works fine, so the question is why ListAdapter doesn't dispatch notify callbacks properly in my case?
The issue with ListAdapter is that it doesn't clearly state that you need to supply a new list for it to function.
In other words, the documentation says: (and I quote the source code):
/**
* Submits a new list to be diffed, and displayed.
* <p>
* If a list is already being displayed, a diff will be computed on a background thread, which
* will dispatch Adapter.notifyItem events on the main thread.
*
* #param list The new list to be displayed.
*/
public void submitList(#Nullable List<T> list) {
mDiffer.submitList(list);
}
The key word being new list.
However, as you can see there, all the adapter does is defer to the DiffUtil and calls submitList there.
So when you look at the actual source code of the AsyncListDiffer you will notice it does, at the beginning of its code block:
if (newList == mList) {
// nothing to do (Note - still had to inc generation, since may have ongoing work)
if (commitCallback != null) {
commitCallback.run();
}
return;
}
In other words, if the new list (reference) is the same as the old one, regardless of their contents, don't do anything.
This may sound cool but it means that if you have this code, the adapter will not really update:
(pseudo...)
var list1 = mutableListOf(...)
adapter.submitList(list1)
list1.add(...)
adapter.submitList(list1)
The reason is list1 is the same reference your adapter has, so the differ exits prematurely, and doesn't dispatch any changes to the adapter.
Quite obscure, I know.
The solution, as pointed in many SO answers is to create a copy of the list itself.
Most users do
var list1 = mutableListOf(...)
adapter.submitList(list1)
var list2 = list1.toMutableList()
list2.add(...)
adapter.submitList(list2)
The call to toMutableList() creates a new list containing the items of list1 and so the comparison above if (newList == mList) { should now be false and the normal code should execute.
UPDATE
Keep in mind that a lot of developers make the mistake of...
var list = mutableListOf...
adapter.submitList(list)
list.add(xxx)
adapter.submitList(list.toList())
This doesn't work, because the new list you create, is referencing the same objects the adapter has. This means that both lists list and list.toList() are pointing to the same things despite being two instances of an ArrayList.
But the side-effect is that DiffUtil compares the items and they are the same, so no diff is dispatched to the adapter either.
The correct sequence is...
val list = mutableListOf(...)
adapter.submitList(list.toList())
// Make a copy first, so we can alter it as we please without the *current list held by the adapter* from being affected.
var modified = list.toMutableList()
modified.add(...)
adapter.submitList(modified)
After taking a look at your sample in GitHub, I was able to reproduce the issue. With only about 30-40 minutes of playing with it, I can say that I'm not 100% sure what component is not updating.
Things I've noticed.
The onBindViewHolder method is not called when you change the locale (except maybe the 1st time?).
I do not understand why the need to post to the adapter after you've submitted the list in the callback:
cyclicViewPager.setCurrentItem(
// Setting view pager item to +- 500
AdapterUtils.getCyclicInitialPosition(
adapter.currentList.size
), false
)
Why ? This means the user loses their current position.
Why not keep the existing?
I noticed you do cyclicViewPager.offscreenPageLimit = 3 this effectively disables the RecyclerView "logic" for handling changes, and uses instead the usual ViewPager state adapter logic of "prefetching/keeping" N (3 in your case) pages in "advance".
At first I thought this was causing issues, but removing it (which sets it to -1 which is the default and the "use RecyclerView" value, didn't make a big change (though I did notice some changes here and there, as in it would sometimes update the current one -but not the next ones within 2~3 pages).
The documentation says:
Set the number of pages that should be retained to either side of the currently visible page(s). Pages beyond this limit will be recreated from the adapter when needed. Set this to OFFSCREEN_PAGE_LIMIT_DEFAULT to use RecyclerView's caching strategy.
So I would have imagined that the default value would be aided by the ListAdapter and its DiffUtil. Doesn't seem to be the case.
What I did try (among a few other things) was to see if the issue was in the actual adapter (or at least the viewPager dependency on its adapter). I ran out of time (work!) but I noticed that if you do:
override fun setState(viewPagerState: CyclicViewPagerState) {
when (viewPagerState.cyclicityState) {
is CyclicViewPagerState.CyclicityState.Enabled -> {
// call initialize again, to recreate the adapter
initialize(this.onClickedCallback, this.onCloseClickedCallback)
adapter.submitList(viewPagerState.pages) {
adapter.isCyclic = true
// Setting vp item to ... (code omitted for brevity)
}
This works. It's theoretically less efficient as you're recreating the whole adapter, but in your example you're effectively creating an ENTIRE new set of data changing every ID, so in terms of performance, I'd argue this is more efficient as there's no need to recalculate changes and dispatch them, since to the eyes of the Diff Util, all the rows are different. By recreating the adapter, well... the VP has to reinit anyway.
I noticed this worked fine in your example.
I went ahead and added two more things, because the "silly" adapter cannot reliably tell you which position is the current... you can naively save it:
In CyclicViewPager:
var currentPos: Int = 0
init {
...
this.cyclicViewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int)
currentPos = position
}
})
}
And then
is CyclicViewPagerState.CyclicityState.Enabled -> {
initialize(this.onClickedCallback, this.onCloseClickedCallback)
adapter.submitList(viewPagerState.pages) {
adapter.isCyclic = true
if (adapter.currentList.size <= currentPos) {
cyclicViewPager.setCurrentItem(currentPos, false)
} else {
cyclicViewPager.setCurrentItem(
// Setting view pager item to +- 500
AdapterUtils.getCyclicInitialPosition(
adapter.currentList.size
), false
)
}
}
}
This does work, but of course, you're recreating the entire VP adapter again, so it may not be desired.
At this point, I'd either need to spend much more time trying to figure out which part of VP, RV, or its dependencies is not "dispatching" the correct data. My guess would be somewhere around some silly ViewPager optimization combined with Android terribly unreliable View system, not picking a message in the queue; but I may be also terribly wrong ;)
I hope someone smarter and/or with more coffee in their system can find out a simpler solution.
(all in all, I found the sample project relatively easy to navigate, but the design of your data a bit convoluted, but... as it was a sample, it's hard to tell what "real-life" data structures you really have).

Delete Item in Android ViewPager2

I am updating my code from using androidx.viewpager to androidx.viewpager2. I am paging through an undetermined number of fragments showing data records retrieved from a database. Loading the view pager and paging through my data works nicely but I'm having some trouble with deleting an item and updating the pager adapter. I want to delete an item at any given position by calling the removeItem() method (see code below) on my adapter. That should remove the item from my database as well as my fragment and then update the view.
Result is that the correct item is removed from the database. But it does not remove the intended fragment from my view pager but the next page instead. The current page remains visible. I have a bit offsetting the position by plus or minus 1 with no success - in contrary: in those cases my delete routine performed as initially expected. I also tried similar considerations as given e.g. here.
I'd like to achieve the following behavior:
When deleting any item, the page should be removed and the next one in the list displayed.
When deleting the last/rightmost item in the list, the page should be removed and the previous (now last) page shown.
When deleting the last remaining item (none left) the activity should finish.
My adapter code:
internal class ShapePagerAdapter(private val activity: AppCompatActivity) : FragmentStateAdapter(activity) {
private val dbManager: DatabaseManager
private var shapeIds: MutableList<String>? = null
init {
dbManager = DatabaseManager(activity)
try {
shapeIds = dbManager.getShapeIds()
} catch (e: DatabaseAccessException) {
// ...
}
}
override fun getItemCount(): Int {
return if (null != shapeIds) shapeIds!!.size else 0
}
override fun createFragment(position: Int): Fragment {
return ShapeFragment.newInstance(shapeIds!![position])
}
fun removeItem(activity: AppCompatActivity, position: Int) {
try {
// Remove from Database.
dbManager.deleteShape(shapeIds!![position])
// Remove from View Pager.
shapeIds!!.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position , itemCount)
// Close if nothing to show anymore.
if (itemCount == 0) {
activity.finish()
}
} catch (e: DatabaseAccessException) {
// ...
}
}
}
Closer study of FragmentStateAdapter reveals that two of its methods must be overridden in this case:
containsItem(long itemId) and getItemId(int position)
Default implementation works for collections that don't add, move,
remove items.
Searching for that I found an answer to a similar question, pointing me in the right direction. It does not produce the exact behavior given in my question, which is why I'm posting a slightly adapted version.
Key is that those two methods are implemented in cases there can be changes to the sequence of items. To enable this I maintain a map of items and item ids and update when there are changes to the sequence, in this case a removed item.
internal class ShapePagerAdapter(private val activity: AppCompatActivity) : FragmentStateAdapter(activity) {
private val dbManager: DatabaseManager
private lateinit var shapeIds: MutableList<String>
private lateinit var itemIds: List<Long>
init {
dbManager = DatabaseManager(activity)
try {
shapeIds = dbManager.getShapeIds()
updateItemIds()
} catch (e: DatabaseAccessException) {
// ...
}
}
override fun getItemCount(): Int = shapeIds.size
override fun createFragment(position: Int): Fragment = ShapeFragment.newInstance(shapeIds[position])
fun removeItem(activity: AppCompatActivity, position: Int) {
try {
dbManager.deleteShape(shapeIds[position])
shapeIds.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position , itemCount)
updateItemIds()
if (itemCount == 0) activity.finish()
} catch (e: DatabaseAccessException) {
// ...
}
}
private fun updateItemIds() {
itemIds = shapeIds.map { it.hashCode().toLong() }
}
override fun getItemId(position: Int): Long = shapeIds[position].hashCode().toLong()
override fun containsItem(itemId: Long): Boolean = itemIds.contains(itemId)
}
}

Android Espresso testing ProgressBar visibility GONE before assert

I'm trying to test a spinner that should display while loading the information from an API.
The problem is that I can't assert the initial state VISIBLE because it disappear too fast when the results are emitted back thus always having a failing test
Expected: (view has effective visibility <VISIBLE> and view.getGlobalVisibleRect() to return non-empty rectangle)
Got: view.getVisibility() was <GONE>
The first attempt using ui-automator
#Test
fun displayLoaderWhileFetchingPlaylistDetails() {
IdlingRegistry.getInstance().unregister(idlingResource)
uiObjectWithId(R.id.playlist_list).getChild(UiSelector().clickable(true).index(0)).click()
val spinner = uiObjectWithId(R.id.playlist_details_loader)
assertTrue(spinner.exists())
}
Another variant for the test without ui-automator
#Test
fun displayLoaderWhileFetchingPlaylistDetails2() {
IdlingRegistry.getInstance().unregister(idlingResource)
onView(
allOf(
withId(R.id.playlist_image),
isDescendantOfA(withPositionInParent(R.id.playlist_list, 0))
)
)
.perform(click())
assertDisplayed(R.id.playlist_details_loader)
}
ui-automator helper
fun uiObjectWithId(#IdRes id: Int): UiObject {
val resourceId = getTargetContext().resources.getResourceName(id);
val selector = UiSelector().resourceId(resourceId)
return UiDevice.getInstance(getInstrumentation()).findObject(selector)
}
Fragment
private fun observeLoaderState() {
viewModel.playlistLoader.observe(this as LifecycleOwner) { playlistSpinner ->
when (playlistSpinner) {
true -> playlist_details_loader.visibility = View.VISIBLE
else -> playlist_details_loader.visibility = View.GONE
}
}
}
ViewModel
class PlaylistDetailViewModel(
private val repository: PlaylistRepository
) : ViewModel() {
val playlistLoader = MutableLiveData<Boolean>()
fun getPlaylistDetails(playlistId: String) = liveData {
playlistLoader.postValue(true)
emitSource(
repository.getPlaylistDetailsById(playlistId)
.onEach { playlistLoader.postValue(false) }
.asLiveData()
)
}
}
Thanks!
In Android when you set a View's visibility to GONE the renderer does not draw the View object, so the View practically has no dimensions. The same applies if you call any function that searchs in the UI tree for the View that has visibility set to GONE, and will return no match. If your only goal is to pass the test, my suggestions would be to set the View to INVISIBLE instead of GONE or to change the way you test for that specific layout.
From Android documentation:
View.GONE This view is invisible, and it doesn't take any space for
layout purposes.
View.INVISIBLE This view is invisible, but it still takes up space
for layout purposes.

RecyclerView sets wrong MotionLayout state for its items

First: I created a sample project showing this problem. By now I begin to think that this is a bug in either RecyclerView or MotionLayout.
https://github.com/muetzenflo/SampleRecyclerView
This project is set up a little bit different than what is described below: It uses data binding to toggle between the MotionLayout states. But the outcome is the same. Just play around with toggling the state and swiping between the items. Sooner than later you'll come upon a ViewHolder with the wrong MotionLayout state.
So the main problem is:
ViewHolders outside of the screen are not updated correctly when transition from one MotionLayout state to another.
So here is the problem / What I've found so far:
I am using a RecyclerView.
It has only 1 item type which is a MotionLayout (so every item of the RV is a MotionLayout).
This MotionLayout has 2 states, let's call them State big and State small
All items should always have the same State. So whenever the state is switched for example from big => small then ALL items should be in small from then on.
But what happens is that the state changes to small and most(!) of the items are also updated correctly. But one or two items are always left with the old State. I am pretty sure it has to do with recycled ViewHolders. These steps produce the issue reliably when using the adapter code below (not in the sample project):
swipe from item 1 to the right to item 2
change from big to small
change back from small to big
swipe from item 2 to the left to item 1
=> item 1 is now in the small state, but should be in the big state
Additional findings:
After step 4 if I continue swiping to the left, there comes 1 more item in the small state (probably the recycled ViewHolder from step 4). After that no other item is wrong.
Starting from step 4, I continue swiping for a few items (let's say 10) and then swipe all the way back, no item is in the wrong small state anymore. The faulty recycled ViewHolder seems to be corrected then.
What did I try?
I tried to call notifyDataSetChanged() whenever the transition has completed
I tried keeping a local Set of created ViewHolders to call the transition on them directly
I tried to use data-binding to set the motionProgress to the MotionLayout
I tried to set viewHolder.isRecycable(true|false) to block recycling during the transition
I searched this great in-depth article about RVs for hint what to try next
Anyone had this problem and found a good solution?
Just to avoid confusion: big and small does not indicate that I want to collapse or expand each item! It is just a name for different arrangement of the motionlayouts' children.
class MatchCardAdapter() : DataBindingAdapter<Match>(DiffCallback, clickListener) {
private val viewHolders = ArrayList<RecyclerView.ViewHolder>()
private var direction = Direction.UNDEFINED
fun setMotionProgress(direction: MatchCardViewModel.Direction) {
if (this.direction == direction) return
this.direction = direction
viewHolders.forEach {
updateItemView(it)
}
}
private fun updateItemView(viewHolder: RecyclerView.ViewHolder) {
if (viewHolder.adapterPosition >= 0) {
val motionLayout = viewHolder.itemView as MotionLayout
when (direction) {
Direction.TO_END -> motionLayout.transitionToEnd()
Direction.TO_START -> motionLayout.transitionToStart()
Direction.UNDEFINED -> motionLayout.transitionToStart()
}
}
}
override fun onBindViewHolder(holder: DataBindingViewHolder<Match>, position: Int) {
val item = getItem(position)
holder.bind(item, clickListener)
val itemView = holder.itemView
if (itemView is MotionLayout) {
if (!viewHolders.contains(holder)) {
viewHolders.add(holder)
}
updateItemView(holder)
}
}
override fun onViewRecycled(holder: DataBindingViewHolder<Match>) {
if (holder.adapterPosition >= 0 && viewHolders.contains(holder)) {
viewHolders.remove(holder)
}
super.onViewRecycled(holder)
}
}
I made some progress but this is not a final solution, it has a few quirks to polish. Like the animation from end to start doesn't work properly, it just jumps to the final position.
https://github.com/fmatosqg/SampleRecyclerView/commit/907ec696a96bb4a817df20c78ebd5cb2156c8424
Some things that I changed but are not relevant to the solution, but help with finding the problem:
made duration 1sec
more items in recycler view
recyclerView.setItemViewCacheSize(0) to try to keep as few unseen items as possible, although if you track it closely you know they tend to stick around
eliminated data binding for handling transitions. Because I don't trust it in view holders in general, I could never make them work without a bad side-effect
upgraded constraint library with implementation "androidx.constraintlayout:constraintlayout:2.0.0-rc1"
Going into details about what made it work better:
all calls to motion layout are done in a post manner
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
Compared actual vs desired properties
val goalProgress =
if (currentState) 1f
else 0f
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
if (motionLayout.currentState != desiredState) {
safeRunBlock {
startTransition(currentState)
}
}
}
This would be the full class of the partial solution
class DataBindingViewHolder<T>(private val binding: ViewDataBinding) :
RecyclerView.ViewHolder(binding.root) {
val motionLayout: MotionLayout =
binding.root.findViewById<MotionLayout>(R.id.root_item_recycler_view)
.also {
it.setTransitionDuration(1_000)
it.setDebugMode(DEBUG_SHOW_PROGRESS or DEBUG_SHOW_PATH)
}
var lastPosition: Int = -1
fun bind(item: T, position: Int, layoutState: Boolean) {
if (position != lastPosition)
Log.i(
"OnBind",
"Position=$position lastPosition=$lastPosition - $layoutState "
)
lastPosition = position
setMotionLayoutState(layoutState)
binding.setVariable(BR.item, item)
binding.executePendingBindings()
}
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
fun setMotionLayoutState(currentState: Boolean) {
val goalProgress =
if (currentState) 1f
else 0f
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
if (motionLayout.currentState != desiredState) {
Log.i("Pprogress", "Desired doesn't match at position $lastPosition")
safeRunBlock {
startTransition(currentState)
}
}
}
}
fun startTransition(currentState: Boolean) {
if (currentState) {
motionLayout.transitionToStart()
} else {
motionLayout.transitionToEnd()
}
}
}
Edit: added constraint layout version

ViewSwitcher not switching view with .showNext() at initialization, after Livedata change is observed

I have a ViewSwitcher inside a DialogFragment. The switcher holds 2 views, a "1. selection view" and a "2. selected view".
The initial view is the defaulted to "1. selection view". When fromType is non-zero from the observing event database, I want the Livedata observer to call dialogViewSwitcher.showNext(), but the showNext() function is not called on dialog's initialization.
DialogFragment, inside onCreateDialog:
viewModel.event.observe(this, Observer {
it?.let {
viewModel.setCurrentFromType(it.fromPlaceType)
}
})
viewModel.currentFromType.observe(this, Observer {
if (it == 0) {
viewModel.setCurrentFromStatus(false)
} else {
viewModel.setCurrentFromStatus(true)
}
})
viewModel.currentFromStatus.observe(this, Observer {
binding.dialogViewSwitcher.showNext()
})
ViewModel:
private val _currentFromType = MutableLiveData(0)
val currentFromType: LiveData<Int>
get() = _currentFromType
fun setCurrentFromType(fromType: Int) {
_currentFromType.value = fromType
}
private val _currentFromStatus = MutableLiveData(false)
val currentFromStatus: LiveData<Boolean>
get() = _currentFromStatus
fun setCurrentFromStatus(status: Boolean) {
_currentFromStatus.value = status
}
Log shows the currentFromStatus observed the change (from false to true) when the dialog opens, but I'm thinking that because the dialogViewSwitcher hasn't initialized yet inside onCreatDialog when the change to currentFromStatus was observed, so showNext() did nothing. I also looked into data binding and didn't find a ViewSwitcher property in xml that shows the second view. What should I do to fix this behaviour?
Didn't find a good way to programmatically showNext() at onCreate. Instead I deleted the viewswitcher and use databinding on the visibility of each of the 2 child views, and this way works well.

Categories

Resources