Jetpack Compose(6)——動畫

本文介紹 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 參數。這裏說明一下:對與 ColorFloat 類型,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 類型轉換爲了 Transition 和 Transition, 實際使用中,子動畫的動畫數值來自於父動畫,某種程度上說,createChildTransition 更像是一種 map 操作。

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.animateColorinfiniteTransition.animateFloat 方法是不需要傳入 typeConverter 參數的,其它類型,我們需要實現 TwoWayConverter。

1.5 小結

關於 Compose 低級別的動畫 API ,我們介紹差不多了,主要是 animtion*AsStateAnimatableTransition,比如上面的例子中,我們用三種動畫都實現了相同的效果。那這三者我們到底應該怎麼理解?

首先 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
        })
}
  1. 給需要預覽調節的 Composable 加上 @Preview 註解。這裏我們還對動畫函數中的 label 參數重新賦值了。這個 label 參數就是方便預覽調節的。
  2. 進入 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 方法接收三個參數,都有默認值。

  1. 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
  1. 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
  1. 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 方法接收三個參數:

  1. durationMillis: 動畫的持續時間,默認值 300ms。
  2. delayMillis: 動畫延遲時間,默認值 0, 即立即執行。
  3. 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 有四個參數:

  1. iterations: 循環次數,理論上應該大於 1 ,等於 1 表示不循環。那也就沒有必要使用 RepeatableSpec 了。
  2. animation: 該參數是一個 DurationBasedAnimationSpec 類型。可以使用
    TweenSpecKeyframesSpec SnapSpec 。SpringSpec 不支持循環播放,這個可以理解,循環的彈性,違揹物理定律。
  3. 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
        })
}
  1. 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 的實例。
fadeInfadeOutslideInslideOutslideInHorizontallyslideOutHorizontallyslideInVerticallyslideOutVerticallyscaleInscaleOutexpandInshrinkOutexpandHorizontallyshrinkHorizontallyexpandVerticallyshrinkVertically
具體效果大家可以動手試一下,也可以到官網查看: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 有兩個關鍵成員:currentStatetargetState,表示當前狀態和目標狀態。兩個狀態的不同驅動了動畫的執行。用法如下:

@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.NoneExitTransition.None

5.1.4 自定義 AnimatedVisibility 動畫

除了使用 Compose 提供的 EnterTransitionExitTransition 動畫以外,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 提供了 slideIntoContainerslideOutOfContainer,可以作爲 slideInHorizontally/VerticallyslideOutHorizontally/Vertically 的便捷替代方案。它們可以根據 另外,AnimatedContent 的初始內容的大小和目標內容的大小來計算滑動距離。

5.2.2 SizeTransform 定義大小動畫

AnimatedContent 中,還可以使用中綴函數 usingSizeTransform 應用於 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
                                    durationMillis = 300
                                }
                            }
                        }
                    )
                )
        }) {
            if (it) {
                Text(
                    text = "Hello, Animation",
                    modifier = Modifier
                        .background(Color.Yellow)
                        .wrapContentSize()
                )
            } else {
                Icon(imageVector = Icons.Filled.Face, contentDescription = "")
            }
        }
    }
}

5.2.3 爲子可組合項添加動畫效果

與 AnimatedVisibility 一樣, AnimatedContent 直接或間接的子可組合項都可以使用修飾符 Modifier.animateEnterExit 爲自身添加動畫,這裏不再贅述。

5.2.4 自定義 AnimatedContent 動畫效果

與 AnimatedVisibility 一樣, AnimatedContent 中的 AnimatedContentScope 內部提供了一個 transition, 可以用來自定義動畫效果,這裏不再贅述。

5.3 Crossfade

Crossfade 可使用淡入淡出動畫在兩個佈局之間添加動畫效果。通過切換傳遞給 current 參數的值,可以使用淡入淡出動畫來切換內容。可以理解爲 AnimatedContent 的一種功能特性。如果只需要淡入淡出動畫效果,則可以使用 Crossfade 來替代 AnimatedContent。
使用方式:

