blob: b73aeb30009c2fff295d237e222e5c97c92f5fb7 [file] [log] [blame]
/*
* Copyright (C) 2020 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.util.animation
import android.animation.ValueAnimator
import android.graphics.PointF
import android.util.MathUtils
import com.android.systemui.Interpolators
/**
* The fraction after which we start fading in when going from a gone widget to a visible one
*/
private const val GONE_FADE_FRACTION = 0.8f
/**
* The amont we're scaling appearing views
*/
private const val GONE_SCALE_AMOUNT = 0.8f
/**
* A controller for a [TransitionLayout] which handles state transitions and keeps the transition
* layout up to date with the desired state.
*/
open class TransitionLayoutController {
/**
* The layout that this controller controls
*/
private var transitionLayout: TransitionLayout? = null
private var currentState = TransitionViewState()
private var animationStartState: TransitionViewState? = null
private var state = TransitionViewState()
private var pivot = PointF()
private var animator: ValueAnimator = ValueAnimator.ofFloat(0.0f, 1.0f)
init {
animator.apply {
addUpdateListener {
updateStateFromAnimation()
}
interpolator = Interpolators.FAST_OUT_SLOW_IN
}
}
private fun updateStateFromAnimation() {
if (animationStartState == null || !animator.isRunning) {
return
}
val view = transitionLayout ?: return
getInterpolatedState(
startState = animationStartState!!,
endState = state,
progress = animator.animatedFraction,
pivot = pivot,
resultState = currentState)
view.setState(currentState)
}
/**
* Get an interpolated state between two viewstates. This interpolates all positions for all
* widgets as well as it's bounds based on the given input.
*/
fun getInterpolatedState(
startState: TransitionViewState,
endState: TransitionViewState,
progress: Float,
pivot: PointF,
resultState: TransitionViewState
) {
this.pivot.set(pivot)
val view = transitionLayout ?: return
val childCount = view.childCount
for (i in 0 until childCount) {
val id = view.getChildAt(i).id
val resultWidgetState = resultState.widgetStates[id] ?: WidgetState()
val widgetStart = startState.widgetStates[id] ?: continue
val widgetEnd = endState.widgetStates[id] ?: continue
var alphaProgress = progress
var widthProgress = progress
val resultMeasureWidth: Int
val resultMeasureHeight: Int
val newScale: Float
val resultX: Float
val resultY: Float
if (widgetStart.gone != widgetEnd.gone) {
// A view is appearing or disappearing. Let's not just interpolate between them as
// this looks quite ugly
val nowGone: Boolean
if (widgetStart.gone) {
// Only fade it in at the very end
alphaProgress = MathUtils.map(GONE_FADE_FRACTION, 1.0f, 0.0f, 1.0f, progress)
nowGone = progress < GONE_FADE_FRACTION
// Scale it just a little, not all the way
val endScale = widgetEnd.scale
newScale = MathUtils.lerp(GONE_SCALE_AMOUNT * endScale, endScale, progress)
// don't clip
widthProgress = 1.0f
// Let's directly measure it with the end state
resultMeasureWidth = widgetEnd.measureWidth
resultMeasureHeight = widgetEnd.measureHeight
// Let's make sure we're centering the view in the gone view instead of having
// the left at 0
resultX = MathUtils.lerp(widgetStart.x - resultMeasureWidth / 2.0f,
widgetEnd.x,
progress)
resultY = MathUtils.lerp(widgetStart.y - resultMeasureHeight / 2.0f,
widgetEnd.y,
progress)
} else {
// Fadeout in the very beginning
alphaProgress = MathUtils.map(0.0f, 1.0f - GONE_FADE_FRACTION, 0.0f, 1.0f,
progress)
nowGone = progress > 1.0f - GONE_FADE_FRACTION
// Scale it just a little, not all the way
val startScale = widgetStart.scale
newScale = MathUtils.lerp(startScale, startScale * GONE_SCALE_AMOUNT, progress)
// Don't clip
widthProgress = 0.0f
// Let's directly measure it with the start state
resultMeasureWidth = widgetStart.measureWidth
resultMeasureHeight = widgetStart.measureHeight
// Let's make sure we're centering the view in the gone view instead of having
// the left at 0
resultX = MathUtils.lerp(widgetStart.x,
widgetEnd.x - resultMeasureWidth / 2.0f,
progress)
resultY = MathUtils.lerp(widgetStart.y,
widgetEnd.y - resultMeasureHeight / 2.0f,
progress)
}
resultWidgetState.gone = nowGone
} else {
resultWidgetState.gone = widgetStart.gone
// Let's directly measure it with the end state
resultMeasureWidth = widgetEnd.measureWidth
resultMeasureHeight = widgetEnd.measureHeight
newScale = MathUtils.lerp(widgetStart.scale, widgetEnd.scale, progress)
resultX = MathUtils.lerp(widgetStart.x, widgetEnd.x, progress)
resultY = MathUtils.lerp(widgetStart.y, widgetEnd.y, progress)
}
resultWidgetState.apply {
x = resultX
y = resultY
alpha = MathUtils.lerp(widgetStart.alpha, widgetEnd.alpha, alphaProgress)
width = MathUtils.lerp(widgetStart.width.toFloat(), widgetEnd.width.toFloat(),
widthProgress).toInt()
height = MathUtils.lerp(widgetStart.height.toFloat(), widgetEnd.height.toFloat(),
widthProgress).toInt()
scale = newScale
// Let's directly measure it with the end state
measureWidth = resultMeasureWidth
measureHeight = resultMeasureHeight
}
resultState.widgetStates[id] = resultWidgetState
}
resultState.apply {
width = MathUtils.lerp(startState.width.toFloat(), endState.width.toFloat(),
progress).toInt()
height = MathUtils.lerp(startState.height.toFloat(), endState.height.toFloat(),
progress).toInt()
translation.x = (endState.width - width) * pivot.x
translation.y = (endState.height - height) * pivot.y
}
}
fun attach(transitionLayout: TransitionLayout) {
this.transitionLayout = transitionLayout
}
/**
* Set a new state to be applied to the dynamic view.
*
* @param state the state to be applied
* @param animate should this change be animated. If [false] the we will either apply the
* state immediately if no animation is running, and if one is running, we will update the end
* value to match the new state.
* @param applyImmediately should this change be applied immediately, canceling all running
* animations
*/
fun setState(
state: TransitionViewState,
applyImmediately: Boolean,
animate: Boolean,
duration: Long = 0,
delay: Long = 0
) {
val animated = animate && currentState.width != 0
this.state = state.copy()
if (applyImmediately || transitionLayout == null) {
animator.cancel()
transitionLayout?.setState(this.state)
currentState = state.copy(reusedState = currentState)
} else if (animated) {
animationStartState = currentState.copy()
animator.duration = duration
animator.startDelay = delay
animator.start()
} else if (!animator.isRunning) {
transitionLayout?.setState(this.state)
currentState = state.copy(reusedState = currentState)
}
// otherwise the desired state was updated and the animation will go to the new target
}
/**
* Set a new state that will be used to measure the view itself and is useful during
* transitions, where the state set via [setState] may differ from how the view
* should be measured.
*/
fun setMeasureState(
state: TransitionViewState
) {
transitionLayout?.measureState = state
}
}