Compose 实践与探索十四 —— 自定义绘制

不论是在传统的 View 体系下,还是在 Compose 中,“自定义 View” 都包含视图的测量、布局、绘制、触摸反馈以及动画。

本节我们先讲 Compose 的自定义绘制。Compose 提供了更加上层的 API,相比于原生的绘制 API 更简单、更直接。比如原生的 Canvas 和 Paint 在 Compose 中进行正常绘制时是用不到的,只有在使用原生独有的绘制功能(比如多维旋转)时才需要下沉到原生这一层去使用它们。

1、绘制常用 API

原生的自定义绘制需要重写自定义 View 的 onDraw(),而 Compose 的所有绘制工作都发生在 DrawModifier 中。比如,我要为 Text 设置黄色背景:

@Composable
fun CustomText() {
    // drawBehind() 会在组件本身内容的后面的 Canvas 中进行绘制,因此常用于绘制背景
    Text("Jetpack Compose", Modifier.drawBehind { drawRect(Color.Yellow) })
}

效果如下:

在这里插入图片描述

虽然调用 drawRect() 时只指定了要绘制的颜色,没有指定要绘制的矩形尺寸,但由于 Compose 为其指定了刚好绘制一个可以覆盖组件大小的默认值,为使用者提供了便利:

	fun drawRect(
        color: Color,
        // 绘制的左上角相对于组件左上角的偏移,默认为 0
        topLeft: Offset = Offset.Zero,
        // 组件尺寸,默认为组件宽高减去左上角偏移
        size: Size = this.size.offsetSize(topLeft),
        /*@FloatRange(from = 0.0, to = 1.0)*/
        alpha: Float = 1.0f,
        style: DrawStyle = Fill,
        colorFilter: ColorFilter? = null,
        blendMode: BlendMode = DefaultBlendMode
    )

而在原生的 onDraw() 中,调用 drawRect() 绘制矩形时必须要指定尺寸,并且需要传入一个 Paint 对象指定绘制的颜色以及是否填充等设置。这样一看,Compose 经过优化后的 API 确实要比原生的在易用性上要好一些。

在讲解 DrawModifier 原理时,我们提到过一个 drawWithContent(),在它的内部可以自定义绘制顺序,而且必须调用 drawContent() 绘制原本的内容:

@Composable
fun CustomText1() {
    Text("Jetpack Compose", Modifier.drawWithContent {
        // 绘制黄色背景
        drawRect(Color.Yellow)
        // 绘制原本内容
        drawContent()
        // 绘制红色中线
        drawLine(
            Color.Red,
            Offset(0f, size.height / 2),
            Offset(size.width, size.height / 2),
            2.dp.toPx()
        )
    })
}

由于先绘制的会被后绘制的内容覆盖,所以 drawWithContent() 先绘制背景,再绘制原有内容,最后绘制上层的红色中线,效果如下:

在这里插入图片描述

假如想从空白开始完全自定义一个组件,而不是像上面的例子那样,基于某些组件进行绘制,可以使用 Box 或 Canvas:

@Composable
fun DrawFromBlank() {
    Box(
        Modifier
            .size(40.dp)
            .drawBehind {
                // 绘制内容
            }
    )

    Canvas(Modifier.size(40.dp)) {
        // 绘制内容
    }
}

使用 Canvas() 相比与 Box() 要更简洁一些,而且二者的底层逻辑是几乎一样的。Box 内使用一个 UI 内容为空的 Layout(),测量策略是 EmptyBoxMeasurePolicy:

@Composable
fun Box(modifier: Modifier) {
    Layout({}, measurePolicy = EmptyBoxMeasurePolicy, modifier = modifier)
}

而 Canvas() 使用的是测量策略为 SpacerMeasurePolicy 的 Layout():

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

@Composable
@NonRestartableComposable
fun Spacer(modifier: Modifier) {
    Layout({}, measurePolicy = SpacerMeasurePolicy, modifier = modifier)
}

除了具体的测量策略不同之外,其余部分都是一样的,并且使用 Canvas() 的代码会更简洁一些。

