android 球面 旋转 坐标系,SphereView-模拟球面的ViewGroup实现

本文介绍了如何在Keep录屏中实现子View的球面旋转和动态布局,通过坐标转换、透明度调整、缩放及高度控制模拟立体效果,同时利用数学公式实现子View在球面上的均匀分布,并处理滑动和旋转事件以实现流畅交互。

效果

之前看到了个keep的录屏,子View就像是被贴在了一个球面上,可以随着球面旋转而移动,方向也可以根据手指滑动方向改变。

125804b4b5d1

keep

分析

坐标

球面是个xyz的三维坐标系,我们需要对(x,y,z)坐标做(jiang)点(wei)处(da)理(ji),转换为平面坐标系的(x,y)坐标。

由于z轴垂直于屏幕,屏幕所在的平面依然是xoy坐标系,所以转换后x,y并不会改变,只需要将z转换为透明度、大小、高度,让子View看起来立体一点就可以了。

这里的高度指的是elevation,不是宽高的高度,View中除了x和y也是有z的,z越大,View底下的阴影就会越大,设置z还有一个好处就是z越大View的层级就越高,这样就不用自己手动处理层级变化了。关于z可以看这篇文章

处理滑动

因为是个球,所以手指在屏幕上滑动的时候实际上是在旋转这个球,所以处理滑动时需要将滑动的偏移量转换为球旋转的弧度偏移量(Math库提供的三角函数接收的都是弧度不是角度,所以用弧度方便点,360角度=2π弧度)。

手指左右滑动时球绕y轴旋转(y不变,只需要处理xoz平面),上下滑动时绕x轴旋转(x不变,只需要处理yoz平面),类比平面坐标系上移动一个View,处理方法是先给x坐标加上x轴上的偏移量,再给y坐标加上y轴上的偏移量,我们可以先处理xoz平面再处理yoz平面,这样就完成了降维打击。

接下来就是高中数学题了,在xoz坐标系中,(x,z)绕圆心旋转了θ度后坐标为多少?

125804b4b5d1

简单的几何学

计算过程不贴了,网上应该都有,直接上答案

125804b4b5d1

计算结果

均匀分布

效果图中的子View是均匀分布在球面上的,如果只在自己项目里用的话可以手动去写坐标,但是作为一个库的话,肯定不能这样,所以要找到一个算法可以把确定数量的点均匀分布在球面上,因为比较懒(凡人只能算算三角函数),所以决定直接上网搜,然后搜到了这个10560 怎样在球面上「均匀」排列许多点?(上)

文章中给出了一个公式,N为点的总数,n为第几个点,ø为黄金分割比

125804b4b5d1

均匀分布公式

公式中指定的是半径为1的圆,所以我们需要把半径R也加到公式中去(这个还是会的),结果如下

125804b4b5d1

均匀分布公式

实现

经过上面的分析之后,应该就很容易实现了,按照流程走就行了,首先是测量,测量模式为EXACTLY时,直接取父布局传入的宽高,测量模式为AT_MOST或UNSPECIFIED时,宽度取最小子View宽度和最大子View宽度的平均值的三倍,高度取最小子View高度和最大子View高度的平均值的五倍,个人觉得这个数值比较合适就这样写了,之后有想法了再改,具体使用应该还是EXACTLY的情况比较多。计算出宽高后取其中较小的值作为球的直径,并记录一下球心位置

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

measureChildren(widthMeasureSpec, heightMeasureSpec)

val width = measureWidth(widthMeasureSpec)

val height = measureHeight(heightMeasureSpec)

mRadius = min(width, height) / 2

mCenter.x = width / 2

mCenter.y = height / 2

setMeasuredDimension(width, height)

}

private fun measureWidth(widthMeasureSpec: Int): Int {

val widthMode = MeasureSpec.getMode(widthMeasureSpec)

return if (widthMode == MeasureSpec.EXACTLY) {

MeasureSpec.getSize(widthMeasureSpec)

} else {

var maxWidth = 0

var minWidth = Int.MAX_VALUE

for (child in children) {

if (maxWidth < child.measuredWidth) {

maxWidth = child.measuredWidth

}

if (minWidth > child.measuredWidth) {

minWidth = child.measuredWidth

}

}

(maxWidth + minWidth) / 2 * 3

}

}

private fun measureHeight(heightMeasureSpec: Int): Int {

val heightMode = MeasureSpec.getMode(heightMeasureSpec)

return if (heightMode == MeasureSpec.EXACTLY) {

MeasureSpec.getSize(heightMeasureSpec)

} else {

...

(maxHeight + minHeight) / 2 * 5

}

}

有了半径和球心就可以布局了,定义一个三维坐标类来辅助layout,在子View被添加进来的时候通过setTag来与子View绑定

data class Coordinate3D(

var x: Double = 0.0,

var y: Double = 0.0,

var z: Double = 0.0

)

override fun onViewAdded(child: View?) {

child?.setTag(R.id.tag_item_coordinate, Coordinate3D())

}

之前说了,屏幕依然在xoy平面上,(x,y)不需要做改变,只需要将z坐标转换为透明度、缩放以及高度就行了

private fun layoutChild(child: View, coordinate: Coordinate3D) {

child.alpha = z2Alpha(coordinate.z).toFloat()

val scale = z2Scale(coordinate.z).toFloat()

child.scaleX = scale

child.scaleY = scale

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {

child.z = z2Elevation(coordinate.z).toFloat()

}

child.layout(

coordinate.x.toInt() + mCenter.x - child.measuredWidth / 2,

coordinate.y.toInt() + mCenter.y - child.measuredHeight / 2,

coordinate.x.toInt() + mCenter.x + child.measuredWidth / 2,

coordinate.y.toInt() + mCenter.y + child.measuredHeight / 2

)

}

