Toolbar animateLayoutChanges strange behavior - android

I set animateLayoutChanges=true in my code, to animate the options item I have in my fragments.
When I open the PreferenceFragment I enable the up navigation icon, then I disable it when I close it, but it behaves in a strange way, leaving a sort of padding to the left of the title, where the NavigationIcon was.
Here's a gif showing what's happening:
Do you guy's have any idea on why is this happening?
I searched far and wide on the internet, but found nothing.
Is there any workaround to animate these items in the same way?
Thanks.

Remark: I know this thread in more than a year long but a lot of people still stumble on this problem.
It took me good few hours to finally dig into this problem and solution is actually relatively easy.
The Scenario
When you call ActionBar.setDisplayHomeAsUpEnabled() or Toolbar.setNavigationIcon(), Toolbar will dynamically add or remove navigation view depending on specified value
public void setNavigationIcon(#Nullable Drawable icon) {
if (icon != null) {
ensureNavButtonView();
if (!isChildOrHidden(mNavButtonView)) {
addSystemView(mNavButtonView, true);
}
} else if (mNavButtonView != null && isChildOrHidden(mNavButtonView)) {
removeView(mNavButtonView);
mHiddenViews.remove(mNavButtonView);
}
if (mNavButtonView != null) {
mNavButtonView.setImageDrawable(icon);
}
}
And in onLayout it will check whether navigation button is present and lay it out.
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// more code before
if (shouldLayout(mNavButtonView)) {
if (isRtl) {
right = layoutChildRight(mNavButtonView, right, collapsingMargins,
alignmentHeight);
} else {
left = layoutChildLeft(mNavButtonView, left, collapsingMargins,
alignmentHeight);
}
}
// more code after. also mTitleTextView is laid out here
}
Toolbar also increments left/right position after laying out each view. These values used as an offset for each subsequent view.
To check whether view should be laid out it uses helper function:
private boolean shouldLayout(View view) {
return view != null && view.getParent() == this && view.getVisibility() != GONE;
}
The Problem
When setting layoutTransition through the code or animateLayoutChanges in the xml we change behaviour a little bit.
Lets start with what happening when we remove child from parent (ViewGroup code):
private void removeFromArray(int index) {
final View[] children = mChildren;
if (!(mTransitioningViews != null && mTransitioningViews.contains(children[index]))) {
children[index].mParent = null;
}
// more code after
}
Did you mention that child's parent is not nulled when we have transition set? This is important!
Also please recall what shouldLayout from above does - it checks for only three cases:
view is not null
view's parent is toolbar
view is not GONE
And here is the root of the problem - navigation button is removed from the toolbar but it doesn't event know about this.
The Solution
In order to fix this problem we need to force toolbar into thinking that view shouldn't be laid out. Ideally it would be nice to override shouldLayout method but we don't have such luxury... So the only way we can accomplish this is by changing visibility on the navigation button.
class FixedToolbar(context: Context, attrs: AttributeSet?) : MaterialToolbar(context, attrs) {
companion object {
private val navButtonViewField = Toolbar::class.java.getDeclaredField("mNavButtonView")
.also { it.isAccessible = true }
}
override fun setNavigationIcon(icon: Drawable?) {
super.setNavigationIcon(icon)
(navButtonViewField.get(this) as? View)?.isGone = (icon == null)
}
}
Now toolbar will know that navigation button must not be laid out and all animations will go as intended.

I found a workaround to get the transition to work.
After calling supportActionBar?.setDisplayHomeAsUpEnabled(false) to hide the back arrow button, call the TransitionManager.beginDelayedTransition() method to trigger the transition manually.
// Hide the back arrow button
supportActionBar?.setDisplayHomeAsUpEnabled(false)
// Add a transition listener to the toolbar to set the toolbar title later
mToolbar.layoutTransition.addTransitionListener(object : LayoutTransition.TransitionListener {
override fun startTransition(transition: LayoutTransition?, container: ViewGroup?, view: View?, transitionType: Int) {}
override fun endTransition(transition: LayoutTransition?, container: ViewGroup?, view: View?, transitionType: Int) {
setToolbarTitle("Agenda")
mToolbar.layoutTransition.removeTransitionListener(this)
}
})
TransitionManager.beginDelayedTransition(mToolbar)
BEFORE
AFTER

