Jetpack Compose 开源实践:含注释199行,锤子的bigBang就被我抄了个七七八八!

353 阅读4分钟

Compose实现功能到底多简单?

包括数据结构+layout+功能实现+点击监听+一点点的布局自适应功能,只需要199行

这还包括了注释和空行!

核心原理?

  1. 拆词逻辑是纯粹的代码逻辑,这里很好单独专研处理,这里不再深究。
  2. 布局不是重点,相信这么简单的布局大家有一万种方法实现
  3. 拖曳选择+点击反选,在compose中用自带的方法就能实现
  4. 拖曳位置识别?怎么判断拖曳到了哪里?然后点亮它?

拖曳识别原理

拖曳行为,是一个个的点组成的。compose的以下自带modifier都有话说了:

pointerInput这个modifierdetectDragGestures()方法提供了足够的信息。

onGloballyPositioned这个modifier提供了足够的每一个触摸项的左上角的位置信息。

onSizeChanged这个modifier提供了足够的每一个触摸项的大小信息。

漏识别优化

如果针对一个个触摸点去做”点在区域内“的判断,点的疏密会随着手指移动速度和设备硬件/系统行为的不同而不同,极端情况下存在理论上的漏识别。

所以我们把点连成线,做线段和矩形的相交判断即可。

原理初中可查,代码网上可查。

source code

闲话休提,直接展示全代码+注释:

@Composable
fun MultiSelectItemMainPage() {
    // demo这么使用vm,正式项目中不要随便这么来一个vm,尽量区分多composable
    val vm = viewModel<MultiSelectVM>()

    // 多行,所以column
    Column(Modifier
        .fillMaxSize()
        .clickable(remember { MutableInteractionSource() }, indication = null) { vm.cancelAll() } // 点击取消功能
    ) {
        // 这里直接取了vm里的一个state对象,不优雅
        for (lineNo in 0 until vm.lineCount.value) {
            // 多列,用row,这里的布局是简单实现的,不要学
            Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
                // 这个对象的产生用derivedStateOf是必要且优雅的,务必学一学
                // 这个对象指示当前行有多少元素
                val curLineCount by remember {
                    derivedStateOf {
                        minOf(vm.items.size - lineNo * MultiSelectVM.ItemPerLineCount, MultiSelectVM.ItemPerLineCount)
                    }
                }
                // 每一列的具体布局,0~当前行有多少元素
                for (item in 0 until curLineCount) {
                    // lineNo、item都是临时变量,所以没法用deriveStateOf,所以此处使用remember+普通val
                    val curIndex = remember(lineNo, item) { lineNo * MultiSelectVM.ItemPerLineCount + item }
                    // 代理一下
                    val selected by vm.items[curIndex].selected
                    // 代理一下,selected是被代理的state对象
                    val color by remember { derivedStateOf { if (selected) Color.Blue else Color.Gray } }

                    // 外框
                    Box(Modifier
                        // 位置信息
                        .onGloballyPositioned { vm.onLocationChange(curIndex, it.localToRoot(Offset.Zero)) }
                        // 大小信息
                        .onSizeChanged { vm.onSizeChange(curIndex, it) }
                        // 拖曳监听
                        .pointerInput(Unit) {
                            detectDragGestures(
                                onDragStart = { vm.dragItemStart(curIndex, it) },
                                onDrag = { _, amount -> vm.onDragItem(curIndex, amount) },
                                onDragCancel = { vm.afterDrag(curIndex) },
                                onDragEnd = { vm.afterDrag(curIndex) }
                            )
                        }
                        // 点击监听,这里可以优化成按下即响应,懒得再写一个pointerInput了
                        .clickable(remember { MutableInteractionSource() }, indication = null) { vm.clickItem(curIndex) }
                        .padding(4.dp)
                    ) {
                        // 内框,写成内外只是我想这么写+方便一点点布局控制
                        Box(Modifier
                            .defaultMinSize(24.dp, 24.dp)
                            .background(color, RoundedCornerShape(8.dp))
                            .padding(horizontal = 4.dp),
                            Alignment.Center
                        ) {
                            // 我不喜欢控制text组件的modifier,而喜欢用一个box包裹并装饰它
                            Text(vm.items[curIndex].text, color = Color.White)
                        }
                    }
                }
                // 这里这个布局临时实现的,通过每行有同样数量的元素+Row自带的布局方案实现布局,不优雅、很hook,不要学
                for (item in curLineCount until MultiSelectVM.ItemPerLineCount) {
                    Spacer(Modifier.size(32.dp))
                }
            }
        }
    }
}

/**
 * Item对象
 */
@Stable
data class Item(
    val text: String,
    val selected: MutableState<Boolean> = mutableStateOf(false),
    val size: MutableState<IntSize> = mutableStateOf(IntSize.Zero),
    val location: MutableState<Offset> = mutableStateOf(Offset.Zero),
)

