Android鬼点子-Q弹的计数器

636 阅读4分钟
静态图
静态图

无意在某设计网站看到一个这样的设计。留下了很深的印象。

preview.gif
preview.gif

然后,我自己尝试的实现了一下,另外丰富了一下效果,如下:


pic2.gif
pic2.gif

这个控件支持设置颜色,支持是否允许小于0。使用Kotlin实现。
好像目前在购物车常用到这个效果。
这个控件主要用到了Google新出物理弹性动画SpringAnimation。SpringAnimation 类是最近(25.3.0版本)才添加在支持库中的一个类,它可以很方便的实现弹簧效果,支持的属性有

TRANSLATION_X
TRANSLATION_Y
TRANSLATION_Z
SCALE_X
SCALE_Y
ROTATION
ROTATION_X
ROTATION_Y
X
Y
Z
ALPHA
SCROLL_X
SCROLL_Y

如果是一个现有的控件,想要实现弹簧效果,可以参考(www.jianshu.com/p/c2962a813…)这个地址。建议先读过上面这篇文章,再读本文。
本文是一个非常规的使用方式,主要是使用了SpringAnimation计算结果,更新中间小球及整个控件的位置。
首先上完整代码:

package com.greendami.ppcountview

import android.content.Context
import android.graphics.*
import android.support.animation.DynamicAnimation
import android.support.animation.SpringAnimation
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import kotlinx.android.synthetic.main.activity_main.view.*


/**
 * 计数器
 * Created by GreendaMi on 2017/7/31.
 */
class PPCountView(context: Context?, attrs: AttributeSet?) : DynamicAnimation.OnAnimationUpdateListener, View(context, attrs) {
    var lenth = height / 8f
    var color: Int = 0
        set(value) {
            field = value
            postInvalidate()
        }
    var canDownzaro = true
        set(value) {
            field = value
            postInvalidate()
        }
    var centerX: Float = 0.toFloat()
        set(value) {
            field = if (value + measuredWidth / 2f <= measuredHeight / 2) measuredHeight / 2f
            else if (value + measuredWidth / 2f >= measuredWidth - measuredHeight / 2f) measuredWidth - measuredHeight / 2f else value + measuredWidth / 2f
            postInvalidate()
        }
    var count: Int = 0//显示的数字
    private var textPaint: Paint = Paint()
    private var mPaint: Paint = Paint()
    private var velocityTracker: VelocityTracker = VelocityTracker.obtain()
    private var downX: Float = 0.toFloat()
    val animX = SpringAnimation(this, SpringAnimation.TRANSLATION_X, 0f)

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        setBackgroundColor(Color.TRANSPARENT)
        initPaint()
        lenth = measuredHeight / 8f
        centerX = 0f
        if (color == 0) {
            color = context.resources.getColor(R.color.colorPrimary)
        }
        animX.addUpdateListener(this)
        animX.spring.stiffness = getStiffness()
        animX.spring.dampingRatio = getDamping()
    }

    private fun initPaint() {
        textPaint.reset()
        mPaint.reset()
        mPaint.color = color
        mPaint.isAntiAlias = true
        textPaint.color = Color.WHITE
        textPaint.textSize = height / 2f
        textPaint.strokeWidth = height / 18f
        textPaint.isAntiAlias = true
    }


    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        drawBackground(canvas)
        drawBackCenterCircle(canvas)
    }

    private fun drawBackCenterCircle(canvas: Canvas?) {


        //画阴影
        var mRadialGradient = RadialGradient(centerX, height / 2f, (height / 2f) * 1.16f, intArrayOf(Color.GRAY, Color.TRANSPARENT), null,
                Shader.TileMode.REPEAT)
        mPaint.shader = mRadialGradient

        //计算阴影半径
        var r = if (centerX > width / 2) Math.min(width - centerX, height / 2f * 1.15f) else Math.min(centerX, height / 2f * 1.15f)

        canvas?.drawCircle(centerX, height / 2f, r, mPaint)

        //画圆
        mPaint.reset()
        mPaint.color = Color.WHITE
        mPaint.isAntiAlias = true
        canvas?.drawCircle(centerX, height / 2f, height / 2f * 0.95f, mPaint)

        //写数字
        textPaint.color = color
        canvas?.drawText(count.toString(), centerX - textPaint.measureText(count.toString()) / 2f, (height - textPaint.ascent() - textPaint.descent()) / 2f, textPaint)
    }

    private fun drawBackground(canvas: Canvas?) {
        initPaint()
        canvas?.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), height / 2f, height / 2f, mPaint)


        textPaint.color = Color.WHITE
        canvas?.drawLine(height / 2f - lenth, height / 2f, height / 2f + lenth, height / 2f, textPaint)
        canvas?.drawLine((width - height / 2f) - lenth, height / 2f, (width - height / 2f) + lenth, height / 2f, textPaint)
        canvas?.drawLine(width - height / 2f, height / 2f - lenth, width - height / 2f, height / 2f + lenth, textPaint)
    }

    override fun onAnimationUpdate(animation: DynamicAnimation<out DynamicAnimation<*>>?, value: Float, velocity: Float) {
        box.centerX = value
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                downX = event.rawX
                velocityTracker.addMovement(event)
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                animX.cancel()
                this.centerX = (event.rawX - downX)
                this.translationX = (event.rawX - downX)
                velocityTracker.addMovement(event)
                return true
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                velocityTracker.computeCurrentVelocity(500)
                if (box.translationX !== 0f) {

                    animX.setStartVelocity(velocityTracker.getXVelocity())
                    animX.start()

                }
                if (event.rawX > downX) {
                    count++
                } else {
                    if (count <= 0 && canDownzaro) count--
                    if (count <= 0 && !canDownzaro) count = 0
                    if (count > 0) count--
                }

                velocityTracker.clear()
                return true
            }
        }
        return false
    }

    private fun getDamping(): Float {
        return 0.4f
    }

    private fun getStiffness(): Float {
        return 50f
    }

}

