I need to build my own animation library/helper, that could move a view from a point A to point B just by updating the animation's progression percentage. This has to be written in Kotlin, but if needed I can translate it in java.
What I've done
class Animator(
private val animations: List<Animation>
) {
constructor(view: View,
startPoint: Point2D = Point2D(view.x, view.y),
endPoint: Point2D) : this(listOf(Animation(view, startPoint, endPoint)))
private val transitionState: TransitionState = TransitionState(0)
private val animationQueue: Handler = Handler()
/**
* Apply a progression in the animation from [startPoint] to [endPoint] or conversely depending
* on the transition state.
* When the [TransitionState.progress] reach 0 the position of the [view] to animate is at [startPoint]. When it
* reach 100 the view will be at the [endPoint]
*
* #param percent an Integer that must be between 0 and 100 because it's percentage. If the
* given percent is below 0 it
* will override it to 0. Same process when it's over 100.
*/
fun progressTo(percent: Int) {
for (anim in animations) {
val finalPercent = if (percent > 100) 100 else if (percent < 0) 0 else percent
if (Math.abs(transitionState.progress - finalPercent) < 10) {
animationQueue.post {
anim.view.x = (Vector2D(anim.startPoint, anim.endPoint) % finalPercent).endPoint.x
anim.view.y = (Vector2D(anim.startPoint, anim.endPoint) % finalPercent).endPoint.y
}
} else {
anim.view.animate().x((Vector2D(anim.startPoint, anim.endPoint) % finalPercent).endPoint.x)
anim.view.animate().y((Vector2D(anim.startPoint, anim.endPoint) % finalPercent).endPoint.y)
}
transitionState.progress = finalPercent
}
}
/**
* Finish the animation to the endPoint or startPoint depending on the [TransitionState.progress]
*/
fun finishAnimation() {
if (transitionState.progress < 50) {
progressTo(0)
} else if (transitionState.progress >= 50) {
progressTo(100)
}
}
}
data class TransitionState(
var progress: Int,
var isOnTransaction: Boolean = progress != 0 && progress != 100
)
data class Vector2D(
val startPoint: Point2D,
val endPoint: Point2D
) {
operator fun rem(percent: Int): Vector2D {
val finalPercent = if (percent > 100) 100 else if (percent < 0) 0 else percent
return Vector2D(startPoint, Point2D(
startPoint.x + ((endPoint.x - startPoint.x) * finalPercent / 100),
startPoint.y + ((endPoint.y - startPoint.y) * finalPercent / 100)
))
}
}
data class Animation(
val view: View,
val startPoint: Point2D = Point2D(view.x, view.y),
val endPoint: Point2D
)
data class Point2D(
val x: Float,
val y: Float
)
As you can see i'm using the property View.setX and View.setY to move the view. It works great it move the view properly with the correct animation and movement.
The problem
When I use the X and Y values, the constraints (LinearLayout, RelativeLayout or ConstraintLayout) of the view's layout parent are not updated or respected. For instance there is a view (the red one in the first capture) in a constraint layout which is constrained to parent left, right and bottom as shown in this layout editor capture.
When I change the y value of the view the bottom constraint is not updated (it does not fit anymore the parent bottom). Here is the captures of the real application before animation and after animation.
Using the View.layout() or View.invalidate() does not change anything, and if there is a method that could redraw and restore the whole layout, I'm not sure that the application will be performant anymore ....
EDIT
I corrected a piece of code (in the for loop) that enables the anmiation to be more fluent.
The question
Is it possible to change the position of a view without breaking its constraints ?
I'm pretty sure I'm misunderstanding something in the android ui positioning/drawing, so feel free to post your understanding of this even you can't answer to my question. Thank you by advance.
Related
I want to display items in a horizontal list using RecyclerView. At a time, only 3 items will be displayed. 1 in the middle and the other 2 on the side, below is an image of what I'm trying to achieve:
I'm using LinearSnapHelper which centers an item all of the time. When an item is moved away from the center I would like the opacity to progessively change from 1f to 0.5f.
Here is the below code which I've written to help:
class CustomRecyclerView(context: Context, attrs: AttributeSet) : RecyclerView(context, attrs) {
private var itemBoundsRect: Rect? = null
init {
itemBoundsRect = Rect()
addOnScrollListener(object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
calculateVisibility()
}
})
}
private fun calculateVisibility() {
val linearLayoutManger: LinearLayoutManager = layoutManager as LinearLayoutManager
val firstVisibleItem = linearLayoutManger.findFirstVisibleItemPosition()
val lastVisibleItem = linearLayoutManger.findLastVisibleItemPosition()
var indexes: MutableList<Int> = mutableListOf()
for (i in firstVisibleItem..lastVisibleItem) {
indexes.add(i)
val item: View = layoutManager?.findViewByPosition(i) ?: continue
item.getGlobalVisibleRect(itemBoundsRect)
var itemSize = layoutManager!!.findViewByPosition(i)!!.width
var visibleSize = 0
if (indexes.size == 1) {
visibleSize = itemBoundsRect!!.right
} else {
visibleSize = itemBoundsRect!!.right - itemBoundsRect!!.left
}
var visibilty = visibleSize * 100 / itemSize
if (visibilty > 0) {
visibilty = 100 - visibilty
}
val viewHolder = findViewHolderForLayoutPosition(i)
viewHolder!!.itemView.alpha = (100 - visibilty).toFloat() / 100f
}
}
}
It doesn't work as expected as the opacity changes at the wrong time. The image below demonstrates this better. I expect the opacity to progressively begin to change when the item edges come out of the red box. However, it only starts when the item reaches the yellow edges.
Is there a way to achieve this effect?
Thank you :)
Your code for calculateVisibility() is looking at global position when looking at the relative position within the RecyclerView is sufficient. Maybe there is more to the code than you posted, but try the following. This code looks at the x position of each visible view and calculates the alpha value as a function of displacement from the center of the RecyclerView. Comments are in the code.
private fun calculateVisibility(recycler: RecyclerView) {
val midRecycler = recycler.width / 2
val linearLayoutManger: LinearLayoutManager = recycler.layoutManager as LinearLayoutManager
val firstVisibleItem = linearLayoutManger.findFirstVisibleItemPosition()
val lastVisibleItem = linearLayoutManger.findLastVisibleItemPosition()
for (i in firstVisibleItem..lastVisibleItem) {
val viewHolder = recycler.findViewHolderForLayoutPosition(i)
viewHolder?.itemView?.apply {
// This is the end of the view in the parent's coordinates
val viewEnd = x + width
// This is the maximum pixels the view can slide left or right until it disappears.
val maxSlide = (midRecycler + width / 2).toFloat()
// Alpha is determined by the percentage of the maximum slide the view has moved.
// This assumes a linear fade but can be adjusted to fade in alternate ways.
alpha = 1f - abs(maxSlide - viewEnd) / maxSlide
Log.d("Applog", String.format("pos=%d alpha=%f", i, alpha))
}
}
}
The foregoing assumes that sizes remain constant.
if you need the center View, you can call
View view = snapHelper.findSnapView(layoutManagaer);
once you have the View, you should be able to get the position on the dataset for that View. For instance using
int pos = adapter.getChildAdapterPosition(view)
And then you can update the center View opacity and invoke
adapter.notifyItemChanged(pos);
I'm trying to get two views to move to the middle of the screen and bounce back again x number of times.
This code does that but it runs only once.
` val view = findViewById(R.id.imageView2)
val animation = SpringAnimation(view, DynamicAnimation.TRANSLATION_Y, 0f)
val view2 = findViewById<View>(R.id.imageView3)
val animation2 = SpringAnimation(view2, DynamicAnimation.TRANSLATION_Y, 0f)
findViewById<View>(R.id.imageView2).also { img ->
SpringAnimation(img, DynamicAnimation.TRANSLATION_Y).apply {
animation.getSpring().setDampingRatio(SpringForce.DAMPING_RATIO_HIGH_BOUNCY)
animation.spring.stiffness = SpringForce.STIFFNESS_VERY_LOW
animation.animateToFinalPosition(50f)
}
}
findViewById<View>(R.id.imageView3).also { img ->
SpringAnimation(img, DynamicAnimation.TRANSLATION_Y).apply {
animation2.getSpring().setDampingRatio(SpringForce.DAMPING_RATIO_HIGH_BOUNCY)
animation2.spring.stiffness = SpringForce.STIFFNESS_VERY_LOW
animation2.animateToFinalPosition(-100f)
}
}`
So how do I get it to run x number of times?
This is obviously Spring Animation, but I'm not married to it. If there is another animation that would accomplish this I'd be totally open to changing.
You can run multiple SpringAnimations on the same View by repeatedly calling animateToFinalPosition(translation) with a sequence of translation values.
For example:
startSpringAnimations(findViewById<View>(R.id.imageView1), 300f, 6)
startSpringAnimations(findViewById<View>(R.id.imageView2), -600f, 6)
with a function
/**
* [view] will be moved using [times] SpringAnimations over a distance of abs([totalTranslation])
* If [totalTranslation] is negative, direction will be up, else down
*/
private fun startSpringAnimations(view: View, totalTranslation: Float, times: Int ) {
if(times <= 0){
return
}
val translation = totalTranslation/ times.toFloat()
SpringAnimation(view, DynamicAnimation.TRANSLATION_Y, 0f).apply{
spring.dampingRatio = SpringForce.DAMPING_RATIO_HIGH_BOUNCY
spring.stiffness = SpringForce.STIFFNESS_VERY_LOW
addEndListener(object: DynamicAnimation.OnAnimationEndListener{
private var count = 1
override fun onAnimationEnd(animation1: DynamicAnimation<*>?, canceled: Boolean, value: Float, velocity: Float) {
Log.d("SpringAnimation", "onAnimationEnd: animation $animation1 canceled $canceled value $value velocity $velocity count $count")
if (canceled) return
count++
if(count <= times){
animateToFinalPosition(translation * count)
}
}
})
animateToFinalPosition(translation)
}
}
Set android:repeatCount="infinite" in anim folder
I want to implement Carousel using View Pager2 with preview of left and right page like this:
Initially I was using view pager1 which supported. Now I think it's removed
viewPagerhost.setPageMargin(20);
Any idea how we can achieve this using View Pager 2
MarginPageTransformer cannot help your need.
You must use custom setPageTrarnsformer.
Step 1
Here is my Extension Method for this.
you can check detail in this article
Medium article
fun ViewPager2.setShowSideItems(pageMarginPx : Int, offsetPx : Int) {
clipToPadding = false
clipChildren = false
offscreenPageLimit = 3
setPageTransformer { page, position ->
val offset = position * -(2 * offsetPx + pageMarginPx)
if (this.orientation == ViewPager2.ORIENTATION_HORIZONTAL) {
if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL) {
page.translationX = -offset
} else {
page.translationX = offset
}
} else {
page.translationY = offset
}
}
}
Step 2
set pageMarginPx and offsetPx with your use case.
<resources>
<dimen name="pageMargin">20dp</dimen>
<dimen name="pagerOffset">30dp</dimen>
<dimen name="pageMarginAndoffset">50dp</dimen>
</resources>
Step 3
set your side margin of layout item in your xml.
like this
<androidx.cardview.widget.CardView
app:cardCornerRadius="12dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:layout_marginLeft="#dimen/pageMarginAndoffset"
android:layout_marginRight="#dimen/pageMarginAndoffset"
android:layout_width="match_parent"
android:layout_height="match_parent">
Now we need to use setPageTransformer() in Version 1.0.0-alpha05
New features
ItemDecorator introduced with a behaviour consistent with RecyclerView.
MarginPageTransformer introduced to provide an ability to create space between pages (outside of page inset).
CompositePageTransformer introduced to provide an ability to combine multiple PageTransformers.
SAMPLE CODE
myViewPager2.setPageTransformer(new MarginPageTransformer(1500));
Check out my previous answer if you want to implement Carousel using View Pager2
I used MJ Studio's approach to create my custom PageTransformer that also changes the page margin as follows:
class OffsetPageTransformer(
#Px private val offsetPx: Int,
#Px private val pageMarginPx: Int
) : ViewPager2.PageTransformer {
override fun transformPage(page: View, position: Float) {
val viewPager = requireViewPager(page)
val offset = position * -(2 * offsetPx + pageMarginPx)
val totalMargin = offsetPx + pageMarginPx
if (viewPager.orientation == ViewPager2.ORIENTATION_HORIZONTAL) {
page.updateLayoutParams<ViewGroup.MarginLayoutParams> {
marginStart = totalMargin
marginEnd = totalMargin
}
page.translationX = if (ViewCompat.getLayoutDirection(viewPager) == ViewCompat.LAYOUT_DIRECTION_RTL) {
-offset
} else {
offset
}
} else {
page.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = totalMargin
bottomMargin = totalMargin
}
page.translationY = offset
}
}
private fun requireViewPager(page: View): ViewPager2 {
val parent = page.parent
val parentParent = parent.parent
if (parent is RecyclerView && parentParent is ViewPager2) {
return parentParent
}
throw IllegalStateException(
"Expected the page view to be managed by a ViewPager2 instance."
)
}
}
That way you can just call:
viewPager.setPageTransformer(OffsetPageTransformer(offsetPx, pageMarginPx))
you can use this code
viewPager.setPageTransformer(new MarginPageTransformer(margin as PX));
but if you want to use DP you can use the below function for convert PX to DP
private int pxToDp(int px) {
return (int) (px / Resources.getSystem().getDisplayMetrics().density);
}
MarginPageTransformer helps to define spaces between pages.
offscreenPageLimit let you define how many pages should be rendered offscreen.
Sample of the code:
viewPager2.offscreenPageLimit = 3
viewPager2.setPageTransformer(MarginPageTransformer({MARGIN AS PX}));
I am implementing a custom KeyboardView in my app and it's all working at the moment, however, when I attempt to press a key on the keyboard using Espresso ViewAction, I am getting an exception saying:
android.support.test.espresso.PerformException:
Error performing 'single click - At Coordinates: 1070, 2809 and
precision: 16, 16' on view 'with id:
com.example.app.mvpdemo:id/keyboardLayout'.
The code throwing the exception is:
#Test
fun enter100AsPriceShouldDisplay120ForA20PercentTip(){
onView(withId(R.id.editTextCheckAmount))
.perform(typeText("100"), closeSoftKeyboard())
val appContext = InstrumentationRegistry.getTargetContext()
val displayMetrics = appContext.resources.displayMetrics
onView(withId(R.id.keyboardLayout)).perform(clickXY(displayMetrics.widthPixels - 10, displayMetrics.heightPixels - 10))
onView(withText("$120.00")).check(matches(isDisplayed()))
}
and the click XY function which came from this post
private fun clickXY(x: Int, y: Int): ViewAction {
return GeneralClickAction(
Tap.SINGLE,
CoordinatesProvider { view ->
val screenPos = IntArray(2)
view.getLocationOnScreen(screenPos)
val screenX = (screenPos[0] + x).toFloat()
val screenY = (screenPos[1] + y).toFloat()
floatArrayOf(screenX, screenY)
},
Press.FINGER, 0, 0)
}
Here is my keyboard layout (pinned to the bottom of the screen inside a ConstraintLayout):
Does anyone know why? Any help is appreciated.
Answering my own question after determining a flexible solution:
First attempt - get DisplayMetrics of the root View and subtract an arbitrary number to attempt to hit the Keyboard.Key
this didn't work because clickXY function uses the position of the view
this ended up being the reason for the exception since the view is smaller than the DisplayMetrics values and adding to the Views on screen position would give a very high number for the x and y.
So I tried again,
Second attempt - use check method on the ViewMatcher to check the KeyBoardView.
by doing so I was able to get access to the KeyboardView's position x
then I was able to get the KeyboardView's width and height
by performing some math, I was able to figure out target index for x & y
the math:
take the widthPercent for the Keyboard.Key (in my case 33.3%)
take the rowCount of the keyboard.xml (in my case 3)
use (viewWidth * widthPercent) / 4 to get relativeButtonX
use (viewHeight / rowCount) / 2 to get relativeButtonY
then for targetY, I took viewHeight - relativeButtonY
finally, for targetX, I took (viewPosX + viewWidth) - relativeButtonX
So enough explanation, here is the code:
#Test
fun enter100AsPriceShouldDisplay120ForA20PercentTip() {
onView(withId(R.id.editTextCheckAmount))
.perform(typeText("100"), closeSoftKeyboard())
// call the function to get the targets
val (viewTargetY, viewTargetX) = getTargetXAndY()
// perform the action
onView(withId(R.id.keyboardLayout)).perform(clickXY(viewTargetX.toInt(), viewTargetY))
onView(withText("Tip: $20.00")).check(matches(isDisplayed()))
onView(withText("Total: $120.00")).check(matches(isDisplayed()))
}
and the helper method with all the math:
private fun getTargetXAndY(): Pair<Int, Double> {
var viewHeight = 0
var viewWidth = 0
var viewPosX = 0F
val viewMatcher = onView(withId(R.id.keyboardLayout))
viewMatcher.check { view, _ ->
viewWidth = view.width
viewHeight = view.height
viewPosX = view.x
}
val keyboardKeyWidthPercent = 0.333
val keyboardRowsCount = 3
val keyboardButtonWidthQuarter = (viewWidth * keyboardKeyWidthPercent) / 4
val keyboardButtonHeightHalf = (viewHeight / keyboardRowsCount) / 2
val viewTargetY = viewHeight - keyboardButtonHeightHalf
val viewTargetX = (viewPosX + viewWidth) - keyboardButtonWidthQuarter
return Pair(viewTargetY, viewTargetX)
}
Now, the click is not perfectly centered but it clicks the button pretty close to the center.
I'm using data binding to setup a RecyclerView. Here is the binding adapter:
fun setRecyclerDevices(recyclerView: RecyclerView, items: List<Device>, itemBinder: MultipleTypeItemBinder,
listener: BindableListAdapter.OnClickListener<Device>?) {
var adapter = recyclerView.adapter as? DevicesBindableAdapter
if (adapter == null) {
val spannedGridLayoutManager = SpannedGridLayoutManager(orientation = SpannedGridLayoutManager.Orientation.VERTICAL,
spans = getSpanSizeFromScreenWidth(recyclerView.context, recyclerView))
recyclerView.layoutManager = spannedGridLayoutManager
recyclerView.addItemDecoration(SpaceItemDecorator(left = 15, top = 15, right = 15, bottom = 15))
adapter = DevicesBindableAdapter(items, itemBinder)
adapter.setOnClickListener(listener)
recyclerView.adapter = adapter
} else {
adapter.setOnClickListener(listener)
adapter.setItemBinder(itemBinder)
adapter.setItems(items)
}
}
getSpanSizeFromScreenWidth needs the recycler's width to do some calculation. But it always returns 0.
I tried to apply a ViewTreeObserver like this:
recyclerView.viewTreeObserver.addOnGlobalLayoutListener(object: ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
recyclerView.viewTreeObserver.removeOnGlobalLayoutListener(this)
val spannedGridLayoutManager = SpannedGridLayoutManager(orientation = SpannedGridLayoutManager.Orientation.VERTICAL,
spans = getSpanSizeFromScreenWidth(recyclerView.context, recyclerView))
recyclerView.layoutManager = spannedGridLayoutManager
}
})
Or use post like this:
recyclerView.post({
val spannedGridLayoutManager = SpannedGridLayoutManager(orientation = SpannedGridLayoutManager.Orientation.VERTICAL,
spans = getSpanSizeFromScreenWidth(recyclerView.context, recyclerView))
recyclerView.layoutManager = spannedGridLayoutManager
})
Code of getSpanSizeFormScreenWidth:
private fun getSpanSizeFromScreenWidth(context: Context, recyclerView: RecyclerView): Int {
val availableWidth = recyclerView.width.toFloat()
val px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 300f, context.resources.displayMetrics)
val margin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, 15f, context.resources.displayMetrics)
return Math.max(1, Math.floor((availableWidth / (px + margin)).toDouble()).toInt()) * DevicesBindableAdapter.WIDTH_UNIT_VALUE
}
But it still returns 0 despite my RecyclerView being displayed on the screen (not 0).
Any ideas?
In inspecting the code, it appears that your RecyclerView may actually be fine, but your logic in getSpanSizeFromScreenWidth may not be.
It looks like this: Math.floor((availableWidth / (px + margin)).toDouble()).toInt() will always be 0 when availableWidth is less than (px + margin). This will then cause getSpanSizeFromScreenWidth to return 0.
Breaking it down:
Math.floor - rounds a double down to a whole number
availableWidth / (px + margin) - will be a low number (a fraction of availableWidth)
Therefore, you're going to get 0 at times especially on smaller screens and/or smaller density screens.
Does that make sense? May not be this issue, but I'd start there. It's hard to tell you exactly the issue without knowing the whole context, but that's likely your issue.
If that is not your issue, could you say what your value is for availableWidth, px, and margin during execution?