注意这个 Canvas() 是 androidx.compose.foundation 包下的 Composable 函数,不是 androidx.compose.ui.graphics 包下的用于创建可以绘制 ImageBitmap 的 Canvas 对象的那个 Canvas 函数,更不是我们熟悉的原生的 android.graphics 包下的 Canvas 类。

通过以上示例代码,能够看出 Compose 的自定义绘制在易用性上要比原生好一些,主要体现在三点:

  1. 对于一些绘制工作,Compose 通过提供函数默认值的方式可以让你少写一些参数
  2. 原生的所有绘制都必须要用到 Canvas 对象,而 Compose 绘制在大多数情况下(除了一些比较特殊的,如三维变换)无需使用原生的 Canvas
  3. 原生的绘制需要通过 Paint 指定绘制风格,如颜色、是否填充等等,而 Compose 是通过函数参数的形式来设置这些参数,不再需要 Paint

2、图像旋转示例

前面提到过 Compose 现有 API 无法进行多维度旋转效果的绘制,现在我们通过一个例子来讲解,Compose 如何绘制 Bitmap,如何使用原生 Canvas 实现多维度旋转的效果。

2.1 Compose 实现图像旋转

首先,我们要先用 Compose 的 API 绘制出一个 Bitmap:

@Composable
fun CustomImage() {
    val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
    Canvas(Modifier.size(100.dp)) {
        // dstSize 将图片尺寸调整到 Canvas 相同的尺寸
        drawImage(bitmap, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()))
    }
}

这里我们稍作讲解,imageResource() 会根据参数传入的资源加载一个 ImageBitmap。ImageBitmap 是一个接口,它在 Compose 中的实现类是 AndroidImageBitmap,实际上加载的就是通过 Android 原生的 Drawable API 加载资源,拿到原生的 Bitmap 对象再装到 Compose 中。

在示例代码中,imageResource() 的调用没有放在 Canvas() 内,这是因为 imageResource() 需要是一个 Composable 函数,它需要在 @Composable 环境中才能被调用,而 Canvas() 的尾随 lambda 参数并没有提供这个环境:

// onDraw 参数并不是 @Composable 函数
@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))

因此,需要将 imageResource() 的调用提到 Canvas() 之外,并且不用 remember() 包住它,因为 imageResource() 内部在返回结果上已经使用了 remember():

@Composable
fun ImageBitmap.Companion.imageResource(@DrawableRes id: Int): ImageBitmap {
    val context = LocalContext.current
    val value = remember { TypedValue() }
    context.resources.getValue(id, value, true)
    // 资源路径,只要资源路径不变,无论经历多少次重组,返回结果都是最初返回的结果
    val key = value.string!!.toString() // image resource must have resource path.
    return remember(key) { imageResource(context.resources, id) }
}

接下来我们再看,使用 Compose API 如何对图片进行单一维度上的旋转:

@Composable
fun CustomImage() {
    val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
    Canvas(Modifier.size(100.dp)) {
        rotate(45f) {
            drawImage(bitmap, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()))
        }
        // 出了 rotate() 的范围,在其余位置绘制时就是没有被旋转的状态
    }
}

与原生的写法相比省去了旋转绘制完成后,恢复 Canvas 的操作:

import android.graphics.Color as AndroidGraphicsColor

class CustomView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    private val paint = Paint()
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        paint.color = AndroidGraphicsColor.YELLOW
        // 变换之前先保存 canvas
        canvas?.save()
        canvas?.rotate(45f)
        canvas?.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
        // 变换之后需要恢复 canvas
        canvas?.restore()
    }
}

Compose 的 rotate() 实际上帮你做了恢复 Canvas 的操作,让你专注于内容绘制本身:

inline fun DrawScope.rotate(
    degrees: Float,
    pivot: Offset = center,
    block: DrawScope.() -> Unit
) = withTransform({ rotate(degrees, pivot) }, block)

inline fun DrawScope.withTransform(
    transformBlock: DrawTransform.() -> Unit,
    drawBlock: DrawScope.() -> Unit
) = with(drawContext) {
    // Transformation can include inset calls which change the drawing area
    // so cache the previous size before the transformation is done
    // and reset it afterwards
    val previousSize = size
    canvas.save() // 绘制前先保存 canvas
    transformBlock(transform) // 变换
    drawBlock() // 绘制
    canvas.restore() // 绘制完成后恢复 canvas
    size = previousSize
}

