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。

作者其他文章:

相关推荐
伍哥的传说6 分钟前
鸿蒙系统(HarmonyOS)应用开发之手势锁屏密码锁(PatternLock)
前端·华为·前端框架·harmonyos·鸿蒙
yugi9878388 分钟前
前端跨域问题解决Access to XMLHttpRequest at xxx from has been blocked by CORS policy
前端
小蜜蜂嗡嗡12 分钟前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0019 分钟前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体
浪裡遊20 分钟前
Sass详解:功能特性、常用方法与最佳实践
开发语言·前端·javascript·css·vue.js·rust·sass
旧曲重听11 小时前
最快实现的前端灰度方案
前端·程序人生·状态模式
默默coding的程序猿1 小时前
3.前端和后端参数不一致,后端接不到数据的解决方案
java·前端·spring·ssm·springboot·idea·springcloud
夏梦春蝉1 小时前
ES6从入门到精通:常用知识点
前端·javascript·es6
归于尽1 小时前
useEffect玩转React Hooks生命周期
前端·react.js
G等你下课2 小时前
React useEffect 详解与运用
前端·react.js