自定义可滑动日历

自定义可滑动日历

先放上效果图,可左右滑动切换月份,默认会显示当前月份,并且会高亮当前日期。有一个类似iphone日历的按下效果。

一、设计思路

整个控件是一个LinearLayout,在其中分为三个部分:1、最上端显示月份,会随着中间日历的滑动改变,用一个TextView实现。2、接着一行显示星期,用自定义View实现。3、主体显示日期,可以通过左右滑动切换月份。通过ViewPager实现左右滑页,每一页的内容为自定义view。其中日期部分想到了两种思路,一种是将日期填充至GridView,一种是自定义view的方式,本文采用了后者。
自定义可滑动日历_第1张图片

二、实现

1、星期部分

这一行是静态的,我是通过一个自定义View实现的,当然也可以放7个TextView,宽度平均分一下就好了。
新建CustomWeekView.java继承自View。
在构造函数中初始化画笔Paint。在设置TextSize的时候,使用了一个固定值乘了一个density,主要是为了适配不同机型不同分辨率的。

private void initPaint() {
    float density = getContext().getApplicationContext().getResources().getDisplayMetrics().density;
    mTtPaint = new Paint();
    mTtPaint.setTextSize(12 * density);
    mTtPaint.setColor(Color.GRAY);
    mTtPaint.setAntiAlias(true);
}

重写onMeasure(),指定控件的宽和高。调用Math.round()方法将得到的heightSize进行四舍五入取整。高度要确保可以将所有的内容显示出来。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);   //获取宽的尺寸
    float heightSize = FontUtil.getFontHeight(mTtPaint);//保证week显示区域可以容下最高的字母
    mOneWidth = widthSize / 7;
    setMeasuredDimension(widthSize, Math.round(heightSize));
}

重写OnDraw(),在for循环中每次调用Canvas.drawText()进行绘制。

private final String[] WEEK_ARRAY = new String[]{"日", "一", "二", "三", "四", "五", "六"};
@Override
protected void onDraw(Canvas canvas) {
    for(int i = 0; i < WEEK_ARRAY.length; i++){
        int len = (int)FontUtil.getFontlength(mTtPaint, WEEK_ARRAY[i]);
        int x = i * mOneWidth + (mOneWidth - len) / 2;
        canvas.drawText(WEEK_ARRAY[i], x, FontUtil.getFontLeading(mTtPaint), mTtPaint);
    }
}

这一块很容易实现,内容也不多,接下来是重点部分了。

2、日期部分

首先确定高度,因为按每行7天计算,有的月份需要5行,有的需要6行,为了统一,我们将整个高度设为每行高度 x 6,这样既能保证任何月份都可显示全,也能避免换页的时候页面高度来回改变。而每行高度设为背景圆圈直径加上间距。接着通过for循环来画这个月所有的日期,如果为当前月,当前日期会画一个背景圈圈,在选择日期时会有一个按下效果。最后在onTouchEvent()中计算这个按下效果。
新建CustomDateView.java,继承自View。
在构造函数中获取一些自定义的属性参数。mMinSlop这个参数可能现在看着有点懵,它的含义会在后面写onTouchEvent()的时候说明。

public CustomDateView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    float density = context.getApplicationContext().getResources().getDisplayMetrics().density;
    TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CustomDateView, defStyleAttr, 0);
    mNormalTextColor = a.getColor(R.styleable.CustomDateView_mTextColorDay, Color.BLACK);
    mSelectTextColor = a.getColor(R.styleable.CustomDateView_mSelectTextColor, Color.BLACK);
    mCurrentTextColor = a.getColor(R.styleable.CustomDateView_mCurrentTextColor, getResources().getColor(R.color.colorCurrentText));
    mTextSize = a.getDimension(R.styleable.CustomDateView_mTextSizeDay, density * 14);
    mCurrentBgd = a.getColor(R.styleable.CustomDateView_mCurrentBg, getResources().getColor(R.color.colorCurrentBackground));
    mSelectBgd = a.getColor(R.styleable.CustomDateView_mSelectBg, getResources().getColor(R.color.colorSelectBgd));
    mBgdRadius = a.getDimension(R.styleable.CustomDateView_mSelectRadius, density * 16);
    mLineSpac = a.getDimension(R.styleable.CustomDateView_mLineSpac, density * 8);
    a.recycle();  //回收

    //背景圈圈识别最小滑动距离
    mMinSlop = Math.min(ViewConfiguration.get(getContext()).getScaledTouchSlop() * density, mBgdRadius);

    dayHeight = FontUtil.getFontHeight(mPaint);
    //每行高度 = 背景圆圈直径 + 间距
    oneHeight = mBgdRadius * 2 + mLineSpac;
}

