Android開(kāi)發(fā)之無(wú)痕過(guò)渡下拉刷新控件的實(shí)現(xiàn)思路詳解
相信大家已經(jīng)對(duì)下拉刷新熟悉得不能再熟悉了,市面上的下拉刷新琳瑯滿目,然而有很多在我看來(lái)略有缺陷,接下來(lái)我將說(shuō)明一下存在的缺陷問(wèn)題,然后提供一種思路來(lái)解決這一缺陷,廢話不多說(shuō)!往下看嘞!
1.市面一些下拉刷新控件普遍缺陷演示
以直播吧APP為例:
第1種情況:
滑動(dòng)控件在初始的0位置時(shí),手勢(shì)往下滑動(dòng)然后再往上滑動(dòng),可以看到滑動(dòng)到初始位置時(shí)滑動(dòng)控件不能滑動(dòng)。
原因:
下拉刷新控件響應(yīng)了觸摸事件,后續(xù)的一系列事件都由它來(lái)處理,當(dāng)滑動(dòng)控件到頂端的時(shí)候,滑動(dòng)事件都被下拉刷新控件消費(fèi)掉了,傳遞不到它的子控件即滑動(dòng)控件,因此滑動(dòng)控件不能滑動(dòng)。
第2種情況:
滑動(dòng)控件滑動(dòng)到某個(gè)非0位置時(shí),這時(shí)下拉回0位置時(shí),可以看到下拉刷新頭部沒(méi)有被拉出來(lái)。
原因:
滑動(dòng)控件響應(yīng)了觸摸事件,后續(xù)的一系列事件都由它來(lái)處理,當(dāng)滑動(dòng)控件到頂端的時(shí)候,滑動(dòng)事件都被滑動(dòng)控件消費(fèi)掉了,父控件即下拉刷新控件消費(fèi)不了滑動(dòng)事件,因此下拉刷新頭部沒(méi)有被拉出來(lái)。

