Compose 仿微信朋友圈九宫格控件以及背后的原理

一、传统 View 中的九宫格控件

在 Jetpack Compose 还没问世的年代,我们用传统 View 来写九宫格控件。虽然有点繁琐,但也不算太难。大致步骤如下:

  1. 确定行列规则:九宫格一般是最多三列,图片数量决定行数。
  2. 测量与布局:根据父布局的宽度,计算每张图片的尺寸和位置。
  3. 绘制:将图片按规则摆放到对应位置。

NineGridView 里找到对应代码大概展示:

ini 复制代码
// 代码都是从 NineGridView 拷贝而来的,点击👆前往出处。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int width = MeasureSpec.getSize(widthMeasureSpec);
    int height = 0;
    int totalWidth = width - getPaddingLeft() - getPaddingRight();
    if (mImageInfo != null && mImageInfo.size() > 0) {
        if (mImageInfo.size() == 1) {
            gridWidth = singleImageSize > totalWidth ? totalWidth : singleImageSize;
            gridHeight = (int) (gridWidth / singleImageRatio);
            //矫正图片显示区域大小,不允许超过最大显示范围
            if (gridHeight > singleImageSize) {
                float ratio = singleImageSize * 1.0f / gridHeight;
                gridWidth = (int) (gridWidth * ratio);
                gridHeight = singleImageSize;
            }
        } else {
            //                gridWidth = gridHeight = (totalWidth - gridSpacing * (columnCount - 1)) / columnCount;
            //这里无论是几张图片,宽高都按总宽度的 1/3
            gridWidth = gridHeight = (totalWidth - gridSpacing * 2) / 3;
        }
        width = gridWidth * columnCount + gridSpacing * (columnCount - 1) + getPaddingLeft() + getPaddingRight();
        height = gridHeight * rowCount + gridSpacing * (rowCount - 1) + getPaddingTop() + getPaddingBottom();
    }
    setMeasuredDimension(width, height);
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    if (mImageInfo == null) return;
    int childrenCount = mImageInfo.size();
    for (int i = 0; i < childrenCount; i++) {
        ImageView childrenView = (ImageView) getChildAt(i);

        int rowNum = i / columnCount;
        int columnNum = i % columnCount;
        int left = (gridWidth + gridSpacing) * columnNum + getPaddingLeft();
        int top = (gridHeight + gridSpacing) * rowNum + getPaddingTop();
        int right = left + gridWidth;
        int bottom = top + gridHeight;
        childrenView.layout(left, top, right, bottom);

        if (mImageLoader != null) {
            mImageLoader.onDisplayImage(getContext(), childrenView, mImageInfo.get(i).thumbnailUrl);
        }
    }
}

额....反正我看到这些代码就莫名其妙的觉得太「」了!!!但...如果你使用 Compose 这一切都变得简单!

二、Compose 代码需要经过几个阶段?

View 系统会经过三个阶段呈现 UI 到手机上,分别是:onMeasure()、onLayout()、onDraw()

而 Compose 是:Compoasition、Layout、Drawing

一、组合(Composition)

组合是指把带有 @Composable注解的代码,形成一个的结构。

二、布局(Layout)

布局是个两步走的过程:

  • 测量(Measure) :确定每个子组件的尺寸。
  • 放置(Place) :把子组件放到指定的位置上。

三、绘制(Drawing)

最后一步是把布局好的 UI 渲染到屏幕上。

可以这么理解:组合阶段是「画草图」,布局阶段是「摆家具」,绘制阶段是「刷油漆」。

参考 Compose官网对应的文章。这不是本文的重点,所以简单的讲下。

三、Modifier.layout {} 和 Layout() 函数。

一、Modifier.layout {} 的作用。

Modifier.layout {} 是一个 LayoutModifier ;对于它的解释看源码:

它的作用:允许调用处的组件修改自己的尺寸和放置的位置

首先看一个什么都不做的写法:

less 复制代码
@Preview @Composable private fun TestModifierLayout() {
    Box(
        modifier = Modifier
            .size(200.dp)
            .background(Color.Blue)
    ) {
        Text(text = "TestModifierLayout",
            color = Color.White,
            modifier = Modifier.layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                layout(placeable.width, placeable.height) {
                    placeable.placeRelative(0, 0)
                }
            }.background(Color.Red))
    }
}

