图片 6

Android自定义日历控件的实现过程详解

为什么要自定义控件

有时,原生控件不能满足我们对于外观和功能的需求,这时候可以自定义控件来定制外观或功能;有时,原生控件可以通过复杂的编码实现想要的功能,这时候可以自定义控件来提高代码的可复用性。

这是一个仿照探探的验证码输入控件,防止输入溢出,支持自身属性自定义等.
该项目以上传至Github中,欢迎star/fork,项目地址VercideEditText

图片 1

如何自定义控件

下面我通过我在github上开源的Android-CalendarView项目为例,来介绍一下自定义控件的方法。该项目中自定义的控件类名是CalendarView。这个自定义控件覆盖了一些自定义控件时常需要重写的一些方法。

一、 项目说明

先看几张动态的效果图吧!

构造函数

为了支持本控件既能使用xml布局文件声明,也可在java文件中动态创建,实现了三个构造函数。

public CalendarView(Context context, AttributeSet attrs, int defStyle);
public CalendarView(Context context, AttributeSet attrs);
public CalendarView(Context context);

可以在参数列表最长的第一个方法中写上你的初始化代码,下面两个构造函数调用第一个即可。

public CalendarView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}
public CalendarView(Context context) {
    this(context, null);
}

那么在构造函数中做了哪些事情呢?

1.1 demo演示

![demo.gif](http://upload-images.jianshu.io/upload_images/2493724-d816fafc6b3d3a8d.gif?imageMogr2/auto-orient/strip
=100×100)

图片 2图片 3图片 4

1 读取自定义参数

读取布局文件中可能设置的自定义属性(该日历控件仅自定义了一个mode参数来表示日历的模式)。代码如下。只要在attrs.xml中自定义了属性,就会自动创建一些R.styleable下的变量。

TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CalendarView);
mode = typedArray.getInt(R.styleable.CalendarView_mode, Constant.MODE_SHOW_DATA_OF_THIS_MONTH);

然后附上res目录下values目录下的attrs.xml文件,需要在此文件中声明你自定义控件的自定义参数。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CalendarView">
        <attr name="mode" format="integer" />
    </declare-styleable>
</resources>

1.2 特性

  • [x] 继承EditText,可以当作EditText使用
  • [x] 防止输入溢出
  • [x] 自定义验证码位数
  • [x] 提供输入内容的监听器
  • [x] 高度自适配
  • [x] 属性自定义配置
项目地址:

这里主要记录一下在编写日历控件过程中一些主要的点:

  • 1、支持农历、节气、常用节假日
  • 2、日期范围设置,默认支持的最大日期范围[1900.1~2049.12]
  • 3、禁用日期范围设置
  • 4、初始化选中单个或多个日期
  • 5、单选、多选操作
  • 6、跳转到指定日期
  • 7、替换农历为指定文字
  • 8、通过自定义属性定制日期外观,以及简单的日期item布局配置
  • 9、……

我们要实现的日历控件采用ViewPager作为主框架,CalendarView继承ViewPager,这样就天生拥有左右滑动和缓存的功能。目前我们设定日历左右滑动为月份切换的操作,每一个月份显示通过自定义ViewGroup实现,也就是我们的MonthView,月份中的日期是通过layout布局解析出的View,根据月份的不同每个MonthView可能包含6
x 7或5 x
7个日期View,由于给ViewPager绑定数据需要通过PagerAdapter,所以继承PagerAdapter我们扩展了一个CalendarPagerAdapter,来完成MonthView的相关初始化和日期数据的绑定。

从上边的截图可以看出,每个MonthView的日期数据应该由上个月的后0~6天、当前月的天数和下个月的前0~6天组成。首先计算出当前月有多少天,这个简单,以及根据年月算出当前月的第一天是星期几:

public static int getFirstWeekOfMonth(int year, int month) { Calendar calendar = Calendar.getInstance(); calendar.set(year, month, 1); return calendar.get(Calendar.DAY_OF_WEEK) - 1; }

返回0代表周日,1~6代表周一到周六,以上边的截图为例,可以知道2017年5月的第一天是周一:week = getFirstWeekOfMonth(2017, 5-1),按照如下伪码则可计算出包含的上个月的日期:

for (int i = 0; i < week; i++) { ld = 上个月天数 - week + 1 + i; }

至于包含的下个月的日期和当前MonthView显示的行数有关,如果
当前月的天数+week可以被7整除则不需要包含下月日期,否则需要计算包含的下月日期,伪码如下:

