square開源日曆控件—CalendarPickerView源碼導讀

前言:公司前陣子更換了一個新的日曆控件,如絲般順滑。看了下發現,竟然出自大名鼎鼎的square!畢竟square出品,必屬精品的,所以研讀了一番,有了這篇文章。

控件的Git項目在此:android-times-square

整個項目結構比較簡單,代碼也不復雜:

這裏寫圖片描述

Demo效果圖:

這裏寫圖片描述

首先來認識下此日曆都由什麼構成,建議邊看下圖,邊看下面這段描述:

  1. 整個日曆 CalendarPickerView 繼承自 ListView

  2. 每個月份MonthView繼承自LinearLayout,裝了一個title和一個CalendarGridView

  3. CalendarGridView並不是繼承GridView,而是一個自定義ViewGroup,內部包含7個CalendarRowView。第一個Row包含7個TextView用於顯示星期,其餘6個Row(爲什麼有6個?因爲每個月最多可能有6行)各自包含7個CalendarCellView用於顯示每週的日期;

  4. CalendarRowView也繼承自ViewGroup,內含7個CalendarCellView。值得一提的是3和4這些控件組成都寫在MonthView的佈局文件:month.xml裏;

  5. CalendarCellView是該日曆控件的最基本單元,繼承自FrameLayout

這裏寫圖片描述

這個5個View就是日曆控件的構成,其餘類例如:MonthCellDescriptorMonthDescriptor都是一些輔助,這裏暫時不表。

下文在導讀源碼的同時還會分析下控件的設計技巧,爲什麼會這樣設計?如果讓我來做,我會怎麼做?瞭解自己跟大神之間的設計思想差異,我覺得這纔是閱讀源碼的精髓。

這裏我主要是想列出讓我覺得耳目一新的點,並分析實現,然後希望以後自己能學以致用!

首先,每個自定義View的實現都會有麻煩的渲染工作。而縱觀此控件所有相關View的源碼,渲染工作有條不紊的發佈給幾個控件,大家各司其職,而另一些View(例如CanlendarCellViewCanlendarRowView)中,只有對外的接口,如此清爽!

再想想我自己寫自定義View的時候,各種自定義屬性解析,onMeasureonDraw滿天飛,不行還得再來個onTouchEvent,然後便堆砌出幾千行代碼……

下面我將根據控件中一些類的作用,把它們比作軟件開發過程中的各個角色,便於大家理解。

產品經理:MonthAdapter + MonthView

MonthView做的工作很簡單:根據佈局生成日曆整體框架,再將MonthAdapter傳遞的“屬性”,進一步傳遞給所有的CanlendarCellView

上面提到的屬性(List<List<MonthCellDescriptor>> cells)是一系列狀態的集合,包括:是否高亮、是否選中、是否是今天,在不在所選日期範圍內等等,而這些屬性最終決定着渲染的外觀。

MonthAdapterCalendarPickerView 中。還記得嗎, CalendarPickerView 是一個ListView,所以MonthAdapter 也是一個普通ListViewAdapter,它長這樣:

@Override public View getView(int position, View convertView, ViewGroup parent) {
      MonthView monthView = (MonthView) convertView;
      if (monthView == null //
          || !monthView.getTag(R.id.day_view_adapter_class).equals(dayViewAdapter.getClass())) {
        monthView =
            MonthView.create(parent, inflater, weekdayNameFormat, listener, today, dividerColor,
                dayBackgroundResId, dayTextColorResId, titleTextColor, displayHeader,
                headerTextColor, decorators, locale, dayViewAdapter);
        monthView.setTag(R.id.day_view_adapter_class, dayViewAdapter.getClass());
      } else {
        monthView.setDecorators(decorators);
      }
      if (monthsReverseOrder) {
        position = months.size() - position - 1;
      }
      monthView.init(months.get(position), cells.getValueAtIndex(position), displayOnly,
          titleTypeface, dateTypeface);
      return monthView;
    }
  }

