學不動也要學,Jetpack Compose 自定義 View 你會不會

業志陳 2021-08-15 18:16:39 阅读数:601

本文一共[544]字,预计阅读时长:1分钟~
也要 jetpack compose 自定 view

公眾號:字節數組,希望對你有所幫助

我曾經寫過兩篇文章,分別在不同平臺上實現了同一種自定義 View 效果:

分別是:

現如今 Jetpack Compose 也發布了正式版,能實現自定義 View 也是對一名應用開發者最基本的要求,本篇文章就再來介紹下如何用 Jetpack Compose 實現上述效果,最終的效果圖:

一、Canvas & DrawScope

在原生的 Android View 體系架構下,我們要實現一個自定義 View 所需要的基本步驟有:

  • 繼承 android.view.View,在子類的構造函數中通過 AttributeSet 拿到在 XML 文件中聲明的各個屬性值
  • 重寫 onMeasure 和 onSizeChanged 兩個方法,拿到 View 的寬高信息
  • 重寫 onLayout 方法,確定子 View 的比特置信息(如果是自定義 ViewGroup 的話)
  • 重寫 onDraw 方法,通過 Paint、Path 等向 Canvas 繪制圖形,從而實現各種自定義效果
  • 重寫 onVisibilityChanged、onAttachedToWindow、onDetachedFromWindow 等方法,在適當的時候開啟動畫或者停止動畫,避免資源浪費和內存泄漏(如果有使用到 Animator 的話)

整個流程的重點就是 onDraw 方法了,開發者在這裏拿到 Canvas 對象,也即畫布,然後通過各種 API 在畫布上繪制圖形。例如,canvas.drawLine就用於繪制直線,canvas.drawPath 就用於繪制路徑

按我自己的理解,通過 Jetpack Compose 實現一個自定義 View 所需要的基本步驟有:

  • 通過 BoxWithConstraints 拿到父項的約束條件,即以此拿到控件允許占有的最大空間和最小空間,包括:minWidth、maxWidth、minHeight、maxHeight 等
  • 通過 Canvas() 函數來調用 drawLine、drawPath 等 API,繪制自定義圖形
  • 對於一些 Jetpack Compose 目前還不支持的繪制功能,可以通過 drawIntoCanvas 方法拿到原生 Android 環境的 Canvas 和 Paint 對象,利用原生 Android 環境的 API 方法來實現部分繪制需求
  • 將上述操作封裝為可組合函數,以函數入參參數的形式向外暴露必要的繪制參數,該可組合函數即我們最終實現的自定義 View 了

可以看到,在 Jetpack Compose 體系架構下,實現自定義 View 的步驟和原生方式相比有著挺大的差別。最終實現的 View 對應的也是一個可組合函數,而非一個類。而且我們不用再多在意 View 本身的可見性和生命周期了,因為 Jetpack Compose 會負責以高效的方式創建和釋放對象,即使我們使用到了 Animator,Jetpack Compose 也會在可組合函數的生命周期結束的時候就自動停止動畫

Jetpack Compose 的主要思路也是通過 Canvas 對象來繪制各種圖形,通過 Canvas() 函數來提供繪制能力。Canvas() 是一個可組合函數,Canvas 通過 DrawScope 來暴露 drawLine 和 drawPath 等 API。DrawScope 是一個維護自身狀態且限定了作用域的繪圖環境

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
Spacer(modifier.drawBehind(onDraw))
複制代碼

例如,如果我們要繪制一條從屏幕左上角到右下角的直線時,可以這樣實現:

/** * @Author: leavesC * @Date: 2021/8/14 16:24 * @Desc: * @Github:https://github.com/leavesC */
@Preview(showBackground = true, widthDp = 160, heightDp = 160)
@Composable
fun DrawLine() {
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
drawLine(
start = Offset(x = 0f, y = 0f),
end = Offset(x = canvasWidth, y = canvasHeight),
color = Color.Green,
strokeWidth = 10f,
)
}
}
複制代碼

繪制一個帶有漸變背景色的半圓:

@Preview(showBackground = true, widthDp = 160, heightDp = 160)
@Composable
fun DrawPath() {
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
val path = Path()
path.addArc(oval = Rect(0f, 0f, canvasWidth, canvasHeight), 0f, 180f)
drawPath(
path = path,
brush = Brush.linearGradient(colors = listOf(Color.Blue, Color.Cyan, Color.LightGray))
)
}
}
複制代碼

此外 DrawScope 也提供了變換坐標系的能力,比如常見的 translate、rotate、scale 等。例如,將上述半圓進行旋轉:

@Preview(showBackground = true, widthDp = 160, heightDp = 160)
@Composable
fun DrawPath() {
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
val path = Path()
path.addArc(oval = Rect(0f, 0f, canvasWidth, canvasHeight), 0f, 180f)
rotate(degrees = 90f, pivot = center) {
drawPath(
path = path,
brush = Brush.linearGradient(colors = listOf(Color.Blue, Color.Cyan, Color.LightGray))
)
}
}
}
複制代碼

二、drawText

目前 DrawScope 還沒有提供類似於 drawText 的方法,即 Jetpack Compose 目前還不支持直接進行文本繪制,這方面的需求需要通過 Android 框架原生的 Canvas 對象來實現

Canvas() 通過 drawIntoCanvas() 函數來向外暴露原生的 Canvas 對象,並提供了 asFrameworkPaint() 函數用於將 Jetpack Compose 環境的 Paint 對象轉換為原生環境的 Paint 對象,這樣我們就可以使用原生 Canvas 環境的各種方法了

例如,可以在上述半圓的基礎上結合原生的 Canvas 再繪制一段文本

@Preview(showBackground = true, widthDp = 160, heightDp = 160)
@Composable
fun DrawPath() {
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
val path = Path()
path.addArc(oval = Rect(0f, 0f, canvasWidth, canvasHeight), 0f, 180f)
rotate(degrees = 90f, pivot = center) {
drawPath(
path = path,
brush = Brush.linearGradient(
colors = listOf(
Color.Blue,
Color.Cyan,
Color.LightGray
)
)
)
}
drawIntoCanvas {
//將 Jetpack Compose 環境的 Paint 對象轉換為原生的 Paint 對象
val textPaint = Paint().asFrameworkPaint().apply {
isAntiAlias = true
isDither = true
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
textAlign = android.graphics.Paint.Align.CENTER
}
textPaint.color = android.graphics.Color.RED
textPaint.textSize = 50f
val fontMetrics = textPaint.fontMetrics
val top = fontMetrics.top
val bottom = fontMetrics.bottom
val centerX = size.width / 2f
val centerY = size.height / 2f - top / 2f - bottom / 2f
//拿到原生的 Canvas 對象
val nativeCanvas = it.nativeCanvas
nativeCanvas.drawText(
"學不動也要學",
centerX, centerY, textPaint
)
}
}
}
複制代碼

三、drawWithContent

我們除了可以直接使用 Canvas() 函數來實現各種自定義 View 外,Jetpack Compose 還提供了 drawWithContent 函數用於擴展現有控件。drawWithContent 函數是 Modifier 的擴展函數,drawWithContent 函數上執行的各種繪制操作,都會同步給 Modifier 所在控件的 Canvas 上,以此對任意控件進行自定義繪制

drawWithContent 函數提供了 ContentDrawScope 對象用於支持外部進行自定義繪制,ContentDrawScope 是 DrawScope 的子接口,所以可以直接使用上述介紹的各種 draw 方法。

fun Modifier.drawWithContent( onDraw: ContentDrawScope.() -> Unit ): Modifier = this.then(
DrawWithContentModifier(
onDraw = onDraw,
inspectorInfo = debugInspectorInfo {
name = "drawWithContent"
properties["onDraw"] = onDraw
}
)
)
複制代碼

例如,假設現在我們希望在任意控件的右上角繪制一個紅色小圓點,那麼就可以將函數聲明為 Modifier 的擴展函數,在 drawWithContent 中獲取到 Canvas 的寬高信息,即拿到 Modifier 所在控件的寬高信息,然後定比特到控件的右上角繪制出一個紅色圓點即可

fun Modifier.redPoint(pointSize: Dp): Modifier = drawWithContent {
drawContent()
drawIntoCanvas {
val paint = Paint().apply {
color = Color.Red
}
it.drawCircle(
center = Offset(x = size.width, y = 0f),
radius = (pointSize / 2).toPx(),
paint = paint
)
}
}
複制代碼