@Composable
fun Demo() {
    var typeState by remember {
        mutableStateOf(true)
    }
    Column {
        Button(onClick = {
            typeState = !typeState
        }) {
            Text(text = "Change Type")
        }
        Crossfade(targetState = typeState, label = "") {
            if (it) {
                Text(
                    text = "Hello, Animation",
                    modifier = Modifier
                        .background(Color.Yellow)
                        .wrapContentSize()
                )
            } else {
                Icon(imageVector = Icons.Filled.Face, contentDescription = "")
            }
        }
    }
}

5.4 修飾符 animateContentSize

使用 animateContentSize 爲可組合項的大小變化添加動畫效果。
注意:animateContentSize 在修飾符鏈中的位置順序很重要。爲使動畫流暢,請務必將其放在任何大小修飾符(如 size 或 defaultMinSize)之前,以確保 animateContentSize 向佈局報告添加動畫效果之後的值更改。
示例:

@Composable
fun Demo() {
    var bigBox by remember {
        mutableStateOf(false)
    }
    Column {
        Button(onClick = {
            bigBox = !bigBox
        }) {
            Text(text = "Change Size")
        }
        Box(
            modifier = Modifier
                .background(Color.DarkGray)
                .animateContentSize()
                .size(if (bigBox) 200.dp else 50.dp)
        )
    }
}

效果如下:

animateContentSize 定義如下:

fun Modifier.animateContentSize(
    animationSpec: FiniteAnimationSpec<IntSize> = spring(
        stiffness = Spring.StiffnessMediumLow
    ),
    finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)? = null
): Modifier =
    this.clipToBounds() then SizeAnimationModifierElement(animationSpec, finishedListener)

兩個參數,animationSpec 用來指定動畫規格,finishedListener 用來監聽動畫結束。

六、特定場景使用動畫

6.1 列表項的動畫

傳統視圖中,使用 RecyclerView 可以爲每一個列表項的更改添加動畫效果,在 Compose 中,延遲佈局 LazyXXX 提供了相同的功能。使用 animateItemPlacement 修飾符即可。

LazyColumn {
    items(books, key = { it.id }) {
        Row(Modifier.animateItemPlacement()) {
            // ...
        }
    }
}

也可以提供自定義的動畫規格:

LazyColumn {
    items(books, key = { it.id }) {
        Row(
            Modifier.animateItemPlacement(
                tween(durationMillis = 250)
            )
        ) {
            // ...
        }
    }
}

使用時需要確保爲子可組合項提供 key ,以便找到被移動的元素的新位置。
animateItemPlacement 提供了子元素重新排序動畫,看起來好像很雞肋。其實實際開發中更需要的增加或者移除某個子元素時的動畫,很遺憾,目前還沒有這樣的 API, 目前正在開發用於添加或移除操作的可組合動畫。

6.2 在 Navigation-Compose 導航中使用動畫

當使用 Navigation-Compose 在不同的 Composable 之間導航時,可以在可組合項上指定 enterTransitionexitTransition 來實現動畫效果,也可以在 NavHost 中設置用於所有目的地的默認動畫。

val navController = rememberNavController()
NavHost(
    navController = navController, startDestination = "landing",
    enterTransition = { EnterTransition.None },
    exitTransition = { ExitTransition.None }
) {
    composable("landing") {
        ScreenLanding(
            // ...
        )
    }
    composable(
        "detail/{photoUrl}",
        arguments = listOf(navArgument("photoUrl") { type = NavType.StringType }),
        enterTransition = {
            fadeIn(
                animationSpec = tween(
                    300, easing = LinearEasing
                )
            ) + slideIntoContainer(
                animationSpec = tween(300, easing = EaseIn),
                towards = AnimatedContentTransitionScope.SlideDirection.Start
            )
        },
        exitTransition = {
            fadeOut(
                animationSpec = tween(
                    300, easing = LinearEasing
                )
            ) + slideOutOfContainer(
                animationSpec = tween(300, easing = EaseOut),
                towards = AnimatedContentTransitionScope.SlideDirection.End
            )
        }
    ) { backStackEntry ->
        ScreenDetails(
            // ...
        )
    }
}

