Android/Kotlin - LAGGY Checking item in BottomNavigationView from ViewPager - android

I am fairly new to programming, and this is my first large/real project.
--Background--
I have a ViewPager set up within my MainActivity, that slides between 3 different fragments. In Addition, one fragment requires menu items within the toolbar, and a BottomNavigationView is held within the MainActivity.
Each time i move to a new item/page of the ViewPager, i want to select(check) the corresponding page item within the BottomNavView.
I have attempted multiple configurations of the one below, but can't get it perfect
--The Problem--
The time it takes to perform bottom_navigation.menu.getItem(pos).isChecked = true is too long... Approx 50ms on my HTC One, or 100ms in the emulator.
This delay causes the ViewPager scrolling action to be jarred/laggy.
I would like to know if there is a different way to check or highlight the corresponding item within the BottomNavBar, or more desirably, perform the checking action at the same time without impeding the scrolling animation of the ViewPager.
--Attempted Fixes--
Perform check within onPageSelected - (Result is slightly more laggy)
Perform check when scroll state is IDLE - (Results in smooth pager scroll, but the checked item is 'left behind' and updated too late, particularly when scrolling fast)
Find the navigation item in Anko's doAsync{ } - (Results don't change)
// Adds a page-change listener
viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
// Set Toolbar title and menu items
override fun onPageSelected(position: Int) {
when (position) {
0 -> {
supportActionBar!!.title = "Page1"
toolbar.menu.setGroupVisible(0, false)
}
1 -> {
supportActionBar!!.title = "Page2"
if (toolbar.menu.size() == 0) {
toolbar.inflateMenu(R.menu.toolbar_list_menu)
} else {
toolbar.menu.setGroupVisible(0, true)
}
}
2 -> {
supportActionBar!!.title = "Page3"
toolbar.menu.setGroupVisible(0, false)
}
}
}
// While view pager settling on new item/page, check the correct bottom navigation view item
override fun onPageScrollStateChanged(state: Int) {
if (state == ViewPager.SCROLL_STATE_SETTLING) {
val pos = viewPager.currentItem
bottom_navigation.menu.getItem(pos).isChecked = true
}
}
// Not needed, must be implemented
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
})
I really hope someone has an answer to help with my apps performance :)
Thanks for your time.

Related

ViewPager with custom pages - kotlin.UninitializedPropertyAccessException

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")
}

Kotlin Recyclerview Stack Layout Type showing wrong views

I am attempting to write a recyclerview which has some of the Viewholders inside it as stacked ontop of one another. The idea is that you can drag the topmost view above the stacked list and have drop it above where it becomes separate.
I managed to get this working using a Recyclerview with a custom RecyclerView.ItemDecoration. However, after I drop the item, i have the adapter call notifyDataSetChange to update the background code. This causes the the next item in the stack to appear to be the wrong one (though this does change sometimes if you touch the item and start scrolling, then it displays the correct one).
The custom RecyclerView.ItemDecoration class:
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
)
{
val itemPosition = parent.getChildAdapterPosition(view)
val adapter = parent.adapter
if (adapter is BaseRecVAdapter)
{
val item = adapter.getDataModel(itemPosition)
if (item is DragDropModel && item.mStackedPos != PMConsts.negNum)
{
if (item.mStackedPos != 0)
{
val context = view.context
val top = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 148f, context.resources.displayMetrics).toInt()
outRect.set(0, -top, 0, 0)
return
}
}
}
super.getItemOffsets(outRect, view, parent, state)
}
The drag interface I made for the Adapter and the ItemTouchHelper.Callback can be found below:
interface ItemTouchHelperListener
{
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
fun onClearView(recyclerView: RecyclerView?, viewHolder: RecyclerView.ViewHolder?)
}
The onItem move code is as follows:
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
{
var newToPosition = toPosition
if (toPosition <= mDragUpLimit)
{//Prevent items from being dragged above maximum movement.
newToPosition = mDragUpLimit + 1
}
else if (toPosition >= mDragDownLimit)
{//Cannot drag below stacked List...
newToPosition = mDragDownLimit - 1
}
if (fromPosition < newToPosition)
{
for (i in fromPosition until newToPosition)
{
swap(mDataList, i, i + 1)
}
}
else
{
for (i in fromPosition downTo newToPosition + 1)
{
swap(mDataList, i, i - 1)
}
}
notifyItemMoved(fromPosition, newToPosition)
return true
}
I have a simple viewholder which is an invisible bar which i mark as the position you need to drag above in order to make a valid change to the list order.
I have the code call notifyDataSetChanged after the onClearView() method is called as I need to update the background features so that the next item in the stack is draggable and the background data feeding into the adapter is also updated. It seems the simplest way to keep the data updating smoothly, but I wonder if it is causing my problems
If someone would be able to give me a hand with this, I would be most grateful. I am tearing my hair out somewhat. I thought I had a good system setup but it was not quite working. I hope that this is enough information to get some help with this issue.
Thank you in advance

ViewPager2 selectCurrentItem - select Tab, but place wrong Fragment inside this tab

