Android自定義日歷控件實(shí)例詳解
為什么要自定義控件
有時(shí),原生控件不能滿足我們對(duì)于外觀和功能的需求,這時(shí)候可以自定義控件來定制外觀或功能;有時(shí),原生控件可以通過復(fù)雜的編碼實(shí)現(xiàn)想要的功能,這時(shí)候可以自定義控件來提高代碼的可復(fù)用性。
如何自定義控件
下面我通過我在github上開源的Android-CalendarView項(xiàng)目為例,來介紹一下自定義控件的方法。該項(xiàng)目中自定義的控件類名是CalendarView。這個(gè)自定義控件覆蓋了一些自定義控件時(shí)常需要重寫的一些方法。
構(gòu)造函數(shù)
為了支持本控件既能使用xml布局文件聲明,也可在java文件中動(dòng)態(tài)創(chuàng)建,實(shí)現(xiàn)了三個(gè)構(gòu)造函數(shù)。
public CalendarView(Context context, AttributeSet attrs, int defStyle); public CalendarView(Context context, AttributeSet attrs); public CalendarView(Context context);
可以在參數(shù)列表最長的第一個(gè)方法中寫上你的初始化代碼,下面兩個(gè)構(gòu)造函數(shù)調(diào)用第一個(gè)即可。
public CalendarView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CalendarView(Context context) {
this(context, null);
}
那么在構(gòu)造函數(shù)中做了哪些事情呢?
1 讀取自定義參數(shù)
讀取布局文件中可能設(shè)置的自定義屬性(該日歷控件僅自定義了一個(gè)mode參數(shù)來表示日歷的模式)。代碼如下。只要在attrs.xml中自定義了屬性,就會(huì)自動(dòng)創(chuàng)建一些R.styleable下的變量。
mode = typedArray.getInt(R.styleable.CalendarView_mode, Constant.MODE_SHOW_DATA_OF_THIS_MONTH);
然后附上res目錄下values目錄下的attrs.xml文件,需要在此文件中聲明你自定義控件的自定義參數(shù)。
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="CalendarView"> <attr name="mode" format="integer" /> </declare-styleable> </resources>
2 初始化關(guān)于繪制控件的相關(guān)參數(shù)
如字體的顏色、尺寸,控件各個(gè)部分尺寸。
3 初始化關(guān)于邏輯的相關(guān)參數(shù)
對(duì)于日歷來說,需要能夠判斷對(duì)應(yīng)于當(dāng)前的年月,日歷中的每個(gè)單元格是否合法,以及若合法,其表示的day的值是多少。未設(shè)定年月之前先用當(dāng)前時(shí)間來初始化。實(shí)現(xiàn)如下。
/**
* 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[]是一個(gè)整型數(shù)組,長度為42,因?yàn)橐粋€(gè)日歷最多需要6行來顯示(6*7=42),curStartIndex和curEndIndex決定了date[]數(shù)組的合法下標(biāo)區(qū)間,即前者表示該月的第一天在date[]數(shù)組的下標(biāo),后者表示該月的最后一天在date[]數(shù)組的下標(biāo)。
4 綁定了一個(gè)OnTouchListener監(jiān)聽器
監(jiān)聽控件的觸摸事件。
onMeasure方法
該方法對(duì)控件的寬和高進(jìn)行測(cè)量。CalendarView覆蓋了View類的onMeasure()方法,因?yàn)槟硞€(gè)月的第一天可能是星期一到星期日的任何一個(gè),而且每個(gè)月的天數(shù)不盡相同,因此日歷控件的行數(shù)會(huì)有多變化,也導(dǎo)致控件的高度會(huì)有變化。因此需要根據(jù)當(dāng)前的年月計(jì)算控件顯示的高度(寬度設(shè)為屏幕寬度即可)。實(shí)現(xiàn)如下。
@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是構(gòu)造函數(shù)中已經(jīng)獲取的屏幕寬度,measureHeight()則是根據(jù)年月計(jì)算控件所需要的高度。實(shí)現(xiàn)如下,已經(jīng)寫了非常詳細(xì)的注釋。
/**
* 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);
}
onDraw方法
該方法實(shí)現(xiàn)對(duì)控件的繪制。其中drawCircle給定圓心和半徑繪制圓,drawText是給定一個(gè)坐標(biāo)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]);
}
}
}
}
需要說明的是,繪制文字時(shí)的這個(gè)x表示開始位置的x坐標(biāo)(文字最左端),這個(gè)y卻不是文字最頂端的y坐標(biāo),而應(yīng)傳入文字的baseline。因此若想要將文字繪制在某個(gè)區(qū)域居中部分,需要經(jīng)過一番計(jì)算。本項(xiàng)目將其封裝在了RenderUtil類中。實(shí)現(xiàn)如下。
/**
* 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;
}
自定義監(jiān)聽器
控件需要自定義一些監(jiān)聽器,以在控件發(fā)生了某種行為或交互時(shí)提供一個(gè)外部接口來處理一些事情。本項(xiàng)目的CalendarView提供了兩個(gè)接口,OnRefreshListener和OnItemClickListener,均為自定義的接口。onItemClick只傳了day一個(gè)參數(shù),年和月可通過CalendarView對(duì)象的getYear和getMonth方法獲取。
interface OnItemClickListener{
void onItemClick(int day);
}
interface OnRefreshListener{
void onRefresh();
}
先介紹一下兩種mode,CalendarView提供了兩種模式,第一種普通日歷模式,日歷每個(gè)位置簡(jiǎn)單顯示了day這個(gè)數(shù)字,第二種本月計(jì)劃完成情況模式,繪制了一些圖形來表示本月的某一天是否完成了計(jì)劃(模仿自悅跑圈,用一個(gè)圈表示本日跑了步)。
OnRefreshListener用于刷新日歷數(shù)據(jù)后進(jìn)行回調(diào)。兩種模式定義了不同的刷新方法,都對(duì)OnRefreshListener進(jìn)行了回調(diào)。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用于響應(yīng)點(diǎn)擊了日歷上的某一天這個(gè)事件。點(diǎn)擊的判斷在onTouch方法中實(shí)現(xiàn)。實(shí)現(xiàn)如下。在同一位置依次接收到ACTION_DOWN和ACTION_UP兩個(gè)事件才認(rèn)為完成了點(diǎn)擊。
@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;
}
關(guān)于該日歷控件
日歷控件demo效果圖如下,分別為普通日歷模式和本月計(jì)劃完成情況模式。


需要說明的是CalendarView控件部分只包括日歷頭與下面的日歷,該控件上方的是其他控件,這里僅用作展示一種使用方法,你完全可以自定義這部分的樣式。
此外,日歷頭的文字支持多種選擇,比如周一有四種表示:一、周一、星期一、Mon。此外還有其他一些控制樣式的接口,詳情見源碼:Android-CalendarView。
以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
Android多功能時(shí)鐘開發(fā)案例(實(shí)戰(zhàn)篇)
這篇文章主要為大家詳細(xì)介紹了Android多功能時(shí)鐘開發(fā)案例,開發(fā)了時(shí)鐘、鬧鐘、計(jì)時(shí)器和秒表,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-05-05
Android如何跳轉(zhuǎn)到應(yīng)用商店的APP詳情頁面
最近做項(xiàng)目遇到這樣的需求,要求從App內(nèi)部點(diǎn)擊按鈕或鏈接,跳轉(zhuǎn)到應(yīng)用商店的某個(gè)APP的詳情頁面,怎么實(shí)現(xiàn)此功能呢?下面小編給大家分享Android如何跳轉(zhuǎn)到應(yīng)用商店的APP詳情頁面,需要的朋友參考下2017-01-01
Android開發(fā)之進(jìn)度條ProgressBar的示例代碼
本篇文章主要介紹了Android開發(fā)之進(jìn)度條ProgressBar的示例代碼,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-03-03
Android框架Volley使用之Post請(qǐng)求實(shí)現(xiàn)方法
這篇文章主要介紹了Android框架Volley使用之Post請(qǐng)求實(shí)現(xiàn)方法,,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2019-05-05
Android 點(diǎn)擊editview以外位置實(shí)現(xiàn)隱藏輸入法
這篇文章主要介紹了Android 點(diǎn)擊editview以外位置實(shí)現(xiàn)隱藏輸入法的相關(guān)資料,需要的朋友可以參考下2017-06-06
Android檢測(cè)手機(jī)多點(diǎn)觸摸點(diǎn)數(shù)的方法
這篇文章主要為大家詳細(xì)介紹了Android檢測(cè)手機(jī)多點(diǎn)觸摸點(diǎn)數(shù)的方法,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-05-05
Android查看電池電量的方法(基于BroadcastReceiver)
這篇文章主要介紹了Android查看電池電量的方法,結(jié)合實(shí)例分析了Android使用BroadcastReceiver實(shí)現(xiàn)針對(duì)電池電量的查詢技巧,需要的朋友可以參考下2016-01-01
詳解Flutter中網(wǎng)絡(luò)框架dio的二次封裝
其實(shí)dio框架已經(jīng)封裝的很好了,但是在實(shí)戰(zhàn)項(xiàng)目中,為了項(xiàng)目可以統(tǒng)一管理,還是需要對(duì)dio框架進(jìn)行二次封裝。本文將詳細(xì)講解一下dio如何二次封裝,需要的可以參考一下2022-04-04
Android中使用Spinner實(shí)現(xiàn)下拉列表功能
Spinner是一個(gè)列表選擇框,會(huì)在用戶選擇后,展示一個(gè)列表供用戶進(jìn)行選擇。下面通過本文給大家實(shí)例詳解android中使用Spinner實(shí)現(xiàn)下拉列表功能,一起看看吧2017-04-04

