之前那篇文章还有很多细节没有处理,最近有时间了想再完善下,效果如上,之前的实现文章点这
Android Compose 自定义ViewGroup实现圆弧滑动效果之FanLayout - 掘金
之前的还有惯性滚动fling , 以及选择之后的回调没有处理。
所以打算再开一篇文章详细说下实现过程。
自定义布局
像这种效果我们还是用自定义布局来实现比较好。
我们来看下measure 和 layout。
kotlin
require(measurables.size > 3)
val placeables = measurables.mapIndexed { index, measurable ->
measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
}
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEachIndexed { index, placeable ->
if (index == 0)
placeable.place(
0,
constraints.maxHeight / 2 - placeable.height / 4, zIndex = 1f
)
else
placeable.place(
0,
constraints.maxHeight / 2
)
}
}
首先必须保证有三个子待测量的,也就是轴承和其他两个item (1个item 还转什么)。
index =0 表示轴承,轴承也就是那个最大的滑稽,轴承zIndex 设置为1,其他的item zIndex为默认值0,表示他会覆盖在全部item之上。
item的坐标 x, y都设置成一样,因为待会还要绕着圆心旋转。和view相比简单吧,是的,Comopse 自定义布局 的measure 和 layout 就是如此简单。
手势的处理
首先定义几个变量
kotlin
var angleAnimator = remember {
Animatable(0f)
}
var angle by remember {
mutableFloatStateOf(0f)
}
val velocityTracker = remember {
VelocityTracker()
}
val splineBasedDecay: DecayAnimationSpec<Float> = with(LocalDensity.current) {
remember {
splineBasedDecay(this)
}
}
angle表示旋转的角度,velocityTracker用来监测速率 ,splineBasedDecay 是衰退动画的曲线,有个指数的但是感觉没splineBasedDecay效果好。
compose的手势在pointerInput方法进行处理,我们使用awaitEachGesture来处理每一次事件流
kotlin
awaitEachGesture {
while (true) {
awaitFirstDown(requireUnconsumed = true).let { change ->
velocityTracker.addPosition(
change.uptimeMillis,
change.position,
)
if (angleAnimator.isRunning) {
scope.launch {
angleAnimator.stop()
}
}
drag(change.id) { change ->
velocityTracker.addPosition(
change.uptimeMillis,
change.position,
)
scope.launch {
angle += getAngle(change)
}
}
scope.launch {
val velocityY = velocityTracker.calculateVelocity().y / 10
if (velocityY.absoluteValue > 0f) {
var lastValue = 0f
angleAnimator = Animatable(0f)
angleAnimator
.animateDecay(
initialVelocity = velocityY,
splineBasedDecay
) {
angle += (value - lastValue)
lastValue = value
}
}
angleAnimator = Animatable(angle)
if (angle.absoluteValue % 36 < 18) {
//不足18度回滚
angleAnimator.animateTo(angle - (angle % 36)) {
angle = value
}
} else {
//大于18度,补足至36度
angleAnimator.animateTo(angle + (36f.withSign(angle.sign) - angle % 36)) {
angle = value
}
}
}
}
}
}
firstdown 的时候如果动画在运行则停止,down 和move 的时候用velocityTracker.addPosition 监控速率,在up 的时候就能获取到fling的速率了,剩下的就是一些边界值的判断了。
getAngle 方法 ,根据上一个触摸点和当前触摸点和圆心三个点计算滚动角度,原理是勾股定理和反三角函数。
kotlin
private fun getAngle(inputChange: PointerInputChange): Float {
val l: Float
val t: Float
val r: Float
val b: Float
val preX = inputChange.previousPosition.x
val preY = inputChange.previousPosition.y
val curX = inputChange.position.x
val curY = inputChange.position.y
if (preX > curX) {
r = preX; l = curX
} else {
r = curX; l = preX
}
if (preY > curY) {
b = preY; t = curY
} else {
b = curY; t = preY
}
val pA1: Float = abs(preX - pivotX)
val pA2: Float = abs(preY - pivotY)
val pB1: Float = abs(curX - pivotX)
val pB2: Float = abs(curY - pivotY)
val hypotenuse =
sqrt((r - l).toDouble().pow(2.0) + (b - t).toDouble().pow(2.0)).toFloat()
val lineA = sqrt(pA1.toDouble().pow(2.0) + pA2.toDouble().pow(2.0)).toFloat()
val lineB = sqrt(pB1.toDouble().pow(2.0) + pB2.toDouble().pow(2.0)).toFloat()
if (hypotenuse > 0 && lineA > 0 && lineB > 0) {
val angle = Math.toDegrees(
acos(
(lineA.toDouble().pow(2.0) + lineB.toDouble().pow(2.0) - hypotenuse.toDouble()
.pow(2.0)) / (2 * lineA * lineB)
)
).toFloat()
if (!java.lang.Float.isNaN(angle)) {
return if (isClockwise(inputChange)) angle else -angle
}
}
return 0f
}
还有一点需要优化,在轴承上触摸移动时需要把事件禁止掉,另外搞一个pointerInput来处理。
kotlin
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(pass = PointerEventPass.Initial).also {
val dis = sqrt(
(it.position.y - pivotY).absoluteValue.pow(2) + (it.position.x - pivotX).absoluteValue.pow(
2
)
)
if (dis < 100.dp.toPx()) {
it.consume()
}
}
}
}
轴承区域是一个直径为100dp的圆,只要触摸点在这个区域内要把事件消费掉,怎么判断触摸点在这个区域内?这时候就需要用到初中数学知识了,只要触摸点和圆心距离小于直径我们就可以认为触摸点在圆内了,在圆内需要把事件消费掉。 计算触摸点和圆心的距离可以使用距离公式
原理其实也是勾股定理,不清理勾股定理的请立即打电话给初中数学老师。
Item选择时候的回调
默认以在屏幕中间那个为选择的,选择之后会标红,可以看上图那个gif,我们可以使用onGloballyPositioned 来处理,每次在屏幕的坐标变了这个方法都会回调。
kotlin
.onGloballyPositioned {
if (it.boundsInParent().topLeft == Offset(
0f,
(height / 2f)
.roundToInt()
.toFloat()
)
) {
curSelectedIndex = index
}
}
完整代码
kotlin
fun FanLayout() {
Surface(modifier = Modifier.fillMaxSize()) {
var angleAnimator = remember {
Animatable(0f)
}
var angle by remember {
mutableFloatStateOf(0f)
}
val velocityTracker = remember {
VelocityTracker()
}
val splineBasedDecay: DecayAnimationSpec<Float> = with(LocalDensity.current) {
remember {
splineBasedDecay(this)
}
}
var curSelectedIndex by remember {
mutableIntStateOf(0)
}
val scope = rememberCoroutineScope()
val height = with(LocalDensity.current) {
pivotY =
LocalConfiguration.current.screenHeightDp.dp.roundToPx() / 2 + 20.dp.toPx()
.roundToInt()
LocalConfiguration.current.screenHeightDp.dp.roundToPx()
}
/* val imageBrush =
ShaderBrush(ImageShader(ImageBitmap.imageResource(id = R.drawable.img)))*/
Layout(
content = {
Image(
modifier = Modifier
.size(100.dp)
.clip(CircleShape),
painter = painterResource(id = R.drawable.img),
contentDescription = ""
)
List(10) { index ->
Row(modifier = Modifier
.graphicsLayer {
transformOrigin = TransformOrigin(0f, 0f)
rotationZ = (360 / 10).toFloat() * index + (angle)
}
.onGloballyPositioned {
if (it.boundsInParent().topLeft == Offset(
0f,
(height / 2f)
.roundToInt()
.toFloat()
)
) {
curSelectedIndex = index
}
}
.fillMaxWidth()
.requiredHeight(50.dp)
.clickable {
if (angleAnimator.isRunning) {
return@clickable
}
scope.launch {
angleAnimator = Animatable(angle)
angleAnimator.animateTo(angle - 36) {
angle = value
}
}
}
.background(if (curSelectedIndex == index) Color.Red else Color.White)) {
repeat(10) {
Image(
painter = painterResource(id = R.drawable.img),
contentDescription = ""
)
}
}
}
},
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(pass = PointerEventPass.Initial).also {
val dis = sqrt(
(it.position.y - pivotY).absoluteValue.pow(2) + (it.position.x - pivotX).absoluteValue.pow(
2
)
)
if (dis < 100.dp.toPx()) {
it.consume()
}
}
}
}
.pointerInput(Unit) {
awaitEachGesture {
while (true) {
awaitFirstDown(requireUnconsumed = true).let { change ->
velocityTracker.addPosition(
change.uptimeMillis,
change.position,
)
if (angleAnimator.isRunning) {
scope.launch {
angleAnimator.stop()
}
}
drag(change.id) { change ->
velocityTracker.addPosition(
change.uptimeMillis,
change.position,
)
scope.launch {
angle += getAngle(change)
}
}
scope.launch {
val velocityY = velocityTracker.calculateVelocity().y / 10
if (velocityY.absoluteValue > 0f) {
var lastValue = 0f
angleAnimator = Animatable(0f)
angleAnimator
.animateDecay(
initialVelocity = velocityY,
splineBasedDecay
) {
angle += (value - lastValue)
lastValue = value
}
}
angleAnimator = Animatable(angle)
if (angle.absoluteValue % 36 < 18) {
angleAnimator.animateTo(angle - (angle % 36)) {
angle = value
}
} else {
angleAnimator.animateTo(angle + (36f.withSign(angle.sign) - angle % 36)) {
angle = value
}
}
}
}
}
}
}) { measurables, constraints ->
require(measurables.size > 3)
val placeables = measurables.mapIndexed { index, measurable ->
measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
}
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEachIndexed { index, placeable ->
if (index == 0)
placeable.place(
0,
constraints.maxHeight / 2 - placeable.height / 4, zIndex = 1f
)
else
placeable.place(
0,
constraints.maxHeight / 2
)
}
}
}
}
}
private var pivotX = 0
private var pivotY = 0
private fun getAngle(inputChange: PointerInputChange): Float {
val l: Float
val t: Float
val r: Float
val b: Float
val preX = inputChange.previousPosition.x
val preY = inputChange.previousPosition.y
val curX = inputChange.position.x
val curY = inputChange.position.y
if (preX > curX) {
r = preX; l = curX
} else {
r = curX; l = preX
}
if (preY > curY) {
b = preY; t = curY
} else {
b = curY; t = preY
}
val pA1: Float = abs(preX - pivotX)
val pA2: Float = abs(preY - pivotY)
val pB1: Float = abs(curX - pivotX)
val pB2: Float = abs(curY - pivotY)
val hypotenuse =
sqrt((r - l).toDouble().pow(2.0) + (b - t).toDouble().pow(2.0)).toFloat()
val lineA = sqrt(pA1.toDouble().pow(2.0) + pA2.toDouble().pow(2.0)).toFloat()
val lineB = sqrt(pB1.toDouble().pow(2.0) + pB2.toDouble().pow(2.0)).toFloat()
if (hypotenuse > 0 && lineA > 0 && lineB > 0) {
val angle = Math.toDegrees(
acos(
(lineA.toDouble().pow(2.0) + lineB.toDouble().pow(2.0) - hypotenuse.toDouble()
.pow(2.0)) / (2 * lineA * lineB)
)
).toFloat()
if (!java.lang.Float.isNaN(angle)) {
return if (isClockwise(inputChange)) angle else -angle
}
}
return 0f
}
private fun isClockwise(inputChange: PointerInputChange): Boolean {
val px = inputChange.previousPosition.x
val py = inputChange.previousPosition.y
val x = inputChange.position.x
val y = inputChange.position.y
return if (abs(y - py) > abs(x - px)) x < pivotX != y > py else y < pivotY == x > px
}