確實極其普通:MonthView.create(…)根據佈局文件month.xml 構建ViewmonthView.init(…)塞數據。months是所有日期數據,它的初始化在這:

    monthCounter.setTime(minCal.getTime());
    final int maxMonth = maxCal.get(MONTH);
    final int maxYear = minCal.get(YEAR);
    while ((monthCounter.get(MONTH) <= maxMonth // Up to, including the month.
        || monthCounter.get(YEAR) < maxYear) // Up to the year.
        && monthCounter.get(YEAR) < maxYear + 1) { // But not > next yr.
      Date date = monthCounter.getTime();
      MonthDescriptor month =
          new MonthDescriptor(monthCounter.get(MONTH), monthCounter.get(YEAR), date,
              monthNameFormat.format(date));
      cells.put(monthKey(month), getMonthCells(month, monthCounter));
      Logr.d("Adding month %s", month);
      months.add(month);
      monthCounter.add(MONTH, 1);
    }

這裏minCalminCal,即最小和最大日期都是用戶可以設置的。

如果把整個控件的渲染工作看作一項開發工作,MonthAdapterMonthView就是產品。現在產品來提需求了,我的刀呢?!

UI:CanlendarGridViewCanlendarRowView

我們先看下產品的“PRD”— month.xml

這裏寫圖片描述

結構很簡單,文章開頭已經簡單介紹過結構,下面我們直接看看CanlendarGridViewCanlendarRowView

二者的主要工作就是:測量和佈局,CanlendarGridView還順帶把日曆的網格給繪製了。同理,把渲染工作看做軟件開發的話,二者在整個開發工作中擔任的是UI!

先看下CanlendarGridView

@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    Logr.d("Grid.onMeasure w=%s h=%s", MeasureSpec.toString(widthMeasureSpec),
        MeasureSpec.toString(heightMeasureSpec));
    int widthMeasureSize = MeasureSpec.getSize(widthMeasureSpec);
    if (oldWidthMeasureSize == widthMeasureSize) {
      Logr.d("SKIP Grid.onMeasure");
      setMeasuredDimension(getMeasuredWidth(), getMeasuredHeight());
      return;
    }
    long start = System.currentTimeMillis();
    oldWidthMeasureSize = widthMeasureSize;
    int cellSize = widthMeasureSize / 7;
    // Remove any extra pixels since /7 is unlikely to give whole nums.
    widthMeasureSize = cellSize * 7;
    int totalHeight = 0;
    final int rowWidthSpec = makeMeasureSpec(widthMeasureSize, EXACTLY);
    final int rowHeightSpec = makeMeasureSpec(cellSize, EXACTLY);
    for (int c = 0, numChildren = getChildCount(); c < numChildren; c++) {
      final View child = getChildAt(c);
      if (child.getVisibility() == View.VISIBLE) {
        if (c == 0) { // It's the header: height should be wrap_content.
          measureChild(child, rowWidthSpec, makeMeasureSpec(cellSize, AT_MOST));
        } else {
          measureChild(child, rowWidthSpec, rowHeightSpec);
        }
        totalHeight += child.getMeasuredHeight();
      }
    }
    final int measuredWidth = widthMeasureSize + 2; // Fudge factor to make the borders show up.
    setMeasuredDimension(measuredWidth, totalHeight);
    Logr.d("Grid.onMeasure %d ms", System.currentTimeMillis() - start);
  }

代碼很長,但是很好理解,就是循環測量所有的Child並測量自己。

這裏的measureChild(child, rowWidthSpec, rowHeightSpec)最終會傳遞到子視圖—CanlendarRowViewonMeasure

另外,還記不記得本文開頭講結構的時候說過,CanlendarGridView有7個Child,而第一行是星期文字,所以它的高度用了AT_MOST模式,不懂的自行查閱資料,我們可以理解爲是wrap_content,這個很好理解。

比較有趣的一點是:

    int cellSize = widthMeasureSize / 7;
    // Remove any extra pixels since /7 is unlikely to give whole nums.
    widthMeasureSize = cellSize * 7;

看似多餘,其實不然。

