SphereView-模拟球面的ViewGroup实现

项目地址

具体使用:SphereView-模拟球面的ViewGroup

效果

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


keep

分析

坐标

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

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

这里的高度指的是elevation,不是宽高的高度,View中除了xy也是有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)绕圆心旋转了θ度后坐标为多少?

SphereView-模拟球面的ViewGroup实现_第1张图片
简单的几何学

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


计算结果

均匀分布

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

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


SphereView-模拟球面的ViewGroup实现_第2张图片
均匀分布公式

公式中指定的是半径为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)
        }
    }

看下效果


SphereView-模拟球面的ViewGroup实现_第3张图片
平均分布效果

再多加几个


SphereView-模拟球面的ViewGroup实现_第4张图片
加几个

靠谱!
SphereView-模拟球面的ViewGroup实现_第5张图片
向大佬低头

接下来就是要让球旋转起来了,先根据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)

SphereView-模拟球面的ViewGroup实现_第6张图片
旋转

没啥问题,最后只需要让它能够自动旋转就行了,可以用 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 怎样在球面上「均匀」排列许多点?(上)

你可能感兴趣的:(SphereView-模拟球面的ViewGroup实现)