I want to animate a view's width on the swipe of ViewPager (Scale a button on the screen).
I'm using the following function to animate the width:
fun View.animateWidth(targetWidth: Int, duration) {
val prevWidth = width
visibility = View.VISIBLE
val valueAnimator = ValueAnimator.ofInt(prevWidth, targetWidth)
valueAnimator.addUpdateListener { animation ->
layoutParams.width = animation.animatedValue as Int
this#animateWidth.requestLayout()
}
valueAnimator.duration = duration
valueAnimator.start()
}
I'm calling this function on every update that I'm getting from onPageScrollListener of my ViewPager. The problem is that the UI is not getting updated until either I stop touching/ moving my fingers or I get to the next page of the screen. It seems that the UI is blocking my animation until I finish my touch moves.
Any idea?
Edit: This is where I call the animateWidth function:
viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrollStateChanged(state: Int) {}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
button.animateWidth(
(fullWidth * positionOffset).toInt(), 1)
}
override fun onPageSelected(position: Int) { }
})
Related
I'm using a custom animator to animate two recycler in two different fragments with custom animations. Everything works, except a minor and a major issue. First thing first, here's the custom animator:
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.util.Log
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
// A custom animator to animate the items in recycler view
class RecyclerAnimator : SimpleItemAnimator() {
// Never called
override fun animateRemove(viewHolder: RecyclerView.ViewHolder): Boolean {
viewHolder.itemView.animate()
.alpha(0F)
.setInterpolator(FastOutSlowInInterpolator())
.setStartDelay(200)
.setDuration(300)
.scaleY(0F)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
dispatchRemoveFinished(viewHolder)
}
})
.start()
Log.d("recycler_animation", "animate remove")
return false
}
// Called when the items appear in the list (launch, fragment change, it was created)
override fun animateAdd(viewHolder: RecyclerView.ViewHolder): Boolean {
val height: Int = viewHolder.itemView.measuredHeight / 3
val view = viewHolder.itemView
view.translationY = height.toFloat()
view.alpha = 0F
view.scaleY = 1F
view.animate()
.translationY(0F)
.alpha(1F)
.setInterpolator(FastOutSlowInInterpolator())
.setDuration(400)
.setStartDelay(viewHolder.bindingAdapterPosition * 50L)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
dispatchAddFinished(viewHolder)
}
})
.start()
Log.d("recycler_animation", "animate add")
return false
}
// Called when an item is being moved to another position in the adapter
override fun animateMove(
viewHolder: RecyclerView.ViewHolder,
fromX: Int, fromY: Int,
toX: Int, toY: Int
): Boolean {
val item = viewHolder.itemView
item.y = fromY.toFloat()
val verticalMovement = if (toY > fromY)
(toY - fromY).toFloat() - (item.measuredHeight)
else (fromY - toY).toFloat() - (item.measuredHeight)
item.animate()
.translationY(verticalMovement)
.setDuration(300)
.setInterpolator(FastOutSlowInInterpolator())
.setStartDelay(200)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
dispatchMoveFinished(viewHolder)
}
})
.start()
Log.d("recycler_animation", "animate move")
return false
}
// Called when an item changes its data
override fun animateChange(
oldHolder: RecyclerView.ViewHolder,
newHolder: RecyclerView.ViewHolder,
fromLeft: Int, fromTop: Int,
toLeft: Int, toTop: Int
): Boolean {
newHolder.itemView.alpha = 0F
val oldAnimation = oldHolder.itemView.animate()
.alpha(0F)
.setInterpolator(FastOutSlowInInterpolator())
.setDuration(300)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
dispatchChangeFinished(oldHolder, true)
}
})
val newAnimation = newHolder.itemView.animate()
.alpha(1F)
.setInterpolator(FastOutSlowInInterpolator())
.setDuration(300)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
dispatchChangeFinished(newHolder, false)
}
})
oldAnimation.start()
newAnimation.start()
Log.d("recycler_animation", "animate change")
return false
}
// Called when an item is deleted from the adapter
override fun animateDisappearance(
viewHolder: RecyclerView.ViewHolder,
preLayoutInfo: ItemHolderInfo,
postLayoutInfo: ItemHolderInfo?
): Boolean {
viewHolder.itemView.animate()
.alpha(0F)
.setInterpolator(FastOutSlowInInterpolator())
.setDuration(400)
.setStartDelay(100)
.scaleY(0F)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
dispatchRemoveFinished(viewHolder)
viewHolder.itemView.alpha = 1F
viewHolder.itemView.scaleY = 1F
}
})
.start()
Log.d("recycler_animation", "animate disappearance")
return false
}
override fun runPendingAnimations() {
Log.d("recycler_animation", "pending animations")
}
override fun endAnimation(viewHolder: RecyclerView.ViewHolder) {
Log.d("recycler_animation", "end animation")
val item = viewHolder.itemView
item.alpha = 1F
item.translationY = 0F
item.scaleY = 1F
}
override fun endAnimations() {
Log.d("recycler_animation", "end animation no arg")
}
override fun isRunning(): Boolean {
return false
}
}
First, major issue: i use two different viewholder layouts in one of the recycler views, and when the elements are moved (animateMove) the shorter elements are moved in the wrong position both when they move up or down. My guess is that the "measured height" is wrong, but i have no idea why. (This issue can be observed in the first part of the gif)
Second, minor issue: since i use a delay to make the items appear gradually, when adding an item back in the list, if the item is in the upper part the animation has no delay, while if the item is in the lower part, the animation starts with a delay based on its position in the list. (This issue can be observed in the second part of the gif)
Any help is well appreciated, since i'm close to the desired effect
On the major issue, may be due to measuredHeight vs height.
From the View page. "A view actually possess two pairs of width and height values." "measured width and measured height. These dimensions define how big a view wants to be" "width and height. These dimensions define the actual size of the view on screen" Because you are calling measuredHeight, the actual height of the item in the final layout may not agree.
On the minor issue, have you tried returning "true" from the animate functions? The wording in the doc is really confusing, yet could be interpreted as "true if a call to runPendingAnimations is requested, false if start all animations together in a later call to runPendingAnimations"
I have a list item with three swipe actions which looks like this:
The regular list item and the buttons are two different layouts defined in xml.
To reveal the button actions I use ItemTouchHelper.SimpleCallback. In onChildDraw I tell the item list item's x-axis to be only drawn until it reaches the width of the button controls.
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
val foreground = (viewHolder as? NachrichtViewHolder)?.binding?.nachrichtListItem
val background = (viewHolder as? NachrichtViewHolder)?.binding?.background
val x: Float = when {
dX.absoluteValue > background?.measuredWidth?.toFloat() ?: dX -> background?.measuredWidth?.toFloat()
?.unaryMinus() ?: dX
else -> dX
}
getDefaultUIUtil().onDraw(
c,
recyclerView,
foreground,
x,
dY,
actionState,
isCurrentlyActive
)
}
Here is an abbreviated layout file demonstrating the way I built the ui:
<FrameLayout
android:id="#+id/container"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="#+id/background"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
android:clickable="#{backgroundVisible}"
android:focusable="#{backgroundVisible}"
android:focusableInTouchMode="#{backgroundVisible}"
android:elevation="#{backgroundVisible ? 4 : 0}">
<ImageButton
android:id="#+id/actionReply"/>
<ImageButton
android:id="#+id/actionShare"/>
<ImageButton
android:id="#+id/actionDelete"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="#+id/nachrichtListItem"
android:elevation="#{backgroundVisible ? 0 : 4}"
android:clickable="#{!backgroundVisible}"
android:focusable="#{!backgroundVisible}"
android:focusableInTouchMode="#{!backgroundVisible}">
<!-- regular list item -->
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
My problem is that the buttons are not clickable.
What I tried so far:
set elevation to bring element on top
set items clickable depending on the visibility state of the buttons
This can be seen in the layout file. I want to define the elements inside xml and not draw them manually if possible.
The problem is solved. ItemTouchHelper.SimpleCallback swallows all your touch events. So you need to register a TouchListener for the buttons. The buttons come in my case from xml. Inspired by this I came up with the following solution:
#SuppressLint("ClickableViewAccessibility")
class NachrichtItemSwipeCallback(private val recyclerView: RecyclerView) :
ItemTouchHelper.SimpleCallback(0, LEFT) {
private val itemTouchHelper: ItemTouchHelper
private var binding: ListItemNachrichtBinding? = null
private var lastSwipedPosition: Int = -1
init {
// Disable animations as they don't work with custom list actions
(this.recyclerView.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
this.recyclerView.setOnTouchListener { _, touchEvent ->
if (lastSwipedPosition < 0) return#setOnTouchListener false
if (touchEvent.action == MotionEvent.ACTION_DOWN) {
val viewHolder =
this.recyclerView.findViewHolderForAdapterPosition(lastSwipedPosition)
val swipedItem: View = viewHolder?.itemView ?: return#setOnTouchListener false
val rect = Rect()
swipedItem.getGlobalVisibleRect(rect)
val point = Point(touchEvent.rawX.toInt(), touchEvent.rawY.toInt())
if (rect.top < point.y && rect.bottom > point.y) {
// Consume touch event directly
val buttons =
binding?.buttonActionBar?.children
.orEmpty()
.filter { it.isClickable }
.toList()
val consumed = consumeTouchEvents(buttons, point.x, point.y)
if (consumed) {
animateClosing(binding?.nachrichtListItem)
}
return#setOnTouchListener false
}
}
return#setOnTouchListener false
}
this.itemTouchHelper = ItemTouchHelper(this)
this.itemTouchHelper.attachToRecyclerView(this.recyclerView)
}
// Only for drag & drop functionality
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean = false
override fun onChildDraw(
canvas: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
binding = (viewHolder as? NachrichtViewHolder)?.binding
val foreground = binding?.nachrichtListItem
val background = binding?.buttonActionBar
val backgroundWidth = background?.measuredWidth?.toFloat()
// only draw until start of action buttons
val x: Float = when {
dX.absoluteValue > backgroundWidth ?: dX -> backgroundWidth?.unaryMinus() ?: dX
else -> dX
}
foreground?.translationX = x
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
this.lastSwipedPosition = viewHolder.adapterPosition
recyclerView.adapter?.notifyItemChanged(this.lastSwipedPosition)
}
private fun animateClosing(
foreground: ConstraintLayout?
) {
foreground ?: return
ObjectAnimator.ofFloat(foreground, "translationX", 0f).apply {
duration = DURATION_ANIMATION
start()
}.doOnEnd { applyUiWorkaround() }
}
// See more at https://stackoverflow.com/a/37342327/3734116
private fun applyUiWorkaround() {
itemTouchHelper.attachToRecyclerView(null)
itemTouchHelper.attachToRecyclerView(recyclerView)
}
private fun consumeTouchEvents(
views: List<View?>,
x: Int,
y: Int
): Boolean {
views.forEach { view: View? ->
val viewRect = Rect()
view?.getGlobalVisibleRect(viewRect)
if (viewRect.contains(x, y)) {
view?.performClick()
return true
}
}
return false
}
companion object {
private const val DURATION_ANIMATION: Long = 250
}
}
I use this code to listen to event ViewPager scroll:
playingMainViewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrollStateChanged(state: Int) {
}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
}
override fun onPageSelected(position: Int) {
}
})
In onPageScrolled function, positionOffset value move from 0 to 1.
So how to use value of positionOffset to create an animation an image move from top to bottom?
You should use https://developer.android.com/reference/android/support/v4/view/ViewPager.PageTransformer
This will give you scrolled offset for performing animations.
I have a custom adapter, and filter that I am currently implementing to filter a recycler view based on a simple substring search on my recycler view entries. Here is my adapter NotifyChanged() function, which updates the RecylerView, and my custom filter() function. Everything works great, except for the auto scrolling afterwards.
private fun notifyChanged() {
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return objects.size
}
override fun getNewListSize(): Int {
return temp.size
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return this#DiffRecyclerViewAdapter.areItemsTheSame(objects[oldItemPosition], temp[newItemPosition])
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return this#DiffRecyclerViewAdapter.areContentsTheSame(objects[oldItemPosition], temp[newItemPosition])
}
})
objects.clear()
objects.addAll(temp)
result.dispatchUpdatesTo(this)
}
fun filter(text : String){
val ob = original_objects as ArrayList<Category>
val filtered_categories = ArrayList<T>() as ArrayList<Category>
for (category in ob){
//val temp_category = category
val list_of_subcategories = ArrayList<T>() as ArrayList<Category>
for (subcategory in category.categories){
val name_of_category = subcategory.name.toLowerCase()
if (name_of_category.contains(text)){
list_of_subcategories?.add(subcategory)
}
}
if (list_of_subcategories.size > 0){
val newCategory = Category(category.id,category.name,category.description,category.videos,list_of_subcategories)
filtered_categories.add(newCategory)
}
}
temp = filtered_categories as MutableList<T>
notifyChanged()
}
In my SearchActivity.kt I have the following listener:
searchEditText.addTextChangedListener(object : TextWatcher{
override fun afterTextChanged(s: Editable?) {}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
adapter.filter(s.toString())
recyclerView.scrollToPosition(0)
}
})
I was looking through the source code of DiffUtil and notifyDataSetChanged() to see how scrolling after filtering works. But didn't have much luck. The whole problem is that after I search text the RecyclerView is filtered fine. But will scroll to inconsistent locations. I want it to scroll back to the top every single time, but this isn't happening. EVEN WITH scrollToPosition(0) it will USUALLY scroll to the top, but not always.
I thought scrolling to the top was typically automatic in this case. I'm curious as to what the best practice is in updating and scrolling.
It needs some time to update the data on recyclerview. Mean while you are trying to scroll which doesn't work in most of the cases. PReferably use a postDelayed with 200 or 300 milliseconds before scrolling
Ex:
new Handler.postDelayed(new Runnable(){
#Override
public void run(){
recyclerView.scrollToPosition(0)
}
}, 300);
I have a TranslateAnimation for a view inside an item of a RecyclerView.Adapter. The animation should be applied to a specific list item when it initially appears, but it only works when you scroll back and the item is recycled again.
My guess is that it has something to do with the RecyclerView's lifecycle, but I couldn't figure out what's causing the animation to not start.
class mAdapter(items: List<String>): RecyclerView.Adapter<mAdapter.ViewHolder>(){
private var mPosition = 0
// The animation will be applied to the first item
override fun getItemViewType(position: Int): Int {
if (position == mPosition){
return 1
} else {
return 0
}
}
override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder)
// Animate the view inside the item
if (holder.itemViewType == 1){
holder.animateItem()
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
super.onBindViewHolder(holder, position, payloads)
// Animate the view inside the item
if (holder.itemViewType == 1){
holder.animateItem()
}
}
inner class ViewHolder(view: View): RecyclerView.ViewHolder(view){
val picture: ImageView? = view.findViewById(R.id.picture)
fun animateItem(){
val itemWidth: Float = itemView.width.toFloat()
val animator = TranslateAnimation(-itemWidth, 0f, 0f, 0f)
animator.repeatCount = 0
animator.interpolator = AccelerateInterpolator(1.0f)
animator.duration = 700
animator.fillAfter = true
background?.animation = animator
background?.startAnimation(animator)
}
}
}
If I log a message inside animateItem it will appear when the RecycleView loads but it will not animate until I scroll down and up.
Answer
As NSimon pointed out, the solution is to write a addOnGlobalLayoutListener
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
holder.itemView.viewTreeObserver.addOnGlobalLayoutListener{
holder.animateItem()
}
}
As discussed in the comments, your first animation was actually playing properly.
However, the first time it was invoked, the View had not been fully layered on screen. Therefore, itemView.width.toFloat() was returning 0, and you were animating from 0 to 0.
The quick solution is to encapsulate the launch of the animation inside a GlobalLayoutListener (which is the system telling you that the view has been layered).
Something like this:
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
holder.itemView.viewTreeObserver.addOnGlobalLayoutListener{
holder.animateItem()
}
}
Bare in mind though, that you should remove the globalLayoutListener once you've started the animation (otherwise it stays there forever and will keep triggering if/when something happens to the view). So a better approach would be to create a helper function like this one:
inline fun <T: View> T.afterMeasured(crossinline f: T.() -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (measuredWidth > 0 && measuredHeight > 0) {
viewTreeObserver.removeOnGlobalLayoutListener(this)
f()
}
}
})
}
and call it inside onBindViewHolder like so:
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
holder.itemView.afterMeasured{
holder.animateItem()
}
}
You're correct. The ViewHolder is only recycled sometime after its initial use. Don't do the animation in onViewRecycled. You might try onBindViewHolder instead, although I'm not sure the exact timing you're looking for.