From 6bd8bc08d8753afa647f871e241eab82fbdf2b24 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 20 Jul 2022 10:47:37 -0300 Subject: [PATCH] Add new gift opening animation and confirmation haptic. --- .../securesms/badges/gifts/OpenableGift.kt | 27 ++++++++ .../gifts/OpenableGiftItemDecoration.kt | 68 ++++++++++++++++--- .../conversation/ConversationItem.java | 9 +++ 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt index c5f929401..460afb713 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt @@ -25,4 +25,31 @@ interface OpenableGift { * Clears any callback created to start the open animation */ fun clearOpenGiftCallback() + + /** + * Gets the appropriate sign for the animation evaluators: + * + * - Incoming and LTR -> Positive + * - Incoming and RTL -> Negative + * - Outgoing and LTR -> Negative + * - Outgoing and RTL -> Positive + */ + fun getAnimationSign(): AnimationSign + + enum class AnimationSign(val sign: Float) { + POSITIVE(1f), + NEGATIVE(-1f); + + companion object { + @JvmStatic + fun get(isLtr: Boolean, isOutgoing: Boolean): AnimationSign { + return when { + isLtr && isOutgoing -> NEGATIVE + isLtr -> POSITIVE + isOutgoing -> POSITIVE + else -> NEGATIVE + } + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt index d21f7bfff..d5fb71990 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt @@ -5,6 +5,7 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.Path import android.graphics.RectF import android.graphics.drawable.Drawable import android.provider.Settings @@ -13,8 +14,10 @@ import android.view.animation.AnticipateInterpolator import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.core.graphics.toRect +import androidx.core.graphics.withRotation import androidx.core.graphics.withSave import androidx.core.graphics.withTranslation +import androidx.core.view.animation.PathInterpolatorCompat import androidx.core.view.children import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -191,7 +194,7 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration } private fun getTranslation(progress: Float): Double { - val interpolated = INTERPOLATOR.getInterpolation(progress) + val interpolated = TRANSLATION_X_INTERPOLATOR.getInterpolation(progress) val evaluated = EVALUATOR.evaluate(interpolated, 0f, 360f) return 0.25f * sin(4 * evaluated * PI / 180f) * 180f / PI @@ -199,17 +202,60 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration } class OpenAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime, OPEN_DURATION_MILLIS) { - override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) { - val interpolatedProgress = INTERPOLATOR.getInterpolation(progress) - val evaluatedValue = EVALUATOR.evaluate(interpolatedProgress, 0f, DimensionUnit.DP.toPixels(161f)) - val interpolatedY = TRANSLATION_Y_INTERPOLATOR.getInterpolation(progress) + private val bowRotationPath = Path().apply { + lineTo(0.13f, -0.75f) + lineTo(0.26f, 0f) + lineTo(0.73f, -1.375f) + lineTo(1f, 1f) + } + + private val boxRotationPath = Path().apply { + lineTo(0.63f, -1.6f) + lineTo(1f, 1f) + } + + private val bowRotationInterpolator = PathInterpolatorCompat.create(bowRotationPath) + + private val boxRotationInterpolator = PathInterpolatorCompat.create(boxRotationPath) + + override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) { + val sign = openableGift.getAnimationSign().sign + + val boxStartDelay: Float = OPEN_BOX_START_DELAY / duration.toFloat() + val boxProgress: Float = max(0f, progress - boxStartDelay) / (1f - boxStartDelay) + + val bowStartDelay: Float = OPEN_BOW_START_DELAY / duration.toFloat() + val bowProgress: Float = max(0f, progress - bowStartDelay) / (1f - bowStartDelay) + + val interpolatedX = TRANSLATION_X_INTERPOLATOR.getInterpolation(boxProgress) + val evaluatedX = EVALUATOR.evaluate(interpolatedX, 0f, DimensionUnit.DP.toPixels(18f * sign)) + + val interpolatedY = TRANSLATION_Y_INTERPOLATOR.getInterpolation(boxProgress) val evaluatedY = EVALUATOR.evaluate(interpolatedY, 0f, DimensionUnit.DP.toPixels(355f)) - canvas.translate(evaluatedValue, evaluatedY) + val interpolatedBowRotation = bowRotationInterpolator.getInterpolation(bowProgress) + val evaluatedBowRotation = EVALUATOR.evaluate(interpolatedBowRotation, 0f, 8f * sign) - drawBox(canvas, projection) - drawBow(canvas, projection) + val interpolatedBoxRotation = boxRotationInterpolator.getInterpolation(boxProgress) + val evaluatedBoxRotation = EVALUATOR.evaluate(interpolatedBoxRotation, 0f, -5f * sign) + + canvas.withTranslation(evaluatedX, evaluatedY) { + canvas.withRotation( + degrees = evaluatedBoxRotation, + pivotX = projection.x + projection.width / 2f, + pivotY = projection.y + projection.height / 2f + ) { + drawBox(this, projection) + canvas.withRotation( + degrees = evaluatedBowRotation, + pivotX = projection.x + projection.width / 2f, + pivotY = projection.y + projection.height / 2f + ) { + drawBow(this, projection) + } + } + } } } @@ -249,11 +295,13 @@ class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration companion object { private val TRANSLATION_Y_INTERPOLATOR = AnticipateInterpolator(3f) - private val INTERPOLATOR = AccelerateDecelerateInterpolator() + private val TRANSLATION_X_INTERPOLATOR = AccelerateDecelerateInterpolator() private val EVALUATOR = FloatEvaluator() private const val SHAKE_DURATION_MILLIS = 1000L - private const val OPEN_DURATION_MILLIS = 700L + private const val OPEN_DURATION_MILLIS = 1400L + private const val OPEN_BOX_START_DELAY = 400L + private const val OPEN_BOW_START_DELAY = 50L private const val ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS = 33 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 069dcdc9b..66e2dd01f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -26,6 +26,7 @@ import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.Typeface; import android.net.Uri; +import android.os.Build; import android.text.Annotation; import android.text.Spannable; import android.text.SpannableString; @@ -41,6 +42,7 @@ import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.AttributeSet; import android.util.TypedValue; +import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.TouchDelegate; import android.view.View; @@ -2149,6 +2151,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyBubble.setOnClickListener(unused -> { openGift.invoke(this); eventListener.onGiftBadgeRevealed(messageRecord); + bodyBubble.performHapticFeedback(Build.VERSION.SDK_INT >= 30 ? HapticFeedbackConstants.CONFIRM + : HapticFeedbackConstants.KEYBOARD_TAP); }); giftViewStub.get().onGiftNotOpened(); } @@ -2163,6 +2167,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + @Override + public @NonNull AnimationSign getAnimationSign() { + return AnimationSign.get(ViewUtil.isLtr(this), messageRecord.isOutgoing()); + } + private class SharedContactEventListener implements SharedContactView.EventListener { @Override public void onAddToContactsClicked(@NonNull Contact contact) {