SphereView-模擬球面的ViewGroup實現

項目地址git

具體使用:SphereView-模擬球面的ViewGroupgithub

效果

以前看到了個keep的錄屏,子View就像是被貼在了一個球面上,能夠隨着球面旋轉而移動,方向也能夠根據手指滑動方向改變。 算法

keep

分析

座標

球面是個xyz的三維座標系,咱們須要對(x,y,z)座標作(jiang)點(wei)處(da)理(ji),轉換爲平面座標系的(x,y)座標。bash

因爲z軸垂直於屏幕,屏幕所在的平面依然是xoy座標系,因此轉換後x,y並不會改變,只須要將z轉換爲透明度、大小、高度,讓子View看起來立體一點就能夠了。ide

這裏的高度指的是elevation,不是寬高的高度,View中除了xy也是有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爲第幾個點,ø爲黃金分割比

均勻分佈公式
公式中指定的是半徑爲1的圓,因此咱們須要把半徑R也加到公式中去(這個仍是會的),結果以下
均勻分佈公式

實現

通過上面的分析以後,應該就很容易實現了,按照流程走就好了,首先是測量,測量模式爲EXACTLY時,直接取父佈局傳入的寬高,測量模式爲AT_MOSTUNSPECIFIED時,寬度取最小子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
    }
複製代碼

自動旋轉
這樣基本功能就完成了,我還加了個添加刪除的功能,可是不太滿意,這裏就不貼了,有興趣能夠去源碼裏看下。效果以下
添加刪除

參考文章

10560 怎樣在球面上「均勻」排列許多點?(上)

相關文章
相關標籤/搜索