因爲widthMeasureSizeint類型,除以7後會丟掉小數。如果沒有這一步的話,那我們CanlendarRowView的長度會比7個CanlendarCellView長一截。不得不感嘆設計人員心思縝密。

佈局過程沒啥可說的,就是簡單的傳遞:

@Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    long start = System.currentTimeMillis();
    top = 0;
    for (int c = 0, numChildren = getChildCount(); c < numChildren; c++) {
      final View child = getChildAt(c);
      final int rowHeight = child.getMeasuredHeight();
      child.layout(left, top, right, top + rowHeight);
      top += rowHeight;
    }
    Logr.d("Grid.onLayout %d ms", System.currentTimeMillis() - start);
  }

繪製網格的過程是以下兩個方法:

@Override protected void dispatchDraw(Canvas canvas) {
    super.dispatchDraw(canvas);
    final ViewGroup row = (ViewGroup) getChildAt(1);//第一行是TXT:週日、週一……
    int top = row.getTop();
    int bottom = getBottom();
    // Left side border.
    final int left = row.getChildAt(0).getLeft() + getLeft();
    canvas.drawLine(left + FLOAT_FUDGE, top, left + FLOAT_FUDGE, bottom, dividerPaint);

    // Each cell's right-side border.
    for (int c = 0; c < 7; c++) {
      float x = left + row.getChildAt(c).getRight() - FLOAT_FUDGE;
      canvas.drawLine(x, top, x, bottom, dividerPaint);
    }
  }

  @Override protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
    final boolean retVal = super.drawChild(canvas, child, drawingTime);
    // Draw a bottom border.
    final int bottom = child.getBottom() - 1;
    canvas.drawLine(child.getLeft(), bottom, child.getRight() - 2, bottom, dividerPaint);
    return retVal;
  }

代碼易懂,主要是這兩個方法想重點說一下,因爲我以前用的不多。

關於dispatchDrawonDraw的區別和聯繫,這裏從源碼的角度解釋一下。

View 的繪製和measurelayout一樣都在ViewRootperformTraversals()的內部發起。

private void performTraversals() {  
    final View host = mView;  
    ...  
    host.measure(childWidthMeasureSpec, childHeightMeasureSpec);  
    ...  
    host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());  
    ...  
    draw(fullRedrawNeeded);  
}  

我們再看看官方給出的View繪製過程的描述:

     Draw traversal performs several drawing steps which must be executed 
     in the appropriate order: 

     1. Draw the background 
     2. If necessary, save the canvas' layers to prepare for fading 
     3. Draw view's content 
     4. Draw children 
     5. If necessary, draw the fading edges and restore layers 
     6. Draw decorations (scrollbars for instance) 

首先繪製背景,然後調用onDraw繪製View本身,接着調用dispatchDraw繪製子視圖。

ViewViewGroup中,onDraw默認都是空實現,因爲繪製的過程要由使用者操作。
dispatchDrawView中是空實現,因爲沒有子視圖,而在ViewGroup中被重載用於繪製子視圖。

所以我們一般繪製View是重寫onDraw,繪製ViewGroup是重寫dispatchDraw

再看dispatchDraw方法:

@Override  
    protected void dispatchDraw(Canvas canvas) {  
       ...  

        if ((flags & FLAG_USE_CHILD_DRAWING_ORDER) == 0) {  
            for (int i = 0; i < count; i++) {  
                final View child = children[i];  
                if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {  
                    more |= drawChild(canvas, child, drawingTime);  
                }  
            }  
        } else {  
            for (int i = 0; i < count; i++) {  
                final View child = children[getChildDrawingOrder(count, i)];  
                if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {  
                    more |= drawChild(canvas, child, drawingTime);  
                }  
            }  
        }  
      ......  
    }  

其核心就是循環調用drawChild繪製子視圖。

說了這麼多,回頭再看日曆網格的繪製,還記得我們的CanlendarGridView是個ViewGroup吧,不記得的現在再記一遍。

