Files
Lawnchair/systemUI/animation/src/com/android/systemui/surfaceeffects/loadingeffect/LoadingEffect.kt
T
2026-01-10 20:48:08 +07:00

367 lines
13 KiB
Kotlin

/*
* 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.PaintDrawCallback
import com.android.systemui.surfaceeffects.RenderEffectDrawCallback
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)
}
/** Updates the noise color that's screen blended on top. */
fun updateScreenColor(newColor: Int) {
turbulenceNoiseShader.setScreenColor(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,
TurbulenceNoiseShader.BACKGROUND_UNIFORM
)
)
}
/**
* 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
}
/** 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 companion object {
private const val MS_TO_SEC = 0.001f
}
}