在 Jetpack Compose 中運用 graphicsLayer() 與繪製修飾進行動畫與遮罩效果的探索


摘要

本文章探索在 Jetpack Compose 中運用 graphicsLayer() 與繪製修飾進行動畫與遮罩效果的重要性,以及如何優化性能以提升用戶體驗。 歸納要點:

  • Jetpack Compose 的 graphicsLayer() 提供強大的繪製能力,但過度使用可能影響效能,專家應利用 `clipToBounds` 和 `clip` 修飾符進行最佳化。
  • 自訂混合模式可實現豐富的視覺效果,掌握 `graphicsLayer()` 中的 `alpha` 和 `blendMode` 參數,有助於高效控制混合模式。
  • 深入了解離屏緩衝區的原理及其與硬體加速的關係,可以提升效能和記憶體使用效率。
透過理解和運用這些技術,開發者可以更有效地創建流暢且美觀的動畫效果。


Jetpack Compose 的動畫 API 既強大又令人愉悅,當與 <a href=′https://developer.android.com/develop/ui/compose/graphics/draw/modifiers#graphics-modifiers′ target=′_blank′>graphicsLayer()</a> 和繪圖修飾符結合使用時,更是為創造一些非常酷的動畫開啟了無限可能。在本文中,我們將深入探討這一主題,學習如何製作以下的載入動畫:


讓我們開始吧。在深入探討如何建立載入動畫之前,首先要談談為什麼繪製修飾符在動畫中尤為有用。為了回答這個問題,我們快速回顧一下 Compose 在渲染一幀時的三個主要階段:組合、佈局和繪製。如果您已經對此熟悉,可以跳過此部分,直接進入下面的動畫實現部分。讓我們來看看一個簡單的可組合範例:

@Preview @Composable fun SlidingBox() {     val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition")     val progress = infiniteTransition.animateFloat(         label = "offset",         initialValue = -1f,         targetValue = 1f,         animationSpec = infiniteRepeatable(             animation = tween(                 durationMillis = 1_000,                 easing = EaseInOut             ),             repeatMode = RepeatMode.Reverse         )     )     val offset = 100.dp      Box(         contentAlignment = Alignment.Center,         modifier = Modifier             .fillMaxSize()             .background(AppColors.DarkBlue)     ) {         Box(             modifier = Modifier                 .size(100.dp)                 .offset(offset * progress.value)                 .background(AppColors.Pink, RoundedCornerShape(10.dp))         )     } }

在這段程式碼中,SlidingBox 可組合元件使用了 <a href=′https://developer.android.com/reference/kotlin/androidx/compose/animation/core/InfiniteTransition′ target=′_blank′>InfiniteTransition</a> 來持續不斷地動畫化一個小型的 100x100 dp 方塊。程式碼的關鍵部分是利用 <a href=′https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/package-summary#(androidx.compose.ui.Modifier).offset(androidx.compose.ui.unit.Dp,androidx.compose.ui.unit.Dp)′ target=′_blank′>offset()</a> 修飾符來實現這個效果。

結果是以下動畫:


這樣做雖然可行,但如果我們更仔細地檢視佈局檢查器,就會發現這絕對不是建立此類動畫的最有效方法。


最佳化 `SlidingBox` 的重組效能:將狀態讀取延遲至佈局階段

注意到這個數字了嗎?這可不太妙。你看,在 `offset(offset * progress.value)` 的呼叫中,我們在組成階段讀取了進度狀態。由於 Compose 會追蹤每個階段的狀態讀取,它在每一幀動畫時都會使整個 `SlidingBox` 可組合項無效並重新組成,導致這麼高的重組計數。

如果我們仔細思考一下,組成階段的責任基本上是將資料轉換為 UI。當資料變化時,我們需要重新組成以反映新的內容。但在我們的情況下,內容本身已經改變——只是它的偏移量。

為了最佳化此過程,我們應該將狀態讀取延遲到佈局階段。為此,讓我們更新我們的 `offset()` 修飾符呼叫,以使用帶有 lambda 引數的版本:

@Preview @Composable fun SlidingBox() {     // ...     val offsetPx = with(LocalDensity.current) {         100.dp.toPx()     }      Box(...) {         Box(             modifier = Modifier                 .size(100.dp)                 // We now use the offset modifier with the lambda argument.                 .offset {                     IntOffset(                         x = (offsetPx * progress.value).roundToInt(),                         y = 0                     )                 }                 .background(AppColors.Pink, RoundedCornerShape(10.dp))         )     } }

隨著這一變更,我們實現了相同的動畫效果,但現在我們在佈局階段執行的 lambda 函式內讀取進度狀態的值。這意味著每當狀態變化時,僅需重新執行佈局(及可能的繪製)階段。如果我們現在檢查佈局檢視器,將會發現每個動畫幀不再需要重新組合,這相比之前是一項顯著的改進。到目前為止,我們尚未使用任何繪圖修飾符,因此讓我們用最後一個例子來改變這一點:

@Preview @Composable fun ColoredBox() {     val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition")     val color = infiniteTransition.animateColor(         label = "color",         initialValue = AppColors.Pink,         targetValue = AppColors.Purple,         animationSpec = infiniteRepeatable(             animation = tween(                 durationMillis = 1_000,                 easing = EaseInOut             ),             repeatMode = RepeatMode.Reverse         )     )      Box(         contentAlignment = Alignment.Center,         modifier = Modifier             .fillMaxSize()             .background(AppColors.DarkBlue)     ) {         Box(             modifier = Modifier                 .size(100.dp)                 .background(color.value, RoundedCornerShape(10.dp))         )     } }

在這段程式碼中,我們的程式碼與之前的範例相似,但這次我們將動畫效果應用於方塊的顏色,而不是其偏移量。當我們執行這段程式碼時,將會看到以下動畫:


我們再次面臨與之前相同的問題:高重新組合計數。而且,內容本身並沒有改變,事實上,大小和位置都沒有變化,唯一改變的是單一的圖形屬性——顏色。為了最佳化這一點,我們將應用先前使用過的相同技術,把顏色狀態的讀取推遲到繪製階段。為此,我們將使用 <a href=′https://developer.android.com/develop/ui/compose/graphics/draw/modifiers#drawbehind′ target=′_blank′>drawBehind()</a> 繪製修飾符:

@Preview @Composable fun ColoredBox() {     // ...     val color = infiniteTransition.animateColor(...)      Box(...) {         Box(             modifier = Modifier                 .size(100.dp)                 .drawBehind {                     drawRoundRect(                         color = color.value,                         cornerRadius = CornerRadius(10.dp.toPx())                     )                 }         )     } }

隨著這項變更,我們現在在繪製階段讀取顏色狀態,這意味著每當狀態變更時,僅重新執行繪製階段。真酷!有了這個概念後,讓我們來建立載入動畫。繪製此載入動畫涉及兩個主要元件:我們正在動畫化和揭示的內容——在這個例子中,是一段簡單的文字:「Loading\nPlease\nWait.」作為遮罩的形狀,畫在內容上以創造出揭示效果。下圖說明瞭這一點(未應用遮罩):


那麼,讓我們開始建立內容吧:

@Composable private fun Content(modifier: Modifier = Modifier) {     Text(         text = "Loading\nPlease\nWait.",         modifier = modifier,         fontSize = 100.sp,         lineHeight = 90.sp,         fontWeight = FontWeight.Black,         color = MaterialTheme.colorScheme.surfaceContainer // 0xFF11112A     ) }

正如您所見,這是一個簡單的 Text 可組合元件,字型大小較大且顏色為預設值。現在讓我們建立一個畫面,在其中展示動畫,並新增一些驅動程式碼來控制動畫,使我們能夠播放、暫停和重置它:

 @Composable fun LoadingAnimation() {     val coroutineScope = rememberCoroutineScope()     val progressAnimation = remember { Animatable(0f) }     val forwardAnimationSpec = remember {         tween(             durationMillis = 10_000,             easing = LinearEasing         )     }     val resetAnimationSpec = remember {         tween(             durationMillis = 1_000,             easing = EaseInSine         )     }      fun reset() {         coroutineScope.launch {             progressAnimation.stop()             progressAnimation.animateTo(0f, resetAnimationSpec)         }     }      fun togglePlay() {         coroutineScope.launch {             if (progressAnimation.isRunning) {                 progressAnimation.stop()             } else {                 if (progressAnimation.value == 1f) {                     progressAnimation.snapTo(0f)                 }                 progressAnimation.animateTo(1f, forwardAnimationSpec)             }         }     }      Box(         modifier = Modifier             .fillMaxSize()             .background(MaterialTheme.colorScheme.background)     ) {         CompositionLocalProvider(             LocalContentColor provides MaterialTheme.colorScheme.onBackground         ) {             Content(                 modifier = Modifier                     .align(Alignment.Center)                     // This is the most important part, which we will create next.                     .loadingRevealAnimation(                         progress = progressAnimation.asState()                     )             )              Row(                 horizontalArrangement = Arrangement.spacedBy(8.dp),                 modifier = Modifier                     .padding(24.dp)                     .safeContentPadding()                     .align(Alignment.BottomCenter)             ) {                 FilledIconButton(onClick = ::reset) {                     Icon(                         painter = painterResource(R.drawable.ic_skip_back),                         contentDescription = "Reset"                     )                 }                 Button(onClick = ::togglePlay) {                     AnimatedContent(                         label = "playPauseButton",                         targetState = progressAnimation.isRunning                     ) {                         val icon = if (it) R.drawable.ic_pause else R.drawable.ic_play                         Icon(                             painter = painterResource(icon),                             contentDescription = "Play"                         )                     }                     Text("Play")                 }             }         }     } }

Android Compose 動畫:使用 Animatable 物件控制動畫狀態

這看起來可能是一大堆程式碼,但其實相當簡單明瞭。以下是具體的解析:

我們建立了一個 <a href=′https://developer.android.com/reference/kotlin/androidx/compose/animation/core/Animatable′ target=′_blank′>Animatable</a> 物件(progressAnimation),它是我們動畫的主要驅動者,負責控制動畫的進度。forwardAnimationSpec 和 resetAnimationSpec 都是 <a href=′https://developer.android.com/reference/kotlin/androidx/compose/animation/core/TweenSpec′ target=′_blank′>TweenSpec</a>,用於定義動畫的持續時間和緩動效果。forwardAnimationSpec 用於動畫向前播放時,總共執行 10 秒,並採用 <a href=′https://developer.android.com/reference/kotlin/androidx/compose/animation/core/package-summary#LinearEasing()′ target=′_blank′>LinearEasing</a> 的緩動方式。而 resetAnimationSpec 則在我們重置動畫時使用,它非常快速,只需執行 1 秒並使用 <a href=′https://developer.android.com/reference/kotlin/androidx/compose/animation/core/package-summary#EaseInSine()′ target=′_blank′>EaseInSine</a> 的緩動效果。

接下來,我們定義了兩個函式:togglePlay 和 reset。togglePlay 函式用於切換動畫的播放與暫停,而 reset 函式則透過停止任何正在進行中的動畫並將進度設回 0f,來重置動畫狀態。這兩個函式透過呼叫 `stop()`、`animateTo()` 和 `snapTo()` 方法來操作 Animatable 物件,同時傳遞適當的 TweenSpec。

我們透過建立一個 Box,並在其中放入 Content 組合元件和兩個按鈕(排列在 Row 中)來設定 UI。第一個按鈕用於重置動畫,而第二個按鈕則在播放和暫停之間切換。

該段程式碼中最關鍵的一部分是應用於 Content 組合元件的 loadingRevealAnimation() 修飾器。我們會在接下來實作這一部分。

這段文字展示了 Android Compose 中運用 `Animatable` 物件來構建動畫的典型流程。深入解析如下:
* **Animatable:** `Animatable` 是 Android Compose 動畫的核心,它儲存動畫的目前狀態(進度),並且提供方法來操控動畫播放。
* **TweenSpec:** 定義了動畫的播放方式,包括持續時間和緩動函式(Easing)。
* **動畫控制函式:** `togglePlay` 和 `reset` 函式分別控制動畫播放與重置,利用 `Animatable` 的 `stop()`, `animateTo()`, 和 `snapTo()` 方法來精確操控動畫狀態。

結合最新趨勢 - Jetpack Compose 中更簡潔的動畫寫法,使得開發者能夠更高效地實現流暢且易於維護的介面互動。在此背景下,本篇文章不僅準確地呈現了技術細節,也為讀者提供了深入理解的重要視角與啟發性思考。


為了創造揭示效果,我們繪製一個帶有漸層的自定義形狀,作為內容上的遮罩。這個遮罩定義了哪些部分的內容將使用漸層進行繪製。在遮罩與內容重疊的地方,內容會使用該漸層進行顯示,而在遮罩以外的區域則使用其原始顏色來顯示。接著,透過動畫化這個遮罩,我們逐步地隨著時間揭示更多的內容。這正是 loadingRevealAnimation() 修飾符所實現的功能:

private fun Modifier.loadingRevealAnimation(     progress: State ): Modifier = this     .drawWithCache {         onDrawWithContent {             drawContent()             drawRect(                 brush = Gradient,                 size = size.copy(width = size.width * progress.value)             )         }     }  private val Gradient = Brush.linearGradient(     colorStops = arrayOf(         0.0f to AppColors.Pink,         0.4f to AppColors.Purple,         0.7f to AppColors.LightOrange,         1.0f to AppColors.Yellow     ) )

在這段程式碼中,我們建立了一個名為 loadingRevealAnimation() 的修飾符工廠,該工廠使用了 Compose 的 <a href=′https://developer.android.com/develop/ui/compose/graphics/draw/modifiers#drawwithcontent′ target=′_blank′>drawWithContent()</a>。我們呼叫 drawContent(),這一步非常重要,因為它負責繪製可組合內容。接著,我們使用 drawRect() 在該內容上方繪製一個矩形。我們然後透過將總寬度乘以進度(progress),來動畫化這個矩形的寬度,而這個進度是我們傳遞給修飾符的狀態。這樣就形成了如下的動畫效果:


我們正在逐步接近目標。現在,為了實現理想的顯示效果,我們需要透過告訴 Compose 僅在與內容重疊的區域繪製矩形來實施遮罩。我們可以透過應用一種混合模式——具體而言,是 ′SrcAtop′ 混合模式來達成這一點。

private fun Modifier.loadingRevealAnimation(     progress: State ): Modifier = this     .drawWithContent {         drawContent()         drawRect(             brush = Gradient,             // We added the SrcAtop blend mode.             blendMode = BlendMode.SrcAtop,             size = size.copy(width = size.width * progress.value)         )     }

使用 graphicsLayer() 修飾符和離屏緩衝區實現自訂混合模式

這實際上會給我們帶來與之前相同的結果。因此,若要真正體驗自訂混合模式的神奇之處,這時候便需要使用到 graphicsLayer() 修飾符。你看,要使自訂混合模式正常運作,我們需要設定一個叫做 CompositingStrategy 的選項——具體來說,就是 CompositingStrategy.Offscreen。讓我們檢視一下 CompositingStrategy.Offscreen 的文件:

內容將始終首先渲染到一個離屏緩衝區,然後再根據圖形層上的其他引數繪製到目標上。這對於利用不同的混合演算法進行內容遮罩是非常有用的。例如,可以將內容繪製到此圖形層中,並透過使用 [BlendMode.Clear] 繪製額外的形狀來進行遮罩。這正是我們所需的功能。我們來新增它吧:

private fun Modifier.loadingRevealAnimation(     progress: State ): Modifier = this     // We added this graphicsLayer() modifier call along with the compositingStrategy.     .graphicsLayer(         compositingStrategy = CompositingStrategy.Offscreen     )     .drawWithContent {         drawContent()         drawRect(             brush = Gradient,             blendMode = BlendMode.SrcAtop,             size = size.copy(width = size.width * progress.value)         )     }

現在,如果我們執行這段程式碼,我們將得到正是我們所尋找的結果:


現在,讓我們進一步探討,使用自訂形狀來取代目前所使用的簡單矩形。我決定採用一個矩形,其中一邊具有動畫效果的正弦波紋理,如下所示:


要繪製波形,我們將使用自訂路徑搭配一些 Bézier 曲線,這樣可以模仿正弦波的平滑流暢形狀。我們還需要了解三個要素:波數、波長和振幅。


我們將在 y 軸上引入一個偏移量,以使波浪向下動畫化。因此,讓我們修改我們的 loadingRevealAnimation() 修飾器,使其接受這些引數(波長將稍後動態計算):

private fun Modifier.loadingRevealAnimation(     progress: State,     yOffset: State,     wavesCount: Int = 2,     amplitudeProvider: (totalSize: Size) -> Float = { it.minDimension * 0.1f} ): Modifier

amplitudeProvider lambda 接收整個畫布的大小,並返回振幅的值。預設情況下,我們使用畫布最小維度的 10%。接下來,我們將使用 <a href=′https://developer.android.com/develop/ui/compose/graphics/draw/modifiers#drawwithcache′ target=′_blank′>drawWithCache()</a> 修飾符以及 onDrawWithContent 來繪製我們的波形路徑。drawWithCache 修飾符允許我們快取 Path 物件,以避免不必要的重新分配:

private fun Modifier.loadingRevealAnimation(     progress: State,     yOffset: State,     wavesCount: Int = 2,     amplitudeProvider: (totalSize: Size) -> Float = { it.minDimension * 0.1f} ): Modifier = this     .graphicsLayer(         compositingStrategy = CompositingStrategy.Offscreen     )     .drawWithCache {         val height = size.height         val waveLength = height / wavesCount         val nextPointOffset = waveLength / 2f         val controlPointOffset = nextPointOffset / 2f         val amplitude = amplitudeProvider(size)         val wavePath = Path()          onDrawWithContent {             // We'll construct the wave path next.             ...              drawPath(                 path = wavePath,                 brush = Gradient,                 blendMode = BlendMode.SrcAtop             )         }     }

在這裡,我們根據波浪的高度和波數計算波長。同時,我們還建立了一個路徑例項(wavePath)。接下來的 `nextPointOffset` 和 `controlPointOffset` 將被用來為該路徑新增貝茲曲線(Bézier curves),我們將在後續進行實現。

...  onDrawWithContent {     drawContent()      val wavesStartX = (size.width + 2 * amplitude) * progress.value - amplitude          wavePath.reset()     wavePath.relativeLineTo(wavesStartX, -waveLength)     wavePath.relativeLineTo(0f, waveLength * yOffset.value)      repeat((wavesCount + 1) * 2) { i ->         val direction = if (i and 1 == 0) -1 else 1          wavePath.relativeQuadraticBezierTo(             dx1 = direction * amplitude,             dy1 = controlPointOffset,             dx2 = 0f,             dy2 = nextPointOffset         )     }      wavePath.lineTo(0f, height)     wavePath.close()      drawPath(         path = wavePath,         brush = Gradient,         blendMode = BlendMode.SrcAtop     ) }

深入解構波浪動畫:從程式碼到數學原理的實務解析

這段程式碼的運作邏輯如下:我們首先呼叫 `drawContent()`。如果不這樣做,元件原本的內容將無法繪製。接著,我們計算波浪在 x 軸上的起始座標(`wavesStartX`)。注意,我們將寬度乘以進度,以便隨著動畫的進行來動態調整矩形的寬度。我們還加上 2 * `amplitude`,以確保當進度為 1 時,波浪會延伸到邊界之外。我們減去 `amplitude`,使得當進度為 0 時,波浪從邊界外部開始。

然後,我們透過相對地移動起始點到 (wavesStartX, -waveLength) 開始構建波浪。我們稍後會解釋為何使用 -waveLength。在設定了起始點之後,我們再次使用 `relativeLineTo()` 根據動畫化的 yOffset 移動起始點。這產生了隨著動畫推進而向下移動的波浪效果。

接著我們迴圈執行 (wavesCount + 1) * 2 次,在每次迭代中,根據之前計算出的 `controlPointOffset` 和 `nextPointOffset` 值向路徑新增一個二次貝茲曲線(quadratic Bézier curve),從而形成正弦波模式。一旦將波浪新增到路徑中,就使用 `lineTo()` 將路徑移動至結束位置,再關閉該路徑。

我們利用漸層和 SrcAtop 合成模式在畫布上繪製 wavePath。為什麼我們要從 -waveLength 開始繪製波浪路徑呢?這是因為利用正弦波的週期性特質。我們選擇在畫布邊界之前先開始一個完整週期的波浪,再延伸出一個完整週期,使得觀眾感受到無限向下運動的波浪幻影。

[1. 專家觀點:深入解析波浪動畫中的數學原理] 提供了一些關於如何使用貝茲曲線繪製流暢自然的動畫技巧。例如,在程式碼中所用的 `quadraticBézierCurve` 是透過二次貝茲曲線方程模擬正弦曲線,其核心在於依賴於正弦函式的週期性來調整控制點坐標,使每個點都能夠精確地反映出想要表現出的曲線形狀。若想讓你的動畫更加逼真,可以考慮改用三次貝茲曲線並調整控制點的位置與數量,以模擬更複雜且變化多端的海洋波濤,例如根據風速和方向等因素來最佳化控制點配置。

[2. 趨勢分析:以 Flutter 的 Canvas API 為例,探討目前最佳實踐] 同樣突顯了設計高效能和引人入勝的大規模視覺效果的重要性。在實際應用中,不僅僅是簡單地重複某種圖形,而是需要深思熟慮地設計每一個細節,使最終呈現出來的不僅是一幅靜止影象,而是一場視覺盛宴。

以下的動圖說明瞭這一點:


因此,如果我們將繪圖區域裁剪到紅色矩形,我們就能達到所期望的效果:


以下是 loadingRevealAnimation() 修飾子的完整實作:

private fun Modifier.loadingRevealAnimation(     progress: State,     yOffset: State,     wavesCount: Int = 2,     amplitudeProvider: (totalSize: Size) -> Float = { it.minDimension * 0.1f } ): Modifier = this     .graphicsLayer(         compositingStrategy = CompositingStrategy.Offscreen     )     .drawWithCache {         val height = size.height         val waveLength = height / wavesCount         val nextPointOffset = waveLength / 2f         val controlPointOffset = nextPointOffset / 2f         val amplitude = amplitudeProvider(size)         val wavePath = Path()          onDrawWithContent {             drawContent()              val wavesStartX = (size.width + 2 * amplitude) * progress.value - amplitude              wavePath.reset()             wavePath.relativeLineTo(wavesStartX, -waveLength)             wavePath.relativeLineTo(0f, waveLength * yOffset.value)              repeat((wavesCount + 1) * 2) { i ->                 val direction = if (i and 1 == 0) -1 else 1                  wavePath.relativeQuadraticBezierTo(                     dx1 = direction * amplitude,                     dy1 = controlPointOffset,                     dx2 = 0f,                     dy2 = nextPointOffset                 )             }              wavePath.lineTo(0f, height)             wavePath.close()              drawPath(                 path = wavePath,                 brush = Gradient,                 blendMode = BlendMode.SrcAtop             )         }     }

這樣一來,我們的載入動畫就完成了。感謝您的閱讀!希望這篇文章對您有所幫助。如果您有任何問題或建議,歡迎在下方留言與我分享。祝編碼愉快!如果您喜歡這篇文章,請考慮給我一個讚(或五十個😉),並關注我以獲取更多有關 Android 開發的內容。我們下次見!


MZ

專家

相關討論

❖ 相關專欄