所以這裏是重寫dispatchDraw,繪製一條左邊界,然後循環繪製7條右邊界。期間super.dispatchDraw(canvas)會調用drawChild循環繪製6條橫線。

至此CanlendarGridView告一段落,來看CanlendarRowView,這就更簡單了。

上面說過,在CanlendarGridViewonMeasure方法中,measureChild(child, rowWidthSpec, rowHeightSpec)會最終傳遞到CanlendarRowViewonMeasure方法,我們來具體看下:


 @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    long start = System.currentTimeMillis();
    final int totalWidth = MeasureSpec.getSize(widthMeasureSpec);
    int rowHeight = 0;
    for (int c = 0, numChildren = getChildCount(); c < numChildren; c++) {
      final View child = getChildAt(c);
      // Calculate width cells, making sure to cover totalWidth.
      int cellSize = totalWidth / 7;
      int cellWidthSpec = makeMeasureSpec(cellSize, EXACTLY);
      int cellHeightSpec = isHeaderRow ? makeMeasureSpec(cellSize, AT_MOST) : cellWidthSpec;//首行高是字體高度,其餘寬高相同
      child.measure(cellWidthSpec, cellHeightSpec);
      // The row height is the height of the tallest cell.
      if (child.getMeasuredHeight() > rowHeight) {
        rowHeight = child.getMeasuredHeight();
      }
    }
    final int widthWithPadding = totalWidth + getPaddingLeft() + getPaddingRight();
    final int heightWithPadding = rowHeight + getPaddingTop() + getPaddingBottom();
    setMeasuredDimension(widthWithPadding, heightWithPadding);
    Logr.d("Row.onMeasure %d ms", System.currentTimeMillis() - start);
  }

  @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    long start = System.currentTimeMillis();
    int cellHeight = bottom - top;
    int width = (right - left);
    for (int c = 0, numChildren = getChildCount(); c < numChildren; c++) {
      final View child = getChildAt(c);
      int l = ((c + 0) * width) / 7;
      int r = ((c + 1) * width) / 7;
      child.layout(l, 0, r, cellHeight);
    }
    Logr.d("Row.onLayout %d ms", System.currentTimeMillis() - start);
  }

是不是感覺跟CanlendarGridView一樣的套路,整個過程簡單易懂,沒啥可說的。

所以總結一下:整個日曆控件的結構佈局工作都是在month.xml完成,然後通過CanlendarGridViewCanlendarRowViewonMeasureonLayout完成日期格子寬高賦值。

高級開發工程師:CanlendarCellView自定義狀態

上面已經聊過了整個控件的測繪和準備工作,而看完整個控件,除了自定義畫了網格,並沒有其餘自定義繪製過程。那每個日期格子各種各樣的狀態呈現出的不同顯示效果是如何實現的呢?

這裏square向我們展示了自定義狀態繪製的技巧,猶如一名經驗豐富的高級開發工程師根據產品的PRD和UI的設計圖,優雅的實現需求。

首先瀏覽下CanlendarCellView,我發現了以前從未見過的東西。

先是一系列自定義屬性的狀態生成的集合。

private static final int[] STATE_SELECTABLE = {
      R.attr.tsquare_state_selectable
  };
  private static final int[] STATE_CURRENT_MONTH = {
      R.attr.tsquare_state_current_month
  };
  ……

然後是從未見過的方法:

 @Override protected int[] onCreateDrawableState(int extraSpace) {
    final int[] drawableState = super.onCreateDrawableState(extraSpace + 5);

    if (isSelectable) {
      mergeDrawableStates(drawableState, STATE_SELECTABLE);
    }

    if (isCurrentMonth) {
      mergeDrawableStates(drawableState, STATE_CURRENT_MONTH);
    }

    ……

    return drawableState;
  }

接着還有一系列對外的接口:

 public void setSelectable(boolean isSelectable) {
    if (this.isSelectable != isSelectable) {
      this.isSelectable = isSelectable;
      //使用該方法後,系統會回調onCreateDrawableState
      refreshDrawableState();
    }
  }
    ……