可能大部分人覺(jué)得無(wú)關(guān)痛癢,把手指抬起再下拉就可以了,but對(duì)于強(qiáng)迫癥的我而言,能提供一個(gè)無(wú)痕過(guò)渡才是最符合操作邏輯的,因此接下來(lái)我來(lái)講解下實(shí)現(xiàn)的思路。
2.實(shí)現(xiàn)的思路講解
2.1.事件分發(fā)機(jī)制簡(jiǎn)介(來(lái)源于Android開(kāi)發(fā)藝術(shù)探索)
dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent方法的關(guān)系偽代碼
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if(onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
} else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
1.由代碼可知若當(dāng)前View攔截事件,就交給自己的onTouchEvent去處理,否則就丟給子View繼續(xù)走相同的流程。
2.事件傳遞順序:Activity -> Window -> View,如果View都不處理,最終將由Activity的onTouchEvent
處理,是一種責(zé)任鏈模式的實(shí)現(xiàn)。
3.正常情況,一個(gè)事件序列只能被一個(gè)View攔截且消耗。
4.某個(gè)View一旦決定攔截,這一個(gè)事件序列只能由它處理,并且它的onInterceptTouchEvent不會(huì)再被調(diào)用
5.不消耗ACTION_DOWN,則事件序列都會(huì)由其父元素處理。
2.2.一般下拉刷新的實(shí)現(xiàn)思路猜想
首先,下拉刷新控件作為一個(gè)容器,需要重寫(xiě)onInterceptTouchEvent和onTouchEvent這兩個(gè)方法,然后在onInterceptTouchEvent中判斷ACTION_DOWN事件,根據(jù)子控件的滑動(dòng)距離做出判斷,若還沒(méi)滑動(dòng)過(guò),則onInterceptTouchEvent返回true表示其攔截事件,然后在onTouchEvent中進(jìn)行下拉刷新的頭部顯示隱藏的邏輯處理;若子控件滑動(dòng)過(guò)了,不攔截事件,onInterceptTouchEvent返回false,后續(xù)其下拉刷新的頭部顯示隱藏的邏輯處理就無(wú)法被調(diào)用了。
2.3.無(wú)痕過(guò)渡下拉刷新控件的實(shí)現(xiàn)思路
從2.2中可以看出,要想無(wú)痕過(guò)渡,下拉刷新控件不能攔截事件,這時(shí)候你可能會(huì)問(wèn),既然把事件給了子控件,后續(xù)拉刷新頭部邏輯怎么實(shí)現(xiàn)呢?
這時(shí)候就要用到一般都忽略的事件分發(fā)方法dispatchTouchEvent了,此方法在ViewGroup默認(rèn)返回true表示分發(fā)事件,即使子控件攔截了事件,父布局的dispatchTouchEvent仍然會(huì)被調(diào)用,因?yàn)槭录莻鬟f下來(lái)的,這個(gè)方法必定被調(diào)用。
所以我們可以在dispatchTouchEvent時(shí)對(duì)子控件的滑動(dòng)距離做出判斷,在這里把下拉刷新的頭部的邏輯處理掉,同時(shí)在函數(shù)調(diào)用return super.dispatchTouchEvent(event) 前把event的action設(shè)置為ACTION_CANCEL,這樣子子控件就不會(huì)響應(yīng)滑動(dòng)的操作。
3.代碼實(shí)現(xiàn)
3.1.確定需求
需要適配任意控件,例如RecyclerView、ListView、ViewPager、WebView以及普通的不能滑動(dòng)的View
不能影響子控件原來(lái)的事件邏輯
暴露方法提供手動(dòng)調(diào)用刷新功能
可以設(shè)置禁止下拉刷新功能
3.2.代碼講解
需要的變量
public class RefreshLayout extends LinearLayout {
// 隱藏的狀態(tài)
private static final int HIDE = 0;
// 下拉刷新的狀態(tài)
private static final int PULL_TO_REFRESH = 1;
// 松開(kāi)刷新的狀態(tài)
private static final int RELEASE_TO_REFRESH = 2;
// 正在刷新的狀態(tài)
private static final int REFRESHING = 3;
// 正在隱藏的狀態(tài)
private static final int HIDING = 4;
// 當(dāng)前狀態(tài)
private int mCurrentState = HIDE;
// 頭部動(dòng)畫(huà)的默認(rèn)時(shí)間(單位:毫秒)
public static final int DEFAULT_DURATION = 200;
// 頭部高度
private int mHeaderHeight;
// 內(nèi)容控件的滑動(dòng)距離
private int mContentViewOffset;
// 記錄上次的Y坐標(biāo)
private int mLastY;
// 最小滑動(dòng)響應(yīng)距離
private int mScaledTouchSlop;
// 滑動(dòng)的偏移量
private int mTotalDeltaY;
// 是否在處理頭部
private boolean mIsHeaderHandling;
// 是否可以下拉刷新
private boolean mIsRefreshable = true;
// 內(nèi)容控件是否可以滑動(dòng),不能滑動(dòng)的控件會(huì)做觸摸事件的優(yōu)化
private boolean mContentViewScrollable = true;
// 頭部,為了方便演示選取了TextView
private TextView mHeader;
// 容器要承載的內(nèi)容控件,在XML里面要放置好
private View mContentView;
// 值動(dòng)畫(huà),由于頭部顯示隱藏
private ValueAnimator mHeaderAnimator;
// 刷新的監(jiān)聽(tīng)器
private OnRefreshListener mOnRefreshListener;
初始化時(shí)創(chuàng)建頭部執(zhí)行顯示隱藏的值動(dòng)畫(huà),添加頭部到布局中,并且通過(guò)設(shè)置paddingTop隱藏頭部
public RefreshLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
addHeader(context);
}
private void init() {
mScaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
mHeaderAnimator = ValueAnimator.ofInt(0).setDuration(DEFAULT_DURATION);
mHeaderAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
if (getContext() == null) {
// 若是退出Activity了,動(dòng)畫(huà)結(jié)束不必執(zhí)行頭部動(dòng)作
return;
}
// 通過(guò)設(shè)置paddingTop實(shí)現(xiàn)顯示或者隱藏頭部
int offset = (Integer) valueAnimator.getAnimatedValue();
mHeader.setPadding(0, offset, 0, 0);
}
});
mHeaderAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (getContext() == null) {
// 若是退出Activity了,動(dòng)畫(huà)結(jié)束不必執(zhí)行頭部動(dòng)作
return;
}
if (mCurrentState == RELEASE_TO_REFRESH) {
// 釋放刷新?tīng)顟B(tài)執(zhí)行的動(dòng)畫(huà)結(jié)束,意味接下來(lái)就是刷新了,改狀態(tài)并且調(diào)用刷新的監(jiān)聽(tīng)
mHeader.setText("正在刷新...");
mCurrentState = REFRESHING;
if (mOnRefreshListener != null) {
mOnRefreshListener.onRefresh();
}
} else if (mCurrentState == HIDING) {
// 下拉狀態(tài)執(zhí)行的動(dòng)畫(huà)結(jié)束,隱藏頭部,改狀態(tài)
mHeader.setText("我是頭部");
mCurrentState = HIDE;
}
}
});
}
// 頭部的創(chuàng)建
private void addHeader(Context context) {
// 強(qiáng)制垂直方法
setOrientation(LinearLayout.VERTICAL);
mHeader = new TextView(context);
mHeader.setBackgroundColor(Color.GRAY);
mHeader.setTextColor(Color.WHITE);
mHeader.setText("我是頭部");
mHeader.setTextSize(TypedValue.COMPLEX_UNIT_SP, 25);
mHeader.setGravity(Gravity.CENTER);
addView(mHeader, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
mHeader.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// 算出頭部高度
mHeaderHeight = mHeader.getMeasuredHeight();
// 移除監(jiān)聽(tīng)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
mHeader.getViewTreeObserver().removeOnGlobalLayoutListener(this);
} else {
mHeader.getViewTreeObserver().removeGlobalOnLayoutListener(this);
}
// 設(shè)置paddingTop為-mHeaderHeight,剛好把頭部隱藏掉了
mHeader.setPadding(0, -mHeaderHeight, 0, 0);
}
});
}
在填充完布局后取出內(nèi)容控件
@Override
protected void onFinishInflate() {
super.onFinishInflate();
// 設(shè)置長(zhǎng)點(diǎn)擊或者短點(diǎn)擊都能消耗事件,要不這樣做,若孩子都不消耗,最終點(diǎn)擊事件會(huì)被它的上級(jí)消耗掉,后面一系列的事件都只給它的上級(jí)處理了
setLongClickable(true);
// 獲取內(nèi)容控件
mContentView = getChildAt(1);
if (mContentView == null) {
// 為空拋異常,強(qiáng)制要求在XML設(shè)置內(nèi)容控件
throw new IllegalArgumentException("You must add a content view!");
}
if (!(mContentView instanceof ScrollingView
|| mContentView instanceof WebView
|| mContentView instanceof ScrollView
|| mContentView instanceof AbsListView)) {
// 不是具有滾動(dòng)的控件,這里設(shè)置標(biāo)志位
mContentViewScrollable = false;
}
}
重頭戲來(lái)了,分發(fā)對(duì)于下拉刷新的特殊處理:
1.mContentViewOffset用于判別內(nèi)容頁(yè)的滑動(dòng)距離,在無(wú)偏移值時(shí)才去處理下拉刷新的操作;
2.在mContentViewOffset!=0即內(nèi)容頁(yè)滑動(dòng)的第一個(gè)瞬間,強(qiáng)制把MOVE事件改為DOWN,是因?yàn)橹癕OVE都被攔截掉了,若不給個(gè)DOWN讓內(nèi)容頁(yè)重新定下滑動(dòng)起點(diǎn),會(huì)有一瞬間滑動(dòng)一大段距離的坑爹效果。
@Override
public boolean dispatchTouchEvent(final MotionEvent event) {
if (!mIsRefreshable) {
// 禁止下拉刷新,直接把事件分發(fā)
return super.dispatchTouchEvent(event);
}
if ((mCurrentState == REFRESHING
|| mCurrentState == RELEASE_TO_REFRESH
|| mCurrentState == HIDING)
&& mHeaderAnimator.isRunning()) {
// 正在刷新,正在釋放,正在隱藏頭部都不處理事件,并且不分發(fā)下去
return true;
}
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE: {
int deltaY = y - mLastY;
if (mContentViewOffset == 0 && (deltaY > 0 || (deltaY < 0 && isHeaderShowing()))) {
// 偏移值為0時(shí),下拉或者在頭部還在顯示的時(shí)候上滑時(shí),交由自己處理滑動(dòng)事件
mTotalDeltaY += deltaY;
if (mTotalDeltaY > 0
&& mTotalDeltaY <= mScaledTouchSlop
&& !isHeaderShowing()) {
// 優(yōu)化下拉頭部,不要稍微一點(diǎn)位移就響應(yīng)
mLastY = y;
return super.dispatchTouchEvent(event);
}
// 處理事件
onHandleTouchEvent(event);
// 正在處理事件
mIsHeaderHandling = true;
if (mCurrentState == REFRESHING) {
// 正在刷新,不讓contentView響應(yīng)滑動(dòng)
event.setAction(MotionEvent.ACTION_CANCEL);
}
} else if (mIsHeaderHandling) {
// 在頭部隱藏的那一瞬間的事件特殊處理
if (mContentViewScrollable) {
// 1.可滑動(dòng)的View,由于之前處理頭部,之前的MOVE事件沒(méi)有傳遞到內(nèi)容頁(yè),這里
// 需要要ACTION_DOWN來(lái)重新告知滑動(dòng)的起點(diǎn),不然會(huì)瞬間滑動(dòng)一段距離
// 2.對(duì)于不滑動(dòng)的View設(shè)置了點(diǎn)擊事件,若這里給它一個(gè)ACTION_DOWN事件,在手指
// 抬起時(shí)ACTION_UP事件會(huì)觸發(fā)點(diǎn)擊,因此這里做了處理
event.setAction(MotionEvent.ACTION_DOWN);
}
mIsHeaderHandling = false;
}
break;
}
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP: {
if (mContentViewOffset == 0 && isHeaderShowing()) {
// 處理手指抬起或取消事件
onHandleTouchEvent(event);
}
mTotalDeltaY = 0;
break;
}
default:
break;
}
mLastY = y;
if (mCurrentState != REFRESHING
&& isHeaderShowing()
&& event.getAction() != MotionEvent.ACTION_UP) {
// 不是在刷新的時(shí)候,并且頭部在顯示, 不讓contentView響應(yīng)事件
event.setAction(MotionEvent.ACTION_CANCEL);
}
return super.dispatchTouchEvent(event);
}
處理事件的邏輯:拿到下拉偏移量,然后動(dòng)態(tài)去設(shè)置頭部的paddingTop值,即可實(shí)現(xiàn)顯示隱藏;手指抬起時(shí)根據(jù)狀態(tài)決定是顯示刷新還是直接隱藏頭部
// 自己處理事件
public boolean onHandleTouchEvent(MotionEvent event) {
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE: {
// 拿到Y(jié)方向位移
int deltaY = y - mLastY;
// 除以3相當(dāng)于阻尼值
deltaY /= 3;
// 計(jì)算出移動(dòng)后的頭部位置
int top = deltaY + mHeader.getPaddingTop();
// 控制頭部位置最大不超過(guò)-mHeaderHeight
if (top < -mHeaderHeight) {
mHeader.setPadding(0, -mHeaderHeight, 0, 0);
} else {
mHeader.setPadding(0, top, 0, 0);
}
if (mCurrentState == REFRESHING) {
// 之前還在刷新?tīng)顟B(tài),繼續(xù)維持刷新?tīng)顟B(tài)
mHeader.setText("正在刷新...");
break;
}
if (mHeader.getPaddingTop() > mHeaderHeight / 2) {
// 大于mHeaderHeight / 2時(shí)可以刷新了
mHeader.setText("可以釋放刷新...");
mCurrentState = RELEASE_TO_REFRESH;
} else {
// 下拉狀態(tài)
mHeader.setText("正在下拉...");
mCurrentState = PULL_TO_REFRESH;
}
break;
}
case MotionEvent.ACTION_UP: {
if (mCurrentState == RELEASE_TO_REFRESH) {
// 釋放刷新?tīng)顟B(tài),手指抬起,通過(guò)動(dòng)畫(huà)實(shí)現(xiàn)頭部回到(0,0)位置
mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), 0);
mHeaderAnimator.setDuration(DEFAULT_DURATION);
mHeaderAnimator.start();
mHeader.setText("正在釋放...");
} else if (mCurrentState == PULL_TO_REFRESH || mCurrentState == REFRESHING) {
// 下拉狀態(tài)或者正在刷新?tīng)顟B(tài),通過(guò)動(dòng)畫(huà)隱藏頭部
mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), -mHeaderHeight);
if (mHeader.getPaddingTop() <= 0) {
mHeaderAnimator.setDuration((long) (DEFAULT_DURATION * 1.0 /
mHeaderHeight * (mHeader.getPaddingTop() + mHeaderHeight)));
} else {
mHeaderAnimator.setDuration(DEFAULT_DURATION);
}
mHeaderAnimator.start();
if (mCurrentState == PULL_TO_REFRESH) {
// 下拉狀態(tài)的話,把狀態(tài)改為正在隱藏頭部狀態(tài)
mCurrentState = HIDING;
mHeader.setText("收回頭部...");
}
}
break;
}
default:
break;
}
mLastY = y;
return super.onTouchEvent(event);
}
你可能會(huì)問(wèn)了,這個(gè)mContentViewOffset怎么知道呢?接下來(lái)就是處理的方法,我會(huì)針對(duì)不同的滑動(dòng)控件,去設(shè)置它們的滑動(dòng)距離的監(jiān)聽(tīng),方法各種各樣,通過(guò)handleTargetOffset去判別View的類型采取不同的策略;然后你可能會(huì)覺(jué)得要是我那個(gè)控件我也要實(shí)現(xiàn)監(jiān)聽(tīng)咋辦?這個(gè)簡(jiǎn)單,繼承我已經(jīng)實(shí)現(xiàn)的監(jiān)聽(tīng)器,再補(bǔ)充你想要的功能即可,這個(gè)時(shí)候就不能再調(diào)handleTargetOffset這個(gè)方法了唄。
// 設(shè)置內(nèi)容頁(yè)滑動(dòng)距離
public void setContentViewOffset(int offset) {
mContentViewOffset = offset;
}
/**
* 根據(jù)不同類型的View采取不同類型策略去計(jì)算滑動(dòng)距離
*
* @param view 內(nèi)容View
*/
public void handleTargetOffset(View view) {
if (view instanceof RecyclerView) {
((RecyclerView) view).addOnScrollListener(new RecyclerViewOnScrollListener());
} else if (view instanceof NestedScrollView) {
((NestedScrollView) view).setOnScrollChangeListener(new NestedScrollViewOnScrollChangeListener());
} else if (view instanceof WebView) {
view.setOnTouchListener(new WebViewOnTouchListener());
} else if (view instanceof ScrollView) {
view.setOnTouchListener(new ScrollViewOnTouchListener());
} else if (view instanceof ListView) {
((ListView) view).setOnScrollListener(new ListViewOnScrollListener());
}
}
/**
* 適用于RecyclerView的滑動(dòng)距離監(jiān)聽(tīng)
*/
public class RecyclerViewOnScrollListener extends RecyclerView.OnScrollListener {
int offset = 0;
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
offset += dy;
setContentViewOffset(offset);
}
}
/**
* 適用于NestedScrollView的滑動(dòng)距離監(jiān)聽(tīng)
*/
public class NestedScrollViewOnScrollChangeListener implements NestedScrollView.OnScrollChangeListener {
@Override
public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
setContentViewOffset(scrollY);
}
}
/**
* 適用于WebView的滑動(dòng)距離監(jiān)聽(tīng)
*/
public class WebViewOnTouchListener implements View.OnTouchListener {
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
setContentViewOffset(view.getScrollY());
return false;
}
}
/**
* 適用于ScrollView的滑動(dòng)距離監(jiān)聽(tīng)
*/
public class ScrollViewOnTouchListener extends WebViewOnTouchListener {
}
/**
* 適用于ListView的滑動(dòng)距離監(jiān)聽(tīng)
*/
public class ListViewOnScrollListener implements AbsListView.OnScrollListener {
@Override
public void onScrollStateChanged(AbsListView absListView, int i) {
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if (firstVisibleItem == 0) {
View c = view.getChildAt(0);
if (c == null) {
return;
}
int firstVisiblePosition = view.getFirstVisiblePosition();
int top = c.getTop();
int scrolledY = -top + firstVisiblePosition * c.getHeight();
setContentViewOffset(scrolledY);
} else {
setContentViewOffset(1);
}
}
}
最后參考谷歌大大的SwipeRefreshLayout提供setRefreshing來(lái)開(kāi)啟或關(guān)閉刷新動(dòng)畫(huà),至于openHeader為啥要post(Runnable)呢?相信用過(guò)SwipeRefreshLayout在onCreate的時(shí)候直接調(diào)用setRefreshing(true)沒(méi)有小圓圈出來(lái)的都知道這個(gè)坑!
public void setRefreshing(boolean refreshing) {
if (refreshing && mCurrentState != REFRESHING) {
// 強(qiáng)開(kāi)刷新頭部
openHeader();
} else if (!refreshing) {
closeHeader();
}
}
private void openHeader() {
post(new Runnable() {
@Override
public void run() {
mCurrentState = RELEASE_TO_REFRESH;
mHeaderAnimator.setDuration((long) (DEFAULT_DURATION * 2.5));
mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), 0);
mHeaderAnimator.start();
}
});
}
private void closeHeader() {
mHeader.setText("刷新完畢,收回頭部...");
mCurrentState = HIDING;
mHeaderAnimator.setIntValues(mHeader.getPaddingTop(), -mHeaderHeight);
// 0~-mHeaderHeight用時(shí)DEFAULT_DURATION
mHeaderAnimator.setDuration(DEFAULT_DURATION);
mHeaderAnimator.start();
}
3.3.效果展示