初始化画笔,一个是画背景圈圈的bgdPaint,一个是画日期的mPaint。

private void init(){
    //初始化画笔
    mPaint = new Paint();
    bgdPaint = new Paint();

    mPaint.setAntiAlias(true); //抗锯齿
    mPaint.setStrokeWidth(1f);
    mPaint.setTextSize(mTextSize);

    bgdPaint.setAntiAlias(true); //抗锯齿
}

初始化数据。通过ViewPager的position确定选择的月份,默认是当前月。将选择的月份与当前月份进行比较,如果是当前月份,将标志位置true,并计算当前日期是该月的第几天,后面好在这一天上画圈圈。计算出该月天数、该月第一天是星期几等数据,供后面使用。
ViewPager是有position的,这里代码中的position就是ViewPager的position,因为我的ViewPager设的初始显示position为250,所以在这里减去250,也就是将当前月份绑定在了250这个position上。随着position的改变,通过selectedMonth.add()就可以获取到以当前月为基准,前后相隔position - 250个月的月份了。

//设置的月份
Calendar selectedMonth = Calendar.getInstance();// 临时
selectedMonth.add(Calendar.MONTH, position - 250);
selectedMonth.set(Calendar.DAY_OF_MONTH, 1);

再获取一个固定的当前月份的Calendar对象,通过与上面的比较,就可得出一个position下对应的月份是否为当前月份。

Calendar calendar = Calendar.getInstance();
calendar.setTime(new Date());
//获取今天日期
isCurrentDay = calendar.get(Calendar.DAY_OF_MONTH);
//判断是否为当月当前日期
if ((selectedMonth.get(Calendar.YEAR) == calendar.get(Calendar.YEAR)) &&
        selectedMonth.get(Calendar.MONTH) == calendar.get(Calendar.MONTH)) {
    isCurrentMonth = true;
}

接下来确定这个月份下一共有多少天、第一天是星期几、行数等数据。Calendar.DAY_OF_WEEK获取到的星期数是以星期日为第一天的,所以要减1。

calendar.setTime(selectedMonth.getTime());
//月份天数
dayOfMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH);
//本月第一天显示在第一行的位置
firstDayIndex = calendar.get(Calendar.DAY_OF_WEEK) - 1;
lineNum = 1;
//日历中第一行显示的天数
firstLineDaysNum = 7 - firstDayIndex;
lastLineDaysNum = 0;
int remainDays = dayOfMonth - firstLineDaysNum;
while (remainDays > 7) {
    lineNum++;
    remainDays -= 7;
}
//日历中最后一行天数
if(remainDays > 0){
    lineNum++;
    lastLineDaysNum = remainDays;
}
mSelectDateStr = Date2str(selectedMonth.getTime());

重写onMeasure(),前面已经提到了,将高度设为6行的高度。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    //控件宽度
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);   //获取宽的尺寸
    columnWidth = widthSize / 7;
    //高度 = 标题高度 + 星期高度 + 日期行数 * 每行高度
    float height = 6 * oneHeight;
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), (int)height);
}

到这里准备工作就算完成了,接下来才是重点
重写onDraw()
主体是一个for循环。知道了第一天是这月星期几,也就知道了该月第一天在第一行的index,从该位置开始绘制,每绘制一个日期就将index加1,每加到7就将index置0,当index为7时说明这一行已经绘制完了,换到下一行,依次绘制所有行。同时判断绘制到的日期下是否需要背景圈圈,前面提到了,圈圈有两种,一种是当前日期的,这个通过前面计算得到的isCurrentMonth和isCurrentDay就可以确定了,还有一种是按下时的,是通过在OnTouchEvent()中得到的selectDay和isPressed来确定的,剩下的日期不需要圈圈。

@Override
protected void onDraw(Canvas canvas) {
    float dayTextLeading = FontUtil.getFontLeading(mPaint);
    int dayIndex = firstDayIndex;
    float top = 0;

    for (int i = 0; i < dayOfMonth; i++) {
        int left = (dayIndex) * columnWidth;
        int day = i + 1;

        if(isCurrentMonth && (isCurrentDay == day)){
            //无论是否选中当前日期
            //设置当前日期背景,设置当前日期字体颜色
            //绘制背景圆圈
            ...
            //Canvas.drawCircle();
        } else if (day == selectDay && isPressed) {
            //选中日期不是当前日期,根据isPressed确定是否需要按下效果,设置选中日期字体颜色
            //绘制按下时的背景圆圈
            ...
            //Canvas.drawCircle();
        } else {
            //设置非选中日期和非当前日期字体颜色,不需要背景圆圈
            ...
        }
        int len = (int)FontUtil.getFontlength(mPaint, day + "");
        int x = left + (columnWidth - len) / 2;
        //绘制日期
        //canvas.drawText();
        //从第一行开始绘制,每行7天,每次循环增加一个行高
        if (++dayIndex == 7) {
            dayIndex = 0;
            top = top + oneHeight;
        }
    }
}

