自定义View学习记录 plinko游戏View
1.创建自定义View属性
在res的value中的attrs.xml中添加如下属性
<declare-styleable name="MyNailView"><attr name="nailBackground" format="reference|color"/> //钉子的背景<attr name="ballIcon" format="reference|color"/> //小球的icon<attr name="row" format="integer"/> //行数<attr name="column" format="integer"/> //列数</declare-styleable>
2.创建自定义View
package com.example.test.ui.widgetimport android.animation.ValueAnimator
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import kotlin.math.min
import kotlin.math.sqrt// 数据类:钉子(圆心的x坐标,圆心的y坐标,行数,列数)
data class MyNailBean(val x: Float, val y: Float, val row: Int = -1, val column: Int = -1)class MyNailView @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyle: Int = 0
) : FrameLayout(context, attrs, defStyle) {private var dropAnim: ValueAnimator? = nullprivate var viewWidth = dp2px(343f) //控件宽度private var viewHeight = dp2px(500f) //控件高度//钉子相关private var nailRow = 6 //钉子行数private var nailColumn = 5 //钉子列数private val nailRadius = dp2px(10f) //钉子半径private var nailDistance = viewWidth / nailColumn.toFloat() //钉子间距private val nailBeanList = mutableListOf<MyNailBean>()private var nailBmp: Bitmap? = null //钉子图标private val nailPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {color = Color.YELLOWstyle = Paint.Style.FILL}//球相关private var ballRadius = dp2px(10f).toFloat() //默认球半径private var ballX = 0f //球圆心X坐标private var ballY = 0f //球圆心Y坐标private var ballVx = 0.1f // 初始水平速度private var ballVy = 10f // 初始垂直速度private val gravity = 1.0f // 重力加速度 (调整)private val damping = 0.6f // 碰撞阻尼 (调整)private val minStopSpeed = 1f // 速度停止阈值 (调整)private var ballBmp: Bitmap? = nullprivate val ballPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {color = Color.BLUEstyle = Paint.Style.FILL}init {ballRadius = if (ballBmp != null) ballBmp!!.width / 2f else ballRadiusballX = viewWidth / 2fballY = ballRadiusnailDistance = viewWidth / nailColumn.toFloat()// 加载自定义属性val typedArray = context.obtainStyledAttributes(attrs,R.styleable.MyNailView,defStyle,0)try {val nailResId = typedArray.getResourceId(R.styleable.MyNailView_nailBackground, -1)if (nailResId != -1) {// 情况1:是资源引用(@drawable/xxx 或 @mipmap/xxx)nailBmp = when (resources.getResourceTypeName(nailResId)) {"drawable", "mipmap" -> BitmapFactory.decodeResource(resources, nailResId)else -> null}} else {// 情况2:可能是颜色值或未设置val color = typedArray.getColor(R.styleable.MyNailView_nailBackground,Color.YELLOW // 默认颜色)nailPaint.color = color}val ballResId = typedArray.getResourceId(R.styleable.MyNailView_ballIcon, -1)if (ballResId != -1) {ballBmp = when (resources.getResourceTypeName(ballResId)) {"drawable", "mipmap" -> BitmapFactory.decodeResource(resources, ballResId)else -> null}} else {// 情况2:可能是颜色值或未设置val color = typedArray.getColor(R.styleable.MyNailView_ballIcon,Color.BLUE // 默认颜色)ballPaint.color = color}// 获取行数和列数nailRow = typedArray.getInt(R.styleable.MyNailView_row, 6)nailColumn = typedArray.getInt(R.styleable.MyNailView_column, 7)} finally {typedArray.recycle()}}private fun updateBallInfo() {//应用重力ballVy += gravity//更新小球速度ballX += ballVxballY += ballVy//检测小球是否触底val bottomLine = viewHeight - ballRadius * 2 - 20fif (ballY >= bottomLine) {// 1. 计算当前速度大小val speed = sqrt(ballVx * ballVx + ballVy * ballVy)// 2. 如果速度小于阈值,则停止if (speed < minStopSpeed) {ballY = bottomLinedropAnim?.cancel()return}// 3. 应用阻尼ballVx *= dampingballVy = damping(-ballVy)}//检测小球是否与钉子碰撞for (nail in nailBeanList) {val dx = ballX - nail.xval dy = ballY - nail.yval distance = sqrt(dx * dx + dy * dy)if (distance <= nailRadius + ballRadius) {// 1. 计算碰撞法线方向(从钉子指向小球)val nx = dx / distanceval ny = dy / distance// 2. 计算入射速度在法线方向上的投影val dotProduct = ballVx * nx + ballVy * ny// 3. 计算反弹后的速度ballVx -= 2 * dotProduct * nxballVy -= 2 * dotProduct * ny// 4.应用阻尼ballVx *= dampingballVy *= damping// 5.避免小球陷入钉子内部ballX = nail.x + (ballRadius + nailRadius) * nxballY = nail.y + (ballRadius + nailRadius) * ny}}//检测小球与两侧墙壁的碰撞if (ballX <= ballRadius) {ballVx = damping(-ballVx)ballX = ballRadius}if (ballX >= width - ballRadius) {ballVx = damping(-ballVx)ballX = width - ballRadius}LogUtil.d("Nail", "位置:$ballX,$ballY")}fun startAnimator(clickX: Float) {initBallInfo(clickX)dropAnim?.cancel()if (dropAnim == null) {dropAnim = ValueAnimator.ofFloat(0f, 1f).apply {duration = 5000repeatCount = 1interpolator = LinearInterpolator()addUpdateListener {updateBallInfo()invalidate()}}}dropAnim?.start()}override fun onDraw(canvas: Canvas) {super.onDraw(canvas)// 绘制钉子nailBeanList.forEach { nail ->if (nailBmp != null) {canvas.drawBitmap(nailBmp!!,nail.x - nailBmp!!.width / 2,nail.y - nailBmp!!.height / 2,null)} else {canvas.drawCircle(nail.x, nail.y, nailRadius.toFloat(), nailPaint)}}// 绘制球if (ballBmp != null) {canvas.drawBitmap(ballBmp!!, ballX - ballRadius, ballY - ballRadius, null)} else {canvas.drawCircle(ballX, ballY, ballRadius, ballPaint)}}private fun createNailList() {nailBeanList.clear()val offsetX = nailDistance / 2 //奇数行 钉子距离两侧增加的偏移距离var offsetY = nailDistancefor (i in 0 until nailRow) {for (j in 0 until nailColumn) {val x =if (i % 2 == 0 && j == nailColumn - 1) continueelse if (i % 2 == 0) (j + 1) * nailDistanceelse offsetX + j * nailDistancenailBeanList.add(MyNailBean(x, offsetY, i, j))}offsetY += nailDistance}}private fun initBallInfo(inputBallX: Float = viewWidth / 2f) {ballX = setInputBallX(inputBallX)ballY = ballRadius + 10fballVx = 0fballVy = 10fballRadius = if (ballBmp != null) ballBmp!!.width / 2f else ballRadius}private fun damping(num: Float): Float {return num * damping}override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {super.onSizeChanged(w, h, oldw, oldh)viewWidth = wviewHeight = hnailDistance = viewWidth / nailColumn.toFloat()createNailList()invalidate()}private fun setInputBallX(inputBallX: Float): Float {return when {inputBallX < nailDistance -> nailDistance + 1finputBallX > viewWidth - nailDistance -> viewWidth - nailDistance - 1felse -> inputBallX}}override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {// 默认宽度(如果未指定具体值)val defaultWidth = dp2px(343f).toInt()// 计算期望高度(基于钉子行数和球半径)val defaultHeight = calculateWrapContentHeight()// 处理宽度测量val width = when (MeasureSpec.getMode(widthMeasureSpec)) {MeasureSpec.EXACTLY -> MeasureSpec.getSize(widthMeasureSpec) // 精确值MeasureSpec.AT_MOST -> min(defaultWidth, MeasureSpec.getSize(widthMeasureSpec)) // 最大不超过else -> defaultWidth // wrap_content 或其他情况}// 处理高度测量val height = when (MeasureSpec.getMode(heightMeasureSpec)) {MeasureSpec.EXACTLY -> MeasureSpec.getSize(heightMeasureSpec) // 精确值MeasureSpec.AT_MOST -> min(defaultHeight,MeasureSpec.getSize(heightMeasureSpec)) // 最大不超过else -> defaultHeight // wrap_content}setMeasuredDimension(width, height)// 更新视图尺寸相关变量viewWidth = widthviewHeight = heightnailDistance = viewWidth / nailColumn.toFloat()createNailList()}/** 计算 wrap_content 时的合适高度 */private fun calculateWrapContentHeight(): Int {// 钉子区域高度 = (行数 + 1) * 钉子间距(+1 给顶部留空间)val nailsHeight = nailDistance * (nailRow + 1)// 球运动区域高度 = 球半径 * 4(保证下落和弹跳空间)val ballMovementHeight = ballRadius * 4return (nailsHeight + ballMovementHeight).toInt()}override fun performClick(): Boolean {super.performClick()return true}override fun onDetachedFromWindow() {dropAnim?.let {it.cancel() // 停止动画it.removeAllListeners() // 移除所有监听器it.removeAllUpdateListeners()}dropAnim = nullif (ballBmp?.isRecycled == false) {ballBmp?.recycle() // 回收位图内存}ballBmp = null// 清空数据集合nailBeanList.clear()super.onDetachedFromWindow()}
}
3.使用
<com.example.test.ui.widget.MyNailViewandroid:id="@+id/nailView"android:layout_margin="20dp"android:layout_width="match_parent"android:layout_height="wrap_content"android:background="@color/white"/>
binding.nailView.setOnTouchListener { v, motionEvent ->if (motionEvent.action == MotionEvent.ACTION_DOWN) {binding.nailView.startAnimator(motionEvent.x)}v.performClick()}