旋转绘制效果如下:

在这里插入图片描述

看似图片是被裁切了,但实际上是因为 Canvas 作为最外层组件只设置了预览框那么大,现在在 Canvas 外部加一个有内边距的 Box 显示所有内容:

@Composable
fun CustomImage() {
    Box(Modifier.padding(30.dp)) {
        val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
        Canvas(
            Modifier
                .border(1.dp, Color.Red) // 为了看清 Canvas 的边界范围,加了红色边框
                .size(100.dp)) {
            rotate(45f) {
                drawImage(
                    bitmap,
                    dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())
                )
            }
        }
    }
}

现在能看到,在 Canvas 边界之外的图片其实是有被绘制出来的:

在这里插入图片描述

Compose 跟 View 系统的一个区别是不会自动裁切组件的边缘。

需要注意的是,withTransform() 内用到的 canvas 是 DrawContext 接口内定义的 Canvas 接口实例,它是多层组件共用的一个 Canvas 对象。这样做不仅便于管理,而且节省性能,不像 View 中的 Canvas 那样,跨了 View 就不是同一个 Canvas 了。

此外,你也可以使用 Modifier 的 graphicsLayer() 指定在单一方向上的旋转角度:

@Composable
fun CustomImage1() {
    Box(
        Modifier
            .padding(30.dp)
            .graphicsLayer { rotationX = 30f }
    ) {
        val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
        Canvas(Modifier.size(100.dp)) {
            drawImage(
                bitmap,
                dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())
            )
        }
    }
}

效果如下:

在这里插入图片描述

使用 graphicsLayer() 虽然可以同时指定多个方向上的旋转,但是它的效果并不是沿着指定的轴旋转指定的度数:

@Composable
fun CustomImage1() {
    Box(
        Modifier
            .padding(30.dp)
            .graphicsLayer {
                rotationX = 70f
                rotationY = 70f
            }
    ) {
        val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
        Canvas(Modifier.size(100.dp)) {
            // drawImage 用于绘制位图
            drawImage(
                bitmap,
                dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())
            )
        }
    }
}

它的效果明显不是分别沿着 X 轴与 Y 轴各自旋转了 70°:

在这里插入图片描述

之所以会怎样,是因为 graphicsLayer() 底层使用的是 RenderNode 或者 View 的 API,具体来说:

  • Android 5.0(API 21)系统之前,使用的是 View 的 API
  • 从 Android 5.0(API 21)开始,使用 RenderNode 的 API,这个 API 在 API 29 开放给开发者使用

这两种 API 在支持多维度旋转时,并不是旋转我们要绘制的图像,而是去旋转画布的 Canvas,并且不是分别在各自的轴上旋转指定的角度,而是先在一个维度上旋转,然后在第一次旋转结果的基础上,再在第二个维度上旋转,因此得到的结果看起来会有些奇怪。

2.2 原生 Canvas 实现图像多维度旋转

既然完全使用 Compose 无法完成多维度的图像旋转,我们可以使用原生的 Canvas 来实现。

Compose 内原生 Canvas 的使用方法

DrawScope 的 drawIntoCanvas() 在它的 block 参数上提供了一个 Compose 的 Canvas 对象:

// block 函数的参数是 Compose 的 Canvas 接口
inline fun DrawScope.drawIntoCanvas(block: (Canvas) -> Unit) = block(drawContext.canvas)

Canvas 接口内定义了众多对 Canvas 本身进行的操作,以及借助 Canvas 进行绘制的函数:

@JvmDefaultWithCompatibility
interface Canvas {
    // 对 Canvas 本身进行操作的函数
    fun save()
    fun restore()
    fun saveLayer(bounds: Rect, paint: Paint)
    fun translate(dx: Float, dy: Float)
    fun scale(sx: Float, sy: Float = sx)
    fun rotate(degrees: Float)
    fun concat(matrix: Matrix)
    fun clipPath(path: Path, clipOp: ClipOp = ClipOp.Intersect)
    ...
    