for (int i = 0; i < 7 * 显示的行数 - 当月天数 - week; i++) { nd = i + 1; }

这样需要的日期数据就计算完了,详细的算法可参考源码。

总页数应由日历的起始年月得到,其实就是确定ViewPager的总页数,这样好理解点。可按照如下方法计算:

count = (dateEnd[0] - dateStart[0]) * 12 + dateEnd[1] - dateStart[1] + 1

其中dateStartdateEnd是包含日历开始年月和结束年月的数组。这个count也是CalendarPagerAdapter必须的。

PagerAdapter有个instantiateItem()方法:

public Object instantiateItem(ViewGroup container, int position) { return instantiateItem container, position); }

来创建ViewPager的每一页,所以日历每一页也是在这里创建的,也就是MonthView,这里有个关键的点就是根据
positon
参数推算出日历每一页对应的年月,然后通过年月计算出当前MonthView需要的日期数据。如何根据position推算出年月呢?

public static int[] positionToDate(int position, int startY, int startM) { int year = position / 12 + startY; int month = position % 12 + startM; if (month > 12) { month = month % 12; year = year + 1; } return new int[]{year, month}; }

其中startY、startM代表日历的其实年月。有了对应的年月就可以用第二点中的方式计算日期数据,然后填充到MothView中。

前边已经提到了,MonthView继承ViewGroup,也就是日历的每一页,接收到日期数据后,在MonthView中根据数据构造对应的日期View,然后添加View到MonthView中,最后通过onMeasureonLayout确定每个View最终大小和位置。到这里运行一个ViewPager的基本条件就满足了,在上边提到的instantiateItem()方法中完成MothView的初始化:

public Object instantiateItem(ViewGroup container, int position) { MonthView view = new MonthView(container.getContext; //根据position计算对应年、月 int[] date = CalendarUtil.positionToDate(position, dateStart[0], dateStart[1]); view.setDateList(CalendarUtil.getMonthDate(date[0], date[1]), SolarUtil.getMonthDays(date[0], date[1])); container.addView; return view; }

这里只保留了核心的代码,当日历切换月份时,会自动根据position计算出对应月份的日期数据,然后传给MonthView,最后将MonthView添加到ViewPager中。

按照目前的设定,当选择当前月的某天后,然后切换月份,新的月份中会找到上次选中的日期,并标记为选中状态,如果找不到则选中新月份的最后一天。其实逻辑很简单,关键是如何在新月份中找到相应的日期并选中。首先记录上次选中的日期,由于ViewPager默认会缓存两页,再加上当前页共三页,在CalendarPagerAdapter中根据position保存三页缓存,当ViewPager切换到某一页后会执行如下回调:

addOnPageChangeListener(new SimpleOnPageChangeListener() { @Override public void onPageSelected(int position) { } });

onPageSelected(int
position)
方法中通过position从缓存中拿到对应的MonthView,也是是切换到的页,这样就能在MonthView中根据记录的日期找到对应的子日期View,然后更改为选中状态。

一个理想的多选功能应该是在当前月份选中多个日期后,切换到其它月份,之后回到有选中日期的月份依然能够标记出选中的日期,因为ViewPager有默认的三页缓存,所以在当前月份切换到上月或下月不会有什么问题,但如果切换到前几个月或后几个月,再回到有选中日期的月份,由于之前缓存的页面已经被销毁重建,所以选中的月份也就看不到了。我们的日期点击事件在MonthView中,当每次点击选中时我们需要记录对应年月选中的日期,取消选中时要从记录中删除对应日期,怎么保存呢?在CalendarView类中我们定义一个SparseArray

SparseArray<HashSet<Integer>> chooseDate = new SparseArray<>()

其中的HashSet就是指定年月选中的日期,按照我们的规则设定不同年月转换得到的position是唯一对应的,所以我们用position作为SparseArray的key,最后在CalendarView中接收选中或取消选中的操作:

public void setChooseDate(int day, boolean flag, int position) { if (position == -1) { position = currentPosition; } HashSet<Integer> days = chooseDate.get; if  { if (days == null) { days = new HashSet<>(); chooseDate.put(position, days); } days.add; positions.add; } else { days.remove; } }

之后就是在月份切换过程中,根据保存的日期数据刷新对应的MonthView,实现选中状态的恢复,这个和第六点类似。

要跳转到指定日期,首先要根据日期的年月计算出目标MonthView在日历中的position:

public static int dateToPosition(int year, int month, int startY, int startM) { return (year - startY) * 12 + month - startM; }

ViewPager有一个setCurrentItem(int item, boolean smoothScroll)方法,这样就能跳转到position对应的MonthView,然后结合第六点的方法选中对应的日期View。这样跳转到日历设定日期范围内的任意一天都是没问题的。

CalendarView提供的自定义属性如下:

属性名 格式 描述 默认值
choose_type enum 设置单选、多选 single
show_lunar boolean 是否显示农历 true
show_last_next boolean 是否在MonthView显示上月和下月日期 true
show_holiday boolean 是否显示节假日 true
show_term boolean 是否显示节气 true
switch_choose boolean 单选时切换月份,是否选中上次的日期 true
solar_color color 阳历日期的颜色
solar_size integer 阳历的日期尺寸 14
lunar_color color 农历的日期颜色
lunar_size integer 农历的日期尺寸 8
holiday_color color 节假日、节气的颜色
choose_color color 选中的日期颜色
day_bg reference 选中的日期背景

CalendarView相关方法:

方法名 描述
setInitDate(String date) 设置日历的初始显示年月
setStartEndDate(String startDate, String endDate) 设置日历开始、结束年月
setDisableStartEndDate(String startDate, String endDate) 设置日历的禁用日期范围(小于startDate、大于endDate禁用)
setSpecifyMap(HashMap<String, String> map) 将显示农历的区域替换成指定文字
setSingleDate(String date) 设置单选时初始选中的日期(不设置则不默认选中)
getSingleDate() 得到单选时选中的日期
setMultiDate(List<String> dates) 设置多选时默认选中的日期集合
getMultiDate() 得到多选时选中的全部日期
toSpecifyDate(int year, int month, int day) 单选时跳转到指定年月日
setOnCalendarViewAdapter(int layoutId, CalendarViewAdapter adapter) 设置自定义日期item样式
init() 日期初始化(以上属性配置完后调用)
setOnPagerChangeListener(OnPagerChangeListener listener) 设置月份切换回调
setOnSingleChooseListener(OnSingleChooseListener listener) 设置单选回调
setOnMultiChooseListener(OnMultiChooseListener listener) 设置多选回调
today() 单选时跳转到今天
nextMonth() 跳转到下个月
lastMonth() 跳转到上个月
nextYear() 跳转到下一年的当前月
lastYear() 跳转到上一年的当前月
toStart() 跳转到日历的开始年月
toEnd() 跳转到日历的结束年月
CalendarUtil.getCurrentDate() 获得当前日期

默认的日期布局是阳历、阴历垂直排列,节假日会覆盖在农历上显示,这个从上边的静态截图可以看出。如果要使用其它的排列方式,例如水平排列等,就需要提供一个自定的layout(但目前只支持两个TextView显示)。例如:

calendarView.setOnCalendarViewAdapter(R.layout.item_layout, new CalendarViewAdapter() { @Override public TextView[] convertView(View view, DateBean date) { TextView solarDay =  view.findViewById(R.id.solar_day); TextView lunarDay =  view.findViewById(R.id.lunar_day); return new TextView[]{solarDay, lunarDay}; } });

给CalendarView绑定一个接口,传入lauoyt,然后返回一个代表阳历和农历的TextView数组。

我们将日期和星期的显示功能分割开了,所以CalendarView并不负责星期的显示,WeekView是星期显示的自定义View,从周日开始依次是周一到周六,可通过自定义属性来配置星期的显示文字,以及文字的颜色、尺寸,这个还是相对简单,具体可见Github中的使用介绍。

这里我们只介绍了日历的基本实现原理,和一些关键的点,其实这种实现方式相对还是比较简单的,容易理解,当然难免有不足的地方,后边根据需要再逐步完善和扩展吧。尽管Github上有许多现成的Calendar,但自己动手实现一个还是收获满满,一个看起来简单的东西,只有亲自尝试了才能体会到其中的滋味,最后希望对大家有所帮助吧!

2 初始化关于绘制控件的相关参数

如字体的颜色、尺寸,控件各个部分尺寸。

1.3 集成

3 初始化关于逻辑的相关参数

对于日历来说,需要能够判断对应于当前的年月,日历中的每个单元格是否合法,以及若合法,其表示的day的值是多少。未设定年月之前先用当前时间来初始化。实现如下。

/**
 * calculate the values of date[] and the legal range of index of date[]
 */
private void initial() {
    int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
    int monthStart = -1;
    if(dayOfWeek >= 2 && dayOfWeek <= 7){
        monthStart = dayOfWeek - 2;
    }else if(dayOfWeek == 1){
        monthStart = 6;
    }
    curStartIndex = monthStart;
    date[monthStart] = 1;
    int daysOfMonth = daysOfCurrentMonth();
    for (int i = 1; i < daysOfMonth; i++) {
        date[monthStart + i] = i + 1;
    }
    curEndIndex = monthStart + daysOfMonth;
    if(mode == Constant.MODE_SHOW_DATA_OF_THIS_MONTH){
        Calendar tmp = Calendar.getInstance();
        todayIndex = tmp.get(Calendar.DAY_OF_MONTH) + monthStart - 1;
    }
}

其中date[]是一个整型数组,长度为42,因为一个日历最多需要6行来显示(6*7=42),curStartIndex和curEndIndex决定了date[]数组的合法下标区间,即前者表示该月的第一天在date[]数组的下标,后者表示该月的最后一天在date[]数组的下标。

JCenter方式

第一步,添加至工程的build.gradle文件中

repositories {
     jcenter()
}

第二步,添加至module的build.gradle文件中

compile 'com.justkiddingbaby:vercodeedittext:1.0.0'

4 绑定了一个OnTouchListener监听器

监听控件的触摸事件。

1.4 属性说明

属性 介绍 取值
figures 验证码位数 integer
verCodeMargin 每个验证码的间隔 dimension
bottomLineSelectedColor 底线选择状态下的颜色 reference
bottomLineNormalColor 底线未选中状态下的颜色 reference
bottomLineHeight 底线高度 dimension
selectedBackgroundColor 选中的背景颜色 reference

onMeasure方法

该方法对控件的宽和高进行测量。CalendarView覆盖了View类的onMeasure()方法,因为某个月的第一天可能是星期一到星期日的任何一个,而且每个月的天数不尽相同,因此日历控件的行数会有多变化,也导致控件的高度会有变化。因此需要根据当前的年月计算控件显示的高度(宽度设为屏幕宽度即可)。实现如下。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(screenWidth, View.MeasureSpec.EXACTLY);
    heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(measureHeight(), View.MeasureSpec.EXACTLY);
    setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

其中screenWidth是构造函数中已经获取的屏幕宽度,measureHeight()则是根据年月计算控件所需要的高度。实现如下,已经写了非常详细的注释。

/**
 * calculate the total height of the widget
 */
private int measureHeight(){
    /**
     * the weekday of the first day of the month, Sunday's result is 1 and Monday 2 and Saturday 7, etc.
     */
    int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
    /**
     * the number of days of current month
     */
    int daysOfMonth = daysOfCurrentMonth();
    /**
     * calculate the total lines, which equals to 1 (head of the calendar) + 1 (the first line) + n/7 + (n%7==0?0:1)
     * and n means numberOfDaysExceptFirstLine
     */
    int numberOfDaysExceptFirstLine = -1;
    if(dayOfWeek >= 2 && dayOfWeek <= 7){
        numberOfDaysExceptFirstLine = daysOfMonth - (8 - dayOfWeek + 1);
    }else if(dayOfWeek == 1){
        numberOfDaysExceptFirstLine = daysOfMonth - 1;
    }
    int lines = 2 + numberOfDaysExceptFirstLine / 7 + (numberOfDaysExceptFirstLine % 7 == 0 ? 0 : 1);
    return (int) (cellHeight * lines);
}

1.5 使用

onDraw方法

该方法实现对控件的绘制。其中drawCircle给定圆心和半径绘制圆,drawText是给定一个坐标x,y绘制文字。

/**
 * render
 */
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    /**
     * render the head
     */
    float baseline = RenderUtil.getBaseline(0, cellHeight, weekTextPaint);
    for (int i = 0; i < 7; i++) {
        float weekTextX = RenderUtil.getStartX(cellWidth * i + cellWidth * 0.5f, weekTextPaint, weekText[i]);
        canvas.drawText(weekText[i], weekTextX, baseline, weekTextPaint);
    }
    if(mode == Constant.MODE_CALENDAR){
        for (int i = curStartIndex; i < curEndIndex; i++) {
            drawText(canvas, i, textPaint, "" + date[i]);
        }
    }else if(mode == Constant.MODE_SHOW_DATA_OF_THIS_MONTH){
        for (int i = curStartIndex; i < curEndIndex; i++) {
            if(i < todayIndex){
                if(data[date[i]]){
                    drawCircle(canvas, i, bluePaint, cellHeight * 0.37f);
                    drawCircle(canvas, i, whitePaint, cellHeight * 0.31f);
                    drawCircle(canvas, i, blackPaint, cellHeight * 0.1f);
                }else{
                    drawCircle(canvas, i, grayPaint, cellHeight * 0.1f);
                }
            }else if(i == todayIndex){
                if(data[date[i]]){
                    drawCircle(canvas, i, bluePaint, cellHeight * 0.37f);
                    drawCircle(canvas, i, whitePaint, cellHeight * 0.31f);
                    drawCircle(canvas, i, blackPaint, cellHeight * 0.1f);
                }else{
                    drawCircle(canvas, i, grayPaint, cellHeight * 0.37f);
                    drawCircle(canvas, i, whitePaint, cellHeight * 0.31f);
                    drawCircle(canvas, i, blackPaint, cellHeight * 0.1f);
                }
            }else{
                drawText(canvas, i, textPaint, "" + date[i]);
            }
        }
    }
}

需要说明的是,绘制文字时的这个x表示开始位置的x坐标(文字最左端),这个y却不是文字最顶端的y坐标,而应传入文字的baseline。因此若想要将文字绘制在某个区域居中部分,需要经过一番计算。本项目将其封装在了RenderUtil类中。实现如下。

/**
 * get the baseline to draw between top and bottom in the middle
 */
public static float getBaseline(float top, float bottom, Paint paint){
    Paint.FontMetrics fontMetrics = paint.getFontMetrics();
    return (top + bottom - fontMetrics.bottom - fontMetrics.top) / 2;
}
/**
 *  get the x position to draw around the middle
 */
public static float getStartX(float middle, Paint paint, String text){
    return middle - paint.measureText(text) * 0.5f;
}

在布局中使用

  <com.jkb.vcedittext.VerificationCodeEditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="number"
        android:text="123"
        android:textColor="@color/colorPrimary"
        android:textSize="40sp"
        app:bottomLineHeight="2dp"
        app:bottomLineNormalColor="@color/gravy_light"
        app:bottomLineSelectedColor="@color/colorAccent"
        app:figures="4"
        app:selectedBackgroundColor="@color/colorPrimary_alpha33"
        app:verCodeMargin="10dp" />

自定义监听器

控件需要自定义一些监听器,以在控件发生了某种行为或交互时提供一个外部接口来处理一些事情。本项目的CalendarView提供了两个接口,OnRefreshListener和OnItemClickListener,均为自定义的接口。onItemClick只传了day一个参数,年和月可通过CalendarView对象的getYear和getMonth方法获取。

interface OnItemClickListener{
    void onItemClick(int day);
}
interface OnRefreshListener{
    void onRefresh();
}

先介绍一下两种mode,CalendarView提供了两种模式,第一种普通日历模式,日历每个位置简单显示了day这个数字,第二种本月计划完成情况模式,绘制了一些图形来表示本月的某一天是否完成了计划(模仿自悦跑圈,用一个圈表示本日跑了步)。

OnRefreshListener用于刷新日历数据后进行回调。两种模式定义了不同的刷新方法,都对OnRefreshListener进行了回调。refresh0用于第一种模式,refresh1用于第二种模式。

/**
 * used for MODE_CALENDAR
 * legal values of month: 1-12
 */
@Override
public void refresh0(int year, int month) {
    if(mode == Constant.MODE_CALENDAR){
        selectedYear = year;
        selectedMonth = month;
        calendar.set(Calendar.YEAR, selectedYear);
        calendar.set(Calendar.MONTH, selectedMonth - 1);
        calendar.set(Calendar.DAY_OF_MONTH, 1);
        initial();
        invalidate();
        if(onRefreshListener != null){
            onRefreshListener.onRefresh();
        }
    }
}

/**
 * used for MODE_SHOW_DATA_OF_THIS_MONTH
 * the index 1 to 31(big month), 1 to 30(small month), 1 - 28(Feb of normal year), 1 - 29(Feb of leap year)
 * is better to be accessible in the parameter data, illegal indexes will be ignored with default false value
 */
@Override
public void refresh1(boolean[] data) {
    /**
     * the month and year may change (eg. Jan 31st becomes Feb 1st after refreshing)
     */
    if(mode == Constant.MODE_SHOW_DATA_OF_THIS_MONTH){
        calendar = Calendar.getInstance();
        selectedYear = calendar.get(Calendar.YEAR);
        selectedMonth = calendar.get(Calendar.MONTH) + 1;
        calendar.set(Calendar.DAY_OF_MONTH, 1);
        for(int i = 1; i <= daysOfCurrentMonth(); i++){
            if(i < data.length){
                this.data[i] = data[i];
            }else{
                this.data[i] = false;
            }
        }
        initial();
        invalidate();
        if(onRefreshListener != null){
            onRefreshListener.onRefresh();
        }
    }
}

OnItemClickListener用于响应点击了日历上的某一天这个事件。点击的判断在onTouch方法中实现。实现如下。在同一位置依次接收到ACTION_DOWNACTION_UP两个事件才认为完成了点击。

@Override
public boolean onTouch(View v, MotionEvent event) {
    float x = event.getX();
    float y = event.getY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            if(coordIsCalendarCell(y)){
                int index = getIndexByCoordinate(x, y);
                if(isLegalIndex(index)) {
                    actionDownIndex = index;
                }
            }
            break;
        case MotionEvent.ACTION_UP:
            if(coordIsCalendarCell(y)){
                int actionUpIndex = getIndexByCoordinate(x, y);
                if(isLegalIndex(actionUpIndex)){
                    if(actionDownIndex == actionUpIndex){
                        actionDownIndex = -1;
                        int day = date[actionUpIndex];
                        if(onItemClickListener != null){
                            onItemClickListener.onItemClick(day);
                        }
                    }
                }
            }
            break;
    }
    return true;
}