private fun z2Alpha(z: Double) = minAlpha + (1f - minAlpha) * (z + mRadius) / (2 * mRadius)

private fun z2Scale(z: Double) = minScale + (maxScale - minScale) * (z + mRadius) / (2 * mRadius)

private fun z2Elevation(z: Double) = maxElevation * (z + mRadius) / (2 * mRadius)

然后根据大佬的平均分布公式来计算出所有子View的初始坐标,将子View贴到球面上

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {

for (i in 0 until childCount) {

val coordinate = this[i].getTag(R.id.tag_item_coordinate) as Coordinate3D

val z = mRadius * ((2 * i + 1.0) / childCount - 1)

val x = sqrt(mRadius * mRadius - z * z) * cos(2 * PI * (i + 1) * GOLDEN_RATIO)

val y = sqrt(mRadius * mRadius - z * z) * sin(2 * PI * (i + 1) * GOLDEN_RATIO)

oldCoordinate.x = coordinate.x

oldCoordinate.y = coordinate.y

oldCoordinate.z = coordinate.z

coordinate.x = x

coordinate.y = y

coordinate.z = z

layoutChild(this[i], coordinate)

}

}

看下效果

125804b4b5d1

平均分布效果

再多加几个

125804b4b5d1

加几个

靠谱!

125804b4b5d1

向大佬低头

接下来就是要让球旋转起来了,先根据mTouchSlop(系统提供的一个滑动阈值)来判断是否需要拦截事件,拦截之后,分别记录下手指在x轴与y轴上滑动的距离,用来重新layout

private val mTouchSlop by lazy { ViewConfiguration.get(context).scaledTouchSlop }

override fun onInterceptTouchEvent(event: MotionEvent): Boolean {

val x = event.x.toInt()

val y = event.y.toInt()

when (event.action) {

MotionEvent.ACTION_DOWN -> {

mLastX = x

mLastY = y

}

MotionEvent.ACTION_MOVE -> {

if (abs(x - mLastX) > mTouchSlop || abs(y - mLastY) > mTouchSlop) {

mLastX = x

mLastY = y

return true

}

}

}

return false

}

@SuppressLint("ClickableViewAccessibility")

override fun onTouchEvent(event: MotionEvent): Boolean {

val x = event.x.toInt()

val y = event.y.toInt()

when (event.action) {

MotionEvent.ACTION_MOVE -> {

mOffsetX = x - mLastX

mOffsetY = y - mLastY

mLastX = x

mLastY = y

relayout()

}

}

return true

}

以一个直径的偏移量=180度,将x轴和y轴的偏移量转换为球面旋转的角度,根据上面总结出来的公式计算出新的坐标,重新layout,就能让子View动起来了

private fun relayout() {

val xozOffsetRadian = -offset2Radian(mOffsetX)

val yozOffsetRadian = -offset2Radian(mOffsetY)

for (child in children) {

val coordinate = child.getTag(R.id.tag_item_coordinate) as Coordinate3D

updateCoordinate(coordinate, xozOffsetRadian, yozOffsetRadian)

layoutChild(child, coordinate)

}

}

private fun updateCoordinate(

coordinate: Coordinate3D,

xozOffsetRadian: Double,

yozOffsetRadian: Double

) {

// 先处理xoz平面

val newX = coordinate.x * cos(xozOffsetRadian) - coordinate.z * sin(xozOffsetRadian)

var newZ = coordinate.x * sin(xozOffsetRadian) + coordinate.z * cos(xozOffsetRadian)

// 再处理yoz平面

val newY = coordinate.y * cos(yozOffsetRadian) - newZ * sin(yozOffsetRadian)

newZ = coordinate.y * sin(yozOffsetRadian) + newZ * cos(yozOffsetRadian)

coordinate.x = newX

coordinate.y = newY

coordinate.z = newZ

}

private fun offset2Radian(offset: Int) = PI * offset / (2 * mRadius)

125804b4b5d1

旋转

没啥问题,最后只需要让它能够自动旋转就行了,可以用post来实现

private val mLoopRunnable by lazy {

object : Runnable {

override fun run() {

mOffsetX = (loopSpeed * cos(mLoopRadian)).toInt()

mOffsetY = (loopSpeed * sin(mLoopRadian)).toInt()

relayout()

post(this)

}

}

}

private fun start() {

if (!mIsLooping) {

post(mLoopRunnable)

mIsLooping = true

}

}

private fun stop() {

if (mIsLooping) {

handler.removeCallbacks(mLoopRunnable)

mIsLooping = false

}

}

在手指按下时需要停止自动旋转,抬起时再恢复。在手指移动的时候记录下移动方向mLoopRadian作为之后自动旋转的方向

override fun onInterceptTouchEvent(event: MotionEvent): Boolean {

...

when (event.action) {

MotionEvent.ACTION_DOWN -> {

stop()

...

}

...

MotionEvent.ACTION_UP,

MotionEvent.ACTION_CANCEL -> {

if (mNeedLoop) start()

}

}

return false

}

@SuppressLint("ClickableViewAccessibility")

override fun onTouchEvent(event: MotionEvent): Boolean {

...

when (event.action) {

MotionEvent.ACTION_MOVE -> {

...

mLoopRadian = atan2(mOffsetY.toDouble(), mOffsetX.toDouble())

relayout()

}

MotionEvent.ACTION_UP,

MotionEvent.ACTION_CANCEL -> {

if (mNeedLoop) start()

return false

}

}

return true

}

125804b4b5d1

自动旋转

这样基本功能就完成了,我还加了个添加删除的功能,但是不太满意,这里就不贴了,有兴趣可以去源码里看下。效果如下

125804b4b5d1

添加删除

参考文章

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值