這一次拆解的是今日頭條的關(guān)注頁面:點擊關(guān)注的頭像會彈出一個文章列表栗菜。在邊界拖拽會出現(xiàn)關(guān)閉提示。這次同時實現(xiàn)了Android端和IOS端的效果像棘。
先講解Android端的實現(xiàn)吧稽亏,畢竟我是個Android開發(fā)仔呀
效果如下圖:
彈出來的頁面可以左右切換,每個頁面是單獨的列表缕题,能上下滑動截歉,所以這里直接用viewPager+recycelrView實現(xiàn)。
當viewPager不能左右滑動的時候烟零,移動整個viewPager瘪松,出現(xiàn)文字提示咸作,當滑動距離超過閾值時,文字改變宵睦。
當手指松開時记罚,若滑動距離未到達閾值,回彈壳嚎;否則結(jié)束頁面桐智。
同樣,當recyclerView在頂部不能滑動時诬辈,移動recyclerView酵使,出現(xiàn)提示,后續(xù)跟viewPager一致故不再贅述焙糟。
ReBoundLayout
這里的回彈我自定義了一個回彈布局口渔,下面介紹一下回彈布局的幾個重要方法:
onInterceptTouchEvent()
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
//記錄坐標
break;
case MotionEvent.ACTION_MOVE:
int difX = (int) (ev.getX() - mDownX);
int difY = (int) (ev.getY() - mDownY);
if (orientation == LinearLayout.HORIZONTAL) {
.....
if (水平滑動) {
if (!innerView.canScrollHorizontally(-1) && difX > 0) {
//右拉到邊界
return true;
}
if (!innerView.canScrollHorizontally(1) && difX < 0) {
//左拉到邊界
return true;
}
}
} else {
......
if (豎直滑動) {
if (!innerView.canScrollVertically(-1) && difY > 0) {
//下拉到邊界
return true;
}
if (!innerView.canScrollVertically(1) && difY < 0) {
//上拉到邊界
return true;
}
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
......重置變量
break;
default:
break;
}
return super.onInterceptTouchEvent(ev);
}
當控件方向為橫向且滑動為水平滑動時,檢測innerView能否在該方向上滑動穿撮;若不能缺脉,則攔截事件,交給自身處理(縱向同理)悦穿。
攔截事件后攻礼,在onTouchEvent()進行處理,實現(xiàn)移動和回彈栗柒。
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
if (orientation == LinearLayout.HORIZONTAL) {
int difX = (int) ((event.getX() - mDownX) / resistance);
boolean isRebound = false;
if (!innerView.canScrollHorizontally(-1) && difX > 0) {
//右拉到邊界
isRebound = true;
} else if (!innerView.canScrollHorizontally(1) && difX < 0) {
//左拉到邊界
isRebound = true;
}
if (isRebound) {
//移動和回調(diào)
return true;
}
} else {
int difY = (int) ((event.getY() - mDownY) / resistance);
boolean isRebound = false;
if (!innerView.canScrollVertically(-1) && difY > 0) {
//下拉到邊界
isRebound = true;
} else if (!innerView.canScrollVertically(1) && difY < 0) {
//上拉到邊界
isRebound = true;
}
if (isRebound) {
//移動和回調(diào)
return true;
}
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (orientation == LinearLayout.HORIZONTAL) {
int difX = (int) innerView.getTranslationX();
if (difX != 0) {
if (Math.abs(difX) <= resetDistance || isNeedReset) {
innerView.animate().translationX(0).setDuration(mDuration).setInterpolator(mInterpolator);
}
//回調(diào)
}
} else {
int difY = (int) innerView.getTranslationY();
if (difY != 0) {
if (Math.abs(difY) <= resetDistance || isNeedReset) {
innerView.animate().translationY(0).setDuration(mDuration).setInterpolator(mInterpolator);
}
//回調(diào)
}
}
break;
default:
break;
}
return super.onTouchEvent(event);
}
MOVE事件
利用setTranslationX()和setTranslationY()改變innerView的位置礁扮,同時將滑動距離和方向通過接口回調(diào)到外面。
UP事件
判斷滑動距離是否小于閾值瞬沦,小于則執(zhí)行回彈動畫太伊;同時回調(diào)到外面。
以上就是回彈布局的簡單實現(xiàn)逛钻,主要是對滑動事件進行攔截處理僚焦,如果不清楚事件傳遞機制可以到這里查看。
布局有3個自定義屬性
<declare-styleable name="ReBoundLayout">
<attr name="reBoundOrientation" format="enum">
<enum name="horizontal" value="0" />
<enum name="vertical" value="1" />
</attr>
<attr name="resistance" format="float" />
<attr name="reBoundDuration" format="integer" />
</declare-styleable>
分別是:回彈方向曙痘、阻力系數(shù)芳悲、回彈時間,剩余屬性可以調(diào)用set()方法修改边坤。
好了名扛,現(xiàn)在回彈實現(xiàn)了,接下來就是將文字提示加上茧痒,結(jié)束動畫加上肮韧。這里有一點需要注意的是:demo中使用的是reBoundLayout+viewPager+fragment(reBoundLayout+recyclerView)的結(jié)構(gòu)實現(xiàn)的。而文字是跟viewPager同一層級的,所以需要把fragment的回調(diào)回調(diào)到activity里(也可以getActivity()獲取對應(yīng)的文字控件)惹苗,詳見代碼。
以下是回調(diào)的偽代碼:
@Override
public void onDistanceChange(int distance, int direction) {
switch (direction) {
case DIRECTION_LEFT:
if (distance > showTipDistance) {
//文字改變耸峭,移動
} else {
rightTip.setVisibility(View.GONE);
}
break;
case DIRECTION_RIGHT:
if (distance > showTipDistance) {
//文字改變桩蓉,移動
} else {
leftTip.setVisibility(View.GONE);
}
break;
case DIRECTION_UP:
break;
case DIRECTION_DOWN:
//fragment的回調(diào)會走到這里
if (distance > showTipDistance) {
//文字改變,移動
} else {
topTip.setVisibility(View.GONE);
}
break;
default:
break;
}
}
@Override
public void onFingerUp(int distance, int direction) {
switch (direction) {
case DIRECTION_LEFT:
if (distance > mResetDistance) {
//結(jié)束頁面
} else {
//文字重置
}
break;
case DIRECTION_RIGHT:
if (distance > mResetDistance) {
//結(jié)束頁面
} else {
//文字重置
}
break;
case DIRECTION_DOWN:
if (distance > mResetDistance) {
//結(jié)束頁面
} else {
//文字重置
}
break;
default:
break;
}
}
大功告成劳闹,Android端的效果比較簡單院究,實現(xiàn)起來也比較容易。
IOS端效果復(fù)雜一丟丟本涕,大家留心看业汰。
效果如下:
當頁面不能拖動時(右滑、左滑菩颖、下滑)样漆,view的位置開始改變,并且整個頁面會縮小成一個圓晦闰;當松手時距離大于閾值放祟,view縮小為一個圓并平移到進入的那個圓位置,結(jié)束當前頁面呻右;否則回彈(demo中只給出一個圓跪妥,若需實現(xiàn)頭條的效果,只需更改對應(yīng)Point點得坐標即可)声滥。
同樣眉撵,自定義一個布局進行滑動事件的處理,至于整個頁面的縮小變圓落塑,這里通過裁剪畫布的方式去實現(xiàn)(圓心固定在屏幕中央)纽疟,也可以通過別的方法(Xfermode)去實現(xiàn)同樣的效果,有興趣的朋友自行探索芜赌。
PS:如果想圓心跟隨手指移動仰挣,需要增加以下計算:圓最大半徑、圓可移動距離與半徑變化關(guān)系
DragZoomLayout
關(guān)鍵變量:
- mMinRadius 圓最小半徑
- mMaxRadius 圓最大半徑
- mRadius 當前半徑
- mTranslationX 當前X移動距離
- mTranslationY 當前Y移動距離
事件攔截跟ReBoundLayout一致缠沈,所以不贅述膘壶,主要看看滑動事件的處理
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
int difX = (int) ((event.getX() - mDownX) / resistance);
int difY = (int) ((event.getY() - mDownY) / resistance);
if (orientation == LinearLayout.HORIZONTAL) {
boolean needDrag = false;
if (!innerView.canScrollHorizontally(-1) && difX > 0) {
//右啦到邊界
needDrag = true;
} else if (!innerView.canScrollHorizontally(1) && difX < 0) {
//左拉到邊界
needDrag = true;
}
if (needDrag) {
//半徑計算
mTranslationX = difX;
mTranslationY = difY;
invalidate();
//回調(diào)
return true;
}
} else {
if (!innerView.canScrollVertically(-1) && difY > 0) {
//下拉到邊界
//回調(diào)
return true;
} else if (!innerView.canScrollVertically(1) && difY < 0) {
//上啦到邊界
innerView.setTranslationY(difY);
return true;
}
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (orientation == LinearLayout.HORIZONTAL) {
//水平
if (Math.abs(mTranslationX) >= resetDistance) {
//回調(diào)
} else {
//重置狀態(tài)
}
} else {
//豎直
if (innerView.getTranslationY() < 0) {
innerView.animate().setDuration(mDuration).translationY(0).setInterpolator(mInterpolator);
} else {
//回調(diào)
}
}
break;
default:
break;
}
return super.onTouchEvent(event);
}
這里跟ReBoundLayout有以下幾點區(qū)別:
- 通過裁剪畫布的方式達到view縮小成圓的效果
- 通過移動畫布達到移動view的效果(setTranslation會觸發(fā)view的重繪,同時改變x跟y洲愤,會調(diào)用2次颓芭,而修改畫布大小又需要重繪,調(diào)用次數(shù)太多柬赐,因此不使用該方式)
- 下滑跟左右滑動一樣亡问,縮小、移動的都是最外層的DragZoomLayout(這樣視覺效果最好,而且能統(tǒng)一處理)州藕;上滑只做移動和回彈束世。
PS:DragZoomLayout一定要設(shè)置背景,不然調(diào)用invalidate()會無效床玻;上下滑動的mTranslationX毁涉、mTranslationY一直都是0(因為下滑我們已經(jīng)回調(diào)給最層的DragZoomLayout),所以在ACTION_UP锈死、ACTION_CANCEL事件贫堰,豎直方向回調(diào)時是使用當前事件的x、y跟點擊的x待牵、y相減的值去回調(diào)其屏。
布局繪制
@Override
protected void onDraw(Canvas canvas) {
if (Math.abs(mTranslationX) > mLargeX) {
mTranslationX = mTranslationX > 0 ? mLargeX : -mLargeX;
}
if (Math.abs(mTranslationY) > mLargeY) {
mTranslationY = mTranslationY > 0 ? mLargeY : -mLargeY;
}
canvas.translate(mTranslationX, mTranslationY);
mPath.reset();
mPath.addCircle(mPoint.x, mPoint.y, mRadius, Path.Direction.CCW);
canvas.clipPath(mPath);
super.onDraw(canvas);
}
進行了一些位置和半徑的限制。
布局完成缨该,接下來處理頁面間的接口回調(diào)及結(jié)束動畫
動畫的計算有一點點麻煩偎行,數(shù)學(xué)不好的同學(xué)請多看幾遍,還是不懂的趁著過年回高中找數(shù)學(xué)老師要回學(xué)費吧压彭。
先來看沒有移動畫布的情況:
啟動頁面時睦优,通過getLocationOnScreen()獲取進入時的坐標,退出時的坐標通過最外層的dragLayout的坐標加上寬高的一半壮不,再減去圓的最小半徑得到汗盘,最后通過這2個差值進行平移。
那么有平移并且半徑未到最小的情況也可以通過這種方式計算:
我們已經(jīng)有一個translationX了询一,那可以計算出目標的translationX隐孽,然后使用ValueAnimator不斷去改變它進行重繪,得到一個平移效果(translationY同理)健蕊。那這個值要怎么得到呢菱阵?上面已經(jīng)說了怎么計算了,沒看懂的再看一遍缩功∏缂埃看幾遍還是不懂的,回去找老師要學(xué)費吧嫡锌。
至于進入動畫原理相同虑稼,只是反過來執(zhí)行罷了,這里不再贅述势木,詳見代碼蛛倦。
有更好實現(xiàn)方式的歡迎下方留言討論,有bug或者疑問的也可以留言啦桌,有空會回復(fù)的溯壶。
由于篇幅關(guān)系,一些細小的地方?jīng)]有提及,有興趣的朋友可以自行查看且改。
最后奉上源碼;
這是年前最后一篇博客了验烧,今年立的flag好像都沒有實現(xiàn),跟大佬的差距還是那么大又跛,Bug仔仍需努力呀噪窘。