二、 代码分析

下面让我们来一步一步对该项目的源码进行分析.

关于该日历控件

日历控件demo效果图如下,分别为普通日历模式和本月计划完成情况模式。

图片 5

图片 6

需要说明的是CalendarView控件部分只包括日历头与下面的日历,该控件上方的是其他控件,这里仅用作展示一种使用方法,你完全可以自定义这部分的样式。

此外,日历头的文字支持多种选择,比如周一有四种表示:一、周一、星期一、Mon。此外还有其他一些控制样式的接口,详情见github项目主页。

github项目主页: Android-CalendarView

2.1 需求分析

这是一个验证码输入控件,根据demo演示分析得出,需要支持的属性有:
1、作为EditText控件使用(继承EditText)
2、自定义验证码输入位数,并且支持每位验证码的margin自定义
3、有背景的选择变化和底部下划线的输入变化,并且支持颜色自定义
4、高度自适配,wrap_content时高度和每位验证码宽度一样
5、支持输入内容变化的监听

2.2 声明接口和属性

本节将对2.1中的需求进行分析,并定义出属于该控件独有的属性。

2.2.1 属性声明

在values目录下创建attrs.xml文件(如果无则创建),这是为了支持在xml中直接配置控件的相关属性。

   <declare-styleable name="VerCodeEditText">
        <!--验证码位数-->
        <attr name="figures" format="integer" />
        <!--验证码每位之间间隔-->
        <attr name="verCodeMargin" format="dimension" />
        <!--底线选中状态下的颜色-->
        <attr name="bottomLineSelectedColor" format="reference" />
        <!--底线未选中状态下的颜色-->
        <attr name="bottomLineNormalColor" format="reference" />
        <!--底线高度-->
        <attr name="bottomLineHeight" format="dimension" />
        <!--验证码选中状态下的背景颜色-->
        <attr name="selectedBackgroundColor" format="reference" />
    </declare-styleable>

