Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 621f58bb authored by Yein Jo's avatar Yein Jo Committed by Android (Google) Code Review
Browse files

Merge "Add LoadingEffect" into main

parents 2ec583f7 a4cbbbfd
Loading
Loading
Loading
Loading
+377 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.surfaceeffects.loadingeffect

import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.graphics.Paint
import android.graphics.RenderEffect
import android.view.View
import com.android.systemui.surfaceeffects.turbulencenoise.TurbulenceNoiseAnimationConfig
import com.android.systemui.surfaceeffects.turbulencenoise.TurbulenceNoiseShader

/**
 * Plays loading effect with the given configuration.
 *
 * @param baseType immutable base shader type. This is used for constructing the shader. Reconstruct
 *   the [LoadingEffect] if the base type needs to be changed.
 * @param config immutable parameters that are used for drawing the effect.
 * @param paintCallback triggered every frame when animation is playing. Use this to draw the effect
 *   with [Canvas.drawPaint].
 * @param renderEffectCallback triggered every frame when animation is playing. Use this to draw the
 *   effect with [RenderEffect].
 * @param animationStateChangedCallback triggered when the [AnimationState] changes. Optional.
 *
 * The client is responsible to actually draw the [Paint] or [RenderEffect] returned in the
 * callback. Note that [View.invalidate] must be called on each callback. There are a few ways to
 * render the effect:
 * 1) Use [Canvas.drawPaint]. (Preferred. Significantly cheaper!)
 * 2) Set [RenderEffect] to the [View]. (Good for chaining effects.)
 * 3) Use [RenderNode.setRenderEffect]. (This may be least preferred, as 2 should do what you want.)
 *
 * <p>First approach is more performant than other ones because [RenderEffect] forces an
 * intermediate render pass of the View to a texture to feed into it.
 *
 * <p>If going with the first approach, your custom [View] would look like as follow:
 * <pre>{@code
 *     private var paint: Paint? = null
 *     // Override [View.onDraw].
 *     override fun onDraw(canvas: Canvas) {
 *         // RuntimeShader requires hardwareAcceleration.
 *         if (!canvas.isHardwareAccelerated) return
 *
 *         paint?.let { canvas.drawPaint(it) }
 *     }
 *
 *     // This is called [Callback.onDraw]
 *     fun draw(paint: Paint) {
 *         this.paint = paint
 *
 *         // Must call invalidate to trigger View#onDraw
 *         invalidate()
 *     }
 * }</pre>
 *
 * <p>If going with the second approach, it doesn't require an extra custom [View], and it is as
 * simple as calling [View.setRenderEffect] followed by [View.invalidate]. You can also chain the
 * effect with other [RenderEffect].
 *
 * <p>Third approach is an option, but it's more of a boilerplate so you would like to stick with
 * the second option. If you want to go with this option for some reason, below is the example:
 * <pre>{@code
 *     // Initialize the shader and paint to use to pass into the [Canvas].
 *     private val renderNode = RenderNode("LoadingEffect")
 *
 *     // Override [View.onDraw].
 *     override fun onDraw(canvas: Canvas) {
 *         // RuntimeShader requires hardwareAcceleration.
 *         if (!canvas.isHardwareAccelerated) return
 *
 *         if (renderNode.hasDisplayList()) {
 *             canvas.drawRenderNode(renderNode)
 *         }
 *     }
 *
 *     // This is called [Callback.onDraw]
 *     fun draw(renderEffect: RenderEffect) {
 *         renderNode.setPosition(0, 0, width, height)
 *         renderNode.setRenderEffect(renderEffect)
 *
 *         val recordingCanvas = renderNode.beginRecording()
 *         // We need at least 1 drawing instruction.
 *         recordingCanvas.drawColor(Color.TRANSPARENT)
 *         renderNode.endRecording()
 *
 *         // Must call invalidate to trigger View#onDraw
 *         invalidate()
 *     }
 * }</pre>
 */