gradle是这样的,我用的是AS3.0。所以引入方式有点不同,但这不是本文的重点。

apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'

apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 26
    buildToolsVersion "26.0.1"
    defaultConfig {
        applicationId "com.greendami.ppcountview"
        minSdkVersion 21
        targetSdkVersion 26
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:26.0.0-beta1'
    implementation 'com.android.support.constraint:constraint-layout:1.0.2'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:0.5'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:2.2.2'
    implementation 'com.android.support:support-dynamic-animation:26.0.0-beta1'
}

Main.java中设置颜色,和是否支持负数。

package com.greendami.ppcountview

import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.*


class MainActivity : AppCompatActivity()  {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
//        box.color = Color.GREEN
        box.canDownzaro = true
    }

}

布局文件是这样的。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    tools:context="com.greendami.ppcountview.MainActivity">

    <com.greendami.ppcountview.PPCountView
        android:id="@+id/box"
        android:layout_width="130dp"
        android:layout_height="50dp"
        android:layout_gravity="center"
        android:background="@color/colorPrimary" />


</LinearLayout>

代码中比较重点的就是DynamicAnimation.OnAnimationUpdateListener,实现了监听器,得到SpringAnimation的计算结果。然后将结果当做坐标,然后重绘小球。

//声明一个动画,第一个参数是要执行动画的View,第二个动画是需要被计算的属性,这里是X轴的移动,第三个参数是动画的结束位置。
val animX = SpringAnimation(this, SpringAnimation.TRANSLATION_X, 0f)
//设置动画监听器
animX.addUpdateListener(this)
//设置弹性的生硬度,stiffness值越小,弹簧越容易摆动,摆动的时间越长,反之摆动时间越短
animX.spring.stiffness = getStiffness()
//方法设置弹性阻尼,dampingRatio越大,摆动次数越少,当到1的时候完全不摆动
animX.spring.dampingRatio = getDamping()

在ontouch中

MotionEvent.ACTION_MOVE -> {
                animX.cancel()
                this.centerX = (event.rawX - downX)
                this.translationX = (event.rawX - downX)
                velocityTracker.addMovement(event)
                return true
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                velocityTracker.computeCurrentVelocity(500)
                if (box.translationX !== 0f) {

                    animX.setStartVelocity(velocityTracker.getXVelocity())
                    animX.start()

                }
                if (event.rawX > downX) {
                    count++
                } else {
                    if (count <= 0 && canDownzaro) count--
                    if (count <= 0 && !canDownzaro) count = 0
                    if (count > 0) count--
                }

                velocityTracker.clear()
                return true
            }

滑动的时候,控件跟随手指移动,松手后,给动画赋一个加速度,然后开始动画,此时之前设置的监听器收到了事件。监听器中收到计算后的数值,然后赋值给centerX。当centerX改变后

var centerX: Float = 0.toFloat()
        set(value) {
            field = if (value + measuredWidth / 2f <= measuredHeight / 2) measuredHeight / 2f
            else if (value + measuredWidth / 2f >= measuredWidth - measuredHeight / 2f) measuredWidth - measuredHeight / 2f else value + measuredWidth / 2f
            postInvalidate()
        }

要保证小球坐标的位置不会超出整个控件的大小,最后呼叫重绘。

最后说一下小球的阴影绘制,阴影的范围要实时计算,当小球运动到两端的时候,是没有阴影的。

//画阴影
var mRadialGradient = RadialGradient(centerX, height / 2f, (height / 2f) * 1.16f, intArrayOf(Color.GRAY, Color.TRANSPARENT), null,Shader.TileMode.REPEAT)
 
mPaint.shader = mRadialGradient

//计算阴影半径
var r = if (centerX > width / 2) Math.min(width - centerX, height / 2f * 1.15f) else Math.min(centerX, height / 2f * 1.15f)

canvas?.drawCircle(centerX, height / 2f, r, mPaint)