Updated on 2026-08-14
This commit is contained in:
parent
5036f8a383
commit
a4e7c2d627
4 changed files with 443 additions and 0 deletions
|
|
@ -0,0 +1,154 @@
|
|||
package com.tangem.tap.common.leapfrogWidget
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorSet
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.view.View
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.LinearInterpolator
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
//fun View.setForeground(hasForegroundAtEnd: Boolean) {
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// foreground = getForegroundDrawable()
|
||||
// }
|
||||
//}
|
||||
|
||||
//fun View.getForegroundDrawable(): Drawable? {
|
||||
// return AppCompatResources.getDrawable(context, R.drawable.shape_rectangle_rounded_8_op_20)?.also {
|
||||
// it.alpha = 60
|
||||
// }
|
||||
//}
|
||||
|
||||
typealias ProgressListener = (Int) -> Unit
|
||||
|
||||
fun AnimatorSet.createProgressListener(duration: Long, listener: ProgressListener?): Animator {
|
||||
val valueAnimator = ValueAnimator.ofInt(0, 100)
|
||||
valueAnimator.duration = duration
|
||||
valueAnimator.addUpdateListener { listener?.invoke((it.animatedValue as? Int) ?: 0) }
|
||||
return valueAnimator
|
||||
}
|
||||
|
||||
fun View.initAnimation(animDuration: Long, properties: Properties): AnimatorSet {
|
||||
elevation = properties.elevationEnd
|
||||
scaleX = properties.scaleEnd
|
||||
scaleY = properties.scaleEnd
|
||||
|
||||
val translate = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, properties.yTranslationStart, properties.yTranslationEnd)
|
||||
translate.duration = animDuration
|
||||
return AnimatorSet().apply { playTogether(translate) }
|
||||
}
|
||||
|
||||
fun View.leapAnimation(animDuration: Long, properties: Properties, overLift: Float): AnimatorSet {
|
||||
val halfDuration = animDuration / 2
|
||||
|
||||
val upTo = (height.toFloat() + properties.yTranslationStart + overLift) * -1
|
||||
val translateUp = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, properties.yTranslationStart, upTo)
|
||||
translateUp.duration = halfDuration
|
||||
translateUp.interpolator = LinearInterpolator()
|
||||
|
||||
val scaleDuration = halfDuration - halfDuration / 5
|
||||
val scaleX = ObjectAnimator.ofFloat(this, View.SCALE_X, properties.scaleStart, properties.scaleEnd)
|
||||
val scaleY = ObjectAnimator.ofFloat(this, View.SCALE_Y, properties.scaleStart, properties.scaleEnd)
|
||||
val scale = AnimatorSet().apply {
|
||||
startDelay = scaleDuration
|
||||
duration = scaleDuration
|
||||
interpolator = LinearInterpolator()
|
||||
playTogether(scaleX, scaleY)
|
||||
}
|
||||
|
||||
val elevation = ValueAnimator.ofFloat(properties.elevationStart, properties.elevationEnd)
|
||||
elevation.addUpdateListener { this.elevation = (it.animatedValue as? Float) ?: properties.elevationStart }
|
||||
elevation.startDelay = halfDuration
|
||||
elevation.duration = 100
|
||||
|
||||
val translateDown = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, upTo, properties.yTranslationEnd)
|
||||
translateDown.startDelay = halfDuration
|
||||
translateDown.duration = halfDuration
|
||||
translateDown.interpolator = LinearInterpolator()
|
||||
|
||||
return AnimatorSet().apply {
|
||||
playTogether(
|
||||
translateUp,
|
||||
scale,
|
||||
elevation,
|
||||
translateDown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.leapBackAnimation(animDuration: Long, properties: Properties, calculator: PropertyCalculator): AnimatorSet {
|
||||
val halfDuration = animDuration / 2
|
||||
|
||||
val upTo = ((height).toFloat() - calculator.yTranslationFactor) * -1
|
||||
val translateUp = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, properties.yTranslationStart, upTo)
|
||||
translateUp.duration = halfDuration
|
||||
translateUp.interpolator = LinearInterpolator()
|
||||
|
||||
val scaleDuration = halfDuration - halfDuration / 5
|
||||
val scaleX = ObjectAnimator.ofFloat(this, View.SCALE_X, properties.scaleStart, properties.scaleEnd)
|
||||
val scaleY = ObjectAnimator.ofFloat(this, View.SCALE_Y, properties.scaleStart, properties.scaleEnd)
|
||||
val scale = AnimatorSet().apply {
|
||||
startDelay = scaleDuration
|
||||
duration = scaleDuration
|
||||
interpolator = LinearInterpolator()
|
||||
playTogether(scaleX, scaleY)
|
||||
}
|
||||
|
||||
val elevation = ValueAnimator.ofFloat(properties.elevationStart, properties.elevationEnd)
|
||||
elevation.addUpdateListener { this.elevation = (it.animatedValue as? Float) ?: properties.elevationStart }
|
||||
elevation.startDelay = halfDuration
|
||||
elevation.duration = 100
|
||||
|
||||
val translateDown = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, upTo, properties.yTranslationEnd)
|
||||
translateDown.startDelay = halfDuration
|
||||
translateDown.duration = halfDuration
|
||||
|
||||
return AnimatorSet().apply {
|
||||
playTogether(
|
||||
translateUp,
|
||||
scale,
|
||||
elevation,
|
||||
translateDown,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.pullUpAnimation(leapDuration: Long, properties: Properties): AnimatorSet {
|
||||
val pullDuration = leapDuration / 2
|
||||
val delayDuration = leapDuration / 2
|
||||
|
||||
val translateUp = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, properties.yTranslationStart, properties.yTranslationEnd)
|
||||
val scaleX = ObjectAnimator.ofFloat(this, View.SCALE_X, properties.scaleStart, properties.scaleEnd)
|
||||
val scaleY = ObjectAnimator.ofFloat(this, View.SCALE_Y, properties.scaleStart, properties.scaleEnd)
|
||||
val elevation = ValueAnimator.ofFloat(properties.elevationStart, properties.elevationEnd)
|
||||
elevation.addUpdateListener { this.elevation = (it.animatedValue as? Float) ?: properties.elevationStart }
|
||||
|
||||
return AnimatorSet().apply {
|
||||
startDelay = delayDuration
|
||||
duration = pullDuration
|
||||
interpolator = DecelerateInterpolator()
|
||||
playTogether(translateUp, scaleX, scaleY, elevation)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.pullDownAnimation(leapDuration: Long, properties: Properties): AnimatorSet {
|
||||
val pullDuration = leapDuration / 2
|
||||
val delayDuration = leapDuration / 4
|
||||
|
||||
val translate = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, properties.yTranslationStart, properties.yTranslationEnd)
|
||||
val scaleX = ObjectAnimator.ofFloat(this, View.SCALE_X, properties.scaleStart, properties.scaleEnd)
|
||||
val scaleY = ObjectAnimator.ofFloat(this, View.SCALE_Y, properties.scaleStart, properties.scaleEnd)
|
||||
val elevation = ValueAnimator.ofFloat(properties.elevationStart, properties.elevationEnd)
|
||||
elevation.addUpdateListener { this.elevation = (it.animatedValue as? Float) ?: properties.elevationStart }
|
||||
|
||||
return AnimatorSet().apply {
|
||||
startDelay = delayDuration
|
||||
duration = pullDuration
|
||||
interpolator = DecelerateInterpolator()
|
||||
playTogether(translate, scaleX, scaleY, elevation)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.tangem.tap.common.leapfrogWidget
|
||||
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class LeapView(
|
||||
val view: View,
|
||||
val index: Int,
|
||||
position: Int,
|
||||
val maximalPosition: Int,
|
||||
val initialProperties: Properties
|
||||
) {
|
||||
var currentPosition: Int = position
|
||||
private set
|
||||
var previousPosition: Int = position
|
||||
private set
|
||||
|
||||
fun changePositionByLeap() {
|
||||
previousPosition = currentPosition
|
||||
currentPosition = when (previousPosition) {
|
||||
0 -> maximalPosition
|
||||
else -> currentPosition - 1
|
||||
}
|
||||
}
|
||||
|
||||
fun changePositionByLeapBack() {
|
||||
previousPosition = currentPosition
|
||||
currentPosition = when (previousPosition) {
|
||||
maximalPosition -> 0
|
||||
else -> currentPosition + 1
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = "index: $index, position: $currentPosition, code: ${view.hashCode()}"
|
||||
}
|
||||
|
||||
data class Properties(
|
||||
val startPosition: Int,
|
||||
val endPosition: Int,
|
||||
val scaleStart: Float,
|
||||
val scaleEnd: Float,
|
||||
val elevationStart: Float,
|
||||
val elevationEnd: Float,
|
||||
val yTranslationStart: Float,
|
||||
val yTranslationEnd: Float,
|
||||
val hasForegroundStart: Boolean,
|
||||
val hasForegroundEnd: Boolean,
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return """
|
||||
scaleStart: $scaleStart, scaleEnd: $scaleEnd,
|
||||
elevationStart: $elevationStart, elevationEnd: $elevationEnd,
|
||||
yTranslationStart: $yTranslationStart, yTranslationEnd: $yTranslationEnd,
|
||||
hasForegroundStart : $hasForegroundStart, hasForegroundEnd: $hasForegroundEnd,
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package com.tangem.tap.common.leapfrogWidget
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorSet
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.children
|
||||
import com.tangem.tangem_sdk_new.extensions.dpToPx
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class LeapfrogWidget(
|
||||
private val parentContainer: FrameLayout,
|
||||
private val calculator: PropertyCalculator = PropertyCalculator(),
|
||||
) {
|
||||
private val initAnimationDuration = 500L
|
||||
private val leapAnimationDuration = 800L
|
||||
private val leapBackAnimationDuration = 800L
|
||||
|
||||
private val leapViews = mutableListOf<LeapView>()
|
||||
|
||||
private var leapProgress: Int = 100
|
||||
private var leapBackProgress: Int = 100
|
||||
|
||||
init {
|
||||
calculator.setTranslationConverter { parentContainer.dpToPx(it) }
|
||||
init()
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
if (!viewIsFullFledged()) return
|
||||
|
||||
val maxPosition = parentContainer.childCount - 1
|
||||
parentContainer.children.forEachIndexed { index, view ->
|
||||
val initialPosition = maxPosition - index
|
||||
val initialProperties = createViewProperty(0, initialPosition)
|
||||
leapViews.add(LeapView(view, index, initialPosition, maxPosition, initialProperties))
|
||||
}
|
||||
resetToInitialState()
|
||||
}
|
||||
|
||||
private fun resetToInitialState(withAnimation: Boolean = true, duration: Long = initAnimationDuration) {
|
||||
if (!viewIsFullFledged()) return
|
||||
|
||||
if (withAnimation) {
|
||||
val animatorsList = mutableListOf<AnimatorSet>()
|
||||
leapViews.forEach { animatorsList.add(it.view.initAnimation(duration, it.initialProperties)) }
|
||||
val animator = AnimatorSet()
|
||||
animator.interpolator = AccelerateDecelerateInterpolator()
|
||||
animator.playTogether(animatorsList.toList())
|
||||
animator.start()
|
||||
} else {
|
||||
leapViews.forEach {
|
||||
it.view.scaleX = it.initialProperties.scaleEnd
|
||||
it.view.scaleY = it.initialProperties.scaleEnd
|
||||
it.view.translationY = it.initialProperties.yTranslationEnd
|
||||
it.view.elevation = it.initialProperties.elevationEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun leap(listener: ProgressListener? = null) {
|
||||
if (!canLeap()) return
|
||||
|
||||
// if not - then it's ready for pullUp
|
||||
fun isReadyForLeap(leapView: LeapView): Boolean {
|
||||
return leapView.previousPosition == 0 && leapView.currentPosition == leapView.maximalPosition
|
||||
}
|
||||
|
||||
val animatorsList = mutableListOf<Animator>()
|
||||
leapViews.forEach {
|
||||
it.changePositionByLeap()
|
||||
val properties = createViewProperty(it.previousPosition, it.currentPosition)
|
||||
if (isReadyForLeap(it)) {
|
||||
val overLift = calculator.overLift(leapViews.size)
|
||||
animatorsList.add(it.view.leapAnimation(leapAnimationDuration, properties, overLift))
|
||||
} else {
|
||||
animatorsList.add(it.view.pullUpAnimation(leapAnimationDuration, properties))
|
||||
}
|
||||
}
|
||||
val animator = AnimatorSet()
|
||||
animatorsList.add(animator.createProgressListener(leapAnimationDuration) { leapProgress = it })
|
||||
animatorsList.add(animator.createProgressListener(leapAnimationDuration, listener))
|
||||
animator.playTogether(animatorsList.toList())
|
||||
animator.start()
|
||||
}
|
||||
|
||||
fun leapBack(listener: ProgressListener? = null) {
|
||||
if (!canLeapBack()) return
|
||||
|
||||
// if not - then it's ready for pullDown
|
||||
fun isReadyForLeapBack(leapView: LeapView): Boolean {
|
||||
return leapView.previousPosition == leapView.maximalPosition && leapView.currentPosition == 0
|
||||
}
|
||||
|
||||
val animatorsList = mutableListOf<Animator>()
|
||||
leapViews.forEach {
|
||||
it.changePositionByLeapBack()
|
||||
val properties = createViewProperty(it.previousPosition, it.currentPosition)
|
||||
if (isReadyForLeapBack(it)) {
|
||||
val overLift = calculator.overLift(leapViews.size)
|
||||
animatorsList.add(it.view.leapBackAnimation(leapBackAnimationDuration, properties, calculator))
|
||||
} else {
|
||||
animatorsList.add(it.view.pullDownAnimation(leapBackAnimationDuration, properties))
|
||||
}
|
||||
}
|
||||
val animator = AnimatorSet()
|
||||
animatorsList.add(animator.createProgressListener(leapBackAnimationDuration) { leapBackProgress = it })
|
||||
animatorsList.add(animator.createProgressListener(leapBackAnimationDuration, listener))
|
||||
animator.playTogether(animatorsList.toList())
|
||||
animator.start()
|
||||
}
|
||||
|
||||
fun getViewByPosition(position: Int): View? {
|
||||
return leapViews.firstOrNull { it.currentPosition == position }?.view
|
||||
}
|
||||
|
||||
private fun viewIsFullFledged(): Boolean = parentContainer.childCount > 1
|
||||
|
||||
private fun canLeap(): Boolean {
|
||||
return when {
|
||||
!viewIsFullFledged() -> false
|
||||
leapBackInProgress() -> false
|
||||
leapProgress < 70 -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun canLeapBack(): Boolean {
|
||||
return when {
|
||||
!viewIsFullFledged() -> false
|
||||
leapInProgress() -> false
|
||||
leapBackProgress < 70 -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun leapInProgress(): Boolean = leapProgress != 100
|
||||
private fun leapBackInProgress(): Boolean = leapBackProgress != 100
|
||||
|
||||
private fun createViewProperty(startPosition: Int, endPosition: Int): Properties {
|
||||
return Properties(
|
||||
startPosition,
|
||||
endPosition,
|
||||
calculator.scale(startPosition),
|
||||
calculator.scale(endPosition),
|
||||
calculator.elevation(startPosition, parentContainer.childCount),
|
||||
calculator.elevation(endPosition, parentContainer.childCount),
|
||||
calculator.yTranslation(startPosition),
|
||||
calculator.yTranslation(endPosition),
|
||||
calculator.hasForeground(startPosition),
|
||||
calculator.hasForeground(endPosition),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class PropertyCalculator(
|
||||
val elevationFactor: Float = 1f,
|
||||
val decreaseScaleFactor: Float = 0.1f,
|
||||
val yTranslationFactor: Float = 20f,
|
||||
val decreaseOverLiftingFactor: Float = 0.85f,
|
||||
) {
|
||||
// 1st view it is view on the top of stack, but it is last in parent.children
|
||||
|
||||
private var translationValueConverter: ((Float) -> Float)? = null
|
||||
fun setTranslationConverter(converter: (Float) -> Float) {
|
||||
translationValueConverter = converter
|
||||
}
|
||||
|
||||
fun scale(position: Int): Float {
|
||||
return 1f - decreaseScaleFactor * position
|
||||
}
|
||||
|
||||
fun elevation(position: Int, count: Int): Float {
|
||||
return elevationFactor * (count - 1 - position)
|
||||
}
|
||||
|
||||
fun yTranslation(position: Int): Float {
|
||||
val yTranslation = yTranslationFactor * position
|
||||
return convertTranslation(yTranslation)
|
||||
}
|
||||
|
||||
fun hasForeground(position: Int): Boolean {
|
||||
return position != 0
|
||||
}
|
||||
|
||||
fun overLift(viewCount: Int): Float {
|
||||
val initialOverLift = when {
|
||||
yTranslationFactor < 0f -> yTranslationFactor * decreaseOverLiftingFactor * viewCount * -1
|
||||
else -> 10f
|
||||
}
|
||||
val scaleOverLift = when {
|
||||
decreaseScaleFactor == 0f -> initialOverLift
|
||||
else -> initialOverLift * decreaseOverLiftingFactor - initialOverLift * decreaseScaleFactor
|
||||
}
|
||||
return convertTranslation(scaleOverLift)
|
||||
}
|
||||
|
||||
private fun convertTranslation(value: Float): Float {
|
||||
return translationValueConverter?.invoke(value) ?: value
|
||||
}
|
||||
}
|
||||
26
app/src/main/res/layout/leapfrog_widget.xml
Normal file
26
app/src/main/res/layout/leapfrog_widget.xml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/leapfrog_views_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<!-- parent container must include android:clipChildren="false" -->
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@drawable/ic_home_front_card" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@drawable/ic_home_front_card" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@drawable/ic_home_front_card" />
|
||||
|
||||
</FrameLayout>
|
||||
Loading…
Add table
Add a link
Reference in a new issue