自定義View實戰(zhàn)-仿京東首頁輪播文字(又名垂直跑馬燈)
京東客戶端的輪播文字效果:
這里寫圖片描述
本次要實現(xiàn)的只是后面滾動的文字(前面的用ImageView或者TextView實現(xiàn)即可),看一下實現(xiàn)的效果:
這里寫圖片描述
經(jīng)過更改后本組件已可以已開源庫的形式添加到項目中使用(不用下載然后導(dǎo)入),使用方法及介紹詳見Github.本文的實例Demo也在這里.ADTextView,歡迎star.
我還寫了另外一個開源庫,多達(dá)288種動畫效果定制的側(cè)滑菜單庫有興趣的點了看一下,歡迎star(不應(yīng)該說歡迎,應(yīng)該說求star,因為快畢業(yè)找工作了,多幾個star簡歷上也好看一下,謝謝了)
關(guān)于開源庫的詳細(xì)介紹可以看這篇博客:多達(dá)288種動態(tài)效果的側(cè)滑菜單開源庫,滿足您項目的各種需求
關(guān)于如何發(fā)布一個開源庫的內(nèi)容可以查看這篇博客:發(fā)布新手的第一個開源庫-快速發(fā)布開源庫到JitPack
好了,接著說垂直跑馬燈的內(nèi)容
實現(xiàn)思路:
這里寫圖片描述
上圖只是一個大概的思路,要實現(xiàn)還需要完善更多的細(xì)節(jié),下面會一步步的來實現(xiàn)這個效果:
1.封裝數(shù)據(jù)源:從圖上可以看到,輪播的文字是分為兩個部分的,暫且把它們分別叫做前綴和內(nèi)容,而且實際的使用過程中點擊輪播圖肯定是需要跳轉(zhuǎn)頁面的,而且大部分應(yīng)該是WebView,不妨我們就設(shè)置點擊時候需要獲取的內(nèi)容就是一個鏈接,那么數(shù)據(jù)源的結(jié)構(gòu)就很明了了
這里寫圖片描述
創(chuàng)建ADEnity
類并設(shè)計參數(shù)完善一些基本的方法,代碼如下
public class ADEnity {
private String mFront ; //前面的文字
private String mBack ; //后面的文字
private String mUrl ;//包含的鏈接
public ADEnity(String mFront, String mBack,String mUrl) {
this.mFront = mFront;
this.mBack = mBack;
this.mUrl = mUrl;
}
public String getmUrl() {
return mUrl;
}
public void setmUrl(String mUrl) {
this.mUrl = mUrl;
}
public String getmFront() {
return mFront;
}
public void setmFront(String mFront) {
this.mFront = mFront;
}
public String getmBack() {
return mBack;
}
public void setmBack(String mBack) {
this.mBack = mBack;
}
}
2.接下來應(yīng)該是定制這個自定義View了,首先理一下思路,看一個構(gòu)造圖
這里寫圖片描述
實現(xiàn)這個自定義View的所有參數(shù)都在上表列出了,大部分參數(shù)很容易理解,個別參數(shù)加進(jìn)去是很有必要的,比如說是否初始化進(jìn)入文字的縱坐標(biāo),文字是否在移動中,是否處于停頓狀態(tài),這三個參數(shù),之后的內(nèi)容會詳細(xì)的敘述一下.
在動手繪制之前還得需要知道一點基礎(chǔ)的知識,就是關(guān)于繪制文字的方法,里面有很多細(xì)節(jié)需要處理
首先是畫布繪制文字的方法:
返回值 | 方法 | 描述 |
---|---|---|
void | drawText(String text, float x, float y, Paint paint) | Draw the text, with origin at (x,y), using the specified paint. |
void | drawText(CharSequence text, int start, int end, float x, float y, Paint paint) | Draw the specified range of text, specified by start/end, with its origin at (x,y), in the specified Paint. |
void | drawText(char[] text, int index, int count, float x, float y, Paint paint) | Draw the text, with origin at (x,y), using the specified paint. |
void | drawText(String text, int start, int end, float x, float y, Paint paint) | Draw the text, with origin at (x,y), using the specified paint. |
方法都比較好理解,繪制指定字符串(可以指定范圍)在坐標(biāo)( x , y )
處,但是其中的x,y
并不是我們所理解的應(yīng)該是文字左上角的坐標(biāo)點.其中的x坐標(biāo)是根據(jù)Paint
的屬性可變換的,默認(rèn)的x是文字的左邊坐標(biāo),如果Paint
設(shè)置了paint.setTextAlign(Paint.Align.CENTER)
;那就是字符的中心位置.Y
坐標(biāo)是文字的baseline
的y
坐標(biāo).
關(guān)于繪制文字的baseline
:
用圖來說話吧
這里寫圖片描述
圖中藍(lán)色的線即為baseline
,可以看出他既不是頂部坐標(biāo)也不是底部坐標(biāo),那么當(dāng)我們繪制文字的時候肯定是希望能把文字繪制在正中間.這時候就要引入paint.getTextBound()
方法了
getTextBounds(String text, int start, int end, Rect bounds)
,傳入一個Rect
對象,調(diào)用此方法之后則會填充這個rect對象,而填充的內(nèi)容就是所繪制的文字相對于baseline
的偏移坐標(biāo),將這個Rect加上baseline
的坐標(biāo),繪制后是這樣的:
這里寫圖片描述
但其實他的值只是(2,-25,76,3)
,是相對于baseline的位置,畫個圖會比較好理解
這里寫圖片描述
那么要將文字繪制在中間,那么實際繪制baseline的坐標(biāo)應(yīng)該是組件的中心,加上文字中心(即圖中框的中間坐標(biāo))相對于baseline的偏移值
這里寫圖片描述
這里寫圖片描述
這張圖中應(yīng)該會好理解實際繪制文字的坐標(biāo)與組件中心坐標(biāo)的關(guān)系.關(guān)于偏移值的計算,按常規(guī)的幾何計算方法,應(yīng)該是組件的中心坐標(biāo)+偏移值的絕對值==baseline坐標(biāo)(即實際繪制的坐標(biāo))
,但是由于框的坐標(biāo)值都是相對于baseline
來計算的,top
為負(fù)值,botton
為正值,那么這個偏移值就可以直接用(top+bottom)/2的絕對值
來表示,沒看懂的同學(xué)可以畫個草圖,用top=-25
,bottom=3
來算一下,看是否結(jié)果是一致的.
經(jīng)過上面的理解,那我們來繪制正確繪制文字的方法也就確定了
已獲得組件的高度int mHeight
, 文字外框Rect bound
的情況下
繪制文字在正中間
mHeight / 2 - (bound.top + bound.bottom) / 2
(因為bound.top + bound.bottom為負(fù)值,所以用減法)
//在縱坐標(biāo)為mY的地方繪制文字
//計算方式
//mheight /2 = mY + (bound.top + bound.bottom) / 2 ;
文字滾動到最高點
mY == 0 - bound.bottom
//在縱坐標(biāo)為mY的地方繪制,此時文字剛好移動到最高點
//計算方式
//mY + bound.bottom = 0 ;
文字滾動到最低點,剛好滾出組件
mY = mHeight - indexBound.top;
//在縱坐標(biāo)為mY的地方繪制,此時文字剛好移動到最高點
//計算方式
//mY + bound.top = mHeight ;
知道了如何正確的繪制文字和邊界情況的坐標(biāo)判斷,下面就到了繪制文字的步驟了
書寫自定義View,定義需要用到的屬性,完成構(gòu)造方法
public class ADTextView extends View {
private int mSpeed; //文字出現(xiàn)或消失的速度 建議1~5
private int mInterval; //文字停留在中間的時長
private int mFrontColor; //前綴顏色
private int mContentColor; //內(nèi)容的顏色
private int mFrontTextSize; //前綴文字大小
private int mContentTextSize; //內(nèi)容文字大小
private List<AdEntity> mTexts; //顯示文字的數(shù)據(jù)源
private int mY = 0; //文字的Y坐標(biāo)
private int mIndex = 0; //當(dāng)前的數(shù)據(jù)下標(biāo)
private Paint mPaintContent; //繪制內(nèi)容的畫筆
private Paint mPaintFront; //繪制前綴的畫筆
private boolean isMove = true; //文字是否移動
private String TAG = "ADTextView";
private boolean hasInit = false;
private boolean isPaused = false;
public ADTextView(Context context) {
this(context, null);
}
public ADTextView(Context context, AttributeSet attrs) {
super(context, attrs);
//獲取資源屬性值
obtainStyledAttrs(attrs);
//初始化數(shù)據(jù)
init();
}
定義資源文件屬性,values下建立attrs.xml文件
<declare-styleable name="ADTextView">
<!--文字進(jìn)入與消失的時間-->
<attr name="ad_text_view_speed" format="integer"/>
<!--文字停留在中心的時間-->
<attr name="ad_text_view_interval" format="integer"/>
<!--前綴文字顏色-->
<attr name="ad_text_front_color" format="color"/>
<!--前綴文字大小-->
<attr name="ad_text_front_size" format="dimension"/>
<!--內(nèi)容文字顏色-->
<attr name="ad_text_content_color" format="color"/>
<!--內(nèi)容文字大小-->
<attr name="ad_text_content_size" format="dimension"/>
</declare-styleable>
代碼中獲取資源文件內(nèi)的屬性值,并賦予默認(rèn)值
//獲取資源文件
private void obtainStyledAttrs(AttributeSet attrs) {
TypedArray array = getContext().obtainStyledAttributes(attrs, R.styleable.ADTextView);
mSpeed = array.getInt(R.styleable.ADTextView_ad_text_view_speed, 1);
mInterval = array.getInt(R.styleable.ADTextView_ad_text_view_interval, 2000);
mFrontColor = array.getColor(R.styleable.ADTextView_ad_text_front_color, Color.RED);
mContentColor = array.getColor(R.styleable.ADTextView_ad_text_content_color, Color.BLACK);
mFrontTextSize = (int) array.getDimension(R.styleable.ADTextView_ad_text_front_size, SizeUtil.Sp2Px(getContext(), 15));
mContentTextSize = (int) array.getDimension(R.styleable.ADTextView_ad_text_content_size, SizeUtil.Sp2Px(getContext(), 15));
array.recycle();
}
注:設(shè)置默認(rèn)值時用到了尺寸的轉(zhuǎn)換,詳情見這篇博客:自定義View之尺寸的轉(zhuǎn)化
初始化數(shù)據(jù)
//初始化默認(rèn)值
private void init() {
mIndex = 0;
mPaintFront = new Paint();
mPaintFront.setAntiAlias(true);
mPaintFront.setDither(true);
mPaintFront.setTextSize(mFrontTextSize);
mPaintFront.setColor(mFrontColor);
mPaintContent = new Paint();
mPaintContent.setAntiAlias(true);
mPaintContent.setDither(true);
mPaintContent.setTextSize(mContentTextSize);
mPaintContent.setColor(mContentColor);
}
重寫onMeasure進(jìn)行寬高的測量(細(xì)節(jié)見注釋)
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = measureWidth(widthMeasureSpec);
int height = measureHeight(heightMeasureSpec);
//設(shè)置寬高
setMeasuredDimension(width, height);
}
//測量寬度
private int measureHeight(int heightMeasureSpec) {
int result = 0;
int mode = MeasureSpec.getMode(heightMeasureSpec);
int size = MeasureSpec.getSize(heightMeasureSpec);
if (mode == MeasureSpec.EXACTLY) {
result = size; //具體的值
} else { //高度至少為兩倍字高
int mfronTextHeight = (int) (mPaintFront.descent() - mPaintFront.ascent()); //前綴文字字高
int mContentTextHeight = (int) (mPaintContent.descent() - mPaintContent.ascent()); //內(nèi)容文字字高
result = Math.max(mfronTextHeight, mContentTextHeight) * 2;
if (mode == MeasureSpec.AT_MOST) {
result = Math.min(result, size);
}
}
return result;
}
//測量高度
private int measureWidth(int widthMeasureSpec) {
int result = 0;
int mode = MeasureSpec.getMode(widthMeasureSpec);
int size = MeasureSpec.getSize(widthMeasureSpec);
if (mode == MeasureSpec.EXACTLY) {
result = size;
} else { //寬度最小十個字的寬度
String text = "十個字十個字十個字字";
Rect rect = new Rect();
mPaintContent.getTextBounds(text, 0, text.length(), rect);
result = rect.right - rect.left;
if (mode == MeasureSpec.AT_MOST) {
result = Math.min(result, size);
}
}
return result;
}
前面的敘述中我們知道,剛開始進(jìn)入的時候文字應(yīng)該是位于組件的底部的,但是這個值是需要獲取組件的高度和當(dāng)前顯示文字的情況下來判斷的,所以應(yīng)該放在onDraw
內(nèi)來初始化這個值,所以需要前面的是否初始化的屬性,判斷當(dāng)mY==0
并且未初始化的時候給mY
賦值.
接下來就是onDraw
內(nèi)的處理
獲取當(dāng)前的數(shù)據(jù)
ADEnity model = mTexts.get(mIndex);
String font = model.getmFront();
String back = model.getmBack();
為測量前綴與內(nèi)容的寬度,獲取文字的Rect對象
//前綴的Bound
Rect indexBound = new Rect();
mPaintFront.getTextBounds(font, 0, font.length(), indexBound);
//內(nèi)容文字的Bound
Rect contentBound = new Rect();
mPaintContent.getTextBounds(back, 0, back.length(), contentBound);
if (mY == 0 && hasInit == false) {
mY = getMeasuredHeight() - indexBound.top;
hasInit = true;
}
對mY
進(jìn)行初始化
if (mY == 0 && hasInit == false) {
mY = getMeasuredHeight() - indexBound.top;
hasInit = true;
}
繪制文字
canvas.drawText(back, 0, back.length(), (indexBound.right - indexBound.left) + 20, mY, mPaintContent);
canvas.drawText(font, 0, font.length(), 10, mY, mPaintFront);
對邊界情況的處理
//移動到最上面
if (mY <= 0 - indexBound.bottom) {
mY = getMeasuredHeight() - indexBound.top; //返回底部
mIndex++; //換下一組數(shù)據(jù)
isPaused = false; //重置暫停狀態(tài)
}
//移動到中間
if (!isPaused && mY <= getMeasuredHeight() / 2 - (indexBound.top + indexBound.bottom) / 2) {
isMove = false;
isPaused = true;
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
postInvalidate();
isMove = true;
}
}, mInterval);
}
mY -= mSpeed;
移動的處理與數(shù)據(jù)源的處理
mY -= mSpeed; //速度即為每次移動的像素值,推薦1~5,這也是前面判斷中間與最上方的時候使用<=的原因,如果每次只移動1像素,使用==完全可以,其他值則有可能跳過==的這個條件,導(dǎo)致不會停頓或者不會循環(huán)
//循環(huán)使用數(shù)據(jù)
if (mIndex == mTexts.size()) {
mIndex = 0;
}
//如果是處于移動狀態(tài)時的,則延遲繪制
//計算公式為一個比例,一個時間間隔移動組件高度,則多少毫秒來移動1像素
if (isMove) {
postInvalidateDelayed(mDuration / getMeasuredHeight());
}
至此對邏輯的處理就完成了,接下來要設(shè)置點擊事件
//設(shè)置一個回調(diào)并設(shè)置setXXX方法
public interface onClickLitener {
public void onClick(String mUrl);
}
private onClickLitener onClickLitener;
public void setOnClickLitener(TextViewAd.onClickLitener onClickLitener) {
this.onClickLitener = onClickLitener;
}
//重寫onTouchEvent事件,并且要返回true,表明當(dāng)前的點擊事件由這個組件自身來處理
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (onClickLitener != null) {
//調(diào)用回調(diào),將當(dāng)前數(shù)據(jù)源的鏈接傳出去 onClickLitener.onClick(mTexts.get(mIndex).getmUrl());
}
break;
}
return true;
}
暴露一些其他屬性的設(shè)置方式
//設(shè)置數(shù)據(jù)源
public void setmTexts(List<AdEntity> mTexts) {
this.mTexts = mTexts;
}
//設(shè)置廣告文字的停頓時間
public void setInterval(int mInterval) {
this.mInterval = mInterval;
}
//設(shè)置速度
public void setSpeed(int spedd) {
this.mSpeed = spedd;
}
//設(shè)置前綴的文字顏色
public void setFrontColor(int mFrontColor) {
mPaintFront.setColor(mFrontColor);
}
//設(shè)置正文內(nèi)容的顏色
public void setBackColor(int mBackColor) {
mPaintContent.setColor(mBackColor);
}
有興趣的同學(xué)可以將這些屬性設(shè)置到attrs.xml
文件中然后就可以在布局文件中設(shè)置屬性了,這里就不演示了,因為覺得每次copy
這個View
還得把xml
文件也copy
比較麻煩,畢竟as有自動補全,可以很方便的看到暴露在外面的方法.(個人感受而已).
貼一下完整的ADTextView的代碼,方便查看
package com.brioal.brioallib.view;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import com.brioal.brioallib.R;
import com.brioal.brioallib.entity.AdEntity;
import com.brioal.baselib.util.klog.KLog;
import com.brioal.baselib.util.SizeUtil;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
/**
* 仿京東垂直滾動廣告欄
* Created by Brioal on 2016/7/22.
*/
public class ADTextView extends View {
private int mSpeed; //文字出現(xiàn)或消失的速度 建議1~5
private int mInterval; //文字停留在中間的時長
private int mFrontColor; //前綴顏色
private int mContentColor; //內(nèi)容的顏色
private int mFrontTextSize; //前綴文字大小
private int mContentTextSize; //內(nèi)容文字大小
private List<AdEntity> mTexts; //顯示文字的數(shù)據(jù)源
private int mY = 0; //文字的Y坐標(biāo)
private int mIndex = 0; //當(dāng)前的數(shù)據(jù)下標(biāo)
private Paint mPaintContent; //繪制內(nèi)容的畫筆
private Paint mPaintFront; //繪制前綴的畫筆
private boolean isMove = true; //文字是否移動
private String TAG = "ADTextView";
private boolean hasInit = false;
private boolean isPaused = false;
public interface onClickListener {
public void onClick(String mUrl);
}
private onClickListener onClickListener;
public void setOnClickListener(onClickListener onClickListener) {
this.onClickListener = onClickListener;
}
public ADTextView(Context context) {
this(context, null);
}
public ADTextView(Context context, AttributeSet attrs) {
super(context, attrs);
obtainStyledAttrs(attrs);
init();
}
//獲取資源文件
private void obtainStyledAttrs(AttributeSet attrs) {
TypedArray array = getContext().obtainStyledAttributes(attrs, R.styleable.ADTextView);
mSpeed = array.getInt(R.styleable.ADTextView_ad_text_view_speed, 1);
mInterval = array.getInt(R.styleable.ADTextView_ad_text_view_interval, 2000);
mFrontColor = array.getColor(R.styleable.ADTextView_ad_text_front_color, Color.RED);
mContentColor = array.getColor(R.styleable.ADTextView_ad_text_content_color, Color.BLACK);
mFrontTextSize = (int) array.getDimension(R.styleable.ADTextView_ad_text_front_size, SizeUtil.Sp2Px(getContext(), 15));
mContentTextSize = (int) array.getDimension(R.styleable.ADTextView_ad_text_content_size, SizeUtil.Sp2Px(getContext(), 15));
array.recycle();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (onClickListener != null) {
onClickListener.onClick(mTexts.get(mIndex).getmUrl());
}
break;
}
return true;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = measureWidth(widthMeasureSpec);
int height = measureHeight(heightMeasureSpec);
setMeasuredDimension(width, height);
}
//測量寬度
private int measureHeight(int heightMeasureSpec) {
int result = 0;
int mode = MeasureSpec.getMode(heightMeasureSpec);
int size = MeasureSpec.getSize(heightMeasureSpec);
if (mode == MeasureSpec.EXACTLY) {
result = size;
} else { //高度至少為兩倍字高
int mfronTextHeight = (int) (mPaintFront.descent() - mPaintFront.ascent()); //前綴文字字高
int mContentTextHeight = (int) (mPaintContent.descent() - mPaintContent.ascent()); //內(nèi)容文字字高
result = Math.max(mfronTextHeight, mContentTextHeight) * 2;
if (mode == MeasureSpec.AT_MOST) {
result = Math.min(result, size);
}
}
return result;
}
//測量高度
private int measureWidth(int widthMeasureSpec) {
int result = 0;
int mode = MeasureSpec.getMode(widthMeasureSpec);
int size = MeasureSpec.getSize(widthMeasureSpec);
if (mode == MeasureSpec.EXACTLY) {
result = size;
} else { //寬度最小十個字的寬度
String text = "十個字十個字十個字字";
Rect rect = new Rect();
mPaintContent.getTextBounds(text, 0, text.length(), rect);
result = rect.right - rect.left;
if (mode == MeasureSpec.AT_MOST) {
result = Math.min(result, size);
}
}
return result;
}
//設(shè)置數(shù)據(jù)源
public void setmTexts(List<AdEntity> mTexts) {
this.mTexts = mTexts;
}
//設(shè)置廣告文字的停頓時間
public void setInterval(int mInterval) {
this.mInterval = mInterval;
}
//設(shè)置速度
public void setSpeed(int spedd) {
this.mSpeed = spedd;
}
//設(shè)置前綴的文字顏色
public void setFrontColor(int mFrontColor) {
mPaintFront.setColor(mFrontColor);
}
//設(shè)置正文內(nèi)容的顏色
public void setBackColor(int mBackColor) {
mPaintContent.setColor(mBackColor);
}
//初始化默認(rèn)值
private void init() {
mIndex = 0;
mPaintFront = new Paint();
mPaintFront.setAntiAlias(true);
mPaintFront.setDither(true);
mPaintFront.setTextSize(mFrontTextSize);
mPaintFront.setColor(mFrontColor);
mPaintContent = new Paint();
mPaintContent.setAntiAlias(true);
mPaintContent.setDither(true);
mPaintContent.setTextSize(mContentTextSize);
mPaintContent.setColor(mContentColor);
}
@Override
protected void onDraw(Canvas canvas) {
if (mTexts != null) {
AdEntity model = mTexts.get(mIndex);
String font = model.getmFront();
String back = model.getmBack();
//繪制前綴
Rect indexBound = new Rect();
mPaintFront.getTextBounds(font, 0, font.length(), indexBound);
//繪制內(nèi)容文字
Rect contentBound = new Rect();
mPaintContent.getTextBounds(back, 0, back.length(), contentBound);
if (mY == 0 && hasInit == false) {
mY = getMeasuredHeight() - indexBound.top;
hasInit = true;
}
//移動到最上面
if (mY <= 0 - indexBound.bottom) {
KLog.i(TAG, "onDraw: " + getMeasuredHeight());
mY = getMeasuredHeight() - indexBound.top;
mIndex++;
isPaused = false;
}
canvas.drawText(back, 0, back.length(), (indexBound.right - indexBound.left) + 20, mY, mPaintContent);
canvas.drawText(font, 0, font.length(), 10, mY, mPaintFront);
//移動到中間
if (!isPaused && mY <= getMeasuredHeight() / 2 - (indexBound.top + indexBound.bottom) / 2) {
isMove = false;
isPaused = true;
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
postInvalidate();
isMove = true;
}
}, mInterval);
}
mY -= mSpeed;
//循環(huán)使用數(shù)據(jù)
if (mIndex == mTexts.size()) {
mIndex = 0;
}
//如果是處于移動狀態(tài)時的,則延遲繪制
//計算公式為一個比例,一個時間間隔移動組件高度,則多少毫秒來移動1像素
if (isMove) {
postInvalidateDelayed(2);
}
}
}
}