    // 利用 Canvas 进行绘制的函数
    fun drawLine(p1: Offset, p2: Offset, paint: Paint)
    fun drawRect(
        left: Float,
        top: Float,
        right: Float,
        bottom: Float,
        paint: Paint
    )
    fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint)
    fun drawCircle(center: Offset, radius: Float, paint: Paint)
    ...
}

该接口有两个实现类 —— EmptyCanvas 与 AndroidCanvas。前者是对 Canvas 接口的空实现,它只是为了保证 DrawScope 内部的 Canvas 对象不为空(先给一个空实现作为默认值,等真正要用的时候再赋值一个有具体实现的 Canvas),所有接口方法内都抛出 UnsupportedOperationException:

internal class EmptyCanvas : Canvas {

    override fun save() {
        throw UnsupportedOperationException()
    }
    ...
}

而 AndroidCanvas 内部持有原生的 Canvas 对象真正地实现了接口内定义的各种操作:

private val EmptyCanvas = android.graphics.Canvas()

@PublishedApi internal class AndroidCanvas() : Canvas {
    // 原生的 Canvas
    @PublishedApi internal var internalCanvas: NativeCanvas = EmptyCanvas

	// 利用原生 Canvas 对应的 API 实现 Compose 的 Canvas 接口
    override fun save() {
        internalCanvas.save()
    }

    override fun restore() {
        internalCanvas.restore()
    }
    
    override fun translate(dx: Float, dy: Float) {
        internalCanvas.translate(dx, dy)
    }

    override fun scale(sx: Float, sy: Float) {
        internalCanvas.scale(sx, sy)
    }
    ...
}

Compose 提供了一个 Canvas 的扩展属性 nativeCanvas 帮助开发者可以拿到 Android 原生的 Canvas 对象,也就是 AndroidCanvas 的 internalCanvas 属性:

actual val Canvas.nativeCanvas: NativeCanvas
    get() = (this as AndroidCanvas).internalCanvas

接下来看源码中如何使用 Canvas 接口,它被定义为 DrawContext 接口内的属性:

interface DrawContext {
    var size: Size
    val canvas: Canvas
    val transform: DrawTransform
}

DrawContext 唯一的实现类是 CanvasDrawScope 内的匿名实现类,DrawParams 内的 canvas 属性是一个 EmptyCanvas:

class CanvasDrawScope : DrawScope {
    @PublishedApi internal val drawParams = DrawParams()
    
    override val drawContext = object : DrawContext {
        override val canvas: Canvas
            get() = drawParams.canvas

        override var size: Size
            get() = drawParams.size
            set(value) {
                drawParams.size = value
            }

        override val transform: DrawTransform = asDrawTransform()
    }
    
    @PublishedApi internal data class DrawParams(
        var density: Density = DefaultDensity,
        var layoutDirection: LayoutDirection = LayoutDirection.Ltr,
        // 默认值是 EmptyCanvas,但它是 var 的,在真正用到它之前会重新赋值的
        var canvas: Canvas = EmptyCanvas(),
        var size: Size = Size.Zero
    )
}

那么我们要使用的 drawIntoCanvas(block) 会将 drawContext.canvas 作为 block 的参数并执行 block,看似 block 的参数好像是一个 EmptyCanvas,在调用 Canvas 的相关函数时会抛出 UnsupportedOperationException。但实际上,在执行绘制操作之前,Compose 会为 DrawParams 的 canvas 属性重新赋值为 AndroidCanvas,因此在 drawIntoCanvas() 中可以放心地使用 Canvas 接口内定义的函数。

DrawParams 的 canvas 属性重新赋值为 AndroidCanvas 的源码过程有些绕,因为我们不是要讲解原理,而是要讲如何使用 drawIntoCanvas() 借助原生 Canvas 进行绘制。这过程中讲到一小部分源码是为了帮助你更好的理解 Compose 的 Canvas 接口与原生 Canvas 的关系以便更好的使用它来编写程序而已。

