先放上效果图,可左右滑动切换月份,默认会显示当前月份,并且会高亮当前日期。有一个类似iphone日历的按下效果。
整个控件是一个LinearLayout,在其中分为三个部分:1、最上端显示月份,会随着中间日历的滑动改变,用一个TextView实现。2、接着一行显示星期,用自定义View实现。3、主体显示日期,可以通过左右滑动切换月份。通过ViewPager实现左右滑页,每一页的内容为自定义view。其中日期部分想到了两种思路,一种是将日期填充至GridView,一种是自定义view的方式,本文采用了后者。
这一行是静态的,我是通过一个自定义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);
}
}
这一块很容易实现,内容也不多,接下来是重点部分了。
首先确定高度,因为按每行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,这样文字可以正好在圆的中间。
但是还要确保圆圈半径不能大于圆心到这一行上边界的距离,不然圆圈会因为太大超出上边界,导致显示不全。
接着重写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;
}
}
到这里,星期和日期两个自定义的部分都已经实现了,剩下的就是将它们合在一起。新建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