无意在某设计网站看到一个这样的设计。留下了很深的印象。
然后,我自己尝试的实现了一下,另外丰富了一下效果,如下:
这个控件支持设置颜色,支持是否允许小于 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
如果是一个现有的控件,想要实现弹簧效果,可以参考(http://www.jianshu.com/p/c2962a8135f5)这个地址。建议先读过上面这篇文章,再读本文。 本文是一个非常规的使用方式,主要是使用了 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"?>
- 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">
- android:id="@+id/box"
- android:layout_width="130dp"
- android:layout_height="50dp"
- android:layout_gravity="center"
- android:background="@color/colorPrimary" />
代码中比较重点的就是 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)
来源: https://juejin.im/post/5a331535f265da432154134f