仿探探卡片滑動選擇
探探的滑動選擇妹子的功能,算是一個很經典的互動方式。自從出來以後可以說是備受關注,漸漸地很多類似功能的app也都有嘗試。實現也是具有綜合性的挑戰,所以說網上也是有不少例子的,在這裡我通過自定義ViewGroup的方式來實現。
需要達到的效果
實現的過程中,當然我們需要參考探探。這裡實現最核心的功能,如下:
- 卡片的層疊顯示
- 拖動選擇卡片
- 載入資料
怎麼實現呢?
當第一眼看到,察覺到的難點當然是拖動的實現。拖動的過程中會旋轉,同時層疊中的view 會改變位置。如果鬆手還會返回原位置或者移除卡片。在自定義viewGroup中拖動事件算是很麻煩的實現。但是呢官方給我們提供一一大神器ViewDragHelper。有了它我們實現起來就事半功倍了,在這裡之前也有文章介紹。如果不太明白使用,參考資料會列出來。既然拖動現在好說了。那麼層疊的效果呢?這裡不得不說算是核心了。在這裡我也走過彎路,因為之前的實現我是想的讓onlayout的時候,讓子view在不同位置,並且縮放的寬高也用onLayout變更left,top,right,bottom實現。但是實踐過程中會變得很複雜,不好實現。後面果斷改變思路。在onLayout中對每一個view都根據它自身的已測量寬高居中顯示,然後通過設定setScale,setTranslationY改變y軸防線的偏移量實現。可以看到我們是居中layout,我們事先的效果是y軸方向的偏移,所以主要看y軸的layout.這裡需要琢磨一下滑動的過程中的顯示,卡片的總量是固定值,我們預設設定為4,當然是可以改變的。我們可以看到探探滑動的時候,最底層的view,跟倒數第二層初始狀態是疊在一起的。我們定義從最頂層為第一層,一次遞增。並且每一層都有一個固定的offset,每一層都有固定的縮放scale。因為縮放也會造成y軸方向的偏移變化,這裡記縮放引起的偏移scaleYOffset.所以總的totalOffset = offset + scaleYOffset.可以看到offset,scaleYOffset都跟子view所在的層次有關。接下來結合程式碼分析
先定義一些常量
private static final float DEFAULT_SCALE = 0.05f;//預設縮放的級別 private static final int DEFAULT_OFFSET = 10;//dp private static final int DEFAULT_MARGIN = 10;//dp private static final int DEFAULT_DEGRESS = 20;//旋轉的度數 private static final int DEFAULT_SHOW_COUNT = 4;//預設顯示數量
layout 實現
protected void onLayout(boolean changed, int l, int t, int r, int b) { float scale = 1f; int level = 0; for (int i = getChildCount() - 1; i >= 0; i--) { View child = getChildAt(i); float scaleValue = scale - DEFAULT_SCALE * (level); int offset = ViewExKt.dp2px(this, DEFAULT_OFFSET); int offsetValue = offset * (level); child.layout(mCenterX - child.getMeasuredWidth() / 2 , mCenterY - child.getMeasuredHeight() / 2 , mCenterX + child.getMeasuredWidth() / 2 , mCenterY + child.getMeasuredHeight() / 2); float yOffset = child.getMeasuredHeight() * DEFAULT_SCALE * (level) / 2; child.setTranslationY(yOffset + offsetValue); child.setScaleX(scaleValue); child.setScaleY(scaleValue); // i > 1 是因為確保最後兩個view是重疊在一起 if (i > 1 || getChildCount() < showCount) { level++; } } }
可以看到以上程式碼對沒個子view進行遍歷,同時根據每個子view的level,最頂部為0.根據level 算出撥通的offsetValue,yOffset,最終相加計算出總偏移量,scaleValue 也根據level 計算。最終判斷i>1 是為了,不計算最底部level增加,讓最底部view跟倒數第二個子view縮放級別一致。在layout之前肯定要先measure,這裡實現比較簡單,僅僅是對自view進行測量,WRAP_CONTENT狀態下沒有根據子view寬高,定義自身寬高,還需要改進根據子view最大寬高。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); measureChildren(widthMeasureSpec, heightMeasureSpec); }
當我們測量,和佈局之後。顯示出來就已經是層疊的效果了,接下來則需要通過ViewDragHelper 對子view進行拖動及觸控反饋了。還有對資料載入的處理。
拖動的處理
可以看到使用ViewDraghelpr處理是非常方便的,每個回撥方法都很清晰,方法也很實用。接下來是ViewDragHelper標準操作如下:
//接管onTneterceptTouchEvent @Override public boolean onInterceptTouchEvent(MotionEvent ev) { return mDragHelper.shouldInterceptTouchEvent(ev); } //處理onTouchEvent,核心方法,處理事件的封裝都在這裡了 @Override public boolean onTouchEvent(MotionEvent event) { mDragHelper.processTouchEvent(event); return true; } //vdh的滑動採用的OverScroll 當然需要實現computeScroll @Override public void computeScroll() { super.computeScroll(); if (mDragHelper.continueSettling(true)) { postInvalidate(); } }
回撥方法,這裡所有重要的操作都在這些方法裡面了,特別是
tryCcaptureView,onViewReleased,onViewPositionChanged.
在拖動的過程中,始終拖動的是最頂部的view,這裡怎麼實現呢?,很簡單,tryCaptureView指定某個view可以被拖動
public boolean tryCaptureView(View child, int pointerId) { // 最top 的view 可滑動 return indexOfChild(child) == getChildCount() - 1; }
現在已經可以拖動最頂部的view了,如果我們鬆手會停留在拖動到的位置,這裡只需要呼叫settleCaptureViewAt,結合computeScroll 可以滑動到指定位置
if (isDraging) { mDragHelper.settleCapturedViewAt(mCenterX - releasedChild.getMeasuredWidth() / 2 , mCenterY - releasedChild.getMeasuredHeight() / 2); invalidate(); }
好了,現在我們具有層疊效果,並且可以拖動頂部view,並且鬆手會返回原位了。接下來就該拖動的時候剩下子view的變化。在拖動的過程中onViewPositionChanged會始終被呼叫,這裡根據拖動的位置left,top,dx,dy的變化,判斷出子view的變化。那麼子view需要什麼變化呢。通過之前onLayout的分析,可知道子view是分level的,比如倒數的二層在onlayout level是1,設定的縮放是0.9f,在這裡我們需要根據頂部view的拖動使其它子view,變大或變小,也就是縮放和translationY的變化,都要結合起onLayout的時候來做。這都需要有一個變化率在[0,1]之前,這裡我們通過
float rate = left * 1.0f / (getMeasuredWidth() / 3); float a = Math.min(1, Math.max(0, Math.abs(rate)));
以上程式碼可以算出我們想要的比例,為什麼是寬除以3,這裡是我選擇的當然也可以選擇其他值。因為我覺得3正好。當然越大rate越大。
int offset = ViewExKt.dp2px(TinderStackLayout.this, DEFAULT_OFFSET); // 這裡為什麼會有判斷 i = 0,i= 1,是因為如果釋放了會把view remove // 所以這裡會做判斷保證佈局底部的顯示,從1開始最底部view 不會有變化 for (int i = getChildCount() < showCount ? 0 : 1; i < getChildCount() - 1; i++) { View child = getChildAt(i); // ds 代表縮放,分為兩部分計算 + 號前面是佈局的時候應該縮放多少,後段是跟隨滑動 // 縮放的變化量 float ds = 1 - DEFAULT_SCALE * (getChildCount() - 1 - i) + DEFAULT_SCALE * a; // 同根據佈局時固定的的偏移量 - 變化量 float doffset = (getChildCount() - 1 - i) * offset - offset * a; // 同佈局時縮放的偏移量 - 變化量 float yOffset = child.getMeasuredHeight() * DEFAULT_SCALE * (getChildCount() - 1 - i - a) / 2; child.setScaleY(ds); child.setScaleX(ds); child.setTranslationY(doffset + yOffset); L.d(TAG, "ds : " + ds + " doffset : " + doffset + " a : " + a); }
以上程式碼,根據onlayout的資料,和rate值的變化設定child的scale,和 translationy的變化。這裡就不多解釋了,程式碼註釋相信可以理解。就是onLayout的值加上 rate的相關變化率。通過這裡程式碼的實現我們已經可以拖動的時候實現其他子view的縮放平移變化了。會發現,可以一直拖動但是我們需要,超過一個限定值就會觸發選擇事件,移除view,並滑向遠方。這裡使用兩個值判斷,a.是否left超過width的三分之一,b.斜率是否超過0.15。
//斜率,有方向 float sloap = top * 1.0f / left;
斜率的計算。
判斷是否是繼續拖動還是觸發事件
// top view 滑動的距離超過 寬度的三分之一,並且斜率 大於0.15 可以視為觸發選擇事件 if (Math.abs(left) > getMeasuredWidth() / 3 && Math.abs(sloap) > 0.15) { mReleasedPoint.x = left; mReleasedPoint.y = top; isDraging = false; }
在這裡因為需要記錄狀態值,和拖動事件觸發的位置,用於釋放時的計算。通過isDraging,mReleasedPoint儲存。接下來看onViewReleased的實現,這裡是實現的事件觸發的關鍵
if (isDraging) {
通過isDraging的判斷是否停止拖動觸發事件
if (mReleasedPoint.x != 0 && mReleasedPoint.y != 0) { final float sloap = mReleasedPoint.y / (mReleasedPoint.x * 1.0f); if (Math.abs(mReleasedPoint.x) > getMeasuredWidth() / 3 && Math.abs(sloap) > 0.15) { mDragHelper.smoothSlideViewTo(releasedChild, getMeasuredWidth(), (int) (getMeasuredWidth() * sloap)); onChoosePick(sloap); invalidate(); mReleasedPoint.x = 0; mReleasedPoint.y = 0; removeView(releasedChild); onAddView(); } }
通過程式碼判斷是否觸發移除和觸發事件。mDraghelper.smoothSlideViewTo 把view 通過動畫移到遠處,並且removeView,觸發onChoosePick(sloap)是左選還是右選,onAddView()新增新的view進來,如果有的話。
通過以上實現我們已經可以拖動到指定限制處釋放view了。實現選擇功能了。但是我們還需要旋轉,這裡很簡單,在onViewPositionChanged裡面的rate可以幫助實現,並且rate是又方向的,這可以實現左右拖動角度的變化
changedView.setRotation(rate * DEFAULT_DEGRESS);
限制基本上效果都有了,但是還有個問題,因為left不會為0,所以rate不會為0 會有偏差,所以需要監聽IDLE狀態,設定到0
public void onViewDragStateChanged(int state) { super.onViewDragStateChanged(state); // 停止滑動的時候,將最後一個view 角度設定為0,因為算斜率的 // 的方式最後滑動完成會有微小的偏差 if (state == ViewDragHelper.STATE_IDLE && isDraging) { View childTop = getChildAt(getChildCount() - 1); if (childTop != null) { childTop.setRotation(0); } } }
這樣基本功能已經實現,但是我們需要資料還有選擇的監聽,這也很重要。這裡採用介面卡實現我們關心的只有是否新增view.還有個數。
public interface BaseCardAdapter { int getItemCount(); View getView(); } public interface OnChooseListener{ // 1 為右邊滑動 0 為左邊滑動 void onPicked(int directon); }
這裡是回撥
private void onAddView() { if (adapter != null) { if (adapter.getView() == null) { return; } addView(adapter.getView(),0); } } private void onChoosePick(float sloap) { if (chooseListener != null) { chooseListener.onPicked(sloap > 0 ? 1 : 0); } }
設定adapter新增初始資料
public void setAdapter(BaseCardAdapter adapter) { this.adapter = adapter; if (adapter != null){ int count = Math.min(adapter.getItemCount(),showCount); if (count <= 0) { return ; } for (int i = 0 ;i < count ; i++) { addView(adapter.getView()); } } }
到這裡已經實現完畢,效果還不錯,如果需要檢視一下demo,請參考原始碼。

device-2019-01-05-181446.png