前言
前兩天在玩今日頭條,覺得今日頭條的下拉刷新蠻有意思的牍疏,就自己實(shí)現(xiàn)了一下挚赊,整體上實(shí)現(xiàn)了同樣的效果。無圖無真相触机,效果圖如下:
今日頭條效果:
實(shí)現(xiàn)效果:
項(xiàng)目地址 TodayNewsHeader
實(shí)現(xiàn)過程分為兩部分:
- 圖形繪制
- 結(jié)合下拉刷新動(dòng)起來
圖形繪制
測量,坐標(biāo)計(jì)算
實(shí)現(xiàn)過程中圖形的繪制全部是通過Path 完成玷或,需要精確計(jì)算 path 各個(gè)部分的坐標(biāo)值
對(duì)Path不熟悉的請(qǐng)看Path使用詳解
這里需要注意的是:在繪制時(shí)坐標(biāo)不能從 0 開始儡首,繪制線條是通過Paint.setStyle(Paint.Style.STROKE)方法,如果從0開始繪制 會(huì)出現(xiàn)左側(cè)偏友,頂部線條只能繪制一半的情況
主要參數(shù):
private int strokeWidth; //線寬
//繪制不能從 坐標(biāo)0 開始 會(huì)有 stroke*1 的偏移量
private int contentWidth, contentHeight; //內(nèi)容寬度 內(nèi)容高度
private float roundCorner; //外層 圓角矩形 圓角半徑
private float lineWidth; // 線條寬度
private float rectWidth; //小矩形寬度
private float shortLineWidth; //短線寬度
private float spaceRectLine; //小矩形距 斷線距離
坐標(biāo)說明圖:
特地說明一下 roundCorner: 為 圓角矩形的圓角
在這里將 contentHeight 分為 7等份蔬胯,roundCorner 為 1/7的contentHeight
之后每個(gè)線條之間間距 一個(gè) roundCorner
測量計(jì)算關(guān)鍵變量代碼:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
//設(shè)定最小值時(shí),增加 stoke 的偏移保證 邊界繪制完整
int minWidth = dip2px(30) + strokeWidth * 2;
int minHeight = dip2px(35) + strokeWidth * 2;
//判斷 測量模式 如果是 wrap_content 需要對(duì) 寬高進(jìn)行限定
//同時(shí)確定 高度 也對(duì) 最小值進(jìn)行限定
if (widthMode == MeasureSpec.AT_MOST) {
width = minWidth;
} else if (widthMode == MeasureSpec.EXACTLY && width < minWidth) {
width = minWidth;
}
if (heightMode == MeasureSpec.AT_MOST) {
height = minHeight;
} else if (heightMode == MeasureSpec.EXACTLY && height < minHeight) {
height = minHeight;
}
// 在確定寬高之后 對(duì)內(nèi)容 寬高再次進(jìn)行計(jì)算位他,留出 stroke 的偏移
contentWidth = width - strokeWidth * 2;
contentHeight = height - strokeWidth * 2;
setMeasuredDimension(width, height);
initNeedParamn();
//初始化最外層 圓角矩形 path
initPath();
}
/**
* 初始化繪制所需要的參數(shù)
*/
private void initNeedParamn() {
//圓角半徑
roundCorner = contentHeight / 7f;
//線條寬度
lineWidth = contentWidth - roundCorner * 2;
//小矩形寬度
rectWidth = lineWidth / 2f;
//短線寬度
shortLineWidth = (lineWidth / 8f) * 3f; //短線條寬度
//矩形與 斷線之間的間距
spaceRectLine = (lineWidth / 8f) * 1f; //矩形與線條之間間距
}
繪制
通過觀察進(jìn)入頭條gif 效果氛濒,圖形的繪制分為兩部分
- 拖拽過程繪制
- 刷新過程繪制
在兩個(gè)過程中,最外層圓角矩形是不變的鹅髓,先來繪制這個(gè)圓角矩形舞竿。最外層圓角矩形Path初始化:
private Path roundPath; //最外層 圓形Path
/**
* 初始化 path
*/
private void initPath() {
roundPath = new Path();
//從右側(cè)第一個(gè)圓角作為起點(diǎn)
roundPath.moveTo(contentWidth, roundCorner);
roundPath.arcTo(contentWidth - roundCorner * 2, 0, contentWidth, roundCorner * 2, 0, -90, false);
roundPath.lineTo(roundCorner, 0);
roundPath.arcTo(0, 0, roundCorner * 2, roundCorner * 2, -90, -90, false);
roundPath.lineTo(0, contentHeight - roundCorner);
roundPath.arcTo(0, contentHeight - roundCorner * 2, roundCorner * 2, contentHeight, -180, -90, false);
roundPath.lineTo(contentWidth - roundCorner, contentHeight);
roundPath.arcTo(contentWidth - roundCorner * 2, contentHeight - roundCorner * 2, contentWidth, contentHeight, -270, -90, false);
//path閉合 自動(dòng) lineTo(contentWidth, roundCorner)
roundPath.close();
}
小矩形與線條Path創(chuàng)建
測量完成后,需要的參數(shù)已經(jīng)計(jì)算完成窿冯,我們可以根據(jù)指定坐標(biāo)提供小矩形和線條的Path
/**
* 根據(jù) 左上 坐標(biāo) 創(chuàng)建 矩形 Path
*
* @param left 左坐標(biāo)
* @param top 上坐標(biāo)
* @return
*/
public Path provideRectPath(float left, float top) {
Path path = new Path();
path.moveTo(left + rectWidth, top);
path.lineTo(left, top);
path.lineTo(left, top + roundCorner * 2f);
path.lineTo(left + rectWidth, top + roundCorner * 2f);
path.close();
return path;
}
/**
* 根據(jù)線條 左上 坐標(biāo)和線寬創(chuàng)建線條 Path
*
* @param left 左坐標(biāo)
* @param top 上坐標(biāo)
* @param lineWidth 線寬
* @return
*/
public Path provideLinePath(float left, float top, float lineWidth) {
Path path = new Path();
path.moveTo(left, top);
path.lineTo(left + lineWidth, top);
return path;
}
每個(gè)圖形都是通過Path繪制骗奖,對(duì)每個(gè)繪制的狀態(tài)進(jìn)行封裝
/**
* 繪制的狀態(tài)
*/
public abstract class State {
protected List<PathWrapper> mPathList;
public State() {
mPathList = new ArrayList<>();
initStatePath();
}
//初始化 PathWrapper集合
protected abstract void initStatePath();
//將繪制分配給 PathWrapper執(zhí)行
void onDraw(Canvas canvas, Paint paint) {
for (PathWrapper path : mPathList) {
path.onDraw(canvas, paint);
}
}
}
這里的 PathWrapper 會(huì)在下面的拖拽過程進(jìn)行解釋
拖拽過程
下拉拖拽過程:
頭部刷新View 跟隨手指下拉顯示,當(dāng)下拉高度超過了一定距離醒串,Path圖形開始繪制执桌,手指繼續(xù)下拉 ,圖形繪制完全芜赌,并且可以看到會(huì)有一個(gè)漸進(jìn)繪制的效果仰挣。這里需要根據(jù)下拉率 fraction來計(jì)算繪制比例
漸進(jìn)繪制分析
每個(gè)圖形根據(jù) fraction 的繪制比例是不同的,我在這里設(shè)計(jì)的映射關(guān)系如下表:
圖形 | fraction | 繪制比例 |
---|---|---|
外層圓角矩形 | 0~1 | 0~1 |
矩形 | 0~0.25 | 0~1 |
短線條1 | 0.25~0.33 | 0~1 |
短線條2 | 0.33~0.41 | 0~1 |
短線條3 | 0.41~0.5 | 0~1 |
長線條1 | 0.5~0.66 | 0~1 |
長線條2 | 0.66~0.82 | 0~1 |
長線條3 | 0.82~1 | 0~1 |
這里需要公式去計(jì)算每個(gè)圖形的繪制比例较鼓,并且需要一個(gè)容器去保存每個(gè)圖形的path 和繪制比例椎木,PathWrapper 就應(yīng)運(yùn)而生。
public class PathWrapper {
protected Path mPath; //圖形 Path
protected float fraction; //繪制的比例
public PathWrapper(Path path, float fraction) {
mPath = path;
this.fraction = fraction;
}
public void onDraw(Canvas canvas, Paint paint) {
if(fraction<=0) {
return;
}
Path dst = new Path();
PathMeasure measure = new PathMeasure(mPath, false); // 將 Path 與 PathMeasure 關(guān)聯(lián)
float length = measure.getLength();
// 截取一部分 并使用 moveTo 保持截取得到的 Path 第一個(gè)點(diǎn)的位置不變
measure.getSegment(0, length*fraction, dst, true);
canvas.drawPath(dst, paint);
}
}
PathWrapper 保存了path 和 繪制比例博烂。
這里有一個(gè)巧妙的設(shè)計(jì)是將 圖形的繪制 封裝到了 PathWrapper中香椎,這么早的好處在哪里呢?不要急禽篱,接下來會(huì)分析到畜伐。而關(guān)于繪制代碼有問題的可以參考 Path使用詳解
這個(gè)圖形可以看到 小矩形有一個(gè)灰色的填充效果,與其他圖形的繪制有所分別躺率,就不能使用通用的繪制方法進(jìn)行繪制玛界,需要特殊對(duì)待万矾。
這時(shí)候PathWrapper 封裝 繪制代碼的作用就提現(xiàn)了出來,對(duì)于線條圖形使用通用的方法慎框,對(duì)于矩形圖形良狈,創(chuàng)建單獨(dú)的 RectPathWrapper 繼承自PathWrapper對(duì) public void onDraw(Canvas canvas, Paint paint)方法進(jìn)行重寫,自定義繪制規(guī)則笨枯。
public class RectPathWrapper extends PathWrapper {
Paint mPaint;
public RectPathWrapper(Path path, float fraction) {
super(path, fraction);
//創(chuàng)建新的畫筆 設(shè)置填充樣式 顏色
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(0x32000000);
}
public void onDraw(Canvas canvas, Paint paint) {
if (fraction <= 0) {
return;
}
Path dst = new Path();
PathMeasure measure = new PathMeasure(mPath, false); // 將 Path 與 PathMeasure 關(guān)聯(lián)
float length = measure.getLength();
measure.getSegment(0, length * fraction, dst, true); // 截取一部分 并使用 moveTo 保持截取得到的 Path 第一個(gè)點(diǎn)的位置不變
//繪制線條
canvas.drawPath(dst, paint);
//繪制填充
canvas.drawPath(dst, mPaint);
}
}
關(guān)于繪制比例計(jì)算
再看一次映射關(guān)系
圖形 | fraction | 繪制比例 |
---|---|---|
外層圓角矩形 | 0~1 | 0~1 |
矩形 | 0~0.25 | 0~1 |
短線條1 | 0.25~0.33 | 0~1 |
短線條2 | 0.33~0.41 | 0~1 |
短線條3 | 0.41~0.5 | 0~1 |
長線條1 | 0.5~0.66 | 0~1 |
長線條2 | 0.66~0.82 | 0~1 |
長線條3 | 0.82~1 | 0~1 |
直接貼出 DragState的代碼
class DragState extends State {
private float fraction = 0f;
public void setFraction(float fraction) {
this.fraction = fraction;
mPathList.clear();
initStatePath();
}
@Override
protected void initStatePath() {
//圓角 矩形
PathWrapper pathWrapper = new PathWrapper(roundPath, fraction);
mPathList.add(pathWrapper);
//小矩形
Path rectPath = provideRectPath(roundCorner, roundCorner);
pathWrapper = new RectPathWrapper(rectPath, Math.min(1, 4 * fraction));
mPathList.add(pathWrapper);
//短線條1
float shortLeft = roundCorner + rectWidth + spaceRectLine;
Path shortLine1 = provideLinePath(shortLeft, roundCorner, shortLineWidth);
pathWrapper = new PathWrapper(shortLine1, Math.min(1, 12.5f * (fraction - 0.25f)));
mPathList.add(pathWrapper);
//
//短線條2
Path shortLine2 = provideLinePath(shortLeft, roundCorner * 2f, shortLineWidth);
pathWrapper = new PathWrapper(shortLine2, Math.min(1, 12.5f * (fraction - 0.33f)));
mPathList.add(pathWrapper);
//
//短線條3
Path shortLine3 = provideLinePath(shortLeft, roundCorner * 3f, shortLineWidth);
pathWrapper = new PathWrapper(shortLine3, Math.min(1, 12.5f * (fraction - 0.41f)));
mPathList.add(pathWrapper);
//
//長線條1
Path longLine1 = provideLinePath(roundCorner, roundCorner * 4f, lineWidth);
pathWrapper = new PathWrapper(longLine1, Math.min(1, 6.25f * (fraction - 0.5f)));
mPathList.add(pathWrapper);
//長線條2
Path longLine2 = provideLinePath(roundCorner, roundCorner * 5f, lineWidth);
pathWrapper = new PathWrapper(longLine2, Math.min(1, 6.25f * (fraction - 0.66f)));
mPathList.add(pathWrapper);
//長線條3
Path longLine3 = provideLinePath(roundCorner, roundCorner * 6f, lineWidth);
pathWrapper = new PathWrapper(longLine3, Math.min(1, 6.25f * (fraction - 0.82f)));
mPathList.add(pathWrapper);
}
}
接下里就可以寫個(gè)按鈕不斷改變 fraction 來觀察繪制效果了
刷新過程
刷新的過程可以分為四中狀態(tài):
刷新過程顯示就是四中狀態(tài)圖形在一定時(shí)間間隔內(nèi)循環(huán)切換顯示
這部分就比較簡單了薪丁,確定好圖形直接繪制即可,這里貼出 第二個(gè)狀態(tài)的代碼
class RefreshState2 extends State {
@Override
protected void initStatePath() {
PathWrapper pathWrapper = new PathWrapper(roundPath, 1);
mPathList.add(pathWrapper);
Path rectPath = provideRectPath(contentWidth-roundCorner-rectWidth, roundCorner);
pathWrapper = new RectPathWrapper(rectPath, 1);
mPathList.add(pathWrapper);
float shortLeft = roundCorner;
Path shortLine1 = provideLinePath(shortLeft, roundCorner, shortLineWidth);
pathWrapper = new RectPathWrapper(shortLine1, 1);
mPathList.add(pathWrapper);
Path shortLine2 = provideLinePath(shortLeft, roundCorner * 2f, shortLineWidth);
pathWrapper = new PathWrapper(shortLine2, 1);
mPathList.add(pathWrapper);
//
Path shortLine3 = provideLinePath(shortLeft, roundCorner * 3f, shortLineWidth);
pathWrapper = new PathWrapper(shortLine3, 1);
mPathList.add(pathWrapper);
//
//
Path longLine1 = provideLinePath(roundCorner, roundCorner * 4f, lineWidth);
pathWrapper = new PathWrapper(longLine1, 1);
mPathList.add(pathWrapper);
//
Path longLine2 = provideLinePath(roundCorner, roundCorner * 5f, lineWidth);
pathWrapper = new PathWrapper(longLine2, 1);
mPathList.add(pathWrapper);
//
Path longLine3 = provideLinePath(roundCorner, roundCorner * 6f, lineWidth);
pathWrapper = new PathWrapper(longLine3, 1);
mPathList.add(pathWrapper);
}
}
代碼比較簡單就是 計(jì)算坐標(biāo)馅精,創(chuàng)建Path 然后繪制交由公共的PathWrapper 完成
狀態(tài)的切換
public void setDragState() {
if (mDragState instanceof DragState) {
mDragState = new RefreshState1();
} else if (mDragState instanceof RefreshState1) {
mDragState = new RefreshState2();
} else if (mDragState instanceof RefreshState2) {
mDragState = new RefreshState3();
} else if (mDragState instanceof RefreshState3) {
mDragState = new RefreshState4();
} else if (mDragState instanceof RefreshState4) {
mDragState = new RefreshState1();
}
postInvalidate();
}
結(jié)合下拉刷新動(dòng)起來
下拉刷新使用 SmartRefreshLayout严嗜,正如它的介紹所說 SmartRefreshLayout是一個(gè)“聰明”或者“智能”的下拉刷新布局,并且支持自定義多種Header洲敢,F(xiàn)ooter漫玄。自定義Header文檔說明
代碼直接貼出來
public class TodayNewsHeader extends LinearLayout implements RefreshHeader {
public static String REFRESH_HEADER_PULLDOWN = "下拉推薦";
public static String REFRESH_HEADER_REFRESHING = "推薦中...";
public static String REFRESH_HEADER_RELEASE = "松開推薦";
private NewRefreshView mNewRefreshView;
private TextView releaseText;
public TodayNewsHeader(Context context) {
this(context, null);
}
public TodayNewsHeader(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public TodayNewsHeader(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView(context);
}
private void initView(Context context) {
this.setGravity(Gravity.CENTER_HORIZONTAL);
this.setOrientation(LinearLayout.VERTICAL);
mNewRefreshView = new NewRefreshView(context);
LinearLayout.LayoutParams lpNewRefresh = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
lpNewRefresh.setMargins(30, dip2px(context,30), 30, 0);
this.addView(mNewRefreshView, lpNewRefresh);
LinearLayout.LayoutParams lpReleaseText = new LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
lpReleaseText.setMargins(0, 30, 0, 30);
releaseText = new TextView(context);
releaseText.setText(REFRESH_HEADER_PULLDOWN);
releaseText.setTextColor(0xff666666);
addView(releaseText, lpReleaseText);
}
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
mNewRefreshView.setDragState();
mHandler.sendEmptyMessageDelayed(0, 250);
}
};
@NonNull
@Override
public View getView() {
return this;
}
@NonNull
@Override
public SpinnerStyle getSpinnerStyle() {
return SpinnerStyle.Translate;
}
@Override
public void setPrimaryColors(int... colors) {
}
@Override
public void onInitialized(@NonNull RefreshKernel kernel, int height, int extendHeight) {
}
@Override
public void onPulling(float percent, int offset, int height, int extendHeight) {
Log.e("TAG", "fraction:" + percent);
mNewRefreshView.setFraction((percent - 0.8f) * 6f);
}
@Override
public void onReleasing(float percent, int offset, int height, int extendHeight) {
onPulling(percent, offset, height, extendHeight);
}
@Override
public void onReleased(RefreshLayout refreshLayout, int height, int extendHeight) {
mHandler.removeCallbacksAndMessages(null);
mHandler.sendEmptyMessage(0);
}
@Override
public void onStartAnimator(@NonNull RefreshLayout refreshLayout, int height, int extendHeight) {
}
@Override
public int onFinish(@NonNull RefreshLayout refreshLayout, boolean success) {
mHandler.removeCallbacksAndMessages(null);
mNewRefreshView.setDrag();
return 0;
}
@Override
public void onHorizontalDrag(float percentX, int offsetX, int offsetMax) {
}
@Override
public boolean isSupportHorizontalDrag() {
return false;
}
@Override
public void onStateChanged(RefreshLayout refreshLayout, RefreshState oldState, RefreshState newState) {
switch (newState) {
case None:
break;
case PullDownToRefresh:
releaseText.setText(REFRESH_HEADER_PULLDOWN);
break;
case PullUpToLoad:
break;
case ReleaseToRefresh:
releaseText.setText(REFRESH_HEADER_RELEASE);
break;
case Refreshing:
releaseText.setText(REFRESH_HEADER_REFRESHING);
break;
case Loading:
break;
}
}
/**
* 根據(jù)手機(jī)的分辨率從 dip 的單位 轉(zhuǎn)成為 px(像素)
*/
public static int dip2px(Context context,float dpValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mHandler.removeCallbacksAndMessages(null);
mHandler = null;
}
}
- TodayNewsHeader繼承自 LinearLayout 在 initView() 方法中 創(chuàng)建 NewRefreshView 和下方顯示文字,并添加到自身中压彭。
- 關(guān)于getSpinnerStyle() 方法說明睦优,參考官方說明
變換方式
Translate 平行移動(dòng) 特點(diǎn): 最常見,HeaderView高度不會(huì)改變壮不,
Scale 拉伸形變 特點(diǎn):在下拉和上彈(HeaderView高度改變)時(shí)候刨秆,會(huì)自動(dòng)觸發(fā)OnDraw事件
FixedFront 固定在前面 特點(diǎn):不會(huì)上下移動(dòng),HeaderView高度不會(huì)改變
FixedBehind 固定在后面 特點(diǎn):不會(huì)上下移動(dòng)忆畅,HeaderView高度不會(huì)改變(類似微信瀏覽器效果)
Screen 全屏幕 特點(diǎn):固定在前面,尺寸充滿整個(gè)布局
- onPulling 與 onReleasoing 拖拽過程與下拉放回過程尸执,執(zhí)行 mNewRefreshView.setFraction();操作家凯,修改 繪制比例
- onReleased 出發(fā)下拉刷新,開啟刷新動(dòng)畫如失,我們?cè)谏厦娣治鏊⑿逻^程是 四中狀態(tài)圖形在一定時(shí)間間隔內(nèi)循環(huán)切換顯示绊诲,這里我采用可Handler 的形式
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
mNewRefreshView.setDragState();
mHandler.sendEmptyMessageDelayed(0, 250);
}
};
...
//使用handler 的好習(xí)慣,先清除消息再發(fā)送
mHandler.removeCallbacksAndMessages(null);
mHandler.sendEmptyMessage(0);
- onFinish刷新完成會(huì)調(diào)用褪贵,返回值為 頭部延遲收回的時(shí)間 在這個(gè)方法里 需要清除 handler 并且重置 NewRefreshView 的狀態(tài)為拖拽狀態(tài)
- onStateChanged方法 刷新狀態(tài)變化時(shí)回調(diào)掂之,在這里完成下方文本的切換顯示
項(xiàng)目地址 TodayNewsHeader