除了以上三個(gè)還有在Demo中實(shí)現(xiàn)了ListView、ViewPager、ScrollView、NestedScrollView,具體看代碼即可
Demo地址:Github:RefreshLayoutDemo,覺(jué)得還不錯(cuò)的話給個(gè)Star哦。
以上所述是小編給大家介紹的Android開(kāi)發(fā)之無(wú)痕過(guò)渡下拉刷新控件的實(shí)現(xiàn)思路詳解,希望對(duì)大家有所幫助,如果大家有任何疑問(wèn)請(qǐng)給我留言,小編會(huì)及時(shí)回復(fù)大家的。在此也非常感謝大家對(duì)腳本之家網(wǎng)站的支持!
- Android開(kāi)發(fā)實(shí)現(xiàn)的ViewPager引導(dǎo)頁(yè)功能(動(dòng)態(tài)加載指示器)詳解
- Android UI設(shè)計(jì)與開(kāi)發(fā)之使用ViewPager實(shí)現(xiàn)歡迎引導(dǎo)頁(yè)面
- Android控件ViewPager實(shí)現(xiàn)帶有動(dòng)畫(huà)的引導(dǎo)頁(yè)
- Android引導(dǎo)頁(yè)面的簡(jiǎn)單實(shí)現(xiàn)
- Android實(shí)現(xiàn)繞球心旋轉(zhuǎn)的引導(dǎo)頁(yè)效果
- Android開(kāi)發(fā)實(shí)戰(zhàn)之漂亮的ViewPager引導(dǎo)頁(yè)
- RxJava兩步打造華麗的Android引導(dǎo)頁(yè)
- Android使用ViewPager實(shí)現(xiàn)啟動(dòng)引導(dǎo)頁(yè)
- Android完美實(shí)現(xiàn)平滑過(guò)渡的ViewPager廣告條
- Android實(shí)現(xiàn)過(guò)渡動(dòng)畫(huà)、引導(dǎo)頁(yè) Android判斷是否第一次啟動(dòng)App
相關(guān)文章
Android為按鈕控件綁定事件的五種實(shí)現(xiàn)方式
本篇文章主要是介紹了Android為按鈕控件綁定事件的五種實(shí)現(xiàn)方式,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下。2016-11-11
Android 使用 Path 實(shí)現(xiàn)搜索動(dòng)態(tài)加載動(dòng)畫(huà)效果
這篇文章主要介紹了Android 使用 Path 實(shí)現(xiàn)搜索動(dòng)態(tài)加載動(dòng)畫(huà)效果,本文通過(guò)圖文并茂的形式給大家介紹的非常詳細(xì),需要的朋友可以參考下2018-08-08
Android自定義view實(shí)現(xiàn)左滑刪除的RecyclerView詳解
RecyclerView是Android一個(gè)更強(qiáng)大的控件,其不僅可以實(shí)現(xiàn)和ListView同樣的效果,還有優(yōu)化了ListView中的各種不足。其可以實(shí)現(xiàn)數(shù)據(jù)縱向滾動(dòng),也可以實(shí)現(xiàn)橫向滾動(dòng)(ListView做不到橫向滾動(dòng))。接下來(lái)講解RecyclerView的用法2022-11-11

