这是我在一个项目中做的日期选择器,用PopupWindow+自定义View(ScrollSelector)来实现的,其中最关键的是三个滚动选择器(年月日),是用我自定义的View:ScrollSelector来实现的。本来网上已经有别人做的类似的控件的了,不过我想要自己做一个。
上效果图
工程目录
我们要关注的就只有这三个文件
import android.app.Activity;
import android.os.Bundle;
import java.util.ArrayList;
public class MainActivity extends Activity {
private ScrollSelector scrollSelector; //滚动选择器
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//获取滚动选择器控件
scrollSelector = (ScrollSelector) findViewById(R.id.scrollSelector);
//初始项列表
ArrayList list = new ArrayList<>();
for (int i = 0; i < 20; i++){
list.add("第" + i + "项");
}
//设置滚动选择器的项列表
scrollSelector.setItemContents(list);
}
}
这里生成20个测试数据,传给ScrollSelector
这里把ScrollSelector的背景色设置为灰色,与布局的背景色不同,是为了能够将控件和布局背景区分开来,方便测试
ScrollSelector.java
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.util.Log;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import java.util.ArrayList;
/**
* Created by Administrator on 2016/9/7.
* 滚动选择器
*/
public class ScrollSelector extends View {
/**
* 获取选中项
*/
public int getSelectedIndex(){
return (int) (-offsetY + 0.5);
}
/**
* 设置选中项
*/
public void setSelectedIndex(int pos){
if (pos < 0 || pos >= contents.size()) return;
offsetY = -pos + 1;
}
/**
* 设置项列表的内容
*/
public void setItemContents(ArrayList list){
/*当前选中项为原本列表项的最后一项时,如果重新指定的列表项的比原本的列表项的小
则会让当前的选中项为空,所以需要重新指定选中项*/
if (getSelectedIndex() >= list.size()){
setSelectedIndex(list.size() - 1);
}
contents = list;
invalidate();
}
/**
* 设置显示的项数
*/
public void setShowItemNum(int num){
showItemNum = num;
offsetY = (showItemNum - 1) / 2; //设置默认项
}
/**
* 设置分割线的颜色
*/
public void setDividerColor(int dividerColor) {
this.dividerColor = dividerColor;
}
/**
* 设置选中状态字体的颜色
*/
public void setTextSelectorColor(int textSelectorColor) {
this.textSelectorColor = textSelectorColor;
}
/**
* 设置正常状态字体的颜色
*/
public void setTextNormalColor(int textNormalColor) {
this.textNormalColor = textNormalColor;
}
private final int DIVIDER_WIDTH = 2; //分割线的宽度
private final int DEFAULT_TEXTSIZE = 50; //默认字体大小
private final int SLEEP_TIME = 1000 / 60; //动画的延时时间,每秒大约80帧
private final int WHAT_INVALIDATE = 0; //重新绘制
private int showItemNum = 3; //显示的项数
private int dividerY; //绘制分隔线的y坐标
private int itemHeight; //每一项所占的高度
private int dividerColor = 0xFF8A8A8A; //分割线的颜色
private int textSelectorColor = 0xFFFF0000; //选中状态文字的颜色
private int textNormalColor = 0xFF000000; //正常状态文字的颜色
private int marqueeX; //跑马灯的x坐标偏移
private int marqueeWidth; //跑马灯的宽度
private int borderWhenDown; //按下时的边界状态
private float offsetY; //项偏移的y坐标
private boolean isPress; //手指是否是按下状态
private boolean isFirst = true; //是否是首次绘制
private boolean isSkiping; //是否正在执行跳转
private boolean isStopSkiping; //是否要停止跳转
private boolean isHoming; //是否正在执行归位
private ArrayList contents; //项的内容
private Paint mPaint; //画笔
private GestureDetector mDetector; //手势
private Handler mHandler; //异步处理
private RollThread rollThread; //滚动线程
private MarqueeThread marqueeThread; //跑马灯线程
public ScrollSelector(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); //实例化画笔
mPaint.setTextSize(DEFAULT_TEXTSIZE); //设置字体大小
mPaint.setStrokeWidth(DIVIDER_WIDTH); //设置线条的宽度
mDetector = new GestureDetector(context, new MyGestureListener()); //实例化手势
contents = new ArrayList<>(); //初始化列表项的内容,防止出现空指针错误
mHandler = new Handler(){ //实例化Handler
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
switch (msg.what){
//重新绘制
case WHAT_INVALIDATE:
invalidate();
break;
}
}
};
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
//计算每一项的高度
itemHeight = h / showItemNum;
//计算分割线的y坐标
dividerY = itemHeight * ((showItemNum - 1) / 2);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//绘制分割线
mPaint.setColor(dividerColor);
canvas.drawLine(0, dividerY, getWidth(), dividerY, mPaint);
canvas.drawLine(0, dividerY + itemHeight, getWidth(), dividerY + itemHeight, mPaint);
//边界限制
borderLimit();
//绘制项
for (int i = 0; i < showItemNum + 1; i++){
//获取要绘制的项的序号
int index = (int) -offsetY + i - (showItemNum - 1) / 2;
if (index >= contents.size()) break;
if (index < 0) continue;
//获取字符串的宽高
String item = contents.get(index);
Rect bound = new Rect();
mPaint.getTextBounds(item, 0, item.length(), bound);
//绘制字符串
int x = bound.width() > getWidth() ? 0 :(getWidth() - bound.width()) / 2; //绘制文本的x坐标
int y = (int) (itemHeight * i + (offsetY - (int) offsetY) * itemHeight); //绘制文本的y坐标
y += (itemHeight + bound.height()) / 2; //绘制文本的基线偏移量
if (getSelectedIndex() == index) {
mPaint.setColor(textSelectorColor); //选中状态的字体颜色
//判断是否需要跑马灯
if (bound.width() > getWidth()) {
marqueeWidth = bound.width();
x = marqueeX;
if (isFirst){
marquee();
}
}else{
marqueeWidth = 0;
}
}else {
mPaint.setColor(textNormalColor); //正常状态的字体颜色
}
canvas.drawText(item, x, y, mPaint);
if (isFirst) isFirst = false;
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//手指按下
if (event.getAction() == MotionEvent.ACTION_DOWN){
isPress = true;
borderWhenDown = borderLimit();
marqueeX = 0;
if (isSkiping) isStopSkiping = true;
}
//手指抬起
if (event.getAction() == MotionEvent.ACTION_UP){
isPress = false;
if (!isSkiping) {
homing();
}
marquee();
}
//手势判断
mDetector.onTouchEvent(event);
return true;
}
/**
* 归位
*/
private void homing(){
if (isHoming) return;
isHoming = true;
new HomingThread().start();
}
/**
* 滚动
*/
private void roll(float speed){
if (rollThread != null && rollThread.isAlive()) return;
rollThread = new RollThread(speed);
rollThread.start();
}
/**
* 跳转
* @param dir true为跳转到顶部,false为跳转到底部
*/
private void skip(boolean dir){
if (isSkiping) return;
isSkiping = true;
Log.e("tag", "skip");
new SkipThread(dir).start();
}
/**
* 跑马灯显示
*/
private void marquee(){
if (marqueeThread != null && marqueeThread.isAlive()) return;
marqueeThread = new MarqueeThread();
marqueeThread.start();
}
/**
* 边界限制
* @return -1为在顶部,1为在底部,0为不在边界
*/
private int borderLimit(){
if (offsetY >= 0) { //顶部边界
offsetY = 0;
return -1;
}
else if (offsetY <= -contents.size() + 1){ //底部边界
offsetY = -contents.size() + 1;
return 1;
}
return 0;
}
/**
* 手势事件
*/
private class MyGestureListener extends GestureDetector.SimpleOnGestureListener{
/**
* 滑动
*/
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
int border = borderLimit();
if (borderWhenDown == -1 && border == -1 && distanceY < 0) { //跳转到底部
skip(false);
} else if (borderWhenDown == 1 && border == 1 && distanceY > 0) { //跳转到顶部
skip(true);
}else if (!isSkiping) {
offsetY -= distanceY / itemHeight; //偏移量
invalidate();
}
return false;
}
/**
* 滚动
*/
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
float speed = velocityY / itemHeight / 20;
if (Math.abs(speed) > 0.5) {
roll(speed);
}
return super.onFling(e1, e2, velocityX, velocityY);
}
}
/**
* 自动返回中间位置的(归位)线程
*/
private class HomingThread extends Thread{
private final float MOVE_DISTANCE = 0.05f; //每一帧的移动距离(itemHeight的比例)
@Override
public void run() {
super.run();
float dy = 0;
while(!isPress){ //手指按下就停止归位
//取小数部分
float decimal = Math.abs(offsetY - (int) offsetY);
//大概达到中间位置
if (decimal > -MOVE_DISTANCE * 1.1 && decimal < MOVE_DISTANCE * 1.1) break;
//移动量
dy = decimal < 0.5 ? MOVE_DISTANCE : -MOVE_DISTANCE;
//防止超过位置
if ((int) offsetY != (int) (offsetY + dy)) break;
offsetY += dy;
try{
Thread.sleep(SLEEP_TIME);
}catch (InterruptedException e){
e.printStackTrace();
}
//重新绘制
mHandler.sendEmptyMessage(WHAT_INVALIDATE);
}
//取整
if (!isPress) {
offsetY = (int) offsetY;
if (dy < 0) { //误差校正
offsetY--;
}
mHandler.sendEmptyMessage(WHAT_INVALIDATE);
}
isHoming = false;
}
}
/**
* 滚动的线程
*/
private class RollThread extends Thread{
private final float DAMPING = 0.1f; //速度的衰减,即每一帧之后的衰减量
private float speed; //滚动的速度,即每一帧移动的距离
public RollThread(float speed){
this.speed = speed;
}
@Override
public void run() {
super.run();
boolean dir = speed > 0; //滚动方向,true为向上,false为向下
while (!isPress){
offsetY += speed;
//显示越界
if (borderLimit() != 0) {
mHandler.sendEmptyMessage(WHAT_INVALIDATE);
break;
}
//速度衰减
speed += (dir ? -DAMPING : DAMPING);
//速度越界
if ((dir && speed < 0) || (!dir && speed > 0)) break;
try {
Thread.sleep(SLEEP_TIME);
}catch (InterruptedException e){
e.printStackTrace();
}
//重新绘制
mHandler.sendEmptyMessage(WHAT_INVALIDATE);
}
//滚动完后归位
if (!isPress) {
homing();
}
}
}
/**
* 顶部和底部的跳转
*/
private class SkipThread extends Thread{
private final float SKIP_TIME = 1000; //跳转时间,1秒
private boolean dir; //true为跳转到顶部,false为跳转到底部
public SkipThread(boolean dir){
this.dir = dir;
}
@Override
public void run() {
super.run();
float framesNum = SKIP_TIME / SLEEP_TIME; //总帧数
float speed = (contents.size()) / framesNum; //每帧移动的距离
if (!dir) speed *= -1;
while (!isStopSkiping && getSelectedIndex() != (dir ? 0 : contents.size() - 1)){
offsetY += speed;
try {
Thread.sleep(SLEEP_TIME);
}catch (InterruptedException e){
e.printStackTrace();
}
mHandler.sendEmptyMessage(WHAT_INVALIDATE);
}
if (!isPress){
homing();
}
isSkiping = false;
isStopSkiping = false;
}
}
/**
* 过长文字跑马灯显示的线程
*/
private class MarqueeThread extends Thread {
private final int moveDistance = 3; //每一帧的移动距离
@Override
public void run() {
super.run();
while (!isPress && marqueeWidth != 0){
marqueeX -= moveDistance;
if (marqueeX < -marqueeWidth) marqueeX = getWidth();
try {
Thread.sleep(SLEEP_TIME);
}catch (InterruptedException e){
e.printStackTrace();
}
mHandler.sendEmptyMessage(WHAT_INVALIDATE);
}
}
}
}
下面给出一些关键的计算过程
分割线位置的计算
在onSizeChanged方法中
//计算分割线的y坐标
dividerY = itemHeight * ((showItemNum - 1) / 2);
分割线有两条,一条在上面一条在下面,很容易就知道分割线的x坐标为从0到getWidth(),关键是分割线y坐标的计算。
用dividerY来存储上面那条分割线的y坐标,下面那条分割线的y坐标就是dividerY + itemHeight,所以只需要计算出dividerY的值就可以了
可以看的出,dividerY = itemHeight * ((showItemNum - 1) / 2)
要绘制的列表项的个数
在onDraw方法中,看这一句
for (int i = 0; i < showItemNum + 1; i++){
我们要显示shwoItemNum个项,但是我们要绘制showItemNum+1项,为什么呢?看图
以showItemItem=3时为例:
这是平常状态,要显示3项
这是拖动状态,要显示4项
那为什么不把这两种状态分开来呢?因为没必要,拖动状态则要频繁的绘制,而平常状态只要绘制一次就可以了,多出的那一项因为超出View的高度所以并不会显示出来,也不会增加多少负担。而且将它们分开处理还会增加代码的复杂性,更容易出错。
拖动绘制的计算
这里用到了手势,如果对手势不了解的可以看这篇文章http://www.runoob.com/w3cnote/android-tutorial-gestures.html
在onScroll方法中
offsetY -= distanceY / itemHeight; //偏移量
distanceY是手指在屏幕上滑动的像素,取它相对于itemHeight的比例,加到offsetY。这里用“-=”是当手指向上滑动时,distanceY的值是正数,而绘制的项y坐标是向上偏移的,所以offsetY的值是变小的。
offsetY的整数部分就是当前的选中项,小数部分就是绘制的y坐标偏移(相对于itemHeight)