2.2.2 接口声明

新建一个接口类VerificationAction,(这是为了支持使用Java代码配置控件的属性)。

interface VerificationAction {
    /**
     * 设置位数
     */
    void setFigures(int figures);
    /**
     * 设置验证码之间的间距
     */
    void setVerCodeMargin(int margin);
    /**
     * 设置底部选中状态的颜色
     */
    void setBottomSelectedColor(@ColorRes int bottomSelectedColor);
    /**
     * 设置底部未选中状态的颜色
     */
    void setBottomNormalColor(@ColorRes int bottomNormalColor);
    /**
     * 设置选择的背景色
     */
    void setSelectedBackgroundColor(@ColorRes int selectedBackground);
    /**
     * 设置底线的高度
     */
    void setBottomLineHeight(int bottomLineHeight);
    /**
     * 设置当验证码变化时候的监听器
     */
    void setOnVerificationCodeChangedListener(OnVerificationCodeChangedListener listener);
    /**
     * 验证码变化时候的监听事件
     */
    interface OnVerificationCodeChangedListener {
        /**
         * 当验证码变化的时候
         */
        void onVerCodeChanged(CharSequence s, int start, int before, int count);
        /**
         * 输入完毕后的回调
         */
        void onInputCompleted(CharSequence s);
    }
}

