具体使用:SphereView-模拟球面的ViewGroup
效果
之前看到了个keep的录屏,子View就像是被贴在了一个球面上,可以随着球面旋转而移动,方向也可以根据手指滑动方向改变。
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)绕圆心旋转了θ度后坐标为多少?
简单的几何学计算过程不贴了,网上应该都有,直接上答案
计算结果
均匀分布
效果图中的子View是均匀分布在球面上的,如果只在自己项目里用的话可以手动去写坐标,但是作为一个库的话,肯定不能这样,所以要找到一个算法可以把确定数量的点均匀分布在球面上,因为比较懒(凡人只能算算三角函数),所以决定直接上网搜,然后搜到了这个10560 怎样在球面上「均匀」排列许多点?(上)
文章中给出了一个公式,N为点的总数,n为第几个点,ø为黄金分割比
均匀分布公式
公式中指定的是半径为1的圆,所以我们需要把半径R也加到公式中去(这个还是会的),结果如下
均匀分布公式
实现
经过上面的分析之后,应该就很容易实现了,按照流程走就行了,首先是测量,测量模式为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)
}
}
看下效果
平均分布效果
再多加几个
加几个
靠谱!
向大佬低头
接下来就是要让球旋转起来了,先根据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)
旋转
没啥问题,最后只需要让它能够自动旋转就行了,可以用
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
}
自动旋转
这样基本功能就完成了,我还加了个添加删除的功能,但是不太满意,这里就不贴了,有兴趣可以去源码里看下。效果如下
添加删除
网友评论