通过上面的for循环,理论上已经可以绘制出这个月所有的日期和需要的圈圈了,但是为了效果美观,我们需要考虑日期在每一行的位置,背景圆圈在每一行的位置,以及两者的相对位置等,这就需要认真计算画笔的坐标了。调用Canvas.drawCircle()画圆圈,不了解的可以自己查一下参数,其中前两个参数是圆心x和y坐标。调用Canvas.drawText()绘制日期,其中第二和第三个参数是文字baseline的x、y坐标。调用这两个函数参数中的x值,就是之前将控件7等分后,每一份的中点。重点是在y轴坐标上,下图截取的是一行(高度为mBgdRadius * 2 + mLineSpac)。为了美观,要保证日期正好在圆圈中间,也就是说圆心要在一行的中间高度:centerY = mBgdRadius * 2 + mLineSpac,日期只需要确定paint的baseline的位置即可,整体的位置如下图所示。因为ascent本就是相对baseline而言的,为负值,文字的高度为descent - ascent,descent也是相对baseline而言的,为正值,所以相对一行而言,文字的baseline为centerY + (descent - ascent) / 2 - descent,这样文字可以正好在圆的中间。
自定义可滑动日历_第2张图片但是还要确保圆圈半径不能大于圆心到这一行上边界的距离,不然圆圈会因为太大超出上边界,导致显示不全。
接着重写onTouchEvent()。记录按下和抬起时的坐标,通过计算偏移量来判断是否有按下效果,如果抬起时已经不在按下时的那个日期上,认为没有按下效果。

private float lastX;
private float lastY;
@Override
public boolean onTouchEvent(MotionEvent event) {
    isSelect = false;
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //touchCompute();
            lastX = event.getX();
            lastY = event.getY();
            isPressed = true;
            break;

        case MotionEvent.ACTION_CANCEL://x轴滑动距离大于viewpage判定的最小距离,viewpage会滑动,子view会触发ACTION_CANCEL

        case MotionEvent.ACTION_UP:
            //计算按下时和抬起时的位移差,判断是否认为选中了某一日期
            if (Math.abs(lastX - event.getX()) < mMinSlop && Math.abs(lastY - event.getY()) < mMinSlop) {
                isSelect = true;
            }
            //touchCompute();
            isPressed = false;
            break;
    }
    return true;
}

判断y轴的偏移Math.abs(lastY - event.getY())很好理解,解释一下x轴偏移的判断和为什么要监听ACTION_CANCEL事件。ViewPager有最小识别滑动的临界值,x轴移动距离大于这个值系统才会认为需要滑动,实现左右滑动的效果。这涉及ViewPager的事件拦截,按下屏幕,事件会传递到子view,ViewPager不会拦截,如果一开始横向滑动,识别到的x轴偏移量大于临界值时,ViewPager会将后续事件拦截,就可以左右换页了。ViewPager拦截了后续事件,表示子view中的事件已经结束了,就会触发子view的ACTION_CANCEL事件,所以在代码中同时监听了ACTION_CANCEL和ACTION_UP事件。我们就是利用这点判断x轴偏移的,避免了滑动中我们的手指还在一个日期坐标范围内,却触发了ViewPager的换页(选择日期和换页不应该共存)。
接着看是如何处理触摸事件坐标的,通过y坐标判断触摸事件是否在有效范围内,如果在范围内,判断在第几行。

private void touchCompute(final PointF point){

    boolean availability = false;  //事件是否有效
    //日期部分
    float top = oneHeight;
    int foucsLine = 1;
    //根据焦点的Y坐标找到所在行
    while(foucsLine <= lineNum){
        if(top >= point.y){
            availability = true;
            break;
        }
        top += oneHeight;
        foucsLine ++;
    }
    ...
}

确定第几行后,由x坐标判断在第几个位置。

...
if (availability) {
    //根据X坐标找到具体的焦点日期
    int xIndex = (int)(point.x / columnWidth) + 1;
    if(foucsLine == 1){
        //第一行
        if (xIndex > firstDayIndex) {
            setSelectedDay(xIndex - firstDayIndex);
        } else {
            invalidate();//第一行1号前的位置认为无效
        }
    } else if(foucsLine == lineNum) {
        //最后一行
        if (xIndex <= lastLineDaysNum) {
            setSelectedDay(firstLineDaysNum + (foucsLine - 2) * 7 + xIndex);
        } else {
            invalidate();//最后一行最后一天后的位置认为无效
        }
    } else {
        setSelectedDay(firstLineDaysNum + (foucsLine - 2) * 7 + xIndex);
    }
} else {
    invalidate();
}