Even with this questions more than a year back, this issue is still happening with the version androidx.navigation:navigation-ui-ktx:2.4.2
According to #MatrixDev, yes, this is still the final solution is:
class FixedToolbar(context: Context, attrs: AttributeSet?) : MaterialToolbar(context, attrs) {
companion object {
private val navButtonViewField = Toolbar::class.java.getDeclaredField("mNavButtonView")
.also { it.isAccessible = true }
}
override fun setNavigationIcon(icon: Drawable?) {
super.setNavigationIcon(icon)
(navButtonViewField.get(this) as? View)?.isGone = (icon == null)
}
}

Related

Android RecycleView how to disable manual scroll but allow item clicked

I am working on an idea, which is make a RecyclerView auto scrolling but allow user to click item without stop scrolling.
First, I create a custom LayoutManager to disable manual scroll, also change the speed of scroll to a certain position
class CustomLayoutManager(context: Context, countOfColumns: Int) :
GridLayoutManager(context, countOfColumns) {
// Custom smooth scroller
private val smoothScroller = object : LinearSmoothScroller(context) {
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float =
500f / displayMetrics.densityDpi
}
// Disable manual scroll
override fun canScrollVertically(): Boolean = false
// Using custom smooth scroller to control the duration of smooth scroll to a certain position
override fun smoothScrollToPosition(
recyclerView: RecyclerView,
state: RecyclerView.State?,
position: Int
) {
smoothScroller.targetPosition = position
startSmoothScroll(smoothScroller)
}
}
Then I do the initial work for the RecyclerView and start smooth scroll after 1 sec
viewBinding.list.apply {
// initial recycler view
setHasFixedSize(true)
customLayoutManager = CustomLayoutManager(context = context, countOfColumns = 2)
layoutManager = customLayoutManager
// data list
val dataList = mutableListOf<TestModel>()
repeat(times = 100) { dataList.add(TestModel(position = it, clicked = false)) }
// adapter
testAdapter =
TestAdapter(clickListener = { testAdapter.changeVhColorByPosition(position = it) })
adapter = testAdapter
testAdapter.submitList(dataList)
// automatically scroll after 1 sec
postDelayed({ smoothScrollToPosition(dataList.lastIndex) }, 1000)
}
Everything goes as my expected until I found that the auto scrolling stopped when I clicked on any item on the RecycelerView, the function when clickListener triggered just change background color of the view holder in TestAdapter
fun changeVhColor(position: Int) {
position
.takeIf { it in 0..itemCount }
?.also { getItem(it).clicked = true }
?.also { notifyItemChanged(it) }
}
here is the screen recording screen recording
issues I encounter
auto scrolling stopped when I tap any item on the ReycelerView
first tap make scrolling stopped, second tap trigger clickListener, but I expect to trigger clickListener by one tap
Can anybody to tell me how to resolve this? Thanks in advance.
There is a lot going on here. You should suspect the touch handling of the RecyclerView and, maybe, the call to notifyItemChanged(it), but I believe that the RecyclerView is behaving correctly. You can look into overriding the touch code in the RecyclerView to make it do what you want - assuming you can get to it and override it.
An alternative would be to overlay the RecyclerView with another view that is transparent and capture all touches on the transparent view. You can then write code for the transparent view that interacts with the RecyclerView in the way that meets your objectives. This will also be tricky and you will have to make changes to the RecyclerView as it is constantly layout out views as scrolling occurs. Since you have your own layout manager, this might be easier if you queue changes to occur pre-layout as scrolling occurs.
After tried several ways, found that the key of keep recycler view scrolling automatically is override onInterceptTouchEvent
Example
class MyRecyclerView #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : RecyclerView(context, attrs, defStyle) {
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean = false
}
that will make the custom RecyclerView ignore all touch event

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

How can I cancel the buttons contained in a LinearLayout from listening to clicks temporarily?