根据上述接口和属性的定义内容可以看出,接口的内容和完全和属性向对应,6个属性对应6个set方法,另外接口中单独定义了验证码输入内容变化的监听接口。

2.3 控件自定义

接下来我们就一步步地完成这个控件。

2.3.1 创建类,并声明相关属性并继承EditText和实现属性接口VerificationAction

public class VerificationCodeEditText extends android.support.v7.widget.AppCompatEditText implements VerificationAction{
    //attrs
    private int mFigures;//需要输入的位数
    private int mVerCodeMargin;//验证码之间的间距
    private int mBottomSelectedColor;//底部选中的颜色
    private int mBottomNormalColor;//未选中的颜色
    private float mBottomLineHeight;//底线的高度
    private int mSelectedBackgroundColor;//选中的背景颜色

    public VerificationCodeEditText(Context context) {
        this(context, null);
    }
    public VerificationCodeEditText(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public VerificationCodeEditText(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initAttrs(attrs);
    }
    /**
     * 初始化属性
     */
    private void initAttrs(AttributeSet attrs) {
        TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.VerCodeEditText);
        mFigures = ta.getInteger(R.styleable.VerCodeEditText_figures, 4);
        mVerCodeMargin = (int) ta.getDimension(R.styleable.VerCodeEditText_verCodeMargin, 0);
        mBottomSelectedColor = ta.getColor(R.styleable.VerCodeEditText_bottomLineSelectedColor,
                getCurrentTextColor());
        mBottomNormalColor = ta.getColor(R.styleable.VerCodeEditText_bottomLineNormalColor,
                getColor(android.R.color.darker_gray));
        mBottomLineHeight = ta.getDimension(R.styleable.VerCodeEditText_bottomLineHeight,
                dp2px(5));
        mSelectedBackgroundColor = ta.getColor(R.styleable.VerCodeEditText_selectedBackgroundColor,
                getColor(android.R.color.darker_gray));
        ta.recycle();
    }
    ...set方法
}

