Compose实现功能到底多简单?
包括数据结构+layout+功能实现+点击监听+一点点的布局自适应功能,只需要199行!
这还包括了注释和空行!
核心原理?
- 拆词逻辑是纯粹的代码逻辑,这里很好单独专研处理,这里不再深究。
- 布局不是重点,相信这么简单的布局大家有一万种方法实现
- 拖曳选择+点击反选,在compose中用自带的方法就能实现
- 拖曳位置识别?怎么判断拖曳到了哪里?然后点亮它?
拖曳识别原理
拖曳行为,是一个个的点组成的。compose的以下自带modifier都有话说了:
pointerInput
这个modifier的detectDragGestures()
方法提供了足够的信息。
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
}
}