当然,为了验证 drawIntoCanvas() 的尾随 lambda 的参数确实是 AndroidCanvas,我们在该 lambda 的内部打印了参数 it 的值,结果证明它确实是一个 AndroidCanvas:

com.jetpack.compose I  it is:androidx.compose.ui.graphics.AndroidCanvas@a2942d8

多维度旋转实现

了解了 Canvas 的相关内容,下面就可以开始进行旋转了。

首先,我们要在 Canvas() 内调用 drawIntoCanvas() 以获取原生的 Canvas 对象进行绘制:

@Composable
fun CustomImage2() {
    // Compose 的 Paint,内部包含着原生的 Paint
    val paint by remember { mutableStateOf(Paint()) }
    Box(Modifier.padding(30.dp)) {
        val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
        Canvas(Modifier.size(100.dp)) {
            // 提供原生 Canvas 对象
            drawIntoCanvas { // it: Compose 的 Canvas,内部会调用原生 Canvas 进行绘制
                it.drawImageRect(
                    bitmap,
                    dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
                    paint = paint
                )
            }
        }
    }
}

然后,还是先对一个维度进行旋转,假设我们让图片绕 X 轴旋转 45°:

@Composable
fun CustomImage2() {
    val paint by remember { mutableStateOf(Paint()) }
    val camera by remember { mutableStateOf(Camera()) }.apply {
        // 对 Camera 在 X 轴旋转 45°
        value.rotateX(45f)
    }
    Box(Modifier.padding(30.dp)) {
        val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
        Canvas(Modifier.size(100.dp)) {
            drawIntoCanvas { // it: Canvas
                // 平移 Canvas,实际是通过 Compose 的 Canvas 操纵原生 Canvas 实现的平移
                it.translate(size.width / 2, size.height / 2)
                // 应用 Camera 的变换到原生 Canvas 上
                camera.applyToCanvas(it.nativeCanvas)
                // 还原 Canvas
                it.translate(-size.width / 2, -size.height / 2)
                it.drawImageRect(
                    bitmap,
                    dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
                    paint = paint
                )
            }
        }
    }
}

对以上代码做出一些解释:

  • Camera 是 android.graphics 包下的,而不是与硬件的相机相关的 android.hardware 包下的 Camera
  • 由于 Camera 旋转的轴心默认为 (0, 0) 且不可更改,因此只能通过平移 Canvas 的方式实现对 Camera 旋转轴心的修改。并且在将 Camera 的变换应用到 Canvas 时,由于 applyToCanvas() 的参数是原生的 Canvas,因此需要传入 Compose 的 Canvas 接口的扩展属性 nativeCanvas(该属性源码已在上方贴出)

代码效果如下:

在这里插入图片描述

然后让 Canvas 在当前变换基础上旋转 45° 实现绕两个轴旋转的效果:

@Composable
fun CustomImage2() {
    val paint by remember { mutableStateOf(Paint()) }
    val camera by remember { mutableStateOf(Camera()) }.apply {
        value.rotateX(45f)
    }
    Box(Modifier.padding(30.dp)) {
        val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
        Canvas(Modifier.size(100.dp)) {
            drawIntoCanvas { // it: Canvas
                it.translate(size.width / 2, size.height / 2)
                it.rotate(-45f)
                camera.applyToCanvas(it.nativeCanvas)
                it.rotate(45f)
                it.translate(-size.width / 2, -size.height / 2)
                it.drawImageRect(
                    bitmap,
                    dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
                    paint = paint
                )
            }
        }
    }
}

效果如下:

在这里插入图片描述

最后添加动画效果:

@Composable
fun CustomImage2() {
    val paint by remember { mutableStateOf(Paint()) }
    val rotationAnimatable = remember { Animatable(0f) }
    val camera by remember { mutableStateOf(Camera()) }
    // 开启协程,执行动画
    LaunchedEffect(Unit) {
        rotationAnimatable.animateTo(360f, infiniteRepeatable(tween(2000)))
    }
    Box(Modifier.padding(30.dp)) {
        val bitmap = ImageBitmap.imageResource(R.drawable.avatar)
        Canvas(Modifier.size(100.dp)) {
            drawIntoCanvas { // it: Canvas
                it.translate(size.width / 2, size.height / 2)
                it.rotate(-45f)
                // Camera 的旋转与还原
                camera.save()
                camera.rotateX(rotationAnimatable.value)
                camera.applyToCanvas(it.nativeCanvas)
                camera.restore()
                it.rotate(45f)
                it.translate(-size.width / 2, -size.height / 2)
                it.drawImageRect(
                    bitmap,
                    dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
                    paint = paint
                )
            }
        }
    }
}

