編輯:關於Android編程
我們用ItemDecoration為RecyclerView打造了帶懸停頭部的分組列表。其實Android版微信的通訊錄界面,它的分組title也不是懸停的,我們已經領先了微信一小步(認真臉)~
再看看市面上常見的分組列表(例如餓了麼點餐商品列表),不僅有懸停頭部,懸停頭部在切換時,還會伴有切換動畫。
關於ItemDecoration還有一個問題,簡單布局還好,我們可以draw出來,如果是復雜的頭部呢?能否寫個xml,inflate進來,這樣使用起來才簡單,即另一種簡單使用onDraw和onDrawOver的姿勢。
so,本文開頭我們就先用兩節完善一下我們的ItemDecoration。然後進入正題:自定義View實現右側索引導航欄IndexBar,對數據源的排序字段按照拼音排序,最後將RecyclerView和IndexBar聯動起來,觸摸IndexBar上相應字母,RecyclerView滾動到相應位置。(在屏幕中間顯示的其實就是一個TextView,我們set個體IndexBar即可)
由於大部分使用右側索引導航欄的場景,都需要這幾個固定步驟,對數據源排序,set給IndexBar,和RecyclerView聯動等,所以最後再將其封裝一把,成一個高度封裝,因此擴展性不太高的控件,更方便使用,如果需要擴展的話,反正看完本文再其基礎上修改應該很簡單~。
最終版預覽:
本文摘要:
1. 用ItemDecoration實現懸停頭部切換動畫
2. 另一種簡單使用onDraw()和onDrawOver()<喎?/kf/ware/vc/" target="_blank" class="keylink">vc3Ryb25nPrXE18vKxjxiciAvPg0KMy4g19S2qNLlVmlld8q1z9bT0rLgKirL99L9tby6vcC4KipJbmRleEJhcjxiciAvPg0KNC4gyrnTw1RpbnlQaW55aW621Mr9vt3UtMXF0PI8YnIgLz4NCjUuIMGqtq9JbmRleEJhcrrNUmVjeWNsZXJWaWV3oaM8YnIgLz4NCjYuILfi17DW2Li0sr3W6KOst72x47b+tM7KudPDo6yyor/JPHN0cm9uZz62qNbGtby6vcr9vt3UtDwvc3Ryb25nPqGjPC9wPg0KPGhyIC8+DQo8aDIgaWQ9"二-懸停頭部的切換動畫">二 懸停頭部的“切換動畫”
實現了兩種,
第一種就是仿餓了麼點餐時,商品列表的懸停頭部切換“動畫效果”,如下:
第二種是一種頭部折疊起來的視效,個人覺得也還不錯~如下:(估計沒人喜歡)
果然比上部殘篇裡的效果好看多了,那麼代碼多不多呢,看我的git show 記錄:
就綠色部分的不到十行代碼就搞定~先上這個圖是為了讓大家安心,代碼不多,分分鐘看完。
下面放上文字版代碼,江湖人稱 注釋張 的我,已經寫滿了注釋,
再簡單說下吧,
滑動時,在判斷頭部即將切換(當前pos的tag和pos+1的tag不等)的時候,
1.計算出當前懸停頭部應該上移的位移,
利用Canvas的畫布移動方法Canvas.translate(),即可實現“餓了麼”懸停頭部切換效果。
2.計算出當前懸停頭部應該在屏幕上還剩余的空間高度,作為頭部繪制的高度
利用Canvas的Canvas.clipRect()方法,剪切畫布,即可實現“折疊”的視效。
@Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {//最後調用 繪制在最上層 int pos = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition(); String tag = mDatas.get(pos).getTag(); //View child = parent.getChildAt(pos); View child = parent.findViewHolderForLayoutPosition(pos).itemView;//出現一個奇怪的bug,有時候child為空,所以將 child = parent.getChildAt(i)。-》 parent.findViewHolderForLayoutPosition(pos).itemView boolean flag = false;//定義一個flag,Canvas是否位移過的標志 if ((pos + 1) < mDatas.size()) {//防止數組越界(一般情況不會出現) if (null != tag && !tag.equals(mDatas.get(pos + 1).getTag())) {//當前第一個可見的Item的tag,不等於其後一個item的tag,說明懸浮的View要切換了 Log.d("zxt", "onDrawOver() called with: c = [" + child.getTop());//當getTop開始變負,它的絕對值,是第一個可見的Item移出屏幕的距離, if (child.getHeight() + child.getTop() < mTitleHeight) {//當第一個可見的item在屏幕中還剩的高度小於title區域的高度時,我們也該開始做懸浮Title的“交換動畫” c.save();//每次繪制前 保存當前Canvas狀態, flag = true; //一種頭部折疊起來的視效,個人覺得也還不錯~ //可與123行 c.drawRect 比較,只有bottom參數不一樣,由於 child.getHeight() + child.getTop() < mTitleHeight,所以繪制區域是在不斷的減小,有種折疊起來的感覺 //c.clipRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + child.getHeight() + child.getTop()); //類似餓了麼點餐時,商品列表的懸停頭部切換“動畫效果” //上滑時,將canvas上移 (y為負數) ,所以後面canvas 畫出來的Rect和Text都上移了,有種切換的“動畫”感覺 c.translate(0, child.getHeight() + child.getTop() - mTitleHeight); } } } mPaint.setColor(COLOR_TITLE_BG); c.drawRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + mTitleHeight, mPaint); mPaint.setColor(COLOR_TITLE_FONT); mPaint.getTextBounds(tag, 0, tag.length(), mBounds); c.drawText(tag, child.getPaddingLeft(), parent.getPaddingTop() + mTitleHeight - (mTitleHeight / 2 - mBounds.height() / 2), mPaint); if (flag) c.restore();//恢復畫布到之前保存的狀態 }
這份代碼核心處c.translate(0, child.getHeight() + child.getTop() - mTitleHeight);實現的是餓了麼效果,被注釋掉的
//c.clipRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + child.getHeight() + child.getTop());
實現的是效果二。
之前我們使用onDraw(),onDrawOver(),都是用canvas的方法活生生的繪制一個出View,這對於很多人(包括我)來說都不容易,xy坐標的確認,尺寸都較難把握,基本上調UI效果時間都很長。尤其是canvas.drawText()方法的y坐標,其實是baseLine的位置,不了解的童鞋肯定要踩很多坑。
當我們想要繪制的分類title、懸停頭部復雜一點時,我都不敢想象要調試多久了,這個時候我們還敢用ItemDecoration嗎。
有沒有一種方法,就像我們平時使用的那樣,在Layout布局xml裡畫好View,然後inflate出來就可以了呢。
這個問題開始確實也把我難住了,難道又要從入門到放棄了嗎?
於是我又搜尋資料,功夫不負有心人。
解決問題的辦法就是,View類的:public void draw(Canvas canvas) {方法
下面我們就看一個用法Demo吧:
布局layout:header_complex.xml(注意有個ProgressBar哦)
onDrawOver代碼如下:簡單講解下,先inflate這個復雜的Layout,然後拿到它的LayoutParams,利用這個lp拿到寬和高的MeasureSpec,然後依次調用 measure,layout,draw方法,將復雜頭部顯示在屏幕上。
(小安利一下,MeasureSpec不太了解的可以看看我的這篇http://blog.csdn.net/zxt0601/article/details/52331007)
View toDrawView = mInflater.inflate(R.layout.header_complex, parent, false); int toDrawWidthSpec;//用於測量的widthMeasureSpec int toDrawHeightSpec;//用於測量的heightMeasureSpec //拿到復雜布局的LayoutParams,如果為空,就new一個。 // 後面需要根據這個lp 構建toDrawWidthSpec,toDrawHeightSpec ViewGroup.LayoutParams lp = toDrawView.getLayoutParams(); if (lp == null) { lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);//這裡是根據復雜布局layout的width height,new一個Lp toDrawView.setLayoutParams(lp); } if (lp.width == ViewGroup.LayoutParams.MATCH_PARENT) { //如果是MATCH_PARENT,則用父控件能分配的最大寬度和EXACTLY構建MeasureSpec。 toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight(), View.MeasureSpec.EXACTLY); } else if (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT) { //如果是WRAP_CONTENT,則用父控件能分配的最大寬度和AT_MOST構建MeasureSpec。 toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight(), View.MeasureSpec.AT_MOST); } else { //否則則是具體的寬度數值,則用這個寬度和EXACTLY構建MeasureSpec。 toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(lp.width, View.MeasureSpec.EXACTLY); } //高度同理 if (lp.height == ViewGroup.LayoutParams.MATCH_PARENT) { toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom(), View.MeasureSpec.EXACTLY); } else if (lp.height == ViewGroup.LayoutParams.WRAP_CONTENT) { toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom(), View.MeasureSpec.AT_MOST); } else { toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(lp.width, View.MeasureSpec.EXACTLY); } //依次調用 measure,layout,draw方法,將復雜頭部顯示在屏幕上。 toDrawView.measure(toDrawWidthSpec, toDrawHeightSpec); toDrawView.layout(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getPaddingLeft() + toDrawView.getMeasuredWidth(), parent.getPaddingTop() + toDrawView.getMeasuredHeight()); toDrawView.draw(c);
這裡還有個有趣的地方,某些需要不斷調用onDraw()更新繪制自己最新狀態的View,例如ProgressBar,由於在屏幕上顯示的並不是真正的View,只是我們手動的調用了一次draw方法,進而調用View的onDraw()顯示的一次“殘影”,所以ProgressBar只會顯示onDraw()當時的樣子,並不會主動刷新了。
看圖說話,還是很容易理解的:
滑動時,由於會回調onDrawOver() 方法,所以ProgressBar又被手動調用了draw(),開始變化,滑動的快的話,progressBar會有動畫效果。
停止不動時,ProgressBar也是靜止的,保持draw()時繪制的狀態。
不管是自定義ItemDecoration還是實現右側索引導航欄,都有大量的自定義View知識在裡面 ,這裡簡單復習一下。
(步驟1-4是自定義View的必須套路,步驟5+是IndexBar特殊定制)
1 自定義View首先要確定這個View需要在xml裡接受哪些屬性?
在IndexBar裡,我們先需要兩個屬性,每個索引的文字大小和手指按下時整個View的背景,
即在attrs.xml如下定義:
2 在View的構造方法中獲得我們自定義的屬性
套路代碼如下,都是套路,記得使用完最後要將typeArray對象 recycle()。
int textSize = (int) TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics());//默認的TextSize mPressedBackground = Color.BLACK;//默認按下是純黑色 TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.IndexBar, defStyleAttr, 0); int n = typedArray.getIndexCount(); for (int i = 0; i < n; i++) { int attr = typedArray.getIndex(i); switch (attr) { case R.styleable.IndexBar_textSize: textSize = typedArray.getDimensionPixelSize(attr, textSize); break; case R.styleable.IndexBar_pressBackground: mPressedBackground = typedArray.getColor(attr, mPressedBackground); default: break; } } typedArray.recycle();
3 重寫onMesure()方法(可選)
onMeasure()方法裡,主要就是遍歷一遍indexDatas,得到index最大寬度和高度。然後根據三種測量模式,分配不同的值給View,
EXACLTY就分配具體的測量值(match_parent,確定數值),
AT_MOST就分配父控件能給的最大值和自己需要的值之間的最小值。(保證不超過父控件限定的值)
UNSPECIFIED則分配自己需要的值。(隨心所欲)
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //取出寬高的MeasureSpec Mode 和Size int wMode = MeasureSpec.getMode(widthMeasureSpec); int wSize = MeasureSpec.getSize(widthMeasureSpec); int hMode = MeasureSpec.getMode(heightMeasureSpec); int hSize = MeasureSpec.getSize(heightMeasureSpec); int measureWidth = 0, measureHeight = 0;//最終測量出來的寬高 //得到合適寬度: Rect indexBounds = new Rect();//存放每個繪制的index的Rect區域 String index;//每個要繪制的index內容 for (int i = 0; i < mIndexDatas.size(); i++) { index = mIndexDatas.get(i); mPaint.getTextBounds(index, 0, index.length(), indexBounds);//測量計算文字所在矩形,可以得到寬高 measureWidth = Math.max(indexBounds.width(), measureWidth);//循環結束後,得到index的最大寬度 measureHeight = Math.max(indexBounds.width(), measureHeight);//循環結束後,得到index的最大高度,然後*size } measureHeight *= mIndexDatas.size(); switch (wMode) { case MeasureSpec.EXACTLY: measureWidth = wSize; break; case MeasureSpec.AT_MOST: measureWidth = Math.min(measureWidth, wSize);//wSize此時是父控件能給子View分配的最大空間 break; case MeasureSpec.UNSPECIFIED: break; } //得到合適的高度: switch (hMode) { case MeasureSpec.EXACTLY: measureHeight = hSize; break; case MeasureSpec.AT_MOST: measureHeight = Math.min(measureHeight, hSize);//wSize此時是父控件能給子View分配的最大空間 break; case MeasureSpec.UNSPECIFIED: break; } setMeasuredDimension(measureWidth, measureHeight); }
4 重寫onDraw()方法
整理一下需求和思路:
利用index數據源的size,和控件可繪制的高度(高度-paddingTop-paddingBottom),求出每個index區域的高度mGapHeight。
每個index在繪制時,都是處於水平居中,豎直方向上在mGapHeight區域高度內居中。
思路整理清楚,代碼很簡單如下:
public static String[] INDEX_STRING = {"A", "B", "C", "D", "E", "F", "G", "H", "I","J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V","W", "X", "Y", "Z", "#"};//#在最後面(默認的數據源) private ListmIndexDatas;//索引數據源 private int mGapHeight;//每個index區域的高度 ..... mIndexDatas = Arrays.asList(INDEX_STRING);//數據源
在onSizeChanged方法裡,獲取控件的寬高,並計算出mGapHeight:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mWidth = w; mHeight = h; mGapHeight = (mHeight - getPaddingTop() - getPaddingBottom()) / mIndexDatas.size(); }
最後在onDraw()方法裡繪制,
如果對於豎直居中baseLine的計算不太理解可以先放置,這塊的確挺繞人,後面應該會寫一篇 canvas.drawText()x y坐標計算的小短文.
可記住重點就是 Paint默認的TextAlign是Left,即x方向,左對齊,所以x坐標決定繪制文字的左邊界。
y坐標是繪制文字的baseLine位置。
@Override protected void onDraw(Canvas canvas) { int t = getPaddingTop();//top的基准點(支持padding) Rect indexBounds = new Rect();//存放每個繪制的index的Rect區域 String index;//每個要繪制的index內容 for (int i = 0; i < mIndexDatas.size(); i++) { index = mIndexDatas.get(i); mPaint.getTextBounds(index, 0, index.length(), indexBounds);//測量計算文字所在矩形,可以得到寬高 Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();//獲得畫筆的FontMetrics,用來計算baseLine。因為drawText的y坐標,代表的是繪制的文字的baseLine的位置 int baseline = (int) ((mGapHeight - fontMetrics.bottom - fontMetrics.top) / 2);//計算出在每格index區域,豎直居中的baseLine值 canvas.drawText(index, mWidth / 2 - indexBounds.width() / 2, t + mGapHeight * i + baseline, mPaint);//調用drawText,居中顯示繪制index } }
以上四步基本完成了IndexBar的繪制工作,下面我們為它添加一些行為的響應。
5 重寫onTouchEvent()方法
我們需要重寫onTouchEvent()方法,
以便處理手指按下時的View背景變色,抬起時恢復原來顏色
並根據手指觸摸的落點坐標,判斷當前處於哪個index區域,回調給相應的監聽器處理(顯示當前index的值,滑動RecyclerView至相應區域等。。)
代碼如下:
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: setBackgroundColor(mPressedBackground);//手指按下時背景變色 //注意這裡沒有break,因為down時,也要計算落點 回調監聽器 case MotionEvent.ACTION_MOVE: float y = event.getY(); //通過計算判斷落點在哪個區域: int pressI = (int) ((y - getPaddingTop()) / mGapHeight); //邊界處理(在手指move時,有可能已經移出邊界,防止越界) if (pressI < 0) { pressI = 0; } else if (pressI >= mIndexDatas.size()) { pressI = mIndexDatas.size() - 1; } //回調監聽器 if (null != mOnIndexPressedListener) { mOnIndexPressedListener.onIndexPressed(pressI, mIndexDatas.get(pressI)); } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: default: setBackgroundResource(android.R.color.transparent);//手指抬起時背景恢復透明 //回調監聽器 if (null != mOnIndexPressedListener) { mOnIndexPressedListener.onMotionEventEnd(); } break; } return true; }
6 聯動IndexBar和RecyclerView
具體的操作交由監聽器處理,定義和實現如下:
值得一提的就是,滑動RecyclerView到指定postion,我們使用的是LinearLayoutManager的scrollToPositionWithOffset(int position, int offset)方法,offset傳入0,postion即目標postion即可。如果使用RecyclerView.scrollToPosition();等方法,滑動會很飄~定位不准。
mPressedShowTextView 就是在屏幕中間顯示的當前處於哪個index的TextView。
/** * 當前被按下的index的監聽器 */ public interface onIndexPressedListener { void onIndexPressed(int index, String text);//當某個Index被按下 void onMotionEventEnd();//當觸摸事件結束(UP CANCEL) } private onIndexPressedListener mOnIndexPressedListener; public void setmOnIndexPressedListener(onIndexPressedListener mOnIndexPressedListener) { this.mOnIndexPressedListener = mOnIndexPressedListener; }
//設置index觸摸監聽器 setmOnIndexPressedListener(new onIndexPressedListener() { @Override public void onIndexPressed(int index, String text) { if (mPressedShowTextView != null) { //顯示hintTexView mPressedShowTextView.setVisibility(View.VISIBLE); mPressedShowTextView.setText(text); } //滑動Rv if (mLayoutManager != null) { int position = getPosByTag(text); if (position != -1) { mLayoutManager.scrollToPositionWithOffset(position, 0); } } } @Override public void onMotionEventEnd() { //隱藏hintTextView if (mPressedShowTextView != null) { mPressedShowTextView.setVisibility(View.GONE); } } });
在我個人的理解裡,程序過多的封裝是會導致擴展性的降低(也是因為我水平有限),然而我們今天要封裝的這個IndexBar,由於使用場景和套路還是挺固定的(城市分組列表,商品分類列表)所以值得將相關的操作都聚合起來,二次使用更方便。畢竟,一個項目裡同樣的代碼寫第二遍的程序員都不是好的聖斗士。(其實是我的leader不想寫第二遍,讓我封裝一下給他秒用)
梳理一下固定的操作:
1 都是先對原始數據sourceDatas源按照排序字段拼音排序。
2 然後將屏幕中hint的TextView ,以及索引數據源indexDatas(通過sourceDatas獲得),通過set方法傳給IndexBar。
3 聯動IndexBar和RecyclerView,使得觸摸IndexBar相應區域RecyclerView會滾動(借助sourceDatas獲得對應postion)。
根據上述,我的設想在使用時,只需要給IndexBar設置 原始數據sourceDatas,HintTextView,和RecyclerView的LinearLayoutManager,在IndexBar內部對sourceDatas排序,並獲得索引數據源indexDatas,然後設置一個默認的index觸摸監聽器,在手指按下滑動時,由於IndexBar持有HintTextView和LayoutManager,則HintTextView的show hide,以及LayoutManager的滾動 都在IndexBar內部完成。
最終使用預覽:
//使用indexBar mTvSideBarHint = (TextView) findViewById(R.id.tvSideBarHint);//HintTextView mIndexBar = (IndexBar) findViewById(R.id.indexBar);//IndexBar mIndexBar.setmPressedShowTextView(mTvSideBarHint)//設置HintTextView .setNeedRealIndex(true)//設置需要真實的索引 .setmLayoutManager(mManager)//設置RecyclerView的LayoutManager .setmSourceDatas(mDatas);//設置數據源
布局xml:
<framelayout android:layout_height="match_parent" android:layout_width="match_parent" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"></framelayout>
其中,setNeedRealIndex(true)//設置需要真實的索引,是指索引欄的數據不是固定的A-Z,#。而是根據真實的sourceDatas生成。
因為鏈式調用用起來很爽,所以在這些set方法裡都return 了 this。
1 抽象兩個實體類和一個接口。
先把tag抽象出來,放在頂層,這裡存放的就是IndexBar顯示的每個index值(A-Z,#)(本例是城市的漢語拼音首字母),而且在聯動滑動時,根據tag獲取postion時,也需要用到tag。它是導航分組列表的基礎。
public class BaseIndexTagBean { private String tag;//所屬的分類(城市的漢語拼音首字母) public String getTag() { return tag; } public void setTag(String tag) { this.tag = tag; } }
然後抽象一個接口和一個實體類,
接口定義一個方法getTarget(),它返回 需要被轉化成拼音,並取出首字母 索引排序的 字段。(本例就是城市的名字)
實體類繼承BaseIndexTagBean,並實現以上接口,且額外存放 需要排序的字段的拼音值,(本例是城市的拼音)。它根據getTarget()返回的值利用TinyPinyin庫得到拼音。
public interface IIndexTargetInterface { String getTarget();//需要被轉化成拼音,並取出首字母 索引排序的 字段 }
public abstract class BaseIndexPinyinBean extends BaseIndexTagBean implements IIndexTargetInterface { private String pyCity;//城市的拼音 public String getPyCity() { return pyCity; } public void setPyCity(String pyCity) { this.pyCity = pyCity; } }
有了以上兩個類一個接口,我們就可以將 對原始數據源sourceDatas按照拼音排序,並取出索引數據源indexDatas的操作封裝起來。
2 封裝原始數據源初始化(利用TinyPinyin獲取全拼音),取出索引數據源indexDatas的操作。
使用時,我們先讓具體的實體bean,繼承自BaseIndexPinyinBean ,在getTarget()方法返回排序目標字段。本例如下:
public class CityBean extends BaseIndexPinyinBean { private String city;//城市名字 public CityBean() { } public CityBean(String city) { this.city = city; } public String getCity() { return city; } public void setCity(String city) { this.city = city; } @Override public String getTarget() { return city; } }
IndexBar類內代碼:
使用時會調用IndexBar.setmSourceDatas()方法傳入原始數據源,在方法內對數據源初始化,並取出索引數據源。
private List mSourceDatas;//Adapter的數據源 public IndexBar setmSourceDatas(List mSourceDatas) { this.mSourceDatas = mSourceDatas; initSourceDatas();//對數據源進行初始化 return this; }
/** * 初始化原始數據源,並取出索引數據源 * * @return */ private void initSourceDatas() { int size = mSourceDatas.size(); for (int i = 0; i < size; i++) { BaseIndexPinyinBean indexPinyinBean = mSourceDatas.get(i); StringBuilder pySb = new StringBuilder(); String target = indexPinyinBean.getTarget();//取出需要被拼音化的字段 //遍歷target的每個char得到它的全拼音 for (int i1 = 0; i1 < target.length(); i1++) { //利用TinyPinyin將char轉成拼音 //查看源碼,方法內 如果char為漢字,則返回大寫拼音 //如果c不是漢字,則返回String.valueOf(c) pySb.append(Pinyin.toPinyin(target.charAt(i1))); } indexPinyinBean.setPyCity(pySb.toString());//設置城市名全拼音 //以下代碼設置城市拼音首字母 String tagString = pySb.toString().substring(0, 1); if (tagString.matches("[A-Z]")) {//如果是A-Z字母開頭 indexPinyinBean.setTag(tagString); if (isNeedRealIndex) {//如果需要真實的索引數據源 if (!mIndexDatas.contains(tagString)) {//則判斷是否已經將這個索引添加進去,若沒有則添加 mIndexDatas.add(tagString); } } } else {//特殊字母這裡統一用#處理 indexPinyinBean.setTag("#"); if (isNeedRealIndex) {//如果需要真實的索引數據源 if (!mIndexDatas.contains("#")) { mIndexDatas.add("#"); } } } } sortData(); }
3 封裝對原始數據源sourceDatas,索引數據源indexDatas的排序操作。
/** * 對數據源排序 */ private void sortData() { //對右側欄進行排序 將 # 丟在最後 Collections.sort(mIndexDatas, new Comparator() { @Override public int compare(String lhs, String rhs) { if (lhs.equals("#")) { return 1; } else if (rhs.equals("#")) { return -1; } else { return lhs.compareTo(rhs); } } }); //對數據源進行排序 Collections.sort(mSourceDatas, new Comparator () { @Override public int compare(BaseIndexPinyinBean lhs, BaseIndexPinyinBean rhs) { if (lhs.getTag().equals("#")) { return 1; } else if (rhs.getTag().equals("#")) { return -1; } else { return lhs.getPyCity().compareTo(rhs.getPyCity()); } } }); }
4 是否需要真實的索引數據源。
相關變量定義:
public static String[] INDEX_STRING = {"A", "B", "C", "D", "E", "F", "G", "H", "I","J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "#"};//#在最後面(默認的數據源) private ListmIndexDatas;//索引數據源 private boolean isNeedRealIndex;//是否需要根據實際的數據來生成索引數據源(例如 只有 A B C 三種tag,那麼索引欄就 A B C 三項)
初始化init時,判斷不需要真實的索引數據源,就用默認值(A-Z,#)
if (!isNeedRealIndex) {//不需要真實的索引數據源 mIndexDatas = Arrays.asList(INDEX_STRING); }
使用時,如果如果真實索引數據源,調用這個方法,傳入true,一定要在設置數據源setmSourceDatas(List)之前調用。
/** * 一定要在設置數據源{@link #setmSourceDatas(List)}之前調用 * * @param needRealIndex * @return */ public IndexBar setNeedRealIndex(boolean needRealIndex) { isNeedRealIndex = needRealIndex; if (isNeedRealIndex){ if (mIndexDatas != null) { mIndexDatas = new ArrayList<>(); } } return this; }
在initSourceDatas() 裡,會根據這個變量往mIndexDatas裡增加index。
5 IndexBar和外部聯動的相關(HintTextView,和RecyclerView的LinearLayoutManager)
set方法很簡單:
public IndexBar setmPressedShowTextView(TextView mPressedShowTextView) { this.mPressedShowTextView = mPressedShowTextView; return this; } public IndexBar setmLayoutManager(LinearLayoutManager mLayoutManager) { this.mLayoutManager = mLayoutManager; return this; }
它們兩最終都是在index觸摸監聽器裡用到,代碼上文已提及,只不過這次挪到IndexBar內部init裡。
init函數如下:
private void init(Context context, AttributeSet attrs, int defStyleAttr) { ... if (!isNeedRealIndex) {//不需要真實的索引數據源 mIndexDatas = Arrays.asList(INDEX_STRING); } //設置index觸摸監聽器 setmOnIndexPressedListener(new onIndexPressedListener() { @Override public void onIndexPressed(int index, String text) { if (mPressedShowTextView != null) { //顯示hintTexView mPressedShowTextView.setVisibility(View.VISIBLE); mPressedShowTextView.setText(text); } //滑動Rv if (mLayoutManager != null) { int position = getPosByTag(text); if (position != -1) { mLayoutManager.scrollToPositionWithOffset(position, 0); } } } @Override public void onMotionEventEnd() { //隱藏hintTextView if (mPressedShowTextView != null) { mPressedShowTextView.setVisibility(View.GONE); } } }); }
/** * 根據傳入的pos返回tag * * @param tag * @return */ private int getPosByTag(String tag) { if (TextUtils.isEmpty(tag)) { return -1; } for (int i = 0; i < mSourceDatas.size(); i++) { if (tag.equals(mSourceDatas.get(i).getTag())) { return i; } } return -1; }
思前想後還是放出來吧,三百多行有點長
/** * 介紹:索引右側邊欄 * 作者:zhangxutong * 郵箱:mcxtzhang@163.com * CSDN:http://blog.csdn.net/zxt0601 * 時間: 16/09/04. */ public class IndexBar extends View { private static final String TAG = "zxt/IndexBar"; public static String[] INDEX_STRING = {"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "#"};//#在最後面(默認的數據源) private ListmIndexDatas;//索引數據源 private boolean isNeedRealIndex;//是否需要根據實際的數據來生成索引數據源(例如 只有 A B C 三種tag,那麼索引欄就 A B C 三項) private int mWidth, mHeight;//View的寬高 private int mGapHeight;//每個index區域的高度 private Paint mPaint; private int mPressedBackground;//手指按下時的背景色 //以下邊變量是外部set進來的 private TextView mPressedShowTextView;//用於特寫顯示正在被觸摸的index值 private List mSourceDatas;//Adapter的數據源 private LinearLayoutManager mLayoutManager; public IndexBar(Context context) { this(context, null); } public IndexBar(Context context, AttributeSet attrs) { this(context, attrs, 0); } public IndexBar(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs, defStyleAttr); } private void init(Context context, AttributeSet attrs, int defStyleAttr) { int textSize = (int) TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics());//默認的TextSize mPressedBackground = Color.BLACK;//默認按下是純黑色 TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.IndexBar, defStyleAttr, 0); int n = typedArray.getIndexCount(); for (int i = 0; i < n; i++) { int attr = typedArray.getIndex(i); switch (attr) { case R.styleable.IndexBar_textSize: textSize = typedArray.getDimensionPixelSize(attr, textSize); break; case R.styleable.IndexBar_pressBackground: mPressedBackground = typedArray.getColor(attr, mPressedBackground); default: break; } } typedArray.recycle(); if (!isNeedRealIndex) {//不需要真實的索引數據源 mIndexDatas = Arrays.asList(INDEX_STRING); } mPaint = new Paint(); mPaint.setAntiAlias(true); mPaint.setTextSize(textSize); mPaint.setColor(Color.BLACK); //設置index觸摸監聽器 setmOnIndexPressedListener(new onIndexPressedListener() { @Override public void onIndexPressed(int index, String text) { if (mPressedShowTextView != null) { //顯示hintTexView mPressedShowTextView.setVisibility(View.VISIBLE); mPressedShowTextView.setText(text); } //滑動Rv if (mLayoutManager != null) { int position = getPosByTag(text); if (position != -1) { mLayoutManager.scrollToPositionWithOffset(position, 0); } } } @Override public void onMotionEventEnd() { //隱藏hintTextView if (mPressedShowTextView != null) { mPressedShowTextView.setVisibility(View.GONE); } } }); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); } @Override protected void onDraw(Canvas canvas) { int t = getPaddingTop();//top的基准點(支持padding) Rect indexBounds = new Rect();//存放每個繪制的index的Rect區域 String index;//每個要繪制的index內容 for (int i = 0; i < mIndexDatas.size(); i++) { index = mIndexDatas.get(i); mPaint.getTextBounds(index, 0, index.length(), indexBounds);//測量計算文字所在矩形,可以得到寬高 Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();//獲得畫筆的FontMetrics,用來計算baseLine。因為drawText的y坐標,代表的是繪制的文字的baseLine的位置 int baseline = (int) ((mGapHeight - fontMetrics.bottom - fontMetrics.top) / 2);//計算出在每格index區域,豎直居中的baseLine值 canvas.drawText(index, mWidth / 2 - indexBounds.width() / 2, t + mGapHeight * i + baseline, mPaint);//調用drawText,居中顯示繪制index } } @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: setBackgroundColor(mPressedBackground);//手指按下時背景變色 //注意這裡沒有break,因為down時,也要計算落點 回調監聽器 case MotionEvent.ACTION_MOVE: float y = event.getY(); //通過計算判斷落點在哪個區域: int pressI = (int) ((y - getPaddingTop()) / mGapHeight); //邊界處理(在手指move時,有可能已經移出邊界,防止越界) if (pressI < 0) { pressI = 0; } else if (pressI >= mIndexDatas.size()) { pressI = mIndexDatas.size() - 1; } //回調監聽器 if (null != mOnIndexPressedListener) { mOnIndexPressedListener.onIndexPressed(pressI, mIndexDatas.get(pressI)); } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: default: setBackgroundResource(android.R.color.transparent);//手指抬起時背景恢復透明 //回調監聽器 if (null != mOnIndexPressedListener) { mOnIndexPressedListener.onMotionEventEnd(); } break; } return true; } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mWidth = w; mHeight = h; mGapHeight = (mHeight - getPaddingTop() - getPaddingBottom()) / mIndexDatas.size(); } /** * 當前被按下的index的監聽器 */ public interface onIndexPressedListener { void onIndexPressed(int index, String text);//當某個Index被按下 void onMotionEventEnd();//當觸摸事件結束(UP CANCEL) } private onIndexPressedListener mOnIndexPressedListener; public onIndexPressedListener getmOnIndexPressedListener() { return mOnIndexPressedListener; } public void setmOnIndexPressedListener(onIndexPressedListener mOnIndexPressedListener) { this.mOnIndexPressedListener = mOnIndexPressedListener; } /** * 顯示當前被按下的index的TextView * * @return */ public IndexBar setmPressedShowTextView(TextView mPressedShowTextView) { this.mPressedShowTextView = mPressedShowTextView; return this; } public IndexBar setmLayoutManager(LinearLayoutManager mLayoutManager) { this.mLayoutManager = mLayoutManager; return this; } /** * 一定要在設置數據源{@link #setmSourceDatas(List)}之前調用 * * @param needRealIndex * @return */ public IndexBar setNeedRealIndex(boolean needRealIndex) { isNeedRealIndex = needRealIndex; if (mIndexDatas != null) { mIndexDatas = new ArrayList<>(); } return this; } public IndexBar setmSourceDatas(List mSourceDatas) { this.mSourceDatas = mSourceDatas; initSourceDatas();//對數據源進行初始化 return this; } /** * 初始化原始數據源,並取出索引數據源 * * @return */ private void initSourceDatas() { int size = mSourceDatas.size(); for (int i = 0; i < size; i++) { BaseIndexPinyinBean indexPinyinBean = mSourceDatas.get(i); StringBuilder pySb = new StringBuilder(); String target = indexPinyinBean.getTarget();//取出需要被拼音化的字段 //遍歷target的每個char得到它的全拼音 for (int i1 = 0; i1 < target.length(); i1++) { //利用TinyPinyin將char轉成拼音 //查看源碼,方法內 如果char為漢字,則返回大寫拼音 //如果c不是漢字,則返回String.valueOf(c) pySb.append(Pinyin.toPinyin(target.charAt(i1))); } indexPinyinBean.setPyCity(pySb.toString());//設置城市名全拼音 //以下代碼設置城市拼音首字母 String tagString = pySb.toString().substring(0, 1); if (tagString.matches("[A-Z]")) {//如果是A-Z字母開頭 indexPinyinBean.setTag(tagString); if (isNeedRealIndex) {//如果需要真實的索引數據源 if (!mIndexDatas.contains(tagString)) {//則判斷是否已經將這個索引添加進去,若沒有則添加 mIndexDatas.add(tagString); } } } else {//特殊字母這裡統一用#處理 indexPinyinBean.setTag("#"); if (isNeedRealIndex) {//如果需要真實的索引數據源 if (!mIndexDatas.contains("#")) { mIndexDatas.add("#"); } } } } sortData(); } /** * 對數據源排序 */ private void sortData() { //對右側欄進行排序 將 # 丟在最後 Collections.sort(mIndexDatas, new Comparator () { @Override public int compare(String lhs, String rhs) { if (lhs.equals("#")) { return 1; } else if (rhs.equals("#")) { return -1; } else { return lhs.compareTo(rhs); } } }); //對數據源進行排序 Collections.sort(mSourceDatas, new Comparator () { @Override public int compare(BaseIndexPinyinBean lhs, BaseIndexPinyinBean rhs) { if (lhs.getTag().equals("#")) { return 1; } else if (rhs.getTag().equals("#")) { return -1; } else { return lhs.getPyCity().compareTo(rhs.getPyCity()); } } }); } /** * 根據傳入的pos返回tag * * @param tag * @return */ private int getPosByTag(String tag) { if (TextUtils.isEmpty(tag)) { return -1; } for (int i = 0; i < mSourceDatas.size(); i++) { if (tag.equals(mSourceDatas.get(i).getTag())) { return i; } } return -1; } }
不管是自定義ItemDecoration還是實現右側索引導航欄,其實大量的自定義View知識在裡面 ,
so 要想自定義ItemDecoration玩得好,自定義View少不了。
對數據源的排序字段按照拼音排序,我們使用TinyPinyin(https://github.com/promeG/TinyPinyin)幫助我們排序。
它的特性很適合Android平台。
1. 生成的拼音不包含聲調,也不處理多音字,默認一個漢字對應一個拼音;
2. 拼音均為大寫;
3. 無需初始化,執行效率很高(Pinyin4J的4倍);
4. 很低的內存占用(小於30KB)。
(介紹來源於其項目github)
其實不僅僅是IndexBar以及它和RecyclerView,HintTextView的聯動可以封裝在一起。
懸停頭部ItemDecoration也可以利用 BaseIndexTagBean 類來抽象一下,不與具體的實體類耦合,
將
private ListmDatas;
替換成
private List mDatas;
即可。
本文起筆於9.5晚八點,項目上線打包期間,每逢打包是非多~你們懂得,結果打包期間出現各種問題,各種bug緊急修復通宵到凌晨,9.6日,兩點起床,又續寫後面三節。本文篇幅也略長,寫到後面自己也有點懵逼(也可能是通宵還沒醒導致),總耗時6小時+,希望大家看後覺得有用可以給我刷波66666.
github地址:歡迎star
https://github.com/mcxtzhang/ItemDecorationIndexBar
微信作為手機端通訊應用,很多人把它當做常用的通訊工具了,如果你還停留在QQ,還沒有開始使用微信的話,說明你就out了,微信功能強大,微信支付,微信叫滴滴,微
目前市面上的應用,貌似除了微信和手Q都會比較擔心被用戶或者系統(廠商)殺死問題。本文對 Android 進程拉活進行一個總結。Android 進程拉活包括兩個層面:A.
首先我們要知道一共有哪幾種動畫,這個面試有可能被問哦^_^。 變換動畫(透明度、縮放、平移、旋轉)、逐幀動畫、布局動畫和屬性動畫一、變換動畫我們可以通過XML文件設置動畫
1 概述在前面的《路徑和文字》中,講解了path的基本用法,這裡講解一些上篇沒有講到的東西。2 Path 這裡講解path相關的方法,後面繼續講解PathMeasure,