之後,只要在任意控件的 Modifier 參數中調用上述擴展函數,就可以直接在該控件上繪制出一個紅色圓點了

@Preview(showBackground = true, widthDp = 160, heightDp = 160)
@Composable
fun DrawWithContentSample() {
Spacer(
modifier = Modifier
.fillMaxSize()
.padding(all = 30.dp)
.background(color = Color.LightGray)
.redPoint(pointSize = 12.dp)
)
}
複制代碼

有個細節需要注意。drawWithContent 中的 drawContent() 代錶的是聲明在 redPoint 之後的繪制行為,如果我們不主動執行該方法,那麼聲明在 redPoint 之後的繪制行為都不會生效。而如果將 drawContent() 放到 drawIntoCanvas 之後執行的話,drawContent() 就會繪制在 redPoint 的上面。即我們可以通過控制 drawContent() 的執行順序來控制 drawIntoCanvas 所繪制的 Z 軸層級

例如,先在 redPoint 之後聲明背景色 background,如果 drawIntoCanvas 先執行的話就會導致紅色圓點被 background 覆蓋了一部分內容區域,就像以下效果圖所示,如果 drawContent 先執行的話就不會被覆蓋住

fun Modifier.redPoint(pointSize: Dp): Modifier = drawWithContent {
drawIntoCanvas {
val paint = Paint().apply {
color = Color.Red
}
it.drawCircle(
center = Offset(x = size.width, y = 0f),
radius = (pointSize / 2).toPx(),
paint = paint
)
}
drawContent()
}
@Preview(showBackground = true, widthDp = 160, heightDp = 160)
@Composable
fun DrawWithContentSample() {
Spacer(
modifier = Modifier
.fillMaxSize()
.padding(all = 30.dp)
.redPoint(pointSize = 12.dp)
.background(color = Color.LightGray)
)
}
複制代碼

四、WaveLoadingView

有了上述基礎後,就可以來動手實現以下效果了

先來總結下 WaveLoadingView 的特點,這樣才能歸納出實現該效果所需要的步驟

  1. View 的主體是一個不規則的半圓,頂部以類似於波浪的形式從左往右上下波動運行
  2. 波浪可以自定義顏色,此處以 waveColor 命名
  3. 波浪的起伏線將嵌入的文本分為上下兩種顏色,上邊的文本顏色和 waveColor 保持一致,下邊的文本顏色以 downTextColor 命名,文本的上下分割線一直在動態變化中

雖然波浪是不斷運動的,但只要能够繪制出其中一幀的圖形,其動態效果就能通過不斷改變波浪的比特置參數來完成,所以這裏先把 View 當成靜態的,先實現其靜態效果即可。將繪制步驟拆解為以下幾步:

  1. 繪制顏色為 waveColor 的文本,將其繪制在 canvas 的最底層。繪制文本的操作都需要交由 Android 原生的 Canvas 和 Path 來實現
  2. 根據 View 的寬高信息構建一個不超出範圍的最大圓形路徑 circlePath
  3. 以 circlePath 的水平中間線作為波浪的起伏線,在起伏線的上邊和下邊分別利用貝塞爾曲線繪制一段連續的波浪 path,將 path 的首尾兩端以矩形的形式連接在一起,構成 wavePath
  4. 取 circlePath 和 wavePath 的交集 resultPath,繪制出 resultPath,用 waveColor 填充, 此時就得到了半圓形的球形波浪了
  5. 通過 clipPath(path = resultPath, clipOp = ClipOp.Intersect) 方法裁切畫布,再繪制顏色為 downTextColor 的文本,此時繪制的 downTextColor 文本只會顯示 resultPath 範圍內的部分,從而使得先後兩次不同時間繪制的文本上下拼接在了一起,從而得到有不同顏色範圍的文本
  6. 利用 rememberInfiniteTransition 動畫不斷改變 wavePath 起始點的 X 坐標,從而得到波浪不斷從左往右前進的效果

有了思路後代碼就很簡單了,總的也就才一百行左右,代碼量比 View 版本和 Flutter 版本都要少得多

