業志陳 2021-08-15 18:16:39 阅读数:601
公眾號:字節數組,希望對你有所幫助
我曾經寫過兩篇文章,分別在不同平臺上實現了同一種自定義 View 效果:
分別是:
現如今 Jetpack Compose 也發布了正式版,能實現自定義 View 也是對一名應用開發者最基本的要求,本篇文章就再來介紹下如何用 Jetpack Compose 實現上述效果,最終的效果圖:
在原生的 Android View 體系架構下,我們要實現一個自定義 View 所需要的基本步驟有:
整個流程的重點就是 onDraw 方法了,開發者在這裏拿到 Canvas 對象,也即畫布,然後通過各種 API 在畫布上繪制圖形。例如,canvas.drawLine
就用於繪制直線,canvas.drawPath
就用於繪制路徑
按我自己的理解,通過 Jetpack Compose 實現一個自定義 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))
)
}
}
}
複制代碼
目前 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
)
}
}
}
複制代碼
我們除了可以直接使用 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 的特點,這樣才能歸納出實現該效果所需要的步驟
雖然波浪是不斷運動的,但只要能够繪制出其中一幀的圖形,其動態效果就能通過不斷改變波浪的比特置參數來完成,所以這裏先把 View 當成靜態的,先實現其靜態效果即可。將繪制步驟拆解為以下幾步:
clipPath(path = resultPath, clipOp = ClipOp.Intersect)
方法裁切畫布,再繪制顏色為 downTextColor 的文本,此時繪制的 downTextColor 文本只會顯示 resultPath 範圍內的部分,從而使得先後兩次不同時間繪制的文本上下拼接在了一起,從而得到有不同顏色範圍的文本有了思路後代碼就很簡單了,總的也就才一百行左右,代碼量比 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