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

preview.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)