上述代码没啥好说的,只是定义了类的基础机构,关于View的三个构造方法的区别,可以参考文章
Android自定义View构造函数详解

2.3.2 测量宽高

测量View的宽高需要重写onMeasure(int,int)方法,因为本控件是继承EditText的,而EditText默认的测量和本需求不同,所以需要完全自己实现该方法,点开super.onMeasure(int,int)方法后,发现最终调用的方法是setMeasuredDimension(int,
int)
,该方法的代码如下:

  @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthResult = 0, heightResult = 0;
        //最终的宽度
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        if (widthMode == MeasureSpec.EXACTLY) {
            widthResult = widthSize;
        } else {
            widthResult = getScreenWidth(getContext());//默认为屏幕的宽度
        }
        //每位验证码的宽度
        mEachRectLength = (widthResult - (mVerCodeMargin * (mFigures - 1))) / mFigures;
        //最终的高度
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        if (heightMode == MeasureSpec.EXACTLY) {
            heightResult = heightSize;
        } else {
            heightResult = mEachRectLength;
        }
        setMeasuredDimension(widthResult, heightResult);
    }

该控件的宽高测量比较简单,相信上述代码还是比较明了的,在此就不再做相关解释了。

2.3.3 绘制

从demo演示中可以看出,该控件并没有什么复杂的场景,只是计算矩形的宽度并绘制矩形、文字、下划线而已,绘制View需要重写onDraw(Canvas)方法。
在重写该方法之前,我们需要定义绘制时候的画笔并进行初始化,一共要绘制的内容有三个,矩形、文字、下划线,而矩形和下划线各自都有两种状态,而文字直接可以用Canvas来绘制,则需要的画笔一共有四个~

    private Paint mSelectedBackgroundPaint;
    private Paint mNormalBackgroundPaint;
    private Paint mBottomSelectedPaint;
    private Paint mBottomNormalPaint;