class LoadingEffect
private constructor(
    baseType: TurbulenceNoiseShader.Companion.Type,
    private val config: TurbulenceNoiseAnimationConfig,
    private val paintCallback: PaintDrawCallback?,
    private val renderEffectCallback: RenderEffectDrawCallback?,
    private val animationStateChangedCallback: AnimationStateChangedCallback? = null
) {
    constructor(
        baseType: TurbulenceNoiseShader.Companion.Type,
        config: TurbulenceNoiseAnimationConfig,
        paintCallback: PaintDrawCallback,
        animationStateChangedCallback: AnimationStateChangedCallback? = null
    ) : this(
        baseType,
        config,
        paintCallback,
        renderEffectCallback = null,
        animationStateChangedCallback
    )
    constructor(
        baseType: TurbulenceNoiseShader.Companion.Type,
        config: TurbulenceNoiseAnimationConfig,
        renderEffectCallback: RenderEffectDrawCallback,
        animationStateChangedCallback: AnimationStateChangedCallback? = null
    ) : this(
        baseType,
        config,
        paintCallback = null,
        renderEffectCallback,
        animationStateChangedCallback
    )

    private val turbulenceNoiseShader: TurbulenceNoiseShader =
        TurbulenceNoiseShader(baseType).apply { applyConfig(config) }
    private var currentAnimator: ValueAnimator? = null
    private var state: AnimationState = AnimationState.NOT_PLAYING
        set(value) {
            if (field != value) {
                animationStateChangedCallback?.onStateChanged(field, value)
                field = value
            }
        }

    // We create a paint instance only if the client renders it with Paint.
    private val paint =
        if (paintCallback != null) {
            Paint().apply { this.shader = turbulenceNoiseShader }
        } else {
            null
        }

    /** Plays LoadingEffect. */
    fun play() {
        if (state != AnimationState.NOT_PLAYING) {
            return // Ignore if any of the animation is playing.
        }

        playEaseIn()
    }

    // TODO(b/237282226): Support force finish.
    /** Finishes the main animation, which triggers the ease-out animation. */
    fun finish() {
        if (state == AnimationState.MAIN) {
            // Calling Animator#end sets the animation state back to the initial state. Using pause
            // to avoid visual artifacts.
            currentAnimator?.pause()
            currentAnimator = null

            playEaseOut()
        }
    }

    /** Updates the noise color dynamically. */
    fun updateColor(newColor: Int) {
        turbulenceNoiseShader.setColor(newColor)
    }

    /**
     * Retrieves the noise offset x, y, z values. This is useful for replaying the animation
     * smoothly from the last animation, by passing in the last values to the next animation.
     */
    fun getNoiseOffset(): Array<Float> {
        return arrayOf(
            turbulenceNoiseShader.noiseOffsetX,
            turbulenceNoiseShader.noiseOffsetY,
            turbulenceNoiseShader.noiseOffsetZ
        )
    }

    private fun playEaseIn() {
        if (state != AnimationState.NOT_PLAYING) {
            return
        }
        state = AnimationState.EASE_IN

        val animator = ValueAnimator.ofFloat(0f, 1f)
        animator.duration = config.easeInDuration.toLong()

        // Animation should start from the initial position to avoid abrupt transition.
        val initialX = turbulenceNoiseShader.noiseOffsetX
        val initialY = turbulenceNoiseShader.noiseOffsetY
        val initialZ = turbulenceNoiseShader.noiseOffsetZ

        animator.addUpdateListener { updateListener ->
            val timeInSec = updateListener.currentPlayTime * MS_TO_SEC
            val progress = updateListener.animatedValue as Float

            turbulenceNoiseShader.setNoiseMove(
                initialX + timeInSec * config.noiseMoveSpeedX,
                initialY + timeInSec * config.noiseMoveSpeedY,
                initialZ + timeInSec * config.noiseMoveSpeedZ
            )

            // TODO: Replace it with a better curve.
            turbulenceNoiseShader.setOpacity(progress * config.luminosityMultiplier)

            draw()
        }

        animator.addListener(
            object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator) {
                    currentAnimator = null
                    playMain()
                }
            }
        )

        animator.start()
        this.currentAnimator = animator
    }

    private fun playMain() {
        if (state != AnimationState.EASE_IN) {
            return
        }
        state = AnimationState.MAIN

        val animator = ValueAnimator.ofFloat(0f, 1f)
        animator.duration = config.maxDuration.toLong()

        // Animation should start from the initial position to avoid abrupt transition.
        val initialX = turbulenceNoiseShader.noiseOffsetX
        val initialY = turbulenceNoiseShader.noiseOffsetY
        val initialZ = turbulenceNoiseShader.noiseOffsetZ

        turbulenceNoiseShader.setOpacity(config.luminosityMultiplier)

        animator.addUpdateListener { updateListener ->
            val timeInSec = updateListener.currentPlayTime * MS_TO_SEC
            turbulenceNoiseShader.setNoiseMove(
                initialX + timeInSec * config.noiseMoveSpeedX,
                initialY + timeInSec * config.noiseMoveSpeedY,
                initialZ + timeInSec * config.noiseMoveSpeedZ
            )

            draw()
        }

        animator.addListener(
            object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator) {
                    currentAnimator = null
                    playEaseOut()
                }
            }
        )

        animator.start()
        this.currentAnimator = animator
    }

    private fun playEaseOut() {
        if (state != AnimationState.MAIN) {
            return
        }
        state = AnimationState.EASE_OUT

        val animator = ValueAnimator.ofFloat(0f, 1f)
        animator.duration = config.easeOutDuration.toLong()

        // Animation should start from the initial position to avoid abrupt transition.
        val initialX = turbulenceNoiseShader.noiseOffsetX
        val initialY = turbulenceNoiseShader.noiseOffsetY
        val initialZ = turbulenceNoiseShader.noiseOffsetZ

        animator.addUpdateListener { updateListener ->
            val timeInSec = updateListener.currentPlayTime * MS_TO_SEC
            val progress = updateListener.animatedValue as Float

            turbulenceNoiseShader.setNoiseMove(
                initialX + timeInSec * config.noiseMoveSpeedX,
                initialY + timeInSec * config.noiseMoveSpeedY,
                initialZ + timeInSec * config.noiseMoveSpeedZ
            )

            // TODO: Replace it with a better curve.
            turbulenceNoiseShader.setOpacity((1f - progress) * config.luminosityMultiplier)

            draw()
        }

        animator.addListener(
            object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator) {
                    currentAnimator = null
                    state = AnimationState.NOT_PLAYING
                }
            }
        )

        animator.start()
        this.currentAnimator = animator
    }

    private fun draw() {
        paintCallback?.onDraw(paint!!)
        renderEffectCallback?.onDraw(
            RenderEffect.createRuntimeShaderEffect(turbulenceNoiseShader, "in_src")
        )
    }

    companion object {
        /**
         * States of the loading effect animation.
         *
         * <p>The state is designed to be follow the order below: [AnimationState.EASE_IN],
         * [AnimationState.MAIN], [AnimationState.EASE_OUT]. Note that ease in and out don't
         * necessarily mean the acceleration and deceleration in the animation curve. They simply
         * mean each stage of the animation. (i.e. Intro, core, and rest)
         */
        enum class AnimationState {
            EASE_IN,
            MAIN,
            EASE_OUT,
            NOT_PLAYING
        }

        /** Client must implement one of the draw callbacks. */
        interface PaintDrawCallback {
            /**
             * A callback with a [Paint] object that contains shader info, which is triggered every
             * frame while animation is playing. Note that the [Paint] object here is always the
             * same instance.
             */
            fun onDraw(loadingPaint: Paint)
        }

        interface RenderEffectDrawCallback {
            /**
             * A callback with a [RenderEffect] object that contains shader info, which is triggered
             * every frame while animation is playing. Note that the [RenderEffect] instance is
             * different each time to update shader uniforms.
             */
            fun onDraw(loadingRenderEffect: RenderEffect)
        }

        /** Optional callback that is triggered when the animation state changes. */
        interface AnimationStateChangedCallback {
            /**
             * A callback that's triggered when the [AnimationState] changes. Example usage is
             * performing a cleanup when [AnimationState] becomes [NOT_PLAYING].
             */
            fun onStateChanged(oldState: AnimationState, newState: AnimationState) {}
        }

        private const val MS_TO_SEC = 0.001f

        private val TAG = LoadingEffect::class.java.simpleName
    }
}
+2 −0
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ class TurbulenceNoiseShader(val baseType: Type = Type.SIMPLEX_NOISE) :
    companion object {
        private const val UNIFORMS =
            """
            uniform shader in_src; // Needed to support RenderEffect.
            uniform float in_gridNum;
            uniform vec3 in_noiseMove;
            uniform vec2 in_size;
@@ -114,6 +115,7 @@ class TurbulenceNoiseShader(val baseType: Type = Type.SIMPLEX_NOISE) :
        setSize(config.width, config.height)
        setLumaMatteFactors(config.lumaMatteBlendFactor, config.lumaMatteOverallBrightness)
        setInverseNoiseLuminosity(config.shouldInverseNoiseLuminosity)
        setNoiseMove(config.noiseOffsetX, config.noiseOffsetY, config.noiseOffsetZ)
    }

    /** Sets the number of grid for generating noise. */
+267 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.surfaceeffects.loadingeffect

import android.graphics.Paint
import android.graphics.RenderEffect
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.systemui.model.SysUiStateTest
import com.android.systemui.surfaceeffects.loadingeffect.LoadingEffect.Companion.AnimationState
import com.android.systemui.surfaceeffects.loadingeffect.LoadingEffect.Companion.AnimationState.EASE_IN
import com.android.systemui.surfaceeffects.loadingeffect.LoadingEffect.Companion.AnimationState.EASE_OUT
import com.android.systemui.surfaceeffects.loadingeffect.LoadingEffect.Companion.AnimationState.MAIN
import com.android.systemui.surfaceeffects.loadingeffect.LoadingEffect.Companion.AnimationState.NOT_PLAYING
import com.android.systemui.surfaceeffects.loadingeffect.LoadingEffect.Companion.AnimationStateChangedCallback
import com.android.systemui.surfaceeffects.loadingeffect.LoadingEffect.Companion.PaintDrawCallback
import com.android.systemui.surfaceeffects.loadingeffect.LoadingEffect.Companion.RenderEffectDrawCallback
import com.android.systemui.surfaceeffects.turbulencenoise.TurbulenceNoiseAnimationConfig
import com.android.systemui.surfaceeffects.turbulencenoise.TurbulenceNoiseShader
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidTestingRunner::class)
class LoadingEffectTest : SysUiStateTest() {

    private val fakeSystemClock = FakeSystemClock()
    private val fakeExecutor = FakeExecutor(fakeSystemClock)

    @Test
    fun play_paintCallback_triggersDrawCallback() {
        var paintFromCallback: Paint? = null
        val drawCallback =
            object : PaintDrawCallback {
                override fun onDraw(loadingPaint: Paint) {
                    paintFromCallback = loadingPaint
                }
            }
        val loadingEffect =
            LoadingEffect(
                baseType = TurbulenceNoiseShader.Companion.Type.SIMPLEX_NOISE,
                TurbulenceNoiseAnimationConfig(),
                paintCallback = drawCallback,
                animationStateChangedCallback = null
            )

        fakeExecutor.execute {
            assertThat(paintFromCallback).isNull()

            loadingEffect.play()
            fakeSystemClock.advanceTime(500L)

            assertThat(paintFromCallback).isNotNull()
        }
    }

    @Test
    fun play_renderEffectCallback_triggersDrawCallback() {
        var renderEffectFromCallback: RenderEffect? = null
        val drawCallback =
            object : RenderEffectDrawCallback {
                override fun onDraw(loadingRenderEffect: RenderEffect) {
                    renderEffectFromCallback = loadingRenderEffect
                }
            }
        val loadingEffect =
            LoadingEffect(
                baseType = TurbulenceNoiseShader.Companion.Type.SIMPLEX_NOISE,
                TurbulenceNoiseAnimationConfig(),
                renderEffectCallback = drawCallback,
                animationStateChangedCallback = null
            )

        fakeExecutor.execute {
            assertThat(renderEffectFromCallback).isNull()

            loadingEffect.play()
            fakeSystemClock.advanceTime(500L)

            assertThat(renderEffectFromCallback).isNotNull()
        }
    }

    @Test
    fun play_animationStateChangesInOrder() {
        val config = TurbulenceNoiseAnimationConfig()
        val expectedStates = arrayOf(NOT_PLAYING, EASE_IN, MAIN, EASE_OUT, NOT_PLAYING)
        val actualStates = mutableListOf(NOT_PLAYING)
        val stateChangedCallback =
            object : AnimationStateChangedCallback {
                override fun onStateChanged(oldState: AnimationState, newState: AnimationState) {
                    actualStates.add(newState)
                }
            }
        val drawCallback =
            object : PaintDrawCallback {
                override fun onDraw(loadingPaint: Paint) {}
            }
        val loadingEffect =
            LoadingEffect(
                baseType = TurbulenceNoiseShader.Companion.Type.SIMPLEX_NOISE,
                config,
                paintCallback = drawCallback,
                stateChangedCallback
            )

        val timeToAdvance =
            config.easeInDuration + config.maxDuration + config.easeOutDuration + 100

        fakeExecutor.execute {
            loadingEffect.play()

            fakeSystemClock.advanceTime(timeToAdvance.toLong())

            assertThat(actualStates).isEqualTo(expectedStates)
        }
    }

    @Test
    fun play_alreadyPlaying_playsOnlyOnce() {
        val config = TurbulenceNoiseAnimationConfig()
        var numPlay = 0
        val stateChangedCallback =
            object : AnimationStateChangedCallback {
                override fun onStateChanged(oldState: AnimationState, newState: AnimationState) {
                    if (oldState == NOT_PLAYING && newState == EASE_IN) {
                        numPlay++
                    }
                }
            }
        val drawCallback =
            object : PaintDrawCallback {
                override fun onDraw(loadingPaint: Paint) {}
            }
        val loadingEffect =
            LoadingEffect(
                baseType = TurbulenceNoiseShader.Companion.Type.SIMPLEX_NOISE,
                config,
                paintCallback = drawCallback,
                stateChangedCallback
            )

        fakeExecutor.execute {
            assertThat(numPlay).isEqualTo(0)

            loadingEffect.play()
            loadingEffect.play()
            loadingEffect.play()
            loadingEffect.play()
            loadingEffect.play()

            assertThat(numPlay).isEqualTo(1)
        }
    }

    @Test
    fun finish_finishesLoadingEffect() {
        val config = TurbulenceNoiseAnimationConfig(maxDuration = 1000f)
        val drawCallback =
            object : PaintDrawCallback {
                override fun onDraw(loadingPaint: Paint) {}
            }
        var isFinished = false
        val stateChangedCallback =
            object : AnimationStateChangedCallback {
                override fun onStateChanged(oldState: AnimationState, newState: AnimationState) {
                    if (oldState == MAIN && newState == NOT_PLAYING) {
                        isFinished = true
                    }
                }
            }
        val loadingEffect =
            LoadingEffect(
                baseType = TurbulenceNoiseShader.Companion.Type.SIMPLEX_NOISE,
                config,
                paintCallback = drawCallback,
                stateChangedCallback
            )

        fakeExecutor.execute {
            assertThat(isFinished).isFalse()

            loadingEffect.play()
            fakeSystemClock.advanceTime(config.easeInDuration.toLong() + 500L)

            assertThat(isFinished).isFalse()

            loadingEffect.finish()

            assertThat(isFinished).isTrue()
        }
    }

    @Test
    fun finish_notMainState_hasNoEffect() {
        val config = TurbulenceNoiseAnimationConfig(maxDuration = 1000f)
        val drawCallback =
            object : PaintDrawCallback {
                override fun onDraw(loadingPaint: Paint) {}
            }
        var isFinished = false
        val stateChangedCallback =
            object : AnimationStateChangedCallback {
                override fun onStateChanged(oldState: AnimationState, newState: AnimationState) {
                    if (oldState == MAIN && newState == NOT_PLAYING) {
                        isFinished = true
                    }
                }
            }
        val loadingEffect =
            LoadingEffect(
                baseType = TurbulenceNoiseShader.Companion.Type.SIMPLEX_NOISE,
                config,
                paintCallback = drawCallback,
                stateChangedCallback
            )

        fakeExecutor.execute {
            assertThat(isFinished).isFalse()

            loadingEffect.finish()

            assertThat(isFinished).isFalse()
        }
    }

    @Test
    fun getNoiseOffset_returnsNoiseOffset() {
        val expectedNoiseOffset = arrayOf(0.1f, 0.2f, 0.3f)
        val config =
            TurbulenceNoiseAnimationConfig(
                noiseOffsetX = expectedNoiseOffset[0],
                noiseOffsetY = expectedNoiseOffset[1],
                noiseOffsetZ = expectedNoiseOffset[2]
            )
        val drawCallback =
            object : PaintDrawCallback {
                override fun onDraw(loadingPaint: Paint) {}
            }
        val loadingEffect =
            LoadingEffect(
                baseType = TurbulenceNoiseShader.Companion.Type.SIMPLEX_NOISE,
                config,
                paintCallback = drawCallback,
                animationStateChangedCallback = null
            )

        assertThat(loadingEffect.getNoiseOffset()).isEqualTo(expectedNoiseOffset)
    }
}