diff --git a/Confetti/build.gradle b/Confetti/build.gradle new file mode 100644 index 00000000..d1db8c07 --- /dev/null +++ b/Confetti/build.gradle @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) +} + +android { + + namespace 'com.infomaniak.lib.confetti' + compileSdk rootProject.ext.coreTargetSdk + + defaultConfig { + minSdkVersion rootProject.ext.coreMinSdk + targetSdkVersion rootProject.ext.coreTargetSdk + } + + compileOptions { + sourceCompatibility rootProject.ext.javaVersion + targetCompatibility rootProject.ext.javaVersion + } + + kotlinOptions { jvmTarget = rootProject.ext.javaVersion } +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/CommonConfetti.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/CommonConfetti.kt new file mode 100644 index 00000000..ea433e97 --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/CommonConfetti.kt @@ -0,0 +1,172 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti + +import android.content.res.Resources +import android.graphics.Rect +import android.view.ViewGroup +import com.infomaniak.lib.confetti.Utils.defaultAlphaInterpolator +import com.infomaniak.lib.confetti.Utils.generateConfettiBitmaps +import com.infomaniak.lib.confetti.confetto.BitmapConfetto +import com.infomaniak.lib.confetti.confetto.Confetto +import java.util.Random + +class CommonConfetti private constructor(container: ViewGroup) { + + lateinit var confettiManager: ConfettiManager + private set + + init { + ensureStaticResources(container.resources) + } + + /** + * Starts a one-shot animation that emits all of the confetti at once. + * + * @return the resulting [ConfettiManager] that's performing the animation. + */ + fun oneShot(): ConfettiManager = confettiManager!! + .setNumInitialCount(100) + .setEmissionDuration(0L) + .animate(false) + + /** + * Starts a stream of confetti that animates for the provided duration. + * + * @param durationInMillis how long to animate the confetti for. + * @return the resulting [ConfettiManager] that's performing the animation. + */ + fun stream(durationInMillis: Long): ConfettiManager = confettiManager!! + .setNumInitialCount(0) + .setEmissionDuration(durationInMillis) + .setEmissionRate(50.0f) + .animate(false) + + /** + * Starts an infinite stream of confetti. + * + * @return the resulting [ConfettiManager] that's performing the animation. + */ + fun infinite(): ConfettiManager = confettiManager!! + .setNumInitialCount(0) + .setEmissionDuration(ConfettiManager.INFINITE_DURATION) + .setEmissionRate(50.0f) + .animate(false) + + private fun getDefaultGenerator(colors: IntArray): ConfettoGenerator { + val bitmaps = generateConfettiBitmaps(colors, defaultConfettiSize) + val numBitmaps = bitmaps.size + return object : ConfettoGenerator { + override fun generateConfetto(random: Random): Confetto = BitmapConfetto(bitmaps[random.nextInt(numBitmaps)]) + } + } + + private fun configureRainingConfetti(container: ViewGroup, confettiSource: ConfettiSource, colors: IntArray) { + val generator = getDefaultGenerator(colors) + confettiManager = ConfettiManager(container.context, generator, confettiSource, container) + .setVelocityX(0.0f, defaultVelocitySlow.toFloat()) + .setVelocityY(defaultVelocityNormal.toFloat(), defaultVelocitySlow.toFloat()) + .setInitialRotation(180, 180) + .setRotationalAcceleration(360.0f, 180.0f) + .setTargetRotationalVelocity(360.0f) + } + + private fun configureExplosion(container: ViewGroup, x: Int, y: Int, colors: IntArray) { + val generator = getDefaultGenerator(colors) + val confettiSource = ConfettiSource(x, y) + confettiManager = ConfettiManager(container.context, generator, confettiSource, container) + .setTTL(1_000L) + .setBound(Rect(x - explosionRadius, y - explosionRadius, x + explosionRadius, y + explosionRadius)) + .setVelocityX(0.0f, defaultVelocityFast.toFloat()) + .setVelocityY(0.0f, defaultVelocityFast.toFloat()) + .enableFadeOut(defaultAlphaInterpolator) + .setInitialRotation(180, 180) + .setRotationalAcceleration(360.0f, 180.0f) + .setTargetRotationalVelocity(360.0f) + } + + companion object { + + private var defaultConfettiSize = 0 + private var defaultVelocitySlow = 0 + private var defaultVelocityNormal = 0 + private var defaultVelocityFast = 0 + private var explosionRadius = 0 + + // region Pre-configured confetti animations + /** + * @param container the container viewgroup to host the confetti animation. + * @param colors the set of colors to colorize the confetti bitmaps. + * @return the created common confetti object. + * @see .rainingConfetti + */ + fun rainingConfetti( + container: ViewGroup, + colors: IntArray, + ) = CommonConfetti(container).apply { + val confettiSource = ConfettiSource(0, -defaultConfettiSize, container.width, -defaultConfettiSize) + configureRainingConfetti(container, confettiSource, colors) + } + + /** + * Configures a confetti manager that has confetti falling from the provided confetti source. + * + * @param container the container viewgroup to host the confetti animation. + * @param confettiSource the source of the confetti animation. + * @param colors the set of colors to colorize the confetti bitmaps. + * @return the created common confetti object. + */ + fun rainingConfetti( + container: ViewGroup, + confettiSource: ConfettiSource, + colors: IntArray, + ) = CommonConfetti(container).apply { + configureRainingConfetti(container, confettiSource, colors) + } + + /** + * Configures a confetti manager that has confetti exploding out in all directions from the + * provided x and y coordinates. + * + * @param container the container viewgroup to host the confetti animation. + * @param x the x coordinate of the explosion source. + * @param y the y coordinate of the explosion source. + * @param colors the set of colors to colorize the confetti bitmaps. + * @return the created common confetti object. + */ + fun explosion( + container: ViewGroup, + x: Int, + y: Int, + colors: IntArray, + ) = CommonConfetti(container).apply { + configureExplosion(container, x, y, colors) + } + // endregion + + private fun ensureStaticResources(resources: Resources) = with(resources) { + if (defaultConfettiSize == 0) { + defaultConfettiSize = getDimensionPixelSize(R.dimen.confetti_size) + defaultVelocitySlow = getDimensionPixelOffset(R.dimen.confetti_velocity_slow) + defaultVelocityNormal = getDimensionPixelOffset(R.dimen.confetti_velocity_normal) + defaultVelocityFast = getDimensionPixelOffset(R.dimen.confetti_velocity_fast) + explosionRadius = getDimensionPixelOffset(R.dimen.confetti_explosion_radius) + } + } + } +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiManager.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiManager.kt new file mode 100644 index 00000000..4aefa0c4 --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiManager.kt @@ -0,0 +1,598 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Rect +import android.view.View +import android.view.View.OnAttachStateChangeListener +import android.view.ViewGroup +import android.view.animation.Interpolator +import com.infomaniak.lib.confetti.confetto.Confetto +import java.util.LinkedList +import java.util.Queue +import java.util.Random +import kotlin.math.roundToInt + +/** + * A helper manager class for configuring a set of confetti and displaying them on the UI. + */ +class ConfettiManager( + private val confettoGenerator: ConfettoGenerator, + private val confettiSource: ConfettiSource, + private val parentView: ViewGroup, + private val confettiView: ConfettiView, +) { + + private val random = Random() + private val recycledConfetti: Queue = LinkedList() + private val confetti: MutableList = ArrayList(300) + private var animator: ValueAnimator? = null + private var lastEmittedTimestamp: Long = 0 + // All of the below configured values are in milliseconds despite the setter methods take them + // in seconds as the parameters. The parameters for the setters are in seconds to allow for + // users to better understand/visualize the dimensions. + // Configured attributes for the entire confetti group + private var numInitialCount = 0 + private var emissionDuration: Long = 0 + private var emissionRate = 0f + private var emissionRateInverse = 0f + private var fadeOutInterpolator: Interpolator? = null + private var bound: Rect + // Configured attributes for each confetto + private var velocityX = 0f + private var velocityDeviationX = 0f + private var velocityY = 0f + private var velocityDeviationY = 0f + private var accelerationX = 0f + private var accelerationDeviationX = 0f + private var accelerationY = 0f + private var accelerationDeviationY = 0f + private var targetVelocityX: Float? = null + private var targetVelocityXDeviation: Float? = null + private var targetVelocityY: Float? = null + private var targetVelocityYDeviation: Float? = null + private var initialRotation = 0 + private var initialRotationDeviation = 0 + private var rotationalVelocity = 0f + private var rotationalVelocityDeviation = 0f + private var rotationalAcceleration = 0f + private var rotationalAccelerationDeviation = 0f + private var targetRotationalVelocity: Float? = null + private var targetRotationalVelocityDeviation: Float? = null + private var ttl: Long + private var animationListener: ConfettiAnimationListener? = null + + constructor( + context: Context, + confettoGenerator: ConfettoGenerator, + confettiSource: ConfettiSource, + parentView: ViewGroup, + ) : this(confettoGenerator, confettiSource, parentView, ConfettiView(context)) + + init { + + confettiView.apply { + bind(confetti) + addOnAttachStateChangeListener(object : OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + // No-op + } + + override fun onViewDetachedFromWindow(v: View) { + terminate() + } + }) + } + + // Set the defaults + ttl = -1 + bound = Rect(0, 0, parentView.width, parentView.height) + } + + /** + * The number of confetti initially emitted before any time has elapsed. + * + * @param numInitialCount the number of initial confetti. + * @return the confetti manager so that the set calls can be chained. + */ + fun setNumInitialCount(numInitialCount: Int) = apply { + this.numInitialCount = numInitialCount + } + + /** + * Configures how long this manager will emit new confetti after the animation starts. + * + * @param emissionDurationInMillis how long to emit new confetti in millis. This value can be + * [.INFINITE_DURATION] for a never-ending emission. + * @return the confetti manager so that the set calls can be chained. + */ + fun setEmissionDuration(emissionDurationInMillis: Long) = apply { + emissionDuration = emissionDurationInMillis + } + + /** + * Configures how frequently this manager will emit new confetti after the animation starts + * if [.emissionDuration] is a positive value. + * + * @param emissionRate the rate of emission in # of confetti per second. + * @return the confetti manager so that the set calls can be chained. + */ + fun setEmissionRate(emissionRate: Float) = apply { + this.emissionRate = emissionRate / 1_000.0f + emissionRateInverse = 1.0f / this.emissionRate + } + + /** + * @param velocityX the X velocity in pixels per second. + * @return the confetti manager so that the set calls can be chained. + * @see .setVelocityX + */ + fun setVelocityX(velocityX: Float) = setVelocityX(velocityX, velocityDeviationX = 0.0f) + + /** + * Set the velocityX used by this manager. This value defines the initial X velocity + * for the generated confetti. The actual confetti's X velocity will be + * (velocityX +- [0, velocityDeviationX]). + * + * @param velocityX the X velocity in pixels per second. + * @param velocityDeviationX the deviation from X velocity in pixels per second. + * @return the confetti manager so that the set calls can be chained. + */ + fun setVelocityX(velocityX: Float, velocityDeviationX: Float) = apply { + this.velocityX = velocityX / 1_000.0f + this.velocityDeviationX = velocityDeviationX / 1_000.0f + } + + /** + * @param velocityY the Y velocity in pixels per second. + * @return the confetti manager so that the set calls can be chained. + * @see .setVelocityY + */ + fun setVelocityY(velocityY: Float) = setVelocityY(velocityY, velocityDeviationY = 0.0f) + + /** + * Set the velocityY used by this manager. This value defines the initial Y velocity + * for the generated confetti. The actual confetti's Y velocity will be + * (velocityY +- [0, velocityDeviationY]). A positive Y velocity means that the velocity + * is going down (because Y coordinate increases going down). + * + * @param velocityY the Y velocity in pixels per second. + * @param velocityDeviationY the deviation from Y velocity in pixels per second. + * @return the confetti manager so that the set calls can be chained. + */ + fun setVelocityY(velocityY: Float, velocityDeviationY: Float) = apply { + this.velocityY = velocityY / 1_000.0f + this.velocityDeviationY = velocityDeviationY / 1_000.0f + } + + /** + * @param accelerationX the X acceleration in pixels per second^2. + * @return the confetti manager so that the set calls can be chained. + * @see .setAccelerationX + */ + fun setAccelerationX(accelerationX: Float) = setAccelerationX(accelerationX, accelerationDeviationX = 0.0f) + + /** + * Set the accelerationX used by this manager. This value defines the X acceleration + * for the generated confetti. The actual confetti's X acceleration will be + * (accelerationX +- [0, accelerationDeviationX]). + * + * @param accelerationX the X acceleration in pixels per second^2. + * @param accelerationDeviationX the deviation from X acceleration in pixels per second^2. + * @return the confetti manager so that the set calls can be chained. + */ + fun setAccelerationX(accelerationX: Float, accelerationDeviationX: Float) = apply { + this.accelerationX = accelerationX / 1_000_000.0f + this.accelerationDeviationX = accelerationDeviationX / 1_000_000.0f + } + + /** + * @param accelerationY the Y acceleration in pixels per second^2. + * @return the confetti manager so that the set calls can be chained. + * @see .setAccelerationY + */ + fun setAccelerationY(accelerationY: Float) = setAccelerationY(accelerationY, accelerationDeviationY = 0.0f) + + /** + * Set the accelerationY used by this manager. This value defines the Y acceleration + * for the generated confetti. The actual confetti's Y acceleration will be + * (accelerationY +- [0, accelerationDeviationY]). A positive Y acceleration means that the + * confetto will be accelerating downwards. + * + * @param accelerationY the Y acceleration in pixels per second^2. + * @param accelerationDeviationY the deviation from Y acceleration in pixels per second^2. + * @return the confetti manager so that the set calls can be chained. + */ + fun setAccelerationY(accelerationY: Float, accelerationDeviationY: Float) = apply { + this.accelerationY = accelerationY / 1_000_000.0f + this.accelerationDeviationY = accelerationDeviationY / 1_000_000.0f + } + + /** + * @param targetVelocityX the target X velocity in pixels per second. + * @return the confetti manager so that the set calls can be chained. + * @see .setTargetVelocityX + */ + fun setTargetVelocityX(targetVelocityX: Float) = setTargetVelocityX(targetVelocityX, targetVelocityXDeviation = 0.0f) + + /** + * Set the target X velocity that confetti can reach during the animation. The actual confetti's + * target X velocity will be (targetVelocityX +- [0, targetVelocityXDeviation]). + * + * @param targetVelocityX the target X velocity in pixels per second. + * @param targetVelocityXDeviation the deviation from target X velocity in pixels per second. + * @return the confetti manager so that the set calls can be chained. + */ + fun setTargetVelocityX(targetVelocityX: Float, targetVelocityXDeviation: Float) = apply { + this.targetVelocityX = targetVelocityX / 1_000.0f + this.targetVelocityXDeviation = targetVelocityXDeviation / 1_000.0f + } + + /** + * @param targetVelocityY the target Y velocity in pixels per second. + * @return the confetti manager so that the set calls can be chained. + * @see .setTargetVelocityY + */ + fun setTargetVelocityY(targetVelocityY: Float) = setTargetVelocityY(targetVelocityY, targetVelocityYDeviation = 0.0f) + + /** + * Set the target Y velocity that confetti can reach during the animation. The actual confetti's + * target Y velocity will be (targetVelocityY +- [0, targetVelocityYDeviation]). + * + * @param targetVelocityY the target Y velocity in pixels per second. + * @param targetVelocityYDeviation the deviation from target Y velocity in pixels per second. + * @return the confetti manager so that the set calls can be chained. + */ + fun setTargetVelocityY(targetVelocityY: Float, targetVelocityYDeviation: Float) = apply { + this.targetVelocityY = targetVelocityY / 1_000.0f + this.targetVelocityYDeviation = targetVelocityYDeviation / 1_000.0f + } + + /** + * @param initialRotation the initial rotation in degrees. + * @return the confetti manager so that the set calls can be chained. + * @see .setInitialRotation + */ + fun setInitialRotation(initialRotation: Int) = setInitialRotation(initialRotation, initialRotationDeviation = 0) + + /** + * Set the initialRotation used by this manager. This value defines the initial rotation in + * degrees for the generated confetti. The actual confetti's initial rotation will be + * (initialRotation +- [0, initialRotationDeviation]). + * + * @param initialRotation the initial rotation in degrees. + * @param initialRotationDeviation the deviation from initial rotation in degrees. + * @return the confetti manager so that the set calls can be chained. + */ + fun setInitialRotation(initialRotation: Int, initialRotationDeviation: Int) = apply { + this.initialRotation = initialRotation + this.initialRotationDeviation = initialRotationDeviation + } + + /** + * @param rotationalVelocity the initial rotational velocity in degrees per second. + * @return the confetti manager so that the set calls can be chained. + * @see .setRotationalVelocity + */ + fun setRotationalVelocity(rotationalVelocity: Float) = + setRotationalVelocity(rotationalVelocity, rotationalVelocityDeviation = 0.0f) + + /** + * Set the rotationalVelocity used by this manager. This value defines the the initial + * rotational velocity for the generated confetti. The actual confetti's initial + * rotational velocity will be (rotationalVelocity +- [0, rotationalVelocityDeviation]). + * + * @param rotationalVelocity the initial rotational velocity in degrees per second. + * @param rotationalVelocityDeviation the deviation from initial rotational velocity in + * degrees per second. + * @return the confetti manager so that the set calls can be chained. + */ + fun setRotationalVelocity(rotationalVelocity: Float, rotationalVelocityDeviation: Float) = apply { + this.rotationalVelocity = rotationalVelocity / 1_000.0f + this.rotationalVelocityDeviation = rotationalVelocityDeviation / 1_000.0f + } + + /** + * @param rotationalAcceleration the rotational acceleration in degrees per second^2. + * @return the confetti manager so that the set calls can be chained. + * @see .setRotationalAcceleration + */ + fun setRotationalAcceleration(rotationalAcceleration: Float) = + setRotationalAcceleration(rotationalAcceleration, rotationalAccelerationDeviation = 0.0f) + + /** + * Set the rotationalAcceleration used by this manager. This value defines the the + * acceleration of the rotation for the generated confetti. The actual confetti's rotational + * acceleration will be (rotationalAcceleration +- [0, rotationalAccelerationDeviation]). + * + * @param rotationalAcceleration the rotational acceleration in degrees per second^2. + * @param rotationalAccelerationDeviation the deviation from rotational acceleration in degrees + * per second^2. + * @return the confetti manager so that the set calls can be chained. + */ + fun setRotationalAcceleration(rotationalAcceleration: Float, rotationalAccelerationDeviation: Float) = apply { + this.rotationalAcceleration = rotationalAcceleration / 1_000_000.0f + this.rotationalAccelerationDeviation = rotationalAccelerationDeviation / 1_000_000.0f + } + + /** + * @param targetRotationalVelocity the target rotational velocity in degrees per second. + * @return the confetti manager so that the set calls can be chained. + * @see .setTargetRotationalVelocity + */ + fun setTargetRotationalVelocity(targetRotationalVelocity: Float) = + setTargetRotationalVelocity(targetRotationalVelocity, targetRotationalVelocityDeviation = 0.0f) + + /** + * Set the target rotational velocity that confetti can reach during the animation. The actual + * confetti's target rotational velocity will be + * (targetRotationalVelocity +- [0, targetRotationalVelocityDeviation]). + * + * @param targetRotationalVelocity the target rotational velocity in degrees per second. + * @param targetRotationalVelocityDeviation the deviation from target rotational velocity + * in degrees per second. + * @return the confetti manager so that the set calls can be chained. + */ + fun setTargetRotationalVelocity(targetRotationalVelocity: Float, targetRotationalVelocityDeviation: Float) = apply { + this.targetRotationalVelocity = targetRotationalVelocity / 1_000.0f + this.targetRotationalVelocityDeviation = targetRotationalVelocityDeviation / 1_000.0f + } + + /** + * Specifies a custom bound that the confetti will clip to. By default, the confetti will be + * able to animate throughout the entire screen. The dimensions specified in bound is + * global dimensions, e.g. x=0 is the top of the screen, rather than relative dimensions. + * + * @param bound the bound that clips the confetti as they animate. + * @return the confetti manager so that the set calls can be chained. + */ + fun setBound(bound: Rect) = apply { + this.bound = bound + } + + /** + * Specifies a custom time to live for the confetti generated by this manager. When a confetti + * reaches its time to live timer, it will disappear and terminate its animation. + * + * + * The time to live value does not include the initial delay of the confetti. + * + * @param ttlInMillis the custom time to live in milliseconds. + * @return the confetti manager so that the set calls can be chained. + */ + fun setTTL(ttlInMillis: Long) = apply { + ttl = ttlInMillis + } + + /** + * Enables fade out for all of the confetti generated by this manager. Fade out means that + * the confetti will animate alpha according to the fadeOutInterpolator according + * to its TTL or, if TTL is not set, its bounds. + * + * @param fadeOutInterpolator an interpolator that interpolates animation progress [0, 1] into + * an alpha value [0, 1], 0 being transparent and 1 being opaque. + * @return the confetti manager so that the set calls can be chained. + */ + fun enableFadeOut(fadeOutInterpolator: Interpolator?) = apply { + this.fadeOutInterpolator = fadeOutInterpolator + } + + /** + * Disables fade out for all of the confetti generated by this manager. + * + * @return the confetti manager so that the set calls can be chained. + */ + fun disableFadeOut() = apply { + fadeOutInterpolator = null + } + + /** + * Enables or disables touch events for the confetti generated by this manager. By enabling + * touch, the user can touch individual confetto and drag/fling them on the screen independent + * of their original animation state. + * + * @param touchEnabled whether or not to enable touch. + * @return the confetti manager so that the set calls can be chained. + */ + fun setTouchEnabled(touchEnabled: Boolean) = apply { + confettiView.setTouchEnabled(touchEnabled) + } + + /** + * Sets a [ConfettiAnimationListener] for this confetti manager. + * + * @param listener the animation listener, or null to clear out the existing listener. + * @return the confetti manager so that the set calls can be chained. + */ + fun setConfettiAnimationListener(listener: ConfettiAnimationListener?) = apply { + animationListener = listener + } + + /** + * Start the confetti animation configured by this manager. + * + * @return the confetti manager itself that just started animating. + */ + fun animate(useGaussian: Boolean) = apply { + if (animationListener != null) animationListener!!.onAnimationStart(this) + cleanupExistingAnimation() + attachConfettiViewToParent() + addNewConfetti(numInitialCount, initialDelay = 0L, useGaussian) + startNewAnimation(useGaussian) + } + + /** + * Terminate the currently running animation if there is any. + */ + fun terminate() { + animator?.cancel() + confettiView.terminate() + animationListener?.onAnimationEnd(this) + } + + private fun cleanupExistingAnimation() { + animator?.cancel() + lastEmittedTimestamp = 0 + val iterator = confetti.iterator() + while (iterator.hasNext()) { + removeConfetto(iterator.next()) + iterator.remove() + } + } + + private fun attachConfettiViewToParent() { + val currentParent = confettiView.parent + if (currentParent == null) { + parentView.addView(confettiView) + } else if (currentParent !== parentView) { + (currentParent as ViewGroup).removeView(confettiView) + parentView.addView(confettiView) + } + confettiView.reset() + } + + private fun addNewConfetti(numConfetti: Int, initialDelay: Long, useGaussian: Boolean) { + for (i in 0 until numConfetti) { + var confetto = recycledConfetti.poll() + if (confetto == null) confetto = confettoGenerator.generateConfetto(random) + confetto.reset() + confetto.configure(confettiSource, random, initialDelay, useGaussian) + confetto.prepare(bound) + addConfetto(confetto) + } + } + + private fun startNewAnimation(useGaussian: Boolean) { + // Never-ending animator, we will cancel once the termination condition is reached. + animator = ValueAnimator.ofInt(0).setDuration(Long.MAX_VALUE).also { + it.addUpdateListener { valueAnimator: ValueAnimator -> + val elapsedTime = valueAnimator.currentPlayTime + processNewEmission(elapsedTime, useGaussian) + updateConfetti(elapsedTime) + if (confetti.size == 0 && elapsedTime >= emissionDuration) { + terminate() + } else { + confettiView.invalidate() + } + } + it.start() + } + } + + private fun processNewEmission(elapsedTime: Long, useGaussian: Boolean) { + if (elapsedTime < emissionDuration) { + if (lastEmittedTimestamp == 0L) { + lastEmittedTimestamp = elapsedTime + } else { + val timeSinceLastEmission = elapsedTime - lastEmittedTimestamp + // Randomly determine how many confetti to emit + val numNewConfetti = (random.nextFloat() * emissionRate * timeSinceLastEmission).toInt() + if (numNewConfetti > 0) { + lastEmittedTimestamp += (emissionRateInverse * numNewConfetti).roundToInt().toLong() + addNewConfetti(numNewConfetti, elapsedTime, useGaussian) + } + } + } + } + + private fun updateConfetti(elapsedTime: Long) { + val iterator = confetti.iterator() + while (iterator.hasNext()) { + val confetto = iterator.next() + if (!confetto.applyUpdate(elapsedTime)) { + iterator.remove() + removeConfetto(confetto) + } + } + } + + private fun addConfetto(confetto: Confetto) { + confetti.add(confetto) + animationListener?.onConfettoEnter(confetto) + } + + private fun removeConfetto(confetto: Confetto) { + animationListener?.onConfettoExit(confetto) + recycledConfetti.add(confetto) + } + + private fun Confetto.configure( + confettiSource: ConfettiSource, + random: Random, + initialDelay: Long, + useGaussian: Boolean, + ) { + setInitialDelay(initialDelay) + setInitialX(confettiSource.getInitialX(random.nextFloat())) + setInitialY(confettiSource.getInitialY(random.nextFloat())) + setInitialVelocityX(getVarianceAmount(velocityX, velocityDeviationX, random, useGaussian)) + setInitialVelocityY(getVarianceAmount(velocityY, velocityDeviationY, random, useGaussian)) + setAccelerationX(getVarianceAmount(accelerationX, accelerationDeviationX, random, useGaussian)) + setAccelerationY(getVarianceAmount(accelerationY, accelerationDeviationY, random, useGaussian)) + setTargetVelocityX(targetVelocityX?.let { getVarianceAmount(it, targetVelocityXDeviation!!, random, useGaussian) }) + setTargetVelocityY(targetVelocityY?.let { getVarianceAmount(it, targetVelocityYDeviation!!, random, useGaussian) }) + setInitialRotation(getVarianceAmount(initialRotation.toFloat(), initialRotationDeviation.toFloat(), random, useGaussian)) + setInitialRotationalVelocity(getVarianceAmount(rotationalVelocity, rotationalVelocityDeviation, random, useGaussian)) + setRotationalAcceleration(getVarianceAmount(rotationalAcceleration, rotationalAccelerationDeviation, random, useGaussian)) + setTargetRotationalVelocity( + targetRotationalVelocity?.let { getVarianceAmount(it, targetRotationalVelocityDeviation!!, random, useGaussian) }, + ) + setTTL(ttl) + setFadeOut(fadeOutInterpolator) + } + + private fun getVarianceAmount(base: Float, deviation: Float, random: Random, useGaussian: Boolean): Float { + // Normalize random to be [-1, 1] rather than [0, 1] + return if (useGaussian) { + base + deviation * (random.nextGaussian().toFloat() * 2 - 1) + } else { + base + deviation * (random.nextFloat() * 2 - 1) + } + } + + interface ConfettiAnimationListener { + fun onAnimationStart(confettiManager: ConfettiManager?) + fun onAnimationEnd(confettiManager: ConfettiManager?) + fun onConfettoEnter(confetto: Confetto?) + fun onConfettoExit(confetto: Confetto?) + } + + class ConfettiAnimationListenerAdapter : ConfettiAnimationListener { + override fun onAnimationStart(confettiManager: ConfettiManager?) { + // No-op + } + + override fun onAnimationEnd(confettiManager: ConfettiManager?) { + // No-op + } + + override fun onConfettoEnter(confetto: Confetto?) { + // No-op + } + + override fun onConfettoExit(confetto: Confetto?) { + // No-op + } + } + + companion object { + const val INFINITE_DURATION = Long.MAX_VALUE + } +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiSource.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiSource.kt new file mode 100644 index 00000000..38b9cc01 --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiSource.kt @@ -0,0 +1,47 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2023-2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti + +/** + * The source from which confetti will appear. This can be either a line or a point. + * + * Please note that the specified source represents the top left corner of the drawn + * confetti. If you want the confetti to appear from off-screen, you'll have to offset it + * with the confetti's size. + * + * Specifies a line source from which all confetti will emit from. + * + * @param x0 x-coordinate of the first point relative to the [ConfettiView]'s parent. + * @param y0 y-coordinate of the first point relative to the [ConfettiView]'s parent. + * @param x1 x-coordinate of the second point relative to the [ConfettiView]'s parent. + * @param y1 y-coordinate of the second point relative to the [ConfettiView]'s parent. + */ +class ConfettiSource(private val x0: Int, private val y0: Int, private val x1: Int, private val y1: Int) { + + /** + * Specifies a point source from which all confetti will emit from. + * + * @param x x-coordinate of the point relative to the [ConfettiView]'s parent. + * @param y y-coordinate of the point relative to the [ConfettiView]'s parent. + */ + constructor(x: Int, y: Int) : this(x, y, x, y) + + fun getInitialX(random: Float): Float = x0 + (x1 - x0) * random + + fun getInitialY(random: Float): Float = y0 + (y1 - y0) * random +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiView.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiView.kt new file mode 100644 index 00000000..8b6db1e5 --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiView.kt @@ -0,0 +1,144 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.View.OnLayoutChangeListener +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import com.infomaniak.lib.confetti.confetto.Confetto + +/** + * A helper temporary view that helps render the confetti. This view will attach itself to the + * view root, perform the animation, and then once all of the confetti has completed its animation, + * it will automatically remove itself from the parent. + */ +class ConfettiView(context: Context, attrs: AttributeSet? = null) : View(context, attrs), OnLayoutChangeListener { + + private var confetti: List? = null + private var terminated = false + private var touchEnabled = false + private var draggedConfetto: Confetto? = null + + init { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + elevation = context.resources.getDimensionPixelOffset(R.dimen.confetti_elevation).toFloat() + } + + /** + * Sets the list of confetti to be animated by this view. + * + * @param confetti the list of confetti to be animated. + */ + fun bind(confetti: List?) { + this.confetti = confetti + } + + /** + * @param touchEnabled whether or not to enable touch + * @see ConfettiManager.setTouchEnabled + */ + fun setTouchEnabled(touchEnabled: Boolean) { + this.touchEnabled = touchEnabled + } + + /** + * Terminate the current running animation (if any) and remove this view from the parent. + */ + fun terminate() { + if (!terminated) { + terminated = true + parent.requestLayout() + } + } + + /** + * Reset the internal state of this view to allow for a new confetti animation. + */ + fun reset() { + terminated = false + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + (parent as ViewGroup).apply { + removeOnLayoutChangeListener(this@ConfettiView) + addOnLayoutChangeListener(this@ConfettiView) + } + + // If we did not bind before attaching to the window, that means this ConfettiView no longer + // has a ConfettiManager backing it and should just be terminated. + if (confetti == null) terminate() + } + + override fun onLayoutChange(view: View, l: Int, t: Int, r: Int, b: Int, oldL: Int, oldT: Int, oldR: Int, oldB: Int) { + if (terminated) { + (parent as ViewGroup).apply { + removeViewInLayout(this@ConfettiView) + removeOnLayoutChangeListener(this@ConfettiView) + invalidate() + } + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (!terminated) { + canvas.save() + for (confetto in confetti!!) confetto.draw(canvas) + canvas.restore() + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + var handled = false + if (!touchEnabled) return super.onTouchEvent(event) + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + for (confetto in confetti!!) { + if (confetto.onTouchDown(event)) { + draggedConfetto = confetto + handled = true + break + } + } + } + MotionEvent.ACTION_MOVE -> { + if (draggedConfetto != null) { + draggedConfetto!!.onTouchMove(event) + handled = true + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (draggedConfetto != null) { + draggedConfetto!!.onTouchUp(event) + draggedConfetto = null + handled = true + } + } + } + + return handled || super.onTouchEvent(event) + } +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettoGenerator.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettoGenerator.kt new file mode 100644 index 00000000..8ac6abc8 --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettoGenerator.kt @@ -0,0 +1,31 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti + +import com.infomaniak.lib.confetti.confetto.Confetto +import java.util.Random + +interface ConfettoGenerator { + /** + * Generate a random confetto to animate. + * + * @param random a [Random] that can be used to generate random confetto. + * @return the randomly generated confetto. + */ + fun generateConfetto(random: Random): Confetto +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/Utils.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/Utils.kt new file mode 100644 index 00000000..b4f8993d --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/Utils.kt @@ -0,0 +1,87 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.view.animation.Interpolator +import kotlin.math.tan + +object Utils { + + private val paint = Paint().apply { style = Paint.Style.FILL } + + @JvmStatic + var defaultAlphaInterpolator: Interpolator? = null + get() { + if (field == null) field = Interpolator { v: Float -> if (v >= 0.9f) 1.0f - (v - 0.9f) * 10.0f else 1.0f } + return field + } + private set + + @JvmStatic + fun generateConfettiBitmaps(colors: IntArray, size: Int): List = mutableListOf().apply { + for (color in colors) { + add(createCircleBitmap(color, size)) + add(createSquareBitmap(color, size)) + add(createTriangleBitmap(color, size)) + } + } + + fun createCircleBitmap(color: Int, size: Int): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val radius = size / 2.0f + paint.color = color + canvas.drawCircle(radius, radius, radius, paint) + return bitmap + } + + fun createSquareBitmap(color: Int, size: Int): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val path = Path().apply { + moveTo(0.0f, 0.0f) + lineTo(size.toFloat(), 0.0f) + lineTo(size.toFloat(), size.toFloat()) + lineTo(0.0f, size.toFloat()) + close() + } + paint.color = color + canvas.drawPath(path, paint) + return bitmap + } + + fun createTriangleBitmap(color: Int, size: Int): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + // Generate equilateral triangle (http://mathworld.wolfram.com/EquilateralTriangle.html). + val path = Path().apply { + val point = tan(15.0f / 180.0f * Math.PI).toFloat() * size + moveTo(0.0f, 0.0f) + lineTo(size.toFloat(), point) + lineTo(point, size.toFloat()) + close() + } + paint.color = color + canvas.drawPath(path, paint) + return bitmap + } +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/BitmapConfetto.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/BitmapConfetto.kt new file mode 100644 index 00000000..de3eb632 --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/BitmapConfetto.kt @@ -0,0 +1,49 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti.confetto + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint + +open class BitmapConfetto(private val bitmap: Bitmap) : Confetto() { + + private val bitmapCenterX: Float = bitmap.width / 2.0f + private val bitmapCenterY: Float = bitmap.height / 2.0f + + override val width: Int + get() = bitmap.width + + override val height: Int + get() = bitmap.height + + override fun drawInternal( + canvas: Canvas, + matrix: Matrix, + paint: Paint, + x: Float, + y: Float, + rotation: Float, + percentAnimated: Float, + ) { + matrix.preTranslate(x, y) + matrix.preRotate(rotation, bitmapCenterX, bitmapCenterY) + canvas.drawBitmap(bitmap, matrix, paint) + } +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/CircleConfetto.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/CircleConfetto.kt new file mode 100644 index 00000000..5f24fd23 --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/CircleConfetto.kt @@ -0,0 +1,54 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti.confetto + +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint + +/** + * A lightly more optimal way to draw a circle shape that doesn't require the use of a bitmap. + */ +class CircleConfetto(private val color: Int, private val radius: Float) : Confetto() { + + private val diameter: Int = (radius * 2.0f).toInt() + + override val width: Int + get() = diameter + + override val height: Int + get() = diameter + + override fun configurePaint(paint: Paint) { + super.configurePaint(paint) + paint.style = Paint.Style.FILL + paint.color = color + } + + override fun drawInternal( + canvas: Canvas, + matrix: Matrix, + paint: Paint, + x: Float, + y: Float, + rotation: Float, + percentAnimated: Float, + ) { + canvas.drawCircle(x + radius, y + radius, radius, paint) + } +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/Confetto.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/Confetto.kt new file mode 100644 index 00000000..4c8db228 --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/Confetto.kt @@ -0,0 +1,491 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti.confetto + +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Rect +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.animation.Interpolator +import kotlin.math.sqrt + +/** + * Abstract class that represents a single confetto on the screen. This class holds all of the + * internal states for the confetto to help it animate. + * + * All of the configured states are in milliseconds, e.g. pixels per millisecond for velocity. + */ +abstract class Confetto { + + private val matrix = Matrix() + private val workPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val workPairs = FloatArray(2) + private var currentVelocityX = 0.0f + private var currentVelocityY = 0.0f + private var currentRotationalVelocity = 0.0f + + // Configured coordinate states + private var bound: Rect? = null + private var initialDelay = 0L + private var initialX = 0.0f + private var initialY = 0.0f + private var initialVelocityX = 0.0f + private var initialVelocityY = 0.0f + private var accelerationX = 0.0f + private var accelerationY = 0.0f + private var targetVelocityX: Float? = null + private var targetVelocityY: Float? = null + private var millisToReachTargetVelocityX: Long? = null + private var millisToReachTargetVelocityY: Long? = null + + // Configured rotation states + private var initialRotation = 0.0f + private var initialRotationalVelocity = 0.0f + private var rotationalAcceleration = 0.0f + private var targetRotationalVelocity: Float? = null + private var millisToReachTargetRotationalVelocity: Long? = null + + // Configured animation states + private var ttl = 0L + private var fadeOutInterpolator: Interpolator? = null + private var millisToReachBound = 0.0f + private var percentageAnimated = 0.0f + + // Current draw states + private var currentX = 0.0f + private var currentY = 0.0f + private var currentRotation = 0.0f + private var alpha = 0 // alpha is [0, 255] + private var startedAnimation = false + private var terminated = false + + // Touch events + private var touchOverride = false + private var velocityTracker: VelocityTracker? = null + private var overrideX = 0.0f + private var overrideY = 0.0f + private var overrideVelocityX = 0.0f + private var overrideVelocityY = 0.0f + private var overrideDeltaX = 0.0f + private var overrideDeltaY = 0.0f + + /** + * This method should be called after all of the confetto's state variables are configured + * and before the confetto gets animated. + * + * @param bound the space in which the confetto can display in. + */ + fun prepare(bound: Rect?) { + this.bound = bound + + millisToReachTargetVelocityX = computeMillisToReachTarget(targetVelocityX, initialVelocityX, accelerationX) + millisToReachTargetVelocityY = computeMillisToReachTarget(targetVelocityY, initialVelocityY, accelerationY) + millisToReachTargetRotationalVelocity = + computeMillisToReachTarget(targetRotationalVelocity, initialRotationalVelocity, rotationalAcceleration) + + // Compute how long it would take to reach x/y bounds or reach TTL. + millisToReachBound = (if (ttl >= 0) ttl else Long.MAX_VALUE).toFloat() + val timeToReachXBound = computeBound( + initialPos = initialX, + velocity = initialVelocityX, + acceleration = accelerationX, + targetTime = millisToReachTargetVelocityX, + targetVelocity = targetVelocityX, + minBound = bound!!.left - width, + maxBound = bound.right, + ) + + millisToReachBound = timeToReachXBound.toFloat().coerceAtMost(millisToReachBound) + val timeToReachYBound = computeBound( + initialPos = initialY, + velocity = initialVelocityY, + acceleration = accelerationY, + targetTime = millisToReachTargetVelocityY, + targetVelocity = targetVelocityY, + minBound = bound.top - height, + maxBound = bound.bottom, + ) + + millisToReachBound = timeToReachYBound.toFloat().coerceAtMost(millisToReachBound) + configurePaint(workPaint) + } + + private fun doesLocationIntercept(x: Float, y: Float): Boolean { + return currentX <= x && x <= currentX + width && currentY <= y && y <= currentY + height + } + + fun onTouchDown(event: MotionEvent): Boolean { + val x = event.x + val y = event.y + return if (doesLocationIntercept(x, y)) { + touchOverride = true + overrideX = x + overrideY = y + overrideDeltaX = currentX - x + overrideDeltaY = currentY - y + velocityTracker = VelocityTracker.obtain().also { it.addMovement(event) } + true + } else { + false + } + } + + fun onTouchMove(event: MotionEvent) { + overrideX = event.x + overrideY = event.y + velocityTracker!!.addMovement(event) + velocityTracker!!.computeCurrentVelocity(1) + overrideVelocityX = velocityTracker!!.xVelocity + overrideVelocityY = velocityTracker!!.yVelocity + } + + fun onTouchUp(event: MotionEvent) { + velocityTracker!!.addMovement(event) + velocityTracker!!.computeCurrentVelocity(1) + initialDelay = RESET_ANIMATION_INITIAL_DELAY + initialX = event.x + overrideDeltaX + initialY = event.y + overrideDeltaY + initialVelocityX = velocityTracker!!.xVelocity + initialVelocityY = velocityTracker!!.yVelocity + initialRotation = currentRotation + velocityTracker!!.recycle() + velocityTracker = null + prepare(bound) + touchOverride = false + } + + /** + * @return the width of the confetto. + */ + abstract val width: Int + + /** + * @return the height of the confetto. + */ + abstract val height: Int + + /** + * Reset this confetto object's internal states so that it can be re-used. + */ + fun reset() { + initialDelay = 0L + initialY = 0.0f + initialX = 0.0f + initialVelocityY = 0.0f + initialVelocityX = 0.0f + accelerationY = 0.0f + accelerationX = 0.0f + targetVelocityY = null + targetVelocityX = null + millisToReachTargetVelocityY = null + millisToReachTargetVelocityX = null + initialRotation = 0.0f + initialRotationalVelocity = 0.0f + rotationalAcceleration = 0.0f + targetRotationalVelocity = null + millisToReachTargetRotationalVelocity = null + ttl = 0L + millisToReachBound = 0.0f + percentageAnimated = 0.0f + fadeOutInterpolator = null + currentY = 0.0f + currentX = currentY + currentVelocityY = 0.0f + currentVelocityX = currentVelocityY + currentRotation = 0.0f + alpha = MAX_ALPHA + startedAnimation = false + terminated = false + } + + /** + * Hook to configure the global paint states before any animation happens. + * + * @param paint the paint object that will be used to perform all draw operations. + */ + open fun configurePaint(paint: Paint) { + paint.alpha = alpha + } + + /** + * Update the confetto internal state based on the provided passed time. + * + * @param passedTime time since the beginning of the animation. + * @return whether this particular confetto is still animating. + */ + fun applyUpdate(passedTime: Long): Boolean { + if (initialDelay == RESET_ANIMATION_INITIAL_DELAY) initialDelay = passedTime + val animatedTime = passedTime - initialDelay + startedAnimation = animatedTime >= 0 + if (startedAnimation && !terminated) { + computeDistance( + pair = workPairs, + t = animatedTime, + xi = initialX, + vi = initialVelocityX, + ai = accelerationX, + targetTime = millisToReachTargetVelocityX, + vTarget = targetVelocityX, + ) + currentX = workPairs[0] + currentVelocityX = workPairs[1] + computeDistance( + pair = workPairs, + t = animatedTime, + xi = initialY, + vi = initialVelocityY, + ai = accelerationY, + targetTime = millisToReachTargetVelocityY, + vTarget = targetVelocityY, + ) + currentY = workPairs[0] + currentVelocityY = workPairs[1] + computeDistance( + pair = workPairs, + t = animatedTime, + xi = initialRotation, + vi = initialRotationalVelocity, + ai = rotationalAcceleration, + targetTime = millisToReachTargetRotationalVelocity, + vTarget = targetRotationalVelocity, + ) + currentRotation = workPairs[0] + currentRotationalVelocity = workPairs[1] + alpha = if (fadeOutInterpolator != null) { + val interpolatedTime = fadeOutInterpolator!!.getInterpolation(animatedTime / millisToReachBound) + (interpolatedTime * MAX_ALPHA).toInt() + } else { + MAX_ALPHA + } + terminated = !touchOverride && animatedTime >= millisToReachBound + percentageAnimated = 1.0f.coerceAtMost(animatedTime / millisToReachBound) + } + return !terminated + } + + private fun computeDistance(pair: FloatArray, t: Long, xi: Float, vi: Float, ai: Float, targetTime: Long?, vTarget: Float?) { + // velocity with constant acceleration + val vX = ai * t + vi + pair[1] = vX + val x = if (targetTime == null || t < targetTime) { + // distance covered with constant acceleration + xi + vi * t + 0.5f * ai * t * t + } else { + // distance covered with constant acceleration + distance covered with max velocity + xi + vi * targetTime + 0.5f * ai * targetTime * targetTime + (t - targetTime) * vTarget!! + } + pair[0] = x + } + + /** + * Primary method for rendering this confetto on the canvas. + * + * @param canvas the canvas to draw on. + */ + fun draw(canvas: Canvas) { + if (touchOverride) { + // Replace time-calculated velocities with touch-velocities + currentVelocityX = overrideVelocityX + currentVelocityY = overrideVelocityY + draw(canvas, overrideX + overrideDeltaX, overrideY + overrideDeltaY, currentRotation, percentageAnimated) + } else if (startedAnimation && !terminated) { + draw(canvas, currentX, currentY, currentRotation, percentageAnimated) + } + } + + private fun draw(canvas: Canvas, x: Float, y: Float, rotation: Float, percentAnimated: Float) { + canvas.save() + canvas.clipRect(bound!!) + matrix.reset() + workPaint.alpha = alpha + drawInternal(canvas, matrix, workPaint, x, y, rotation, percentAnimated) + canvas.restore() + } + + /** + * Subclasses need to override this method to optimize for the way to draw the appropriate + * confetto on the canvas. + * + * @param canvas the canvas to draw on. + * @param matrix an identity matrix to use for draw manipulations. + * @param paint the paint to perform canvas draw operations on. This paint has already been + * configured via [.configurePaint]. + * @param x the x position of the confetto relative to the canvas. + * @param y the y position of the confetto relative to the canvas. + * @param rotation the rotation (in degrees) to draw the confetto. + * @param percentAnimated the percentage [0.0f, 1f] of animation progress for this confetto. + */ + abstract fun drawInternal( + canvas: Canvas, + matrix: Matrix, + paint: Paint, + x: Float, + y: Float, + rotation: Float, + percentAnimated: Float, + ) + + // region Helper methods to set all of the necessary values for the confetto. + fun setInitialDelay(value: Long) { + initialDelay = value + } + + fun setInitialX(value: Float) { + initialX = value + } + + fun setInitialY(value: Float) { + initialY = value + } + + fun setInitialVelocityX(value: Float) { + initialVelocityX = value + } + + fun setInitialVelocityY(value: Float) { + initialVelocityY = value + } + + fun setAccelerationX(value: Float) { + accelerationX = value + } + + fun setAccelerationY(value: Float) { + accelerationY = value + } + + fun setTargetVelocityX(value: Float?) { + targetVelocityX = value + } + + fun setTargetVelocityY(value: Float?) { + targetVelocityY = value + } + + fun setInitialRotation(value: Float) { + initialRotation = value + } + + fun setInitialRotationalVelocity(value: Float) { + initialRotationalVelocity = value + } + + fun setRotationalAcceleration(value: Float) { + rotationalAcceleration = value + } + + fun setTargetRotationalVelocity(value: Float?) { + targetRotationalVelocity = value + } + + fun setTTL(value: Long) { + ttl = value + } + + fun setFadeOut(fadeOutInterpolator: Interpolator?) { + this.fadeOutInterpolator = fadeOutInterpolator + } + // endregion + + private companion object { + const val MAX_ALPHA = 255 + const val RESET_ANIMATION_INITIAL_DELAY: Long = -1 + + fun computeMillisToReachTarget(targetVelocity: Float?, initialVelocity: Float, acceleration: Float): Long? { + return if (targetVelocity != null) { + if (acceleration != 0.0f) { + val time = ((targetVelocity - initialVelocity) / acceleration).toLong() + if (time > 0) time else 0 + } else { + if (targetVelocity < initialVelocity) 0L else null + } + } else { + null + } + } + + fun computeBound( + initialPos: Float, + velocity: Float, + acceleration: Float, + targetTime: Long?, + targetVelocity: Float?, + minBound: Int, + maxBound: Int, + ): Long { + return if (acceleration == 0.0f) { + computeBoundWithoutAcceleration(initialPos, velocity, targetTime, targetVelocity, minBound, maxBound) + } else { + // non-zero acceleration + val bound = if (acceleration > 0) maxBound else minBound + if (targetTime == null || targetTime < 0) { + computeBoundWithoutTargetTime(initialPos, velocity, acceleration, bound) + } else { + computeBoundWithTargetTime(initialPos, velocity, acceleration, targetTime, targetVelocity, bound) + } + } + } + + fun computeBoundWithoutAcceleration( + initialPos: Float, + velocity: Float, + targetTime: Long?, + targetVelocity: Float?, + minBound: Int, + maxBound: Int, + ): Long { + val actualVelocity = if (targetTime == null) velocity else targetVelocity!! + val bound = if (actualVelocity > 0) maxBound else minBound + return if (actualVelocity == 0.0f) { + Long.MAX_VALUE + } else { + val time = ((bound - initialPos) / actualVelocity).toDouble() + if (time > 0) time.toLong() else Long.MAX_VALUE + } + } + + fun computeBoundWithoutTargetTime(initialPos: Float, velocity: Float, acceleration: Float, bound: Int): Long { + // https://www.wolframalpha.com/input/ + // ?i=solve+for+t+in+(d+%3D+x+%2B+v+*+t+%2B+0.5+*+a+*+t+*+t) + val tmp = sqrt((2 * acceleration * bound - 2 * acceleration * initialPos + velocity * velocity).toDouble()) + val firstTime = (-tmp - velocity) / acceleration + if (firstTime > 0) return firstTime.toLong() + val secondTime = (tmp - velocity) / acceleration + return if (secondTime > 0.0f) secondTime.toLong() else Long.MAX_VALUE + } + + fun computeBoundWithTargetTime( + initialPos: Float, + velocity: Float, + acceleration: Float, + targetTime: Long, + targetVelocity: Float?, + bound: Int, + ): Long { + // d = x + v * tm + 0.5 * a * tm * tm + tv * (t - tm) + // d - x - v * tm - 0.5 * a * tm * tm = tv * t - tv * tm + // d - x - v * tm - 0.5 * a * tm * tm + tv * tm = tv * t + // t = (d - x - v * tm - 0.5 * a * tm * tm + tv * tm) / tv + val time = (bound - initialPos - velocity * targetTime - 0.5 * acceleration + * targetTime * targetTime + targetVelocity!! * targetTime) / targetVelocity + return if (time > 0.0f) time.toLong() else Long.MAX_VALUE + } + } +} diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/ShimmeringConfetto.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/ShimmeringConfetto.kt new file mode 100644 index 00000000..8bf5d523 --- /dev/null +++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/ShimmeringConfetto.kt @@ -0,0 +1,63 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2023 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.lib.confetti.confetto + +import android.animation.ArgbEvaluator +import android.graphics.* +import android.os.SystemClock +import java.util.Random +import kotlin.math.abs + +class ShimmeringConfetto( + bitmap: Bitmap?, + private val fromColor: Int, + private val toColor: Int, + private val waveLength: Long, + random: Random, +) : BitmapConfetto(bitmap!!) { + + private val evaluator = ArgbEvaluator() + private val halfWaveLength: Long = waveLength / 2L + private val randomStart: Long + + init { + val currentTime = abs(SystemClock.elapsedRealtime().toInt()) + randomStart = (currentTime - random.nextInt(currentTime)).toLong() + } + + override fun drawInternal( + canvas: Canvas, + matrix: Matrix, + paint: Paint, + x: Float, + y: Float, + rotation: Float, + percentAnimated: Float, + ) { + val currentTime = SystemClock.elapsedRealtime() + val fraction = (currentTime - randomStart) % waveLength + val animated = if (fraction < halfWaveLength) { + fraction.toFloat() / halfWaveLength + } else { + (waveLength.toFloat() - fraction) / halfWaveLength + } + val color = evaluator.evaluate(animated, fromColor, toColor) as Int + paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP) + super.drawInternal(canvas, matrix, paint, x, y, rotation, percentAnimated) + } +} diff --git a/Confetti/src/main/res/values/dimens.xml b/Confetti/src/main/res/values/dimens.xml new file mode 100644 index 00000000..dedb7e9b --- /dev/null +++ b/Confetti/src/main/res/values/dimens.xml @@ -0,0 +1,28 @@ + + + 9999dp + 6dp + 100dp + 20dp + 50dp + 100dp + 200dp + 275dp + 2375dp +