@Composable
fun WaveLoadingView( modifier: Modifier, text: String, textSize: TextUnit, waveColor: Color, downTextColor: Color, ) {
BoxWithConstraints(modifier = modifier) {
val circleSizeDp = minOf(maxWidth, maxHeight)
val density = LocalDensity.current.density
val circleSizePx = circleSizeDp.value * density
val waveWidth = circleSizePx / 3f
val waveHeight = circleSizePx / 26f
val textPaint by remember {
mutableStateOf(Paint().asFrameworkPaint().apply {
isAntiAlias = true
isDither = true
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
textAlign = android.graphics.Paint.Align.CENTER
})
}
val wavePath by remember {
mutableStateOf(Path())
}
val circlePath by remember {
mutableStateOf(Path().apply {
addArc(
Rect(0f, 0f, circleSizePx, circleSizePx),
0f, 360f
)
})
}
val animateValue by rememberInfiniteTransition().animateFloat(
initialValue = 0f, targetValue = waveWidth,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 500, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
)
Canvas(modifier = modifier.requiredSize(size = circleSizeDp)) {
drawIntoCanvas {
textPaint.textSize = textSize.toPx()
drawText(
canvas = it.nativeCanvas,
size = circleSizePx,
textPaint = textPaint,
textColor = waveColor.toArgb(),
text = text
)
}
wavePath.reset()
wavePath.moveTo(-waveWidth + animateValue, circleSizePx / 2.2f)
var i = -waveWidth
while (i < circleSizePx + waveWidth) {
wavePath.relativeQuadraticBezierTo(waveWidth / 4f, -waveHeight, waveWidth / 2f, 0f)
wavePath.relativeQuadraticBezierTo(waveWidth / 4f, waveHeight, waveWidth / 2f, 0f)
i += waveWidth
}
wavePath.lineTo(circleSizePx, circleSizePx)
wavePath.lineTo(0f, circleSizePx)
wavePath.close()
val resultPath = Path.combine(
path1 = circlePath,
path2 = wavePath,
operation = PathOperation.Intersect
)
drawPath(path = resultPath, color = waveColor)
clipPath(path = resultPath, clipOp = ClipOp.Intersect) {
drawIntoCanvas {
drawText(
canvas = it.nativeCanvas,
size = circleSizePx,
textPaint = textPaint,
textColor = downTextColor.toArgb(),
text = text
)
}
}
}
}
}
private fun drawText( canvas: Canvas, size: Float, textPaint: android.graphics.Paint, textColor: Int, text: String ) {
textPaint.color = textColor
val fontMetrics = textPaint.fontMetrics
val top = fontMetrics.top
val bottom = fontMetrics.bottom
val centerX = size / 2f
val centerY = size / 2f - top / 2f - bottom / 2f
canvas.drawText(text, centerX, centerY, textPaint)
}
複制代碼

之後就像普通的可組合函數一樣進行調用即可。這裏還有個小細節,在 View 視圖架構中,我們在布局文件中聲明的寬高信息可以分為三類:match_parent、wrap_content、確定的大小。而 WaveLoadingView 的寬高信息都交由 Modifier 來傳入,這三類情况就分別對應:fillMaxSize、wrapContentSize、requiredSize。由於我並沒有為 WaveLoadingView 指定默認大小,所以最終 wrapContentSize 的效果就會和 fillMaxSize 一樣

 WaveLoadingView(
modifier = Modifier.requiredSize(size = 240.dp),
text = "最",
textSize = 170.sp,
waveColor = colorOf("#FF50A4F7"),
downTextColor = Color.White
)
WaveLoadingView(
modifier = Modifier.fillMaxSize(),
text = "重",
textSize = 240.sp,
waveColor = colorOf("#FF6200EE"),
downTextColor = Color.White
)
WaveLoadingView(
modifier = Modifier.wrapContentSize(),
text = "要",
textSize = 260.sp,
waveColor = colorOf("#FF009688"),
downTextColor = Color.White
)
複制代碼

五、結尾

最後當然也少不了 WaveLoadingView 的完整示例代碼了,有需要的同學點擊這裏下載:AndroidOpenSourceDemo

版权声明:本文为[業志陳]所创,转载请带上原文链接,感谢。 https://gsmany.com/2021/08/20210815181418997l.html