这里有一点需要说明的是,将绕 X 轴旋转的逻辑从 camera 的 apply() 中拿到了 drawIntoCanvas() 内,因为 Camera 的 rotateX() 每次调用旋转的度数都是基于上一次的位置,而不是 0。可动画的 value 是基于初始值的,不是基于上一次返回的值的增量,所以把 rotateX() 拿到 drawIntoCanvas() 后,可以通过 save() 与 restore() 操作保证 Camera 每次旋转都是相对于初始位置(0° 的位置),而不是上一次动画执行时的位置。

效果如下:

请添加图片描述

本例主要还是介绍当 Compose 的绘制 API 做不到一些功能时,可以通过 Compose 的 Canvas 获取原生 Canvas 来实现。除了三维旋转,文字的测量也可能会用到原生的 Canvas。

### 使用 Jetpack Compose 构建实际项目的指南 Jetpack Compose 是一种现代工具包,用于在 Android 上构建原生用户界面。此框架允许开发者创建美观且响应式的 UI,并提供了一系列功能强大的 API 来简化开发过程[^1]。 #### 基础入门案例 对于初学者来说,《Jetpack Compose强化实战》提供了详尽的内容覆盖从基础到高级的主题。第三章介绍了基本概念并给出了简单的例子帮助理解如何开始使用 Jetpack Compose 进行编程[^2]。 ```kotlin // 定义一个可组合函数 HelloMessage, 显示一条消息给定的名字. @Composable fun HelloMessage(name: String) { Text(text = "Hello $name!") } ``` #### 高级布局自定义组件 第四章深入探讨了 Compose 的核心布局机制以及如何利用 `ConstraintLayout` 和其他控件来自定义复杂页面结构。这有助于掌握更灵活多变的设计方案。 ```xml <!-- 自定义约束布局 --> <androidx.constraintlayout.widget.ConstraintLayout> <!-- 子视图按照特定规则排列 --> </androidx.constraintlayout.widget.ConstraintLayout> ``` #### 动态效果实现 第五章讲解了动画系统的运用方法论——包括但不限于淡入淡出(`Crossfade`)、尺寸变化(`animateContentSize`)等特效处理技术;这些技能能够显著提升用户体验质量。 ```kotlin // 创建一个带有过渡动画的状态变量 var expanded by remember { mutableStateOf(false) } val size by animateDpAsState(if (expanded) Dp(200f) else Dp(50f)) Box(modifier = Modifier.size(size).clickable(onClick = { expanded = !expanded })) { // 内容... } ``` #### 图形绘制能力展示 第六章则聚焦于图形操作方面的能力培养,比如通过 Canvas 对象来进行低级别的绘图工作,这对于游戏开发或者其他需要精细控制渲染的应用非常有用。 ```kotlin // 在画布上绘画圆形图案 Canvas(modifier = Modifier.fillMaxSize()) { drawCircle(color = Color.Blue, radius = 80.dp.toPx()) } ``` #### 核心控件综合应用实例 第七章汇总了一些重要的内置组件如 Scaffold 或 LazyColumn ,它们可以作为搭建整个应用程序骨架的重要组成部分。 ```kotlin Scaffold( topBar = { /* TopAppBar */ }, content = { padding -> LazyColumn(contentPadding = padding) { items(listOf(/* item data */)) { item -> // Item layout here } } } ) ``` 以上就是基于 Jetpack Compose 开发的一个全面的学习路径概览,涵盖了由浅入深各个层面的知识要点及其实操练习建议。希望这份资料能为想要深入了解该领域的朋友带来启发和支持。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值