自定义可滑动日历

自定义可滑动日历

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

一、设计思路

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

二、实现

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。

扫描二维码关注公众号,回复: 9053549 查看本文章
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,这样文字可以正好在圆的中间。
在这里插入图片描述但是还要确保圆圈半径不能大于圆心到这一行上边界的距离,不然圆圈会因为太大超出上边界,导致显示不全。
接着重写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

发布了10 篇原创文章 · 获赞 19 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/songkai0825/article/details/89926605
今日推荐