I have basic ViewPager2 with Tablayout - and in each page I have different fragments.
When I need to open this view NOT from first (default) tab I'm doing smth like this:
viewPager.currentItem = selectedTabPosition
This code select tab, but inside it is opening fragment from first tab! Only when I'm selecting the tabs by tapping on it - I can see the right fragment in each tabs.
I've also try to select with Tablayout like this:
tabLayout.getTabAt(position)?.select()
But this code doesn't help and also work with this bug.
Also I've try to set viewPager.currentItem with post / postDelay - but this doesn't work too.
Maybe I've lost something? Or it is a bug in ViewPager2 ?
(EDIT - ViewPager code)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupPagerAdapter()
}
private fun setupPagerAdapter() {
val adapter = MainDocumentScreenPagerAdapter(this)
binding?.viewPager?.letUnit {
it.adapter = adapter
binding?.tabsPagerView?.attachViewPager(requireContext(), it, adapter)
// set tab
it.currentItem = params.pageType.ordinal
}
Adapter code
class MainDocumentScreenPagerAdapter (fragment: Fragment) : ViewPager2TitleAdapter(fragment) {
override fun getItemCount(): Int = DocumentPageType.values().size
override fun createFragment(position: Int): Fragment {
val pageType = DocumentPageType.values().firstOrNull { it.ordinal == position } ?: throw IllegalStateException()
val params = DocumentListFragment.createParams(pageType)
return DocumentListFragment.newInstance(params)
}
override fun getPageTitle(position: Int): Int? {
return when (position) {
DocumentPageType.ALL.ordinal -> DocumentPageType.ALL.title
DocumentPageType.SIGN.ordinal -> DocumentPageType.SIGN.title
DocumentPageType.ACCEPT.ordinal -> DocumentPageType.ACCEPT.title
DocumentPageType.CONFIRM.ordinal -> DocumentPageType.CONFIRM.title
DocumentPageType.REJECT.ordinal -> DocumentPageType.REJECT.title
else -> null
}
}
Where ViewPager2TitleAdapter is :
abstract class ViewPager2TitleAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
abstract fun getPageTitle(position: Int): Int?
DocumentListFragment inside it create view, based on param object.
I've also try to create adapter inside OnCreate - but it doesn't affect this case.
The last, but not least - when I've try to open tab, which is out from screen (I've scrollable tabs) - viewPager open selected tab with correct fragment on it.. So, the problem occurs only when I've try to open first 4 tabs (look at the image), which is showing on screen. Bit starting from 5 and next tabs - has selected correct.
So, the decision is in this line of code:
it.setCurrentItem(params.pageType.ordinal, false)
but I was doing it like this:
it.currentItem = params.pageType.ordinal
boolean false make magic in this case - It disable smooth scroll. I've got it from this answer about ViewPager2:
https://stackoverflow.com/a/67319847/4809482
I think an easier more reliable fix is to defer to next run cycle instead of unsecure delay e.g
viewPager.post {
viewPager.setCurrentItem(1, true)
}

How to prevent loading all Tabs in Tablayout with Viewpager2 during initialization via TabLayoutMediator?

I have a Viewpager2 set up with Tablyout via TabLayoutMediator. Adapter for the Viewpager2 is RecyclerView.Adapter
I am initializing Viewpager2 and Tablayout, and loading image into tabs as below:
myViewPager.apply {
offscreenPageLimit = 6
adapter = myRecycerViewAdapter
}
TabLayoutMediator(myTabLayout, myViewPager) { tab, position ->
val tabView: View =
LayoutInflater.from(context).inflate(R.layout.tab_magazine_preview, null)
Glide.with(context)
.load(urls[position])
.into(tabView.imageView)
tab.customView = tabView
}.attach()
Now this implementation creates all tabs during initializing and loads all urls into imageview in tabs. This is resource consuming approach as user might not swipe to all the way till the end. And If I have huge list of urls, this is even more resource consuming.
There is nice feature of Viewpager2 - offscreenPageLimit which helps to load only certain amount of adjacent pages. Can I have similar feature for Tablayout?
I want to load only ceratin amount of adjacent tabs at time as user swipes.
You can detect swipe gestures on TabLayout using View.OnScrollChangeListener
and then load images, but quick test showed me that you need to load images manually on initialization for visible tabs, because there was no scrolling.
tabLayout.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY ->
if (v is TabLayout) {
val scrollBounds = Rect()
v.getHitRect(scrollBounds)
for (i in 0 until v.tabCount) {
if (v.getTabAt(i)!!.view.getLocalVisibleRect(scrollBounds)) {
Log.i("!!!!!!!!!!", "child visible at: " + i);
// check if image is not loaded/loading and load
} else {
Log.i("!!!!!!!!!!", "child invisible: " + i);
}
}
}
}
OLD ANSWER. I misunderstood the question:
You can do that by listening for ViewPager2.OnPageChangeCallback and telling desired fragments to load their images based on page position.
Make sure your page fragment implements interface that allows it to load image:
interface ILoadImage {
fun loadImage()
}
class MyPageFragment : Fragment(), ILoadImage {
private var isImageLoaded = false
override fun loadImage() {
if (isImageLoaded) return
/*
... load image
*/
isImageLoaded = true
}
}
Then inside your myRecycerViewAdapter create function that will accept selected page position and update fragments
class MyRecycerViewAdapter /* ... */ {
val fragments: List<ILoadImage> = //...
fun loadImagesForPosition(position: Int) {
fragments[position].loadImage()
// also you can load images for next or prev fragments
}
}
And finally listen for OnPageChangedCallback and tell adapter to update fragments
myViewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
myRecycerViewAdapter.loadImagesForPosition(position)
}
})

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

Categories

Resources