然後CanlendarCellView中就別無它物了,如此清爽!看完感覺一臉懵逼……

查閱了一些資料以後,我才認識了這幾個系統接口,這裏就要插入一些控件狀態的知識了,首先先看一段Android源碼,我註釋的很詳細了:

//當View的狀態發生變化時,會調用drawableStateChanged方法。
public void refreshDrawableState() {
        //給這個View設置上 PFLAG_DRAWABLE_STATE_DIRTY 標誌
        mPrivateFlags |= PFLAG_DRAWABLE_STATE_DIRTY;
        //接着調用drawableStateChanged
        drawableStateChanged();
        ……
    }

 protected void drawableStateChanged() {
        //調用 getDrawableState 方法獲取當前狀態
        final int[] state = getDrawableState();
        boolean changed = false;
        //並把狀態賦值給 mBackground 和StateListAnimator
        final Drawable bg = mBackground;
        if (bg != null && bg.isStateful()) {
            changed |= bg.setState(state);
        }
        ……
    }

public final int[] getDrawableState() {
        if ((mDrawableState != null) && ((mPrivateFlags & PFLAG_DRAWABLE_STATE_DIRTY) == 0)) {
            return mDrawableState;
        } else {//發現存在 PFLAG_DRAWABLE_STATE_DIRTY 標誌,說明緩存的 drawable state已經失效
            mDrawableState = onCreateDrawableState(0);//組裝新的 drawable state 
            mPrivateFlags &= ~PFLAG_DRAWABLE_STATE_DIRTY;
            return mDrawableState;
        }
    }

看到這裏是不是看出了一些端倪。

在各個對外接口中,例如:

 public void setSelectable(boolean isSelectable) {
    if (this.isSelectable != isSelectable) {
      this.isSelectable = isSelectable;
      refreshDrawableState();
    }
  }

refreshDrawableState方法最終會調用onCreateDrawableState,而CanlendarCellView重載了onCreateDrawableState方法,把控件當前的狀態添加到基本狀態集合中。

在得到了新的狀態數組之後,繼續查閱Android源碼,發現系統會調用DrawablesetState方法來對狀態進行更新,最終會先調用indexOfStateSet方法來找到當前視圖狀態所對應的Drawable資源下標,然後進行繪製。

整個自定義狀態更新的過程提煉一下就是,在合適的時機使控件處於某個drawable state,然後系統會接受剩餘的繪製工作。如果我們也想這樣用,可以像日曆控件源碼這樣:

  • 自定義控件狀態,例如我們的日曆控件自定義的狀態:
 <declare-styleable name="calendar_cell">
    <attr name="tsquare_state_selectable" format="boolean" />
    <attr name="tsquare_state_current_month" format="boolean" />
    <attr name="tsquare_state_today" format="boolean" />
    <attr name="tsquare_state_highlighted" format="boolean" />
    <attr name="tsquare_state_range_first" format="boolean" />
    <attr name="tsquare_state_range_middle" format="boolean" />
    <attr name="tsquare_state_range_last" format="boolean" />
  </declare-styleable>
  • 在控件狀態改變時,比如,在選中某天時調用 refreshDrawableState 方法:
 public void setSelectable(boolean isSelectable) {
    if (this.isSelectable != isSelectable) {
      this.isSelectable = isSelectable;
      refreshDrawableState();
    }
  }
  • 重寫 onCreateDrawableState, 根據自己的標誌來確定是否加上更多狀態,例如控件中的源碼:
 @Override protected int[] onCreateDrawableState(int extraSpace) {
    final int[] drawableState = super.onCreateDrawableState(extraSpace + 5);

    if (isSelectable) {
      mergeDrawableStates(drawableState, STATE_SELECTABLE);
    }
    ……
}

如此優雅,夫復何求?

到此CalendarPickerView源碼導讀就告一段落,縱觀整個控件的設計,無論是代碼技巧還是設計思想都值得學習回味,也不枉我在週五晚上加班敲出這篇文章。

發佈了87 篇原創文章 · 獲贊 364 · 訪問量 27萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章