由此得出该日期值,并通过前面得出的isSelect判断是否选择了该日期,我这用了一个Toast体现。

/*选中的日期*/
private void setSelectedDay(int day){
    selectDay = day;
    if (isSelect) {
        Toast.makeText(getContext(), "选中日期: " + mSelectDateStr + selectDay + "日", Toast.LENGTH_SHORT).show();
    }
    invalidate();
}

这样,一个不可滑动的日历就完成了,接下来将其添加到ViewPager中。
新建DayViewPager.java,继承自ViewPager。重写onMeasure(),将高度设为子View高度。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int height = getChildAt(1).getMeasuredHeight();
    setMeasuredDimension(widthSize, height);
}

在setAdapter()后直接调用setCurrentItem()方法将当前item设为250,目的是与前面CustomDateView中计算日期对上。

@Override
public void setAdapter(PagerAdapter adapter) {
    super.setAdapter(adapter);
    setCurrentItem(250);
}

接着写一个自己的adapter,新建DayPagerAdapter.java,继承自PagerAdapter。在getCount()中返回500,保证当前月份前后的200多个月都能被选到。在instantiateItem()中添加前面的自定义CustomDateView。

public class DayPagerAdapter extends PagerAdapter {

    private Context mcontext;

    public DayPagerAdapter(Context context) {
        mcontext = context;
    }

    @Override
    public int getCount() {
        return 500;
    }

    @Override
    public boolean isViewFromObject(@NonNull View view, @NonNull Object object) {
        return view == object;
    }

    @Override
    public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
        container.removeView((View) object);
    }

    @NonNull
    @Override
    public Object instantiateItem(@NonNull ViewGroup container, int position) {
        CustomDateView customDateView = new CustomDateView(mcontext, position);
        container.addView(customDateView);
        return customDateView;
    }

}

3、组合

到这里,星期和日期两个自定义的部分都已经实现了,剩下的就是将它们合在一起。新建date_view.xml,将两个自定义view添加进来,并添加一个TextView用于显示月份,这三部分组成了一个完整的日历布局。

<?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:orientation="vertical">

    <TextView
        android:id="@+id/year_textview"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:textSize="20sp"
        android:textColor="#000000"/>

    <com.example.mycalendar.CustomView.CustomWeekView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp" />

    <com.example.mycalendar.CustomView.DayViewPager
        android:id="@+id/day_viewpager"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp" />

</LinearLayout>

新建DateView.java,继承自LinearLayout,添加date_view.xml的布局。

View view = LayoutInflater.from(context).inflate(R.layout.date_view, null);
addView(view);

监听ViewPager的OnPageChangeListener,得到position,通过该position确定选择的月份,并转化为显示的月份格式,实现月份随ViewPager的滑动而改变的效果。

...
final Calendar calendar = Calendar.getInstance();
final SimpleDateFormat dateFormat = new SimpleDateFormat("MM月  yyyy");
dateFormat.format(calendar.getTime());
dayViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
    @Override
    public void onPageScrolled(int position, float offset, int offsetPixels) {

    }

    @Override
    public void onPageSelected(int position) {
        if (lastPosition < position) {
            calendar.add(Calendar.MONDAY,+1);
            yearTv.setText(dateFormat.format(calendar.getTime()));
        } else if (lastPosition > position) {
            calendar.add(Calendar.MONDAY,-1);
            yearTv.setText(dateFormat.format(calendar.getTime()));
        }
        lastPosition = position;
    }

    @Override
    public void onPageScrollStateChanged(int i) {

    }

});

整个日历控件到这就完成了,在要使用该日历控件的界面中添加这个自定义的DateView就可以了。

<?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:orientation="vertical"
    tools:context=".MainActivity">

    <com.example.mycalendar.DateView
        android:id="@+id/date_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingTop="15dp"
        android:background="#f2f2f2">
    </com.example.mycalendar.DateView>

</LinearLayout>

转载请附上本文链接,谢谢~~~
备注:
2019.7.17:更新了布局的算法,原算法太复杂了,自己都看不下去了。。。

最后附上源码,鉴于本人能力有限。。。
本文及代码中有什么错误或不足的地方还请大家谅解并指出~~~
项目地址:GitHub

你可能感兴趣的:(自定义可滑动日历)