項目地址git
具體使用:SphereView-模擬球面的ViewGroupgithub
以前看到了個keep的錄屏,子View就像是被貼在了一個球面上,能夠隨着球面旋轉而移動,方向也能夠根據手指滑動方向改變。 算法
球面是個xyz的三維座標系,咱們須要對(x,y,z)座標作(jiang)點(wei)處(da)理(ji),轉換爲平面座標系的(x,y)座標。bash
因爲z軸垂直於屏幕,屏幕所在的平面依然是xoy座標系,因此轉換後x,y並不會改變,只須要將z轉換爲透明度、大小、高度,讓子View看起來立體一點就能夠了。ide
這裏的高度指的是elevation,不是寬高的高度,View
中除了x
和y
也是有z
的,z
越大,View
底下的陰影就會越大,設置z
還有一個好處就是z
越大View
的層級就越高,這樣就不用本身手動處理層級變化了。關於z
能夠看這篇文章函數
由於是個球,因此手指在屏幕上滑動的時候其實是在旋轉這個球,因此處理滑動時須要將滑動的偏移量轉換爲球旋轉的弧度偏移量(Math
庫提供的三角函數接收的都是弧度不是角度,因此用弧度方便點,360角度=2π弧度)。oop
手指左右滑動時球繞y軸旋轉(y不變,只須要處理xoz平面),上下滑動時繞x軸旋轉(x不變,只須要處理yoz平面),類比平面座標系上移動一個View,處理方法是先給x座標加上x軸上的偏移量,再給y座標加上y軸上的偏移量,咱們能夠先處理xoz平面再處理yoz平面,這樣就完成了降維打擊。佈局
接下來就是高中數學題了,在xoz座標系中,(x,z)繞圓心旋轉了θ度後坐標爲多少?post
效果圖中的子View是均勻分佈在球面上的,若是隻在本身項目裏用的話能夠手動去寫座標,可是做爲一個庫的話,確定不能這樣,因此要找到一個算法能夠把肯定數量的點均勻分佈在球面上,由於比較懶(凡人只能算算三角函數),因此決定直接上網搜,而後搜到了這個10560 怎樣在球面上「均勻」排列許多點?(上)ui
文章中給出了一個公式,N爲點的總數,n爲第幾個點,ø爲黃金分割比
通過上面的分析以後,應該就很容易實現了,按照流程走就好了,首先是測量,測量模式爲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
}
複製代碼