I have a bunch of buttons that depending on the state of the application don't make sense to listen to events. Is there a property/method on the LinearLayout or some other method that prevents its children buttons from listening to events? Of course I could go I search for a way to detach the events for each a every button but of course I want to go the easy and most handy way to do that. Also I could disable all the buttons. I've learned that disabled buttons don't listen to events in the hard way, when I tried to show a tost for a disabled button.
There is a way which you can extend LinearLayout and override its onInterceptTouchEvent method and return true if you want to steal touch events from its children or false if you want its children to receive touch events. Something like this
class CustomLinearLayout extends LinearLayout {
//...
private preventTouchOnChildren = false;
public void setPreventTouchOnChildren(boolean value) {
preventTouchOnChildren = value;
}
public boolean onInterceptTouchEvent (MotionEvent ev) {
if (preventTouchOnChildren)
return true;
else
return false;
}
//...
}
ALTERNATIVE 1
Updated answer!
I have tested setting all click listeners to all view hierarchy as follows, using kotlin extension function on View class
private fun View.dissableClick() {
setOnClickListener(null)
if (this is ViewGroup) {
for (v in children) {
v.setOnClickListener(null)
}
}
}
So you can call root.dissableClick()
You can also try using isEnabled = false and v.isEnabled = false in place of setOnClickListener(null)
ALTERNATIVE 2 (Credit #Amin Mousavi Answer)
Also from this documentation Manage touch events in a ViewGroup. You need to create a custom view group in your case a custom LinearLayout
class InterceptLinearLayout #JvmOverloads constructor(
context: Context, attr: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attr, defStyleAttr) {
var intercept = false
override fun onInterceptTouchEvent(ev: MotionEvent?) = when (intercept) {
true -> true
else -> super.onInterceptTouchEvent(ev)
}
}
Note: If you don't provide overloads for all super constructors your app is going to crash
Change your xml from <LinearLayout ... to <package.InterceptLinearLayout ...
On your Activity grab reference to InterceptLinearLayout
root.intercept = true
Both solutions worked for me

How to add marquee animation to icon titles on Bottom Navigation View?

I was wondering if there is a way that makes the bottom navigation bar icon titles to scroll like marquee. I have seen some apps like that and tried to find some ways but couldn't find any. so if anyone could help me i would appreciate it.
There is the simplest solution. Works on Material Components versions 1.0.0 - 1.2.0-alpha02.
fun BottomNavigationView.findAllLabels(): Sequence<TextView> {
return sequence {
children.forEach { bottomNavigationChild ->
if (bottomNavigationChild is ViewGroup) {
bottomNavigationChild.children.forEach {
val smallLabel = it.findViewById<TextView>(
com.google.android.material.R.id.smallLabel
)
yield(smallLabel)
val largeLabel = it.findViewById<TextView>(
com.google.android.material.R.id.largeLabel
)
yield(largeLabel)
}
}
}
}
}
// Your BottomNavigationView
bottomNavigationView.findAllLabels().forEach {
it.apply {
ellipsize = TextUtils.TruncateAt.MARQUEE
setSingleLine(true)
// For infinite loop, comment if it's not required
marqueeRepeatLimit = -1
}
}

How to change view of expandable list view children when parent is collapsed in Android?

I am implementing a selection mode in ExpandableListView. The selection toggles when I click a child. I have a CheckBox in each parent, from which I want to control the selection of all the children at once.
My problem is that when the parent is collapsed and I click its CheckBox, the app crashes due to null pointer exception because when I try to change the selection of the children, I can't find the children and get null. But everything works fine when the parent is expanded.
So, what is a good approach to tackle such kind of problem?
I solved by adding some lines of code without changing the previous code, so this answer may be helpful for someone who doesn't want to rewrite the code with a different approach.
In the calling Fragment or Activity, where the Adapter is being set, add:
private val isMyGroupExpanded = SparseBooleanArray()
val MyAdapterEV = AdapterEV(/* params */) { isChecked, groupPosition ->
changeSelection(isChecked, groupPosition)
}
// record which groups are expanded and which are not
MyAdapterEV.setOnGroupExpandListener { i -> isMyGroupExpanded.put(i, true) }
MyAdapterEV.setOnGroupCollapseListener { i -> isMyGroupExpanded.put(i, false) }
// and where changing the selection of child
private fun changeSelection(isChecked: Boolean, groupPosition: Int) {
if (isMyGroupExpanded.get(groupPosition, false)) {
/* change only if the group is expanded */
}
}
So, the children of the collapsed group are not affected, but they are needed to be changed when the group expands, for that, add some lines of code in the Adapter:
private val isGroupChecked = SparseBooleanArray()
// inside override fun getGroupView(...
MyCheckBoxView.setOnCheckedChangeListener { _, isChecked ->
onCheckedChangeListener(isChecked, groupPosition)
isGroupChecked.put(groupPosition, isChecked)
}
// inside override fun getChildView(...
if (isGroupChecked.contains(groupPosition)) {
myView.visibility = if (isGroupChecked.get(groupPosition)) View.VISIBLE else View.INVISIBLE
}

Categories

Resources