它的效果:

如果想让Text()增加一个对于Top的间距,除了Modifier.padding()之外就是可以使用Modifier.layout {}实现。

具体做法:

less 复制代码
@Preview @Composable private fun TestModifierLayout() {
    Box(
        modifier = Modifier
            .size(200.dp)
            .background(Color.Gray)
    ) {
        Text(text = "TestModifierLayout",
             color = Color.White,
             modifier = Modifier.layout { measurable, constraints ->
                 val placeable = measurable.measure(constraints)
                 layout(placeable.width, placeable.height) {
                     // 不同处 👇
                     placeable.placeRelative(0, 100)
                 }
             }.background(Color.Red))
    }
}

它的效果:

有人就会说:那我用Modifier.padding()就好了,何必使用复杂得多的Modifier.layout {}

事实也是如此,但我描述的是Modifier.layout {}具体作用做的一个案例而已,并不比直接使用Modifier.padding()有多出其他的作用和好处。

细节讲解:

  • Modifier.layout {} 函数

    • 这是一个高阶函数,用于定义测量和放置逻辑。

    • 它有两个参数:

      • measurable: Measurable:表示需要测量的子组件。
      • constraints: Constraints:表示父布局传递下来的约束条件(比如最小宽度、最大宽度等)。
    • 它返回一个 MeasureResult,其中包含:

      • 组件的宽高尺寸。
      • 子组件的放置逻辑。
  • measurable.measure(constraints)

    • 测量子组件,返回一个 Placeable 对象。
    • Placeable 包含子组件的宽高信息,并提供放置方法。
  • layout(width, height)

    • 定义组件的最终宽高,并且返回一个MeasureResult
  • placeable.place(x, y)/placeRelative(x, y)

    • 定义组件在父组件中的位置,也就是放置。
    • placeRelative(x, y) 是针对从右到左布局(阿拉伯语)的特殊函数。

带入到案例代码中:

less 复制代码
@Preview @Composable private fun TestModifierLayout() {
   Box(
        modifier = Modifier
            .size(200.dp)
            .background(Color.Gray)
    ) {
        Text(text = "TestModifierLayout",
            color = Color.White,
            modifier = Modifier
                .background(Color.Red)
                .layout { measurable, constraints ->
                    // 这个案例中:measurable.measure(constraints) 就是 Text 的测量过程
                    val placeable = measurable.measure(constraints)
                    // 这里的 placeable 就是 Text 的测量结果
                    // 这里的 placeable.width 和 placeable.height 就是 Text 的宽高
                    // 这里的 placeable.placeRelative(0, 0) 就是 Text 的布局过程
                    // layout 的返回值就是这个自定义布局的宽高
                    //                             👇这里加了 100
                    layout(placeable.width, placeable.height + 100) {
                        // 对从右到左的布局
                        //                      👇这里加了 100
                        placeable.placeRelative(0, 100)
                        // 对从左到右的布局
                        //placeable.place(0, 0)
                    }
                }
        )
    }
}

它的效果:

二、Layout() 函数的作用。

它的源代码:

其他的代码先不管,都不是本文的重点。注意 MeasurePolicy,点进去查看代码:

没有看错,Layout()里面的参数和Modifier.layout {}的参数不能说一模一样,只能说完全一致。

那它们两个在使用上有什么不同处呢?或者说使用场景有什么不同?

  • Modifier.layout {} 是一种 修饰符(Modifier) ,用于修改现有组件的布局行为。它不能创建新的布局,而是基于现有组件,调整它的测量和放置逻辑。
  • Layout() 是一种 Composable 函数,用于创建一个全新的布局。它可以包含多个子组件,并完全控制这些子组件的测量、布局和放置。

如何选择?

  • 如果你需要 调整单个组件的布局行为 ,比如改变宽高、位置、对齐方式等,使用 Modifier.layout {}
  • 如果你需要 创建一个新的布局容器 ,并控制多个子组件的排列方式,使用 Layout()