目前 navigation-compose 還在不斷迭代過程中,有些版本支持上述 enterTransition 和 exitTransition 設置動畫,有些版本則沒有相關 API。如果你當前使用的版本不支持,可以使用 Accompanist Navigation Animation 庫。它提供了一套 API,幫助在 navigation-compose 中使用導航動畫。
具體使用步驟:

implementation "com.google.accompanist:accompanist-navigation-animation:${version}"
  • 使用 rememberAnimatedNavController() 替換rememberNavController()
  • 使用 AnimatedNavHost 替換 NavHost
  • 使用 import com.google.accompanist.navigation.animation.navigation 替換 import androidx.navigation.compose.navigation
  • 使用 import com.google.accompanist.navigation.animation.composable 替換 import androidx.navigation.compose.composable

每個 composable 目的地都有四個新參數可以設置:

  • enterTransition: 指定當您使用 navigate() 導航至該目的地時執行的動畫。
  • exitTransition: 指定當您通過導航至另一個目的地的方式離開該目的地時執行的動畫。
  • popEnterTransition: 指定當該目的地在經過調用 popBackStack() 後重新入場時執行的動畫。默認爲 enterTransition。
  • popExitTransition: 指定當該目的地在以彈出返回棧的方式離開屏幕時執行的動畫。默認爲 exitTransition。

6.3 結合手勢使用動畫

除了單獨處理 Composable 的動畫外,還有一種常見的場景是動畫和觸摸事件需要同時處理。
可能需要考慮一下幾點:

  • 當觸摸時間開始時,我們可能需要中斷正在播放的動畫,因爲響應用戶觸摸應當具有最高優先級。
  • 動畫的值與觸摸事件的值同步。
    這裏屬於相對複雜的動畫場景了。鑑於還沒有講解 Compose 手勢相關的知識(後面計劃再寫一篇文章介紹 Compose 的手勢處理),本文就不展開講了,以後寫自定義可組合函數的時候可能會有涉及。如想學習可參考官網講解:
    高級動畫實例:手勢 https://developer.android.com/develop/ui/compose/animation/advanced?hl=zh-cn

6.4 Compose 中的矢量圖動畫

6.4.1 AnimatedVectorDrawable 文件格式

如需使用 AnimatedVectorDrawable 資源,請使用 animatedVectorResource 加載可繪製對象文件,並傳入 boolean 以在可繪製對象的開始和結束狀態之間切換,從而執行動畫。

@Composable
fun AnimatedVectorDrawable() {
    val image = AnimatedImageVector.animatedVectorResource(R.drawable.ic_hourglass_animated)
    var atEnd by remember { mutableStateOf(false) }
    Image(
        painter = rememberAnimatedVectorPainter(image, atEnd),
        contentDescription = "Timer",
        modifier = Modifier.clickable {
            atEnd = !atEnd
        },
        contentScale = ContentScale.Crop
    )
}

6.4.2 將 ImageVector 與 Compose 動畫 API 結合使用

該場景不常用,本人還沒仔細研究過。可參考一下文章,看起來效果不錯。
參考文章: Making Jellyfish move in Compose: Animating ImageVectors and applying AGSL RenderEffects

6.4.3 使用 Lottie 動畫

首先添加依賴:

dependencies {
    ...
    implementation "com.airbnb.android:lottie-compose:$lottieVersion"
    ...
}

基本使用方式:

@Composable
fun Loader() {
    val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading))
    val progress by animateLottieCompositionAsState(composition)
    LottieAnimation(
        composition = composition,
        progress = { progress },
    )
}

或者使用合併了 LottieAnimationanimateLottieCompositionsState() 的重載方法:

@Composable
fun Loader() {
    val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading))
    LottieAnimation(composition)
}

七、總結

經過前面的講解,動畫的知識點基本就都講完了。內容還是有點多的,本節對 Compose 的動畫做一個小結。常用 API 如何理解記憶,可見下圖。

高級別動畫 API 底層實現都是基於低級別動畫 API,提供了一個開箱即用的 ComposableModifier

如果需要更全面的 API,官方給了一個決策樹圖提供指導。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章