为何要从头开发滚动组件,为了能够让自己更加清楚和理解拖动效果是如何实现的,投掷效果是如何实现的。
我自己完成一个滚动选择控件,能够拖动选择,并且可以手指进行投掷操作(fling),手指离开,他也会继续滚动一段距离。因为做的是滚动选择,所以需要每次回自动滚动到恰好的位置。效果图如下
首先我需要绘画出一个类似LinearLayout里排放很多个相同的TextView的效果
但是因为选择控件固定显示几个item,所以我通过canvas.clipRect限制显示的范围
canvas.clipRect(0, 0, 300, (int) (3.5 * interval + textSize * 3));
然后需要根据list的数据绘画相应的item,这里要注意每个item的高度等于字的高度textSize加上间距interval,然后drawText的startY是从字的左下角开始的,而且通过不断累积start来得出下一个item的高度
for (String s : dataList) {
Rect rect = new Rect();
paint.getTextBounds(s, 0, s.length(), rect);
// 获取到的是实际文字宽度
int textWidth = rect.width();
start = start + interval;
canvas.drawText(s, (width - textWidth) / 2, start + textSize, paint);
start = start + textSize;
}
然后我们需要在显示的三个item的中间,绘画一个矩形,表示选中的item,画一个矩形需要四条线
canvas.drawLine(linePad,
(float) (interval * 1.5 + textSize), width - linePad, (float) (interval * 1.5 + textSize), zPaint);
canvas.drawLine(linePad, (float) (interval * 2.5 + textSize * 2),
width - linePad, (float) (interval * 2.5 + textSize * 2), zPaint);
canvas.drawLine(linePad,
(float) (interval * 1.5 + textSize), linePad, (float) (interval * 2.5 + textSize * 2), zPaint);
canvas.drawLine(width - linePad, (float) (interval * 1.5 + textSize),
width - linePad, (float) (interval * 2.5 + textSize * 2), zPaint);
还记得之前的start吗,如果要移动整个布局,我是通过改变start来完成的,而start只是一个onDraw里的变量,所以需要一个全局变量来保存当前布局在y轴上移动的矢量距离
float start = offSet;
这个offSet是在onTouchEvent里计算得出的,在手指按下时记录触摸事件的y坐标,在手指移动时,通过不断以上一次触摸事件(ACTION_DOWN或者ACTION_MOVE)的y坐标算差值,差值给offSet累加,并且刷新重新绘画。
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
now = event.getY();
break;
case MotionEvent.ACTION_MOVE:
move = event.getY() - now;
now = event.getY();
offSet = offSet + move;
invalidate();
break;
case MotionEvent.ACTION_UP:
break;
}
return true;
}
总结:拖动效果是通过计算每一次ACTION_DOWN、ACTION_MOVE事件的y坐标的差值,这个差值就是我们要移动的矢量距离
关于投掷效果,主要通过计算脱手的一瞬间的速度,然后通过这个初速度计算移动距离和移动时间,将这个移动过程分成很多块来完成
首先介绍一些变量,投掷最小速度,投掷最大速度
final ViewConfiguration viewConfiguration = ViewConfiguration.get(getContext());
mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity();
mMaximumFlingVelocity = viewConfiguration.getScaledMaximumFlingVelocity();
然后介绍一些函数,这些函数比较多,我就贴部分代码,但是作用有三个,通过初速度获取移动距离,通过距离获取初速度,通过初速度获取移动时间。这些计算公式是Android源码里的,我这里是直接借用,
//获取滑动的时间
private int getSplineFlingDuration(int velocit) {
final double l = getSplineDeceleration(velocit);
final double decelMinusOne = DECELERATION_RATE - 1.0;
return (int) (1000.0 * Math.exp(l / decelMinusOne));
}
//通过初始速度获取最终滑动距离
private double getSplineFlingDistance(int velocity) {
final double l = getSplineDeceleration(velocity);
final double decelMinusOne = DECELERATION_RATE - 1.0;
return mFlingFriction * mPhysicalCoeff * Math.exp(DECELERATION_RATE / decelMinusOne * l);
}
//通过需要滑动的距离获取初始速度
public int getVelocityByDistance(double distance) {
final double l = getSplineDecelerationByDistance(distance);
int velocity = (int) (Math.exp(l) * mFlingFriction * mPhysicalCoeff / INFLEXION);
return Math.abs(velocity);
}
首先在获取ACTION_UP事件时,得到手指移动的初速度,这个由VelocityTracker来实现,在触发ACTION_DOWN事件时,初始化对象
private void initOrResetVelocityTracker() {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
} else {
mVelocityTracker.clear();
}
}
然后每一次的触摸事件都给了这个类对象
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(event);
}
最后在触发ACTION_UP事件时,通过VelocityTracker对象获取初速度,移动距离、移动时间,然后通过代码你们可以看得出来,我开启了一个周期任务,关于这个周期任务是实现fling投掷效果的关键。注意这个10
case MotionEvent.ACTION_UP:
final VelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
if (Math.abs(velocityTracker.getYVelocity()) > mMinimumFlingVelocity) {
yVelocity = (int) velocityTracker.getYVelocity();
duration = getSplineFlingDuration(yVelocity);
distance = getSplineFlingDistance(yVelocity);
Log.v(TAG, "初速度:" + yVelocity + "移动距离:" + distance + "移动时间:" + duration);
if (flingmTask != null) {
flingmTask.cancel();
flingmTask = null;
}
flingmTask = new MyTimerTask(flingHandler);
flingtimer.schedule(flingmTask, 0, 10);
}
invalidate();
break;
}
直接来看看这个flingHandler写了啥,计算在10毫秒内以初速度做匀速运行的距离,然后把原来的移动距离减去10毫秒移动距离,再通过公式计算出以这个距离得出的初速度,覆盖掉原来的初速度,最后重新绘画。
直到这个初速度小于最小投掷速度,关闭周期任务。
Handler flingHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (Math.abs(yVelocity) < mMinimumFlingVelocity) {
yVelocity = 0;
if (flingmTask != null) {
flingmTask.cancel();
flingmTask = null;
}
} else if (yVelocity != 0) {
offSet = (float) (offSet + yVelocity * 0.01);
double d = distance - Math.abs(yVelocity) * 0.01;
yVelocity = getVelocityByDistance(distance) * yVelocity / Math.abs(yVelocity);
distance = d;
invalidate();
}
}
};
总结:要实现投掷效果,就是将一段非匀速减速的运行变成一段段10毫秒匀速运动
首先我们要知道什么时候进行摆正,在我们完成投掷效果再进行摆正
Handler flingHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (Math.abs(yVelocity) < mMinimumFlingVelocity) {
......
guiwei();
} else if (yVelocity != 0) {
.......
}
}
};
因为手指离开手机的初速度可能为0,所以这个情况直接进行摆正
case MotionEvent.ACTION_UP:
final VelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
if (Math.abs(velocityTracker.getYVelocity()) > mMinimumFlingVelocity) {
yVelocity = (int) velocityTracker.getYVelocity();
......
} else {
guiwei();
}
额,那个guiwei函数就是我们摆正效果实现的核心代码,我们看看首先通过offSet的正负来区分出不同的方案,
首先我们来说一说offSet为负的情况,通过给offSet(布局偏差值)给 item的高度(textSize+interval)取余,赋值给surplus,然后通过判断这个surplus是否大于item的高度的一半,如果小于不作处理,就是说后面要给布局往下移动,如果大于那就得取 item的高度减去surplus的相反数,就是说要把布局往上移动。如果offSet为正,情况恰好相反
public void guiwei() {
if (offSet < 0) {
surplus = (int) (-offSet) % (int) (textSize + interval);
if (surplus < (textSize + interval) / 2) {
} else {
surplus = -(textSize + interval - surplus);
}
} else {
surplus = (int) (offSet) % (int) (textSize + interval);
if (surplus < (textSize + interval) / 2) {
surplus = -surplus;
} else {
surplus = textSize + interval - surplus;
}
}
......
}
最后我们同样是通过周期任务来实现摆正效果的实现
if (mTask != null) {
mTask.cancel();
mTask = null;
}
mTask = new MyTimerTask(updateHandler);
timer.schedule(mTask, 0, 10);
看看updateHandler,就是将这个摆正过程分成一段段匀速运动,规定每一次做匀速运行的距离。就是每隔10毫秒给offSet增加固定值,然后重新绘画
Handler updateHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (Math.abs(surplus) < SPEED) {
surplus = 0;
if (mTask != null) {
mTask.cancel();
mTask = null;
}
} else if (surplus != 0) {
offSet = surplus / Math.abs(surplus) * SPEED + offSet;
surplus = (int) (surplus - surplus / Math.abs(surplus) * SPEED);
invalidate();
}
}
};
然后我们要添加选择回调就在之前的guiwei函数里完成,通过计算最后的offSet与item高度的取余来得出 选中item的位置,最后选的的item的位置,因为初始选中的是第二个,而初始位置可以往下拉一个位置,也就是头部选中时,offSet是一个item的高度的负值,所以所以我们这里需要加1
currentItem= -(int) ((surplus+offSet)/(textSize+interval))+1;
Log.v(TAG,"currentItem: "+currentItem);
if(selectItemListener!=null){
selectItemListener.selectItem(currentItem);
}
总结:摆正效果就是,计算offSet偏差值与item的高度的取余,根据情况来判断是往下还是往上移动一段距离,来完成选中item的归位
MyScrollView
public class MyScrollView extends View {
public static final String TAG = "MyScrollView";
Paint paint, zPaint;
//数据源
List dataList;
//字的大小
int textSize = 50;
//item之间的间距
int interval = 30;
int lineSize = 30;
//偏差
float offSet = 0;
//view的宽高
int width, height;
//总高度
int sumHeight;
float centerY;
float linePad = 30;
float now;
float move;
private VelocityTracker mVelocityTracker;
/**
* 自动回滚到中间的速度
*/
public static final float SPEED = 5;
//甩手和归为
private Timer timer;
private MyTimerTask mTask;
//归位,为了一定选中某个item
private Timer flingtimer;
private MyTimerTask flingmTask;
Handler updateHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (Math.abs(surplus) < SPEED) {
surplus = 0;
if (mTask != null) {
mTask.cancel();
mTask = null;
}
} else if (surplus != 0) {
offSet = surplus / Math.abs(surplus) * SPEED + offSet;
surplus = (int) (surplus - surplus / Math.abs(surplus) * SPEED);
invalidate();
}
}
};
//fling实现
Handler flingHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (Math.abs(yVelocity) < mMinimumFlingVelocity) {
yVelocity = 0;
if (flingmTask != null) {
flingmTask.cancel();
flingmTask = null;
}
guiwei();
} else if (yVelocity != 0) {
offSet = (float) (offSet + yVelocity * 0.01);
double d = distance - Math.abs(yVelocity) * 0.01;
yVelocity = getVelocityByDistance(distance) * yVelocity / Math.abs(yVelocity);
distance = d;
invalidate();
}
}
};
public MyScrollView(Context context) {
super(context);
init();
}
public MyScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public void init() {
paint = new Paint();
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setStyle(Paint.Style.FILL);
paint.setTextAlign(Paint.Align.CENTER);
paint.setColor(Color.BLACK);
paint.setTextSize(textSize);
zPaint = new Paint();
zPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
zPaint.setStyle(Paint.Style.FILL);
zPaint.setTextAlign(Paint.Align.CENTER);
zPaint.setColor(Color.BLUE);
zPaint.setTextSize(textSize);
dataList = new ArrayList<>();
sumHeight = interval * (dataList.size() + 1) + textSize * dataList.size();
centerY = (float) (interval + textSize * 1.5);
timer = new Timer();
flingtimer = new Timer();
final ViewConfiguration viewConfiguration = ViewConfiguration.get(getContext());
mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity();
mMaximumFlingVelocity = viewConfiguration.getScaledMaximumFlingVelocity();
minFLingDistance = getSplineFlingDistance(mMinimumFlingVelocity);
}
double minFLingDistance;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//invalidate();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
width = w;
height = h;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if(dataList==null||dataList.size()==0){
return ;
}
canvas.clipRect(0, 0, 300, (int) (3.5 * interval + textSize * 3));
//滑动下限
if (offSet <= -sumHeight + textSize * 2 + interval * 3) {
offSet = -sumHeight + textSize * 2 + interval * 3;
}
//滑动上限
if (offSet > textSize + interval) {
offSet = textSize + interval;
}
float start = offSet;
for (String s : dataList) {
Rect rect = new Rect();
paint.getTextBounds(s, 0, s.length(), rect);
// 获取到的是实际文字宽度
int textWidth = rect.width();
start = start + interval;
canvas.drawText(s, (width - textWidth) / 2, start + textSize, paint);
start = start + textSize;
}
canvas.drawLine(linePad,
(float) (interval * 1.5 + textSize), width - linePad, (float) (interval * 1.5 + textSize), zPaint);
canvas.drawLine(linePad, (float) (interval * 2.5 + textSize * 2),
width - linePad, (float) (interval * 2.5 + textSize * 2), zPaint);
canvas.drawLine(linePad,
(float) (interval * 1.5 + textSize), linePad, (float) (interval * 2.5 + textSize * 2), zPaint);
canvas.drawLine(width - linePad, (float) (interval * 1.5 + textSize),
width - linePad, (float) (interval * 2.5 + textSize * 2), zPaint);
}
int surplus;
private int mMaximumFlingVelocity;
private int mMinimumFlingVelocity;
int yVelocity;
int duration;
double distance;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
now = event.getY();
initOrResetVelocityTracker();
break;
case MotionEvent.ACTION_MOVE:
move = event.getY() - now;
now = event.getY();
offSet = offSet + move;
invalidate();
break;
case MotionEvent.ACTION_UP:
final VelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
if (Math.abs(velocityTracker.getYVelocity()) > mMinimumFlingVelocity) {
yVelocity = (int) velocityTracker.getYVelocity();
duration = getSplineFlingDuration(yVelocity);
distance = getSplineFlingDistance(yVelocity);
Log.v(TAG, "初速度:" + yVelocity + "移动距离:" + distance + "移动时间:" + duration);
if (flingmTask != null) {
flingmTask.cancel();
flingmTask = null;
}
flingmTask = new MyTimerTask(flingHandler);
flingtimer.schedule(flingmTask, 0, 10);
} else {
guiwei();
}
/* if(offSet<0){
surplus= (int)(-offSet)%(int)(textSize+interval);
if(surplus<(textSize+interval)/2){
}else {
surplus=-(textSize+interval-surplus);
}
}else {
surplus= (int)(offSet)%(int)(textSize+interval);
if(surplus<(textSize+interval)/2){
surplus=-surplus;
}else {
surplus=textSize+interval-surplus;
}
}
if (mTask != null)
{
mTask.cancel();
mTask = null;
}
mTask = new MyTimerTask(updateHandler);
timer.schedule(mTask, 0, 10);*/
invalidate();
break;
}
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(event);
}
return true;
}
class MyTimerTask extends TimerTask {
Handler handler;
public MyTimerTask(Handler handler) {
this.handler = handler;
}
@Override
public void run() {
handler.sendMessage(handler.obtainMessage());
}
}
private void initOrResetVelocityTracker() {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
} else {
mVelocityTracker.clear();
}
}
private void resetVelocityTracker() {
if (mVelocityTracker != null) {
mVelocityTracker.clear();
}
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
final float ppi = getContext().getResources().getDisplayMetrics().density * 160.0f;
mPhysicalCoeff = SensorManager.GRAVITY_EARTH // g (m/s^2)
* 39.37f // inch/meter
* ppi
* 0.84f; // look and feel tuning
}
private static final float INFLEXION = 0.35f; // Tension lines cross at (INFLEXION, 1)
// A context-specific coefficient adjusted to physical values.
private float mPhysicalCoeff;
private static float DECELERATION_RATE = (float) (Math.log(0.78) / Math.log(0.9));
// Fling friction
private float mFlingFriction = ViewConfiguration.getScrollFriction();
//获取滑动的时间
private int getSplineFlingDuration(int velocit) {
final double l = getSplineDeceleration(velocit);
final double decelMinusOne = DECELERATION_RATE - 1.0;
return (int) (1000.0 * Math.exp(l / decelMinusOne));
}
private double getSplineDeceleration(float velocity) {
return Math.log(INFLEXION * Math.abs(velocity) / (mFlingFriction * mPhysicalCoeff));
}
//通过初始速度获取最终滑动距离
private double getSplineFlingDistance(int velocity) {
final double l = getSplineDeceleration(velocity);
final double decelMinusOne = DECELERATION_RATE - 1.0;
return mFlingFriction * mPhysicalCoeff * Math.exp(DECELERATION_RATE / decelMinusOne * l);
}
//通过需要滑动的距离获取初始速度
public int getVelocityByDistance(double distance) {
final double l = getSplineDecelerationByDistance(distance);
int velocity = (int) (Math.exp(l) * mFlingFriction * mPhysicalCoeff / INFLEXION);
return Math.abs(velocity);
}
private double getSplineDecelerationByDistance(double distance) {
final double decelMinusOne = DECELERATION_RATE - 1.0;
return decelMinusOne * (Math.log(distance / (mFlingFriction * mPhysicalCoeff))) / DECELERATION_RATE;
}
int currentItem;
public void guiwei() {
if (offSet < 0) {
surplus = (int) (-offSet) % (int) (textSize + interval);
if (surplus < (textSize + interval) / 2) {
} else {
surplus = -(textSize + interval - surplus);
}
} else {
surplus = (int) (offSet) % (int) (textSize + interval);
if (surplus < (textSize + interval) / 2) {
surplus = -surplus;
} else {
surplus = textSize + interval - surplus;
}
}
currentItem= -(int) ((surplus+offSet)/(textSize+interval))+1;
Log.v(TAG,"currentItem: "+currentItem);
if(selectItemListener!=null){
selectItemListener.selectItem(currentItem);
}
if (mTask != null) {
mTask.cancel();
mTask = null;
}
mTask = new MyTimerTask(updateHandler);
timer.schedule(mTask, 0, 10);
}
public interface SelectItemListener{
public void selectItem(int index);
}
private SelectItemListener selectItemListener;
public void setSelectItemListener(SelectItemListener selectItemListener){
this.selectItemListener=selectItemListener;
}
public void setDataList(List dataList){
this.dataList=dataList;
sumHeight = interval * (dataList.size() + 1) + textSize * dataList.size();
invalidate();
}
}
MainActivity
public class MainActivity extends Activity {
MyScrollView mySV;
TextView tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mySV=findViewById(R.id.mySV);
tv=(TextView)findViewById(R.id.tv);
mySV.setSelectItemListener(new MyScrollView.SelectItemListener() {
@Override
public void selectItem(int index) {
tv.setText(""+index);
}
});
List dataList=new ArrayList<>();
for (int i = 0; i < 30; i++) {
dataList.add("" + i);
}
mySV.setDataList(dataList);
}
}
activity_main