四、Compose 实战九宫格控件。

前面铺垫了 Modifier.layout {} Layout() ,是因为仿微信朋友圈九宫格控件的实现原理就是它们两个,要讲清楚它们的机制才可以实现九宫格控件。

对应代码:

less 复制代码
// 使用
@Preview 
@Composable private fun NineGridLayoutPreview() {
    NineGridLayout(modifier = Modifier.background(Color.Gray), itemSize = 100.dp, padding = 10.dp) {
        repeat(9) {
            Image(
                painter = painterResource(id = R.mipmap.ic_test_image), contentDescription = null,
            )
        }
    }
}

/**
 * 九宫格布局
 * 
 * @param singleItemSize 单个元素的大小
 * @param itemSize 其他元素的大小
 * @param padding 元素之间的间距
 * @param content 元素内容
 */
@Composable
fun NineGridLayout(
    modifier: Modifier = Modifier,
    singleItemSize: Dp = 200.dp,
    itemSize: Dp = 100.dp,
    padding: Dp = 8.dp,
    content: @Composable @UiComposable () -> Unit
) {
     // 使用 Layout 进行自定义布局
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        // 在 Layout 内部获取 Density 并计算 px 值
        val paddingPx = padding.roundToPx()
        val itemSizePx = itemSize.roundToPx()
        val singleSizePx = singleItemSize.roundToPx()

        // 子组件的数量
        when (measurables.size) {
            1 -> {
                // 只有一个元素时,使用固定大小进行布局
                // 测量出结果并且放置
                val placeable = measurables.first().measure(constraints)
                layout(singleSizePx, singleSizePx) {
                    placeable.placeRelative(0, 0)
                }
            }

            else -> {
                // 不止一个组件时,要计算数量来决定高度和宽度,这部分是通用代码就不过多解释了。
                // 计算行数和总宽高,避免重复计算
                val rowCount = (measurables.size + 2) / 3
                val totalWidth = (itemSizePx + paddingPx) * 3 - paddingPx
                val totalHeight = (itemSizePx + paddingPx) * rowCount - paddingPx

                // 测量每个元素
                val placeables = measurables.map { it.measure(Constraints.fixed(itemSizePx, itemSizePx)) }

                // 布局逻辑:按 3 列排列
                layout(totalWidth, totalHeight) {
                    // 遍历所有元素,计算位置并放置
                    placeables.forEachIndexed { index, placeable ->
                        val xPosition = (index % 3) * (itemSizePx + paddingPx)
                        val yPosition = (index / 3) * (itemSizePx + paddingPx)
                        placeable.placeRelative(xPosition, yPosition)
                    }
                }
            }
        }
    }
}

最终效果图:

其实细心的朋友们就会发现、使用 Compose 开发遇到微信朋友圈九宫格需求的时候根本是不需要NineGridLayout 也能实现需求,事实还是这么回事。

但我想的是借助**「微信朋友圈九宫格」的噱头来讲解Layout()Modifier.layout {}的机制,学习效果会不会翻倍呢?「🐶」

End。

作者其他文章:

相关推荐
Anna_Tong30 分钟前
ASP.NET Core 与 Blazor:现代 Web 开发技术的全新视角
前端·后端·微软·asp.net·web·技术
阿髙3 小时前
nginx 代理文件并下载,同时设置文件名,axios取不到Content-Disposition解决办法
前端·javascript·nginx
夕阳_醉了3 小时前
JS里面Map的使用以及与Object的对比
前端·javascript·vue.js
ling081408144 小时前
Vue3全局挂载Dialog组件
前端·javascript·vue
Catherinemin4 小时前
CSS|12 display属性
前端·css
Amo 67295 小时前
css filter: drop-shadow() 高级阴影效果
前端·css
天天进步20155 小时前
CSS中的深度选择器 deep 详解
前端·css
95岁IT民工5 小时前
大屏项目使用css混合实现光源扫描高亮效果
前端·css·vue.js
匹马夕阳5 小时前
一篇梳理清楚JavaScript ES6中的Promise
前端·javascript·es6
叶羽西5 小时前
Android Overlay Priority Rules
android