class MultiSelectVM : ViewModel() {
    // items列表,stateList方便界面自动更新
    val items = mutableStateListOf<Item>()

    // 跟随item改变而改变的一个行数统计
    val lineCount = derivedStateOf { items.count() / ItemPerLineCount + if (items.count() % ItemPerLineCount > 0) 1 else 0 }

    // 包含drag信息的stateMap,用来支持多指操作
    private val dragStateMap = mutableStateMapOf<Int, Offset>()

    init {
        // 直接这么初始化了,不要学
        resetItemTo("人活着哪有不发疯的,硬撑罢了!\n人活着哪有不发疯的,硬撑罢了!")
    }

    /**
     * 重设item,能通过[Char.isWhitespace]自动移除空白字符
     */
    fun resetItemTo(newString: String) {
        items.clear()
        items.addAll(newString.mapNotNull { if (it.isWhitespace()) null else Item("$it") })
    }

    fun cancelAll() {
        items.forEach { it.selected.value = false }
    }

    fun clickItem(index: Int) {
        items[index].selected.value = !items[index].selected.value
    }

    fun onSizeChange(index: Int, size: IntSize) {
        items[index].size.value = size
    }

    fun onLocationChange(index: Int, location: Offset) {
        items[index].location.value = location
    }

    fun dragItemStart(index: Int, startOffset: Offset) {
        dragStateMap[index] = items[index].location.value + startOffset
        // 开始拖动先选中他自己,直接选中可以看起来选中更快
        items[index].selected.value = true
    }

    fun onDragItem(index: Int, amount: Offset) {
        val last = dragStateMap.getOrDefault(index, null) ?: return
        val next = last + amount

        items.forEach {
            val location = it.location.value
            val size = it.size.value

            // 判断两点组成的线段在区域内
            val isIn = isLineIntersectRectangle(
                PointF(last.x, last.y),
                PointF(next.x, next.y),
                PointF(location.x, location.y),
                location.x + size.width,
                location.y + size.height
            )
            // 当然也可以做成拖曳也支持反选,不过反选逻辑需要更复杂一些
            // 直接变成置反是有问题的,小朋友们可以先试试,再想想这是为什么?
            // ——答案是:需要让item置反之后不会立即又返回来,需要做类似防抖逻辑,不过不难
            if (isIn) {
                it.selected.value = true
            }
        }
        dragStateMap[index] = next
    }

    fun afterDrag(index: Int) {
        dragStateMap.remove(index)
    }

    /**
     * 网上抄来的代码,useful
     */
    private fun isLineIntersectRectangle(
        lineP1: PointF,
        lineP2: PointF,
        rectLeftTopPointF: PointF,
        rectRightBottomX: Float,
        rectRightBottomY: Float,
    ): Boolean {
        var rectLeftTopX = rectLeftTopPointF.x
        var rectLeftTopY = rectLeftTopPointF.y
        var rectangleRightBottomX = rectRightBottomX
        var rectangleRightBottomY = rectRightBottomY
        val lineHeight = lineP1.y - lineP2.y
        val lineWidth = lineP2.x - lineP1.x // 计算叉乘 
        val c = lineP1.x * lineP2.y - lineP2.x * lineP1.y
        if (lineHeight * rectLeftTopX + lineWidth * rectLeftTopY + c >= 0 && lineHeight * rectangleRightBottomX + lineWidth * rectangleRightBottomY + c <= 0 ||
            lineHeight * rectLeftTopX + lineWidth * rectLeftTopY + c <= 0 && lineHeight * rectangleRightBottomX + lineWidth * rectangleRightBottomY + c >= 0 ||
            lineHeight * rectLeftTopX + lineWidth * rectangleRightBottomY + c >= 0 && lineHeight * rectangleRightBottomX + lineWidth * rectLeftTopY + c <= 0 ||
            lineHeight * rectLeftTopX + lineWidth * rectangleRightBottomY + c <= 0 && lineHeight * rectangleRightBottomX + lineWidth * rectLeftTopY + c >= 0
        ) {
            if (rectLeftTopX > rectangleRightBottomX) {
                val temp = rectLeftTopX
                rectLeftTopX = rectangleRightBottomX
                rectangleRightBottomX = temp
            }
            if (rectLeftTopY < rectangleRightBottomY) {
                val temp1 = rectLeftTopY
                rectLeftTopY = rectangleRightBottomY
                rectangleRightBottomY = temp1
            }
            return !(lineP1.x < rectLeftTopX && lineP2.x < rectLeftTopX ||
                    lineP1.x > rectangleRightBottomX && lineP2.x > rectangleRightBottomX ||
                    lineP1.y > rectLeftTopY && lineP2.y > rectLeftTopY ||
                    lineP1.y < rectangleRightBottomY && lineP2.y < rectangleRightBottomY)
        }
        return false
    }

    companion object {
        const val ItemPerLineCount = 10
    }
}