目錄一、低級別動畫 API1.1 animate*AsState1.2 Animatable1.3 Transition 動畫1.3.1 updateTransition1.3.2 createChildTransition1.3.3 封裝並復用 Transition 動畫1.4 remeberIn ...
目錄
- 一、低級別動畫 API
- 二、Android Studio 對 Compose 動畫調試的支持
- 三、AnimationSpec 動畫規格
- 四、TwoWayConverter
- 五、高級動畫 API
- 六、特定場景使用動畫
- 七、總結
本文介紹 Jetpack Compose 動畫。
官方文檔
關於動畫這塊,第一次看官網,覺得內容很雜,很難把握住整個框架結構,很難去對動畫進行分類。參考了很多文獻資料,大多數都是從高級別 API 開始講解,包括官網也是如此。我發現這樣不太容易理解,因為高級別 API 中可能會涉及到低級別 API 中的一些方法,術語等。所以本文從低級別 API 講起。
一、低級別動畫 API
1.1 animate*AsState
animate*AsState
函數是 Compose 動畫中最常用的低級別 API 之一,它類似於傳統 View 中的屬性動畫,你只需要提供結束值(或者目標值),API 就會從當前值到目標值開始動畫。
看一個改變 Composable 組件大小的例子:
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
// I'm here
val boxSize by animateDpAsState(targetValue = if (bigBox) 200.dp else 50.dp, label = "")
Box(modifier = Modifier
.background(Color.LightGray)
.size(boxSize) // I'm here
.clickable {
bigBox = !bigBox
})
}
運行一下看看效果:
上述示例中我們使用了 animateDpAsState
這個函數,定義了一個 “Dp” 相關的動畫。
其實 animate*AsState
並不是只某一個具體方法,而是只形如 animate*AsState
的一系列方法,具體如下:
是的,你沒有看錯,甚至可以使用 animateColorAsState
方法對顏色做動畫。
聰明的你,肯定會有一個疑問,這個方法是從當前值到設定的目標值啟動動畫,但是動畫具體執行過程是怎樣的,比如持續時間等等,這個有辦法控制嗎?還是以 AnimateDpAsState
為例,看看這個參數的完整簽名:
@Composable
fun animateDpAsState(
targetValue: Dp,
animationSpec: AnimationSpec<Dp> = dpDefaultSpring,
label: String = "DpAnimation",
finishedListener: ((Dp) -> Unit)? = null
): State<Dp> {
return animateValueAsState(
targetValue,
Dp.VectorConverter,
animationSpec,
label = label,
finishedListener = finishedListener
)
}
實際上這個方法有4個參數。
targetValue
是沒有預設值的,表示動畫的目標值。animationSpec
動畫規格,這裡有一個預設值,實際上就是這個參數決定了動畫的執行邏輯。lable
這個參數是為了區別在 Android Studio 中進行動畫預覽時,區別其它動畫的。finishedListener
可以用來監聽動畫的結束。
關於動畫規格 AnimationSpec, 此處不展開,後面會詳細講解。
再延伸一點,看看該方法的實現,實際上是調用了 animateValueAsState
方法。事實上前面展示的 animate*AsState
的系列方法都是調用的 animateValueAsState
。
看看源碼:
@Composable
fun <T, V : AnimationVector> animateValueAsState(
targetValue: T,
typeConverter: TwoWayConverter<T, V>,
animationSpec: AnimationSpec<T> = remember { spring() },
visibilityThreshold: T? = null,
label: String = "ValueAnimation",
finishedListener: ((T) -> Unit)? = null
): State<T> {
val toolingOverride = remember { mutableStateOf<State<T>?>(null) }
val animatable = remember { Animatable(targetValue, typeConverter, visibilityThreshold, label) }
val listener by rememberUpdatedState(finishedListener)
val animSpec: AnimationSpec<T> by rememberUpdatedState(
animationSpec.run {
if (visibilityThreshold != null && this is SpringSpec &&
this.visibilityThreshold != visibilityThreshold
) {
spring(dampingRatio, stiffness, visibilityThreshold)
} else {
this
}
}
)
val channel = remember { Channel<T>(Channel.CONFLATED) }
SideEffect {
channel.trySend(targetValue)
}
LaunchedEffect(channel) {
for (target in channel) {
// This additional poll is needed because when the channel suspends on receive and
// two values are produced before consumers' dispatcher resumes, only the first value
// will be received.
// It may not be an issue elsewhere, but in animation we want to avoid being one
// frame late.
val newTarget = channel.tryReceive().getOrNull() ?: target
launch {
if (newTarget != animatable.targetValue) {
animatable.animateTo(newTarget, animSpec)
listener?.invoke(animatable.value)
}
}
}
}
return toolingOverride.value ?: animatable.asState()
}
稍微看下源碼,大致能發現,實際上是啟動了一個協程,然後協程內部不斷調用了 animatable.animateTo()
這樣一個方法。下一節講 animatable
, 收回來,這裡我想要表達的意思是,使用接受通用類型的 animateValueAsState()
可以輕鬆添加對其他數據類型的支持只需要自行實現一個 TwoWayConverter
。具體如何實現,下文第四節會詳細講解。
1.2 Animatable
前面的 animate*AsState
只需要指定目標值,無需指定初始值,而 Animatable 則是一個能夠指定初始值的更基礎的 API。 animate*AsState
調用了AnimateValueAsState
, 而 AnimateValueAsState
內部使用 Animatable
定製完成。
對於 Animatable 而言,動畫數值更新需要在協程中完成,也就是調用 animateTo 方法。此時我們需要確保 Animatable 的初始狀態與 LaunchedEffect 代碼塊首次執行時狀態保持一致。
接下來,我們使用 animatable 實現前面的例子。
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val customSize = remember {
Animatable(50.dp, Dp.VectorConverter)
}
LaunchedEffect(key1 = bigBox) {
customSize.animateTo(if (bigBox) 200.dp else 50.dp)
}
Box(modifier = Modifier
.background(Color.LightGray)
.size(customSize.value)
.clickable {
bigBox = !bigBox
})
}
值得註意的是,當我們在 Composable 使用 Animatable 時,其必須包裹在 rememebr 中,如果你沒有這麼做,編譯器會貼心的提示你添加 rememeber 。
同樣,與 animate*AsState 一樣, animateTo 方法接收 AnimationSpec
參數用來指定動畫的規格。
suspend fun animateTo(
targetValue: T,
animationSpec: AnimationSpec<T> = defaultSpringSpec,
initialVelocity: T = velocity,
block: (Animatable<T, V>.() -> Unit)? = null
)
再看看增加一個顏色變化的動畫:
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val customSize = remember {
androidx.compose.animation.core.Animatable(50.dp, Dp.VectorConverter)
}
val customColor = remember {
androidx.compose.animation.Animatable(Color.Blue)
}
LaunchedEffect(key1 = bigBox) {
customSize.animateTo(if (bigBox) 200.dp else 50.dp)
customColor.animateTo(if (bigBox) Color.Red else Color.Blue)
}
Box(modifier = Modifier
.background(customColor.value)
.size(customSize.value)
.clickable {
bigBox = !bigBox
})
}
我們看到,前面使用 Dp 類型時,我們使用的是 androidx.compose.animation.core.Animatable.kt
文件中的方法, 此時使用的是 androidx.compose.animation.SingleValueAnimation.kt
文件中的方法, 且沒有傳入 TwoWayConverter 參數。這裡說明一下:對與 Color
和 Float
類型,Compose 已經進行了封裝,不需要我們傳入 TwoWayConverter 參數,對於其它的常用數據類型,Compose 也提供了對應的 TwoWayConverter 實現方法。比如 Dp.VectorConverter
, 直接傳入即可。
另外 Launched 會在 onAtive 時執行,此時要確保, animateTo 的 targetValue 與 Animatable 的預設值相同。否則在頁面首次渲染時,便會發生動畫,可能與預期結果不相符。
最後我們看一下執行效果:
可以看到,實際上 size 和 Color 並不是同時執行的,而是先執行 size 的動畫, 後執行 Color 的動畫。我們做如下修改,讓兩個動畫併發執行。
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val customSize = remember {
androidx.compose.animation.core.Animatable(50.dp, Dp.VectorConverter)
}
val customColor = remember {
androidx.compose.animation.Animatable(Color.Blue)
}
LaunchedEffect(key1 = bigBox) {
launch{
customSize.animateTo(if (bigBox) 200.dp else 50.dp)
}
launch {
customColor.animateTo(if (bigBox) Color.Red else Color.Blue)
}
}
Box(modifier = Modifier
.background(customColor.value)
.size(customSize.value)
.clickable {
bigBox = !bigBox
})
}
此時的執行效果,如下:
對比看還是能看到其中差距,如果感覺不明顯,可以在開發者模式裡面將動畫執行時間調整為 5 倍,則能更清晰地觀察動畫執行過程。這樣看起來,size 和 color 動畫是在非同步併發執行。
1.3 Transition 動畫
Animate*AsState 和 Animatable 都是針對單個目標值的動畫,而 Transition 可以面向多個目標值應用動畫,並保持它們同步結束。這聽起來是不是類似傳統 View 中的 AnimationSet ?
1.3.1 updateTransition
我們可以使用 updateTransition 創建一個 Transition 動畫,還是先上代碼,看看 updateTransition 的用法:
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val transition = updateTransition(targetState = bigBox, label = "")
val customSize by transition.animateDp(label = "") {
when(it) {
true -> 200.dp
false -> 50.dp
}
}
val customColor by transition.animateColor(label = "") {
when(it) {
true -> Color.Red
false -> Color.Blue
}
}
Box(modifier = Modifier
.background(customColor)
.size(customSize)
.clickable {
bigBox = !bigBox
})
}
Transition 也需要依賴狀態執行,需要枚舉出所有可能的狀態。然後基於這個狀態,通過 updateTransition
創建了一個 Transition 對象,然後使用 transiton 動畫,依次創建出 Size 和 Color。效果如下:
咋一看,怎麼感覺跟前面實現的 Size 和 Color 的調整後的這個動畫是一樣的?這裡可以這樣理解,Transition 動畫是其依賴的狀態變化後,會同步改變由它創建出來的其它多個屬性,能保證各個屬性同時變化,動畫真正的同時啟動,並同時結束。
1.3.2 createChildTransition
通過 createChildTransition 可以將一種類型的 Transition 轉換為其它的 Transition。
舉例:
var bigBox by remember {
mutableStateOf(false)
}
val transition = updateTransition(targetState = bigBox, label = "")
val sizeTransition = transition.createChildTransition(label = "") {
when(it) {
true -> 200.dp
false -> 50.dp
}
}
val colorTransition = transition.createChildTransition(label = "") {
when(it) {
true -> Color.Red
false -> Color.Blue
}
}
將一個 Transition
1.3.3 封裝並復用 Transition 動畫
使用 updateTransition 方法操作動畫,沒有問題,現在假設某個動畫效果很複雜,我們不希望每次用的時候都去重新實現一遍,我們希望將上述動畫效果封裝起來,並可以復用。如何做呢?還是以上面的動畫效果為例.
首先把動畫涉及到的屬性做一個封裝:
class TransitionData(
size: State<Dp>,
color: State<Color>
) {
val size by size
val color by color
}
然後定義動畫,並返回對應的值:
@Composable
fun ChangeBoxSizeAndColor(bigBox: Boolean): TransitionData {
val transition = updateTransition(targetState = bigBox, label = "")
val size = transition.animateDp(label = "") {
when(it) {
true -> 200.dp
false -> 50.dp
}
}
val color = transition.animateColor(label = "") {
when(it) {
true -> Color.Red
false -> Color.Blue
}
}
return remember (transition) {
TransitionData(size, color)
}
}
最後使用封裝的動畫:
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val sizeAndColor = ChangeBoxSizeAndColor(bigBox)
Box(modifier = Modifier
.background(sizeAndColor.color)
.size(sizeAndColor.size)
.clickable {
bigBox = !bigBox
})
}
執行結果和前面一致。這裡就不在貼圖了。
1.4 remeberInfiniteTransition —— 無限迴圈的 transition 動畫
顧名思義,remeberInfiniteTransition 就是一個無限迴圈的 transition 動畫。一旦動畫開始便會無限迴圈下去,直到 Composable 進入 onDispose。
看下用法:
@Composable
fun Demo() {
val infiniteTransition = rememberInfiniteTransition(label = "")
val color by infiniteTransition.animateColor(
initialValue = Color.Blue,
targetValue = Color.Red,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = ""
)
val size by infiniteTransition.animateValue(
initialValue = 50.dp,
targetValue = 200.dp,
typeConverter = Dp.VectorConverter,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = ""
)
Box(modifier = Modifier
.background(color)
.size(size)
)
}
效果如下:
首先使用 remeberInfiniteTransition
創建一個 InfiniteTransition 對象,然後通過該對象,創建出具體的要進行動畫的屬性,這裡使用到了 animationSpec 動畫規格,關於動畫規格,不著急,下文馬上講解。需要註意到的是,infiniteTransition.animateColor
和 infiniteTransition.animateFloat
方法是不需要傳入 typeConverter 參數的,其它類型,我們需要實現 TwoWayConverter。
1.5 小結
關於 Compose 低級別的動畫 API ,我們介紹差不多了,主要是 animtion*AsState
、Animatable
和 Transition
,比如上面的例子中,我們用三種動畫都實現了相同的效果。那這三者我們到底應該怎麼理解?
首先
animtion*AsState
底層實現實際上就是使用的Animatable
,我們可以把animtion*AsState
理解為Animatable
的一種更簡便更直接的用法。這兩個實際上可以歸為同一類。
而 Transition 的核心思想與 Animatable 不一樣。Animatable 的核心思想是面向值的,在多個動畫,多個狀態的情況下,存在不方便管理的問題。比如針對 size 和 color,我們需要創建出兩個 Animatable 對象,並且需要啟動兩個協程,如果有更多的還要同時執行更多的動畫,則會更複雜。Transition 的核心思想是面向狀態的,如果多個動畫依賴一個共同的狀態,則可以做到統一管理。updateTransition 只會創建一次協程,根據一種狀態的變化,控制不同的動畫效果。很明顯,transition 在代碼結構上以及邏輯上更清晰。
另外,Transition 還支持一個非常牛叉的功能:支持 Compose 動畫預覽調節!!!
二、Android Studio 對 Compose 動畫調試的支持
為了方便後面在體驗不同動畫規格時,感受效果差異,在講解動畫規格之前,這裡插入一節,將一下 Android Studio 對與 Compose 動畫的預覽調試,這個是真正的生產力。我們不需要在編譯 apk 在真機或者模擬器上運行,就可以預覽動畫效果,這個是不是大大提高了效率。怎麼用?—— @Preview
註解。
首先,不論是 Animatable 動畫還是 Transition 動畫, Android Studio 都支持預覽功能。
還是上面的例子:
@Preview
@Composable
fun Demo1() {
var bigBox by remember {
mutableStateOf(false)
}
val customSize = remember {
androidx.compose.animation.core.Animatable(50.dp, Dp.VectorConverter, label = "demo1Size")
}
val customColor = remember {
androidx.compose.animation.Animatable(Color.Blue)
}
LaunchedEffect(key1 = bigBox) {
launch {
customSize.animateTo(if (bigBox) 200.dp else 50.dp)
}
launch {
customColor.animateTo(if (bigBox) Color.Red else Color.Blue)
}
}
Box(modifier = Modifier
.background(customColor.value)
.size(customSize.value)
.clickable {
bigBox = !bigBox
})
}
@Preview
@Composable
fun Demo2() {
var bigBox by remember {
mutableStateOf(false)
}
val transition = updateTransition(targetState = bigBox, label = "demo2SizeAndColor")
val customSize by transition.animateDp(label = "demo2Size") {
when(it) {
true -> 200.dp
false -> 50.dp
}
}
val customColor by transition.animateColor(label = "demo2Color") {
when(it) {
true -> Color.Red
false -> Color.Blue
}
}
Box(modifier = Modifier
.background(customColor)
.size(customSize)
.clickable {
bigBox = !bigBox
})
}
- 給需要預覽調節的 Composable 加上
@Preview
註解。這裡我們還對動畫函數中的 label 參數重新賦值了。這個 label 參數就是方便預覽調節的。 - 進入 Android Studio 預覽模式
然後將滑鼠移動到對應的 Composable 名稱上,可以看到有多種模式:
一般會有如下預覽模式:
start UI check mode
用來檢查 UI 狀態,比如橫豎屏,暗黑模式,亮色模式,rtl 佈局,不同尺寸的設備,不同主題等 UI 適配問題,非常方便。
run preview
是在真機或者模擬器中運行當前的 Composable,進行預覽,切合實際場景。
start interact mode
啟動交互模式,顧名思義,比如點擊、長按,拖拽等等事件交互進行預覽,當然動畫也是一種交互形式,可以在這個模式下進行動畫預覽。
start animation preview
啟動動畫預覽模式,這個就是上一節提到的,Transition 動畫特有的預覽調節模式。
我們可以看到只有 Demo2 有 start animation preview
選項。啟動該模式:
①區域,可以播放動畫,是否迴圈,播放速度,跳轉到動畫起始位置或者結束位置。
②區域,可以設置動畫依賴的狀態變化。
③區域,可以展開或者收起具體的動畫,展開後,④區域會顯示當前具體每個動畫執行過程,前面個動畫設置 label, 就是用作在此做區別的。並且有一個時間軸,可以自行調節。
雖然 animate*AsState 也支持添加 label,但是該類型動畫只支持預覽,不支持調節。但即便如此,是不是比傳統 View 方便了一萬倍。更多預覽調試功能大家可以自行探索。
三、AnimationSpec 動畫規格
終於講到動畫規格了。動畫規格實際上就是控制動畫如何執行的。前面出現的代碼中多次提到 animationSpec 參數,大多數 Compose 動畫 API 都支持設置 animationSpec 參數定義動畫效果。前面我們沒有傳入這個參數,是因為使用到的 API 該參數都有一個預設值。
比如:
@Composable
fun animateDpAsState(
targetValue: Dp,
animationSpec: AnimationSpec<Dp> = dpDefaultSpring,
label: String = "DpAnimation",
finishedListener: ((Dp) -> Unit)? = null
): State<Dp> {
return animateValueAsState(
targetValue,
Dp.VectorConverter,
animationSpec,
label = label,
finishedListener = finishedListener
)
}
animateDpAsState 方法對於 animationSpec 參數賦了預設值 dpDefaultSpring。我們先看看這個 AnimationSpec 定義:
interface AnimationSpec<T> {
fun <V : AnimationVector> vectorize(
converter: TwoWayConverter<T, V>
): VectorizedAnimationSpec<V>
}
AnimationSpec 是一個介面,只有一個方法,泛型 T 是當前動畫的數值類型,vectorize 方法用來創建一個 VectorizedAnimationSpec,這是一個矢量動畫的配置。AnimationVector 其實就是一個函數,用來參與計算動畫矢量,TwoWayConverter 用來將 T 類型轉換成參與動畫計算的矢量數據。
AnimationSpec 的實現類如下:
下麵按照類別介紹:
3.1 SpringSpec 彈跳動畫
Spring 彈性動畫。可以使用 spring() 方法進行創建。
@Stable
fun <T> spring(
dampingRatio: Float = Spring.DampingRatioNoBouncy,
stiffness: Float = Spring.StiffnessMedium,
visibilityThreshold: T? = null
): SpringSpec<T> =
SpringSpec(dampingRatio, stiffness, visibilityThreshold)
spring 方法接收三個參數,都有預設值。
- dampingRatio: 彈簧的阻尼比,阻尼比可以定義震動從一次彈跳到下一次彈跳所衰減的速度有多快。當阻尼比 < 1 時,阻尼比越小,彈簧越有彈性,預設值為
Spring.DampingRatioNoBouncy = 1f
。
- 當 dampingRatio > 1 時會出現過阻尼現象,這會使彈簧快速地返回到靜止狀態。
- 當 dampingRatio = 1 時,沒有彈性的阻尼比,會使得彈簧在最短的時間內返回到靜止狀態。
- 當 0 < dampingRatio < 1 時, 彈簧會圍繞最終靜止為止多次反覆振動。
註意 dampingRatio 不能小於0。
Compose 為 spring 提供了一組常用的阻尼比常量。
const val DampingRatioHighBouncy = 0.2f
const val DampingRatioMediumBouncy = 0.5f
const val DampingRatioLowBouncy = 0.75f
const val DampingRatioNoBouncy = 1f
- stiffness: 彈簧的剛度,剛度值越大,彈簧到靜止的速度就越快。預設值為
Spring.StiffnessMedium = 1500f
。
註意 stiffness 必須大於 0 。
Compose 為 spring 提供了一組常量值:
const val StiffnessHigh = 10_000f
const val StiffnessMedium = 1500f
const val StiffnessMediumLow = 400f
const val StiffnessLow = 200f
const val StiffnessVeryLow = 50f
- visibilityThreshold: 可見性閾值。這個參數是一個泛型,次泛型與 targetValue 的類型保持一致,又開發者指定一個閾值,當動畫達到這個閾值時,動畫會立即停止。預設值為 null。
示例:
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val customSize by animateDpAsState(
targetValue = if (bigBox) 200.dp else 50.dp,
animationSpec = spring(dampingRatio = -0.2f),
label = ""
)
Box(modifier = Modifier
.background(Color.LightGray)
.size(customSize)
.clickable {
bigBox = !bigBox
})
}
這個 Gif 錄製效果著實不太好,大家可以試一下,實際效果很明顯,像彈框一樣反覆振動直到靜止。
3.2 TweenSpec 補間動畫
TweenSpec 是 DurationBasedAnimationSpec 的子類。TweenSpec 的動畫必須在規定的時間內完成,它的動畫效果是基於時間參數計算的,可以使用 Easing 來指定不同的時間曲線動畫效果。可以使用 tween() 方法進行創建。
@Stable
fun <T> tween(
durationMillis: Int = DefaultDurationMillis,
delayMillis: Int = 0,
easing: Easing = FastOutSlowInEasing
): TweenSpec<T> = TweenSpec(durationMillis, delayMillis, easing)
tween 方法接收三個參數:
- durationMillis: 動畫的持續時間,預設值 300ms。
- delayMillis: 動畫延遲時間,預設值 0, 即立即執行。
- easing: 動畫曲線變化,預設值為 FastOutSlowInEasing。
Easing
下麵介紹一下 Easing,
@Stable
fun interface Easing {
fun transform(fraction: Float): Float
}
Easing 是一個介面,只有一個方法, transform 用來計算變化曲線,允許過度元素加速或者減速,而不是以恆定的速度變化。參數 fraction 是一個 [0.0f, 1.0f] 區間的值。其中 0.0f 表示 開始,1.0f 表示結束。
Compose 給出了 Easing 的兩個實現類:
PathEasing
class PathEasing(path: Path) : Easing
PathEasing 需要創建一個 Path 對象傳入。關於 Path 的使用,和 View 中的 Path API 有一些類似,具體可以查看源碼,需要註意的是這個 Path 必須從 (0,0) 開始,到 (1, 1) 結束,並且 x 方向上不得有間隙,也不得自行迴圈,避免有兩個點共用相同的 x 坐標。
CubicBezierEasing
class CubicBezierEasing(
private val a: Float,
private val b: Float,
private val c: Float,
private val d: Float
) : Easing
實際開發中,使用更多的是三階貝塞爾曲線。a、b, 是第一個控制的 x 坐標和 y 坐標,c、d 是第二個控制點的 x 坐標和 y 坐標。
關於三階貝塞爾曲線的知識,本文就展開了,如不清楚的可以查閱相關資料。Compose 提供了一些常用的實現。
val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f)
如需自定義控制點坐標,可以到一些線上網站上預覽曲線變化效果。比如:
LinearEasing
除此之外,還有一個線性變化曲線。
val LinearEasing: Easing = Easing { fraction -> fraction }
自定義 Easing
自定義 Easing 實際上就是自己顯示 Easing 介面。
例如:
val CustomEasing = Easing { fraction -> fraction * fraction }
3.3 KeyframesSpec 關鍵幀動畫
KeyframesSpec 也是 DurationBasedAnimationSpec,基於時間的動畫規格,在不同的時間戳定義值,更精細地來實現關鍵幀的動畫。可以使用 keyframes() 方法來創建 KeyframesSpec。
@Stable
fun <T> keyframes(
init: KeyframesSpec.KeyframesSpecConfig<T>.() -> Unit
): KeyframesSpec<T> {
return KeyframesSpec(KeyframesSpec.KeyframesSpecConfig<T>().apply(init))
}
keyframes 方法只有一個參數。直接看用法:
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val customSize by animateDpAsState(
targetValue = if (bigBox) 200.dp else 50.dp,
animationSpec = keyframes {
durationMillis = 10000
50.dp at 0 with LinearEasing
100.dp at 1000 with FastOutLinearInEasing
150.dp at 9000 with LinearEasing
200.dp at 10000
},
label = ""
)
Box(modifier = Modifier
.background(Color.LightGray)
.size(customSize)
.clickable {
bigBox = !bigBox
})
}
解釋:指定在什麼時間,值應該是多少,是以怎樣的曲線變化到該值。
註意,這個示例有個奇怪的點,由小變到大是符合邏輯的,而由大變大小,這個效果會顯得很奇怪。
3.4 SnapSpec 跳切動畫
SnapSpec 表示跳切動畫,它立即將動畫值捕捉到最終值。它的 targetValue 發生變化時,當前值會立即更新為 targetValue, 沒有中間過渡,動畫會瞬間完成,常用於跳過過場動畫的場景。使用 snap() 方法創建。
@Stable
fun <T> snap(delayMillis: Int = 0) = SnapSpec<T>(delayMillis)
接收一個參數,表示延遲多久後執行。
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val customSize by animateDpAsState(
targetValue = if (bigBox) 200.dp else 50.dp,
animationSpec = snap(),
label = ""
)
Box(modifier = Modifier
.background(Color.LightGray)
.size(customSize)
.clickable {
bigBox = !bigBox
})
}
3.5 RepeatableSpec 迴圈動畫
使用 repeatable() 方法可以創建一個 RepeatableSpec 示例,前面介紹的動畫規格都是單次執行的動畫,而 RepeatableSpec 是一個可循播放的動畫。
@Stable
fun <T> repeatable(
iterations: Int,
animation: DurationBasedAnimationSpec<T>,
repeatMode: RepeatMode = RepeatMode.Restart,
initialStartOffset: StartOffset = StartOffset(0)
): RepeatableSpec<T> =
RepeatableSpec(iterations, animation, repeatMode, initialStartOffset)
repeatable 有四個參數:
- iterations: 迴圈次數,理論上應該大於 1 ,等於 1 表示不迴圈。那也就沒有必要使用 RepeatableSpec 了。
- animation: 該參數是一個 DurationBasedAnimationSpec 類型。可以使用
TweenSpec
、KeyframesSpec
、SnapSpec
。SpringSpec 不支持迴圈播放,這個可以理解,迴圈的彈性,違背物理定律。 - repeatMode: 重覆模式,枚舉類型。
enum class RepeatMode {
Restart,
Reverse
}
示例:
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val customSize by animateDpAsState(
targetValue = if (bigBox) 200.dp else 50.dp,
animationSpec = repeatable(
iterations = 10,
animation = tween()
),
label = ""
)
Box(modifier = Modifier
.background(Color.LightGray)
.size(customSize)
.clickable {
bigBox = !bigBox
})
}
- initialStartOffset: 動畫開始的偏移。可用於延遲動畫的開始或將動畫快進到給定的播放時間。此起始偏移量不會重覆,而動畫中的延遲(如果有)將重覆。 預設情況下,偏移量為 0。
3.6 InfiniteRepeatableSpec 無限迴圈動畫
InfiniteRepeatableSpec 表示無限迴圈的動畫,使用 infiniteRepeatable() 方法創建。
@Stable
fun <T> infiniteRepeatable(
animation: DurationBasedAnimationSpec<T>,
repeatMode: RepeatMode = RepeatMode.Restart,
initialStartOffset: StartOffset = StartOffset(0)
): InfiniteRepeatableSpec<T> =
InfiniteRepeatableSpec(animation, repeatMode, initialStartOffset)
與 repeatable() 方法相比少了一個參數 iterations。無限迴圈動畫自然是不需要指定重覆次數的,其餘參數一樣。
示例:
@Composable
fun Demo() {
var bigBox by remember {
mutableStateOf(false)
}
val customSize by animateDpAsState(
targetValue = if (bigBox) 200.dp else 50.dp,
animationSpec = infiniteRepeatable(
animation = tween()
),
label = ""
)
Box(modifier = Modifier
.background(Color.LightGray)
.size(customSize)
.clickable {
bigBox = !bigBox
})
}
這裡註意和前面講到的 remeberInfiniteTransition
用法做一個區分。
remeberInfiniteTransition 是一種動畫類型,需要結合 infiniteRepeatable 一起使用,是在 transition 動畫中使用,而 infiniteRepeatableSpec 是一種動畫規格,可以使用在任何需要 animationSpec 參數的方法中。
3.7 FloatAnimationSpec
FloatAnimationSpec
是一個介面,有兩個實現類,FloatTweenSpec
僅針對 Float 類型做 TweenSpec 動畫,FloatSpringSpec
僅針對 Float 類型做 SpringSpec 動畫。官方沒有提供可以直接進行使用的方法,因為 tween() 和 spring() 支持全量數據類型,FloatAnimationSpec 是底層做更精細的計算的時候才會去使用。
3.8 KeyframesWithSplineSpec
KeyframesWithSplineSpec 是基於三次埃爾米特樣條曲線的變化,截止目前,該類是一個實驗性質的 API, 目前官方文檔沒有說明其使用方法。等待後續穩定可用。
關於動畫規格的介紹這些了。
四、TwoWayConverter
4.1 TwoWayConterver 是什麼
TwoWayConterver 是一個介面:
interface TwoWayConverter<T, V : AnimationVector> {
val convertToVector: (T) -> V
val convertFromVector: (V) -> T
}
它可以需要實現將任意 T 類型的數值轉換成標準的 AnimationVector 類型。以及將標準的 AnimationVector 類型轉換為任意的 T 類型數值。
這個 AnimationVerction 有如下子類:
分別表示 1 維到 4 維的的矢量值。
我們看看幾個 Compose 實現的方式。
private val DpToVector: TwoWayConverter<Dp, AnimationVector1D> = TwoWayConverter(
convertToVector = { AnimationVector1D(it.value) },
convertFromVector = { Dp(it.value) }
)
private val SizeToVector: TwoWayConverter<Size, AnimationVector2D> =
TwoWayConverter(
convertToVector = { AnimationVector2D(it.width, it.height) },
convertFromVector = { Size(it.v1, it.v2) }
)
private val OffsetToVector: TwoWayConverter<Offset, AnimationVector2D> =
TwoWayConverter(
convertToVector = { AnimationVector2D(it.x, it.y) },
convertFromVector = { Offset(it.v1, it.v2) }
)
可以看到,常用類型都已經提供了 TwoWayConverter 的拓展實現。可以在這些類型的半生對象中找到,並可以直接使用。
4.2 自定義 TwoWayConterver
對於沒有提供預設支持的數據類型,可以自定義 TwoWayConterver。
示例:
data class HealthData(val height: Float, val weight: Float)
@Composable
fun MyAnimation(targetHealthData: HealthData) {
val healthDataToVector: TwoWayConverter<HealthData, AnimationVector2D> = TwoWayConverter(
convertToVector = { AnimationVector2D(it.height, it.weight) },
convertFromVector = { HealthData(it.v1, it.v2) }
)
val animationHeath by animateValueAsState(
targetValue = targetHealthData,
typeConverter = healthDataToVector,
label = ""
)
}
我們定義了一個健康數據類,包含身高和體重。然後實現一個 TwoWayConterver。並基於這個 TwoWayConterver 實現了一個自定義動畫。
五、高級動畫 API
所謂高級別動畫 API ,是指這些 API 是基於前面講到的低級別動畫 API 進行封裝的,使用起來更方便。
很多 Compose 教程都是先介紹高級別動畫 API ,再介紹低級別動畫 API,因為高級別的 API 使用起來更簡單,服務於常見的業務,開箱即用。我先介紹了低級別動畫 API ,是因為我覺得這樣更好理解。明白了背後的原理,再去看上層實現,才能心中的脈絡框架。下麵逐一介紹高級別的動畫 API。
5.1、AnimatedVisibility
5.1.1 基本使用
AnimatedVisibility 是一個用於可組合項出現/消失的過渡動畫效果。藉助 AnimatedVisibility 可以輕鬆實現隱藏和顯示可組合項。
看一下效果:
使用方法如下:
var visible by remember {
mutableStateOf(true)
}
// Animated visibility will eventually remove the item from the composition once the animation has finished.
AnimatedVisibility(visible) {
// your composable here
// ...
}
AnimatedVisibility 本身就是一個 Composable。定義如下:
@Composable
fun AnimatedVisibility(
visible: Boolean,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandIn(),
exit: ExitTransition = shrinkOut() + fadeOut(),
label: String = "AnimatedVisibility",
content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
val transition = updateTransition(visible, label)
AnimatedVisibilityImpl(transition, { it }, modifier, enter, exit, content = content)
}
可以看到,AnimatedVisibility 是基於 Transition 動畫實現的。關鍵參數如下:
- visible: 表示可組合項出現或者消失。
- content: 表示要添加出現/消失動畫的可組合項內容。
- enter: 表示 content 出現的動畫。EnterTransition 類型。
- exit: 表示 content 消失的動畫。ExitTransition 類型。
對於動畫,可以使用 +
運算符,組合多個 EnterTransition 或者 ExitTransition 對象。預設情況下內容以淡入和擴大的方式出現,以淡出和縮小的方式消失。
Compose 提供了多種 EnterTransition 或者 ExitTransition 的實例。
fadeIn
、fadeOut
、slideIn
、slideOut
、slideInHorizontally
、slideOutHorizontally
、slideInVertically
、slideOutVertically
、scaleIn
、scaleOut
、expandIn
、shrinkOut
、expandHorizontally
、shrinkHorizontally
、expandVertically
、shrinkVertically
。
具體效果大家可以動手試一下,也可以到官網查看:animatedvisibility
示例:
@Composable
fun Demo() {
var visible by remember {
mutableStateOf(false)
}
Column {
AnimatedVisibility(visible) {
Box(modifier = Modifier.background(Color.LightGray).size(200.dp))
}
Button(onClick = {
visible = !visible
}) {
Text(text = "click me")
}
}
}
效果如下:
5.1.2 MutableTransitionState 監聽動畫執行狀態
AnimatedVisibility 還提供了另一個重載方法。它接受一個 MutableTransitionState
類型的參數。
@Composable
fun AnimatedVisibility(
visibleState: MutableTransitionState<Boolean>,
modifier: Modifier = Modifier,
enter: EnterTransition = fadeIn() + expandIn(),
exit: ExitTransition = fadeOut() + shrinkOut(),
label: String = "AnimatedVisibility",
content: @Composable() AnimatedVisibilityScope.() -> Unit
) {
val transition = updateTransition(visibleState, label)
AnimatedVisibilityImpl(transition, { it }, modifier, enter, exit, content = content)
}
它與上一小節的那個方法,只有第一個參數不一樣,它支持監聽動畫狀態。看一下 MutableTransitionState
的定義:
class MutableTransitionState<S>(initialState: S) : TransitionState<S>() {
override var currentState: S by mutableStateOf(initialState)
internal set
override var targetState: S by mutableStateOf(initialState)
val isIdle: Boolean
get() = (currentState == targetState) && !isRunning
override fun transitionConfigured(transition: Transition<S>) {
}
}
MutableTransitionState 有兩個關鍵成員:currentState
和 targetState
,表示當前狀態和目標狀態。兩個狀態的不同驅動了動畫的執行。用法如下:
@Composable
fun Demo() {
val visible = remember {
MutableTransitionState<Boolean>(false)
}
Column {
AnimatedVisibility(visible) {
Box(modifier = Modifier.background(Color.LightGray).size(200.dp))
}
Button(onClick = {
visible.targetState = !visible.currentState
}) {
Text(text = "click me")
}
}
}
當需要執行動畫時,只需要改變 MutableTransitionState 對象的 targetState,讓它與 currentState 不同。效果與上一小節完全一致,這裡就不貼圖了。
另外 MutableTransitionState 還可以方便實現 AnimatedVisibility 首次添加到組合樹中,就立即觸發動畫。只需在初始化 MutableTransitionState 對象是,讓 targetState 和 currentState 不同即可。可以用此來實現一些開屏動畫的效果。
val visible = remember {
MutableTransitionState<Boolean>(false).apply {
targetState = true
}
}
此外,MutableTransitionState 的意義還在於可以通過 currentState 和 isIdle 的值,獲取動畫的執行狀態。
@Composable
fun Demo() {
val visible = remember {
MutableTransitionState<Boolean>(false)
}
Column {
AnimatedVisibility(visible) {
Box(
modifier = Modifier
.background(Color.LightGray)
.size(200.dp)
)
}
Button(onClick = {
visible.targetState = !visible.currentState
}) {
Text(text = "click me")
}
Text(
text = when {
visible.isIdle && visible.currentState -> "Visible"
!visible.isIdle && visible.currentState -> "Disappearing"
visible.isIdle && !visible.currentState -> "Invisible"
else -> "Appearing"
}
)
}
}
效果如下:
5.1.3 為子可組合項添加進入和退出的動畫
AnimatedVisibility 中直接或者間接的子可組合項,可以使用 Modifier.animateEnterExit
修飾符單獨設置進入和退出的過渡動畫。這樣子項的動畫就是 AnimatedVisibility 中設置的動畫與子項自己設置的動畫結合在一起構成的。
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun Demo() {
var visible by remember {
mutableStateOf(false)
}
Column {
AnimatedVisibility(
visible = visible,
) {
Box(
modifier = Modifier
.background(Color.LightGray)
.size(200.dp)
.animateEnterExit(
enter = slideInHorizontally(),
exit = slideOutHorizontally(),
label = ""
)
)
}
Button(onClick = {
visible = !visible
}) {
Text(text = "click me")
}
}
}
如果希望每個子項完全自己定義不同的動畫效果,則可以將 AnimatedVisibility 中的動畫設置為 EnterTransition.None
和 ExitTransition.None
。
5.1.4 自定義 AnimatedVisibility 動畫
除了使用 Compose 提供的 EnterTransition
和 ExitTransition
動畫以外,AnimatedVisibility 還支持自定義動畫效果。通過在 AnimatedVisibility 的 AnimatedVisibilityScope 中的 transition 訪問底層的 Transition 實例。添加到 transition 的動畫會和 AnimatedVisibility 中設置的動畫同時運行,AnimatedVisibility 會等到 Transition 中的所有動畫都完成後,再移除其內容。對於獨立於 Transition 創建的動畫(比如使用 animate*AsState
創建的動畫), AnimatedVisibility 將無法解釋這些動畫。因此可能會在動畫完成之前移除內容可組合項。
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun Demo() {
var visible by remember {
mutableStateOf(false)
}
Column {
AnimatedVisibility(
visible = visible,
) {
val bgColor by transition.animateColor(label = "") { state ->
if (state == EnterExitState.Visible) Color.Red else Color.Blue
}
Box(
modifier = Modifier
.background(bgColor)
.size(200.dp)
)
}
Button(onClick = {
visible = !visible
}) {
Text(text = "click me")
}
}
}
直白點理解就是 AnimatedVisibilityScope 裡面提供了一個 trasition 對象,然後通過 transition 對象做動畫處理。
這也是我為什麼先講解低級別動畫 API 的原因,不然在這裡自定義動畫,可能有些術語就會造成困擾。
我們是否也可以自己使用前面講解的 updateTransition()
方法創建一個 trasition 對象,然後使用這個 trasition 做動畫處理呢?可以是可以,但是與使用 animate*AsState
一樣,AnimatedVisibility 將無法解釋這些動畫。可能會在動畫完成之前移除內容可組合項。
如下代碼做了一個對比:
@Preview
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun Demo1() {
var visible by remember {
mutableStateOf(false)
}
Column {
AnimatedVisibility(
visible = visible,
) {
val bgColor by transition.animateColor(label = "bgColor",
transitionSpec = {
tween(durationMillis = 3000, delayMillis = 0, easing = LinearEasing)
}
) { state ->
if (state == EnterExitState.Visible) Color.Red else Color.Blue
}
Box(
modifier = Modifier
.background(bgColor)
.size(200.dp)
)
}
Button(onClick = {
visible = !visible
}) {
Text(text = "click me")
}
}
}
@Preview
@Composable
fun Demo2() {
var visible by remember {
mutableStateOf(false)
}
Column {
AnimatedVisibility(
visible = visible,
) {
val myTransition = updateTransition(targetState = visible, label = "myColor")
val myColor by myTransition.animateColor(
label = "",
transitionSpec = {
tween(durationMillis = 3000, delayMillis = 0, easing = LinearEasing)
}
) {
if (it) Color.Red else Color.Blue
}
Box(
modifier = Modifier
.background(myColor)
.size(200.dp)
)
}
Button(onClick = {
visible = !visible
}) {
Text(text = "click me")
}
}
}
此處特意將動畫持續時間設置為了 3000ms, 最終的效果就是,Demo1 中,在 enter 時,我們能看到顏色漸變動畫的整個過程。而 Demo2 就感覺不明顯。
使用動畫調節器可以更清晰看到區別:
Demo1:
當我們使用 AnimatedVisibilityScope 裡面提供的 transition 對象做動畫,所有的動畫都包含在 AnimatedVisility 這個動畫之內。以最長的時間為動畫執行時間。
Demo2:
當我們通過 updateTransition() 創建 transition 對象做動畫,這個動畫和 AnimatedVisility 動畫是獨立的。
5.2 AnimatedContent
AnimatedContent 可組合項會在內容根據目標狀態發生變化時,為內容添加動畫效果。
5.2.1 基本用法
用法:
@Composable
fun Demo() {
var typeState by remember {
mutableStateOf(true)
}
Column {
Button(onClick = {
typeState = !typeState
}) {
Text(text = "Change Type")
}
AnimatedContent(targetState = typeState, label = "") {
if (it) {
Text(
text = "Hello, Animation",
modifier = Modifier
.background(Color.Yellow)
.wrapContentSize()
)
} else {
Icon(imageVector = Icons.Filled.Face, contentDescription = "")
}
}
}
}
為了清楚看到效果,我在開發者模式,把動畫持續時間改為 5 倍了。
看一下定義:
@Composable
fun <S> AnimatedContent(
targetState: S,
modifier: Modifier = Modifier,
transitionSpec: AnimatedContentTransitionScope<S>.() -> ContentTransform = {
(fadeIn(animationSpec = tween(220, delayMillis = 90)) +
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)))
.togetherWith(fadeOut(animationSpec = tween(90)))
},
contentAlignment: Alignment = Alignment.TopStart,
label: String = "AnimatedContent",
contentKey: (targetState: S) -> Any? = { it },
content: @Composable() AnimatedContentScope.(targetState: S) -> Unit
) {
val transition = updateTransition(targetState = targetState, label = label)
transition.AnimatedContent(
modifier,
transitionSpec,
contentAlignment,
contentKey,
content = content
)
}
簡單理解 AnimatedContent
這個可組合函數,AnimatedContent 內部維護著 targetState 到 content 的映射表,當 targetState 發生變化了, AnimatedContentScope 中的可組合函數 content 就會發生重組,在 content 重組時附加的動畫效果就會執行。
預設情況下,動畫效果是初始內容淡出,目標內容淡入。可以從 AnimatedContent 的聲明中看到。自定義動畫效果,即給 transitionSpec 參數指定一個 ContentTransform
對象。ContentTransform 也是由 EnterTransition 和 ExitTranstion 組合的,可以使用中綴運算符 with 將 EnterTransition 和 ExitTransition 組合起來以常見 ContentTransform。
infix fun EnterTransition.togetherWith(exit: ExitTransition) = ContentTransform(this, exit)
我當前是 Compose 1.6.5 版本,比較新,顯示 with 已經過時了。現在使用 togetherWith
,用法一樣。
infix fun EnterTransition.togetherWith(exit: ExitTransition) = ContentTransform(this, exit)
下麵看下例子:
@Composable
fun Demo() {
var typeState by remember {
mutableStateOf(true)
}
Column {
Button(onClick = {
typeState = !typeState
}) {
Text(text = "Change Type")
}
AnimatedContent(targetState = typeState, label = "", transitionSpec = {
slideInVertically { fullHeight -> fullHeight }.togetherWith(slideOutVertically { fullHeight -> -fullHeight })
}) {
if (it) {
Text(
text = "Hello, Animation",
modifier = Modifier
.background(Color.Yellow)
.wrapContentSize()
)
} else {
Icon(imageVector = Icons.Filled.Face, contentDescription = "")
}
}
}
}
效果:
另外,AnimatedContent 提供了 slideIntoContainer
和 slideOutOfContainer
,可以作為 slideInHorizontally/Vertically
和 slideOutHorizontally/Vertically
的便捷替代方案。它們可以根據 另外,AnimatedContent 的初始內容的大小和目標內容的大小來計算滑動距離。
5.2.2 SizeTransform 定義大小動畫
AnimatedContent 中,還可以使用中綴函數 using
將 SizeTransform
應用於 ContentTransform 來定義大小動畫。
看下 SizeTransform :
fun SizeTransform(
clip: Boolean = true,
sizeAnimationSpec: (initialSize: IntSize, targetSize: IntSize) -> FiniteAnimationSpec<IntSize> =
{ _, _ ->
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = IntSize.VisibilityThreshold
)
}
): SizeTransform = SizeTransformImpl(clip, sizeAnimationSpec)
SizeTransform
定義了大小應如何在初始內容與目標內容之間添加動畫效果。在創建動畫時,可以訪問初始大小和目標大小。SizeTransform
還可控制在動畫播放期間是否應將內容裁剪為組件大小。
示例:
@Composable
fun Demo() {
var typeState by remember {
mutableStateOf(true)
}
Column {
Button(onClick = {
typeState = !typeState
}) {
Text(text = "Change Type")
}
AnimatedContent(targetState = typeState, label = "", transitionSpec = {
fadeIn(animationSpec = tween(150, 150))
.togetherWith(fadeOut(animationSpec = tween(150)))
.using(
SizeTransform(
clip = true,
sizeAnimationSpec = { initSize, targetSize ->
if (targetState) {
keyframes {
// 展開時,先水平方向展開.
IntSize(targetSize.width, initSize.height) at 150
durationMillis = 300
}
} else {
keyframes {
// 收起時,先垂直方向收起.
IntSize(initSize.width, targetSize.height) at 150