然后进行画笔的初始化操作,画笔的初始化可以在初始化属性之后调用。

    private void initPaint() {
        mSelectedBackgroundPaint = new Paint();
        mSelectedBackgroundPaint.setColor(mSelectedBackgroundColor);
        mNormalBackgroundPaint = new Paint();
        mNormalBackgroundPaint.setColor(getColor(android.R.color.transparent));

        mBottomSelectedPaint = new Paint();
        mBottomNormalPaint = new Paint();
        mBottomSelectedPaint.setColor(mBottomSelectedColor);
        mBottomNormalPaint.setColor(mBottomNormalColor);
        mBottomSelectedPaint.setStrokeWidth(mBottomLineHeight);
        mBottomNormalPaint.setStrokeWidth(mBottomLineHeight);
    }

接下来,我们需要根据验证码的位数绘制不同的矩形颜色和下划线颜色,定义一个全局变量mCurrentPosition在绘制的时候使用,然后重写onDraw(Canvas)方法。

    @Override
    protected void onDraw(Canvas canvas) {
        mCurrentPosition = getText().length(); //获取验证码位数
      //支持padding属性
        int width = mEachRectLength - getPaddingLeft() - getPaddingRight();
        int height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
        //循环绘制
        for (int i = 0; i < mFigures; i++) {
            canvas.save();
            int start = width * i + i * mVerCodeMargin;
            int end = width + start;
            //画一个矩形
            if (i == mCurrentPosition) {//选中的下一个状态
                canvas.drawRect(start, 0, end, height, mSelectedBackgroundPaint);
            } else {
                canvas.drawRect(start, 0, end, height, mNormalBackgroundPaint);
            }
            canvas.restore();
        }
        //绘制文字
        String value = getText().toString();
        for (int i = 0; i < value.length(); i++) {
            canvas.save();
            int start = width * i + i * mVerCodeMargin;
            float x = start + width / 2;
            TextPaint paint = getPaint();
            paint.setTextAlign(Paint.Align.CENTER);
            paint.setColor(getCurrentTextColor());
            Paint.FontMetrics fontMetrics = paint.getFontMetrics();
            float baseline = (height - fontMetrics.bottom + fontMetrics.top) / 2
                    - fontMetrics.top;
            canvas.drawText(String.valueOf(value.charAt(i)), x, baseline, paint);
            canvas.restore();
        }
        //绘制底线
        for (int i = 0; i < mFigures; i++) {
            canvas.save();
            float lineY = height - mBottomLineHeight / 2;
            int start = width * i + i * mVerCodeMargin;
            int end = width + start;
            if (i < mCurrentPosition) {
                canvas.drawLine(start, lineY, end, lineY, mBottomSelectedPaint);
            } else {
                canvas.drawLine(start, lineY, end, lineY, mBottomNormalPaint);
            }
            canvas.restore();
        }
    }

以上代码中绘制矩形和下划线并没有什么复杂的,主要是在绘制文字的时候需要注意文字的居中问题,关于该问题可以参考文章Android
Canvas
drawText实现中文垂直居中

2.3.4 防止输入溢出

在输入验证码之后,我们需要对其进行边界控制,默认会一直向后绘制,这样在超出验证码位数后点击删除则不会第一时间删除最后一位验证码,这也是为了逻辑的完整性,我们需要对输入内容进行监听,则需要调用方法addTextChangedListener(),该方法可以在构造方法中进行调用。

  @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        mCurrentPosition = getText().length();
        postInvalidate();
    }
    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {
        mCurrentPosition = getText().length();
        postInvalidate();
        if (onCodeChangedListener != null) {
            onCodeChangedListener.onVerCodeChanged(getText(), start, before, count);
        }
    }
    @Override
    public void afterTextChanged(Editable s) {
        mCurrentPosition = getText().length();
        postInvalidate();
        if (getText().length() == mFigures) {
            if (onCodeChangedListener != null) {
                onCodeChangedListener.onInputCompleted(getText());
            }
        } else if (getText().length() > mFigures) {
            getText().delete(mFigures, getText().length());//防止输入溢出
        }
    }

以上代码在防止输入溢出的同时监听了验证码输入内容变化时候的监听。

以上是对该控件的代码分析过程,因为控件比较简单,没有进行详细的说明,要是有问题请留言,同时该项目已经上传至github中,项目地址VercideEditText

发表评论

电子邮件地址不会被公开。 必填项已用*标注