背景
對于電商App,商品詳情無疑是很重要的一個子產品,觀察主流購物App的詳情界面,發現大部分都是做成了上下兩部分,上面展示商品規格資訊,下面是H5商品詳情,或者是嵌套了一個包含H5詳情及評論清單的ViewPager界面,本文就是實作了一個相容不同需求的上下滾動黏滞View-DragScrollDetailsLayout。
DragScrollDetailsLayout GitHub連結
實作效果圖
首先看一下實作效果圖
簡單的ScrollView+Webview
當然,如果将Webview替換成其他的ListView之類的也是支援的。
ScrollView+ViewPager
适用場景:底部需要添加多個界面,并且需要滑動
ScrollView+Fragmenttabhost
适用場景:底部需要添加多個界面,但是不需要滑動
實作
對于這個需求的場景,很容易想到可以分成上下兩部分來實作,隻需要一個Vertical的LinearLayout,其餘的就是處理滾動及動畫的問題,首先自定義ViewGroup内部先聲明兩個頂層子ViewmUpstairsView、 View mDownstairsView,并且采用一個變量CurrentTargetIndex标記目前處于操作那個View,
public class DragScrollDetailsLayout extends LinearLayout {
private View mUpstairsView;
private View mDownstairsView;
private View mCurrentTargetView;
public enum CurrentTargetIndex {
UPSTAIRS,
DOWNSTAIRS;
public static CurrentTargetIndex valueOf(int index) {
return 1 == index ? DOWNSTAIRS : UPSTAIRS;
}
}
然後集中處理滾動事件,對于滾動與動畫主要有如下幾個問題需要解決:
- 如何知道上面或者下面的View已經滾動的到頂部或者底部
- 滾動到邊界時,如何攔截處理滑動
- 松手後如何處理後續的動效
如何判斷滾動邊界
首先來看第一個問題,如何知道上面或者下面的View滾動到了邊界,其實Android源碼中有個類ViewCompat,它有個函數canScrollVertically(View view, int offSet, MotionEvent ev)就可以判斷目前View是否可以向哪個方向滾動,offset的正負值用來判斷向上還是向下,當然,僅僅靠這個函數還是不夠的,因為ViewGroup是可以互相嵌套的,也許ViewGroup本身不能滾動,但是其内部的子View卻可以滾動,這時候,就需要遞歸周遊相關的View,比如對于ViewPager中嵌套了包含WebView或者List的Fragment。不過,并非所有的子View都需要周遊,隻有與TouchEvent相關的View才需要判斷。是以還需要寫個函數判斷View是否在TouchEvent所在的區域,如下函數isTransformedTouchPointInView:
/***
* 判斷MotionEvent是否處于View上面
*/
protected boolean isTransformedTouchPointInView(MotionEvent ev, View view) {
float x = ev.getRawX();
float y = ev.getRawY();
int[] rect = new int[2];
view.getLocationInWindow(rect);
float localX = x - rect[0];
float localY = y - rect[1];
return localX >= 0 && localX < (view.getRight() - view.getLeft())
&& localY >= 0 && localY < (view.getBottom() - view.getTop());
}
之後我們可以利用該函數對View進行遞歸周遊,判斷最上層的ViewGroup是否可以上下滑動
private boolean canScrollVertically(View view, int offSet, MotionEvent ev) {
if (!mChildHasScrolled && !isTransformedTouchPointInView(ev, view)) {
return false;
}
if (ViewCompat.canScrollVertically(view, offSet)) {
mChildHasScrolled = true;
return true;
}
if (view instanceof ViewPager) {
return canViewPagerScrollVertically((ViewPager) view, offSet, ev);
}
if (view instanceof ViewGroup) {
ViewGroup vGroup = (ViewGroup) view;
for (int i = 0; i < vGroup.getChildCount(); i++) {
if (canScrollVertically(vGroup.getChildAt(i), offSet, ev)) {
mChildHasScrolled = true;
return true;
}
}
}
return false;
}
知道View是否可以上下滑動到邊界後,攔截事件的時機就比較清晰了,那麼接着看第二個問題,如何攔截滑動。
事件攔截處理
onInterceptTouchEvent在傳回True之後,就不會再執行了,我們隻需要把握準确的攔截時機,比如如果處于上面的View,就要對上拉事件比較敏感,處于底部就要對下拉事件敏感,同時還要将無效的手勢歸零,比如,操作上面的View時,如果先是下拉,并且是無效的下拉,那麼就要将攔截點重置。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mDownMotionX = ev.getX();
mDownMotionY = ev.getY();
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.clear();
mChildHasScrolled=false;
break;
case MotionEvent.ACTION_MOVE:
adjustValidDownPoint(ev);
return checkCanInterceptTouchEvent(ev);
default:
break;
}
return false;
}
checkCanInterceptTouchEvent主要用來判斷是否需要攔截,并非不可滾動,就需要攔截事件,不可滾動隻是一個必要條件而已,
private boolean checkCanInterceptTouchEvent(MotionEvent ev) {
final float xDiff = ev.getX() - mDownMotionX;
final float yDiff = ev.getY() - mDownMotionY;
if (!canChildScrollVertically((int) yDiff,ev)) {
mInitialInterceptY = (int) ev.getY();
if (Math.abs(yDiff) > mTouchSlop && Math.abs(yDiff) >= Math.abs(xDiff)
&& !(mCurrentViewIndex == CurrentTargetIndex.UPSTAIRS && yDiff > 0
|| mCurrentViewIndex == CurrentTargetIndex.DOWNSTAIRS && yDiff < 0)) {
return true;
}
}
return false;
}
事件攔截之後,就是對Move事件進行處理
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
flingToFinishScroll();
recycleVelocityTracker();
break;
case MotionEvent.ACTION_MOVE:
scroll(ev);
break;
default:
break;
}
return true;
}
滾動比較簡單,直接調用scrollTo就可以,同時為了收集滾動速度,還可以用VelocityTracker做一下記錄:
private void scroll(MotionEvent event) {
if (mCurrentViewIndex == CurrentTargetIndex.UPSTAIRS) {
if (getScrollY() <= 0 && event.getY() > mInitialInterceptY) {
mInitialInterceptY = (int) event.getY();
}
scrollTo(0, (int) (mInitialInterceptY - event.getY()));
} else {
if (getScrollY() >= mUpstairsView.getMeasuredHeight() && event.getY() < mInitialInterceptY) {
mInitialInterceptY = (int) event.getY();
}
scrollTo(0, (int) (mInitialInterceptY - event.getY() + mUpstairsView.getMeasuredHeight()));
}
mVelocityTracker.addMovement(event);
}
收尾動畫
在Up事件之後,還要簡單的處理一下一下收尾的滾動動畫,比如,滾動距離不夠要複原,否則,就滾動到目标視圖,這裡主要是根據Up事件的位置,計算需要滾動的距離,并通過Scroller來完成剩下的滾動。
private void flingToFinishScroll() {
final int pHeight = mUpstairsView.getMeasuredHeight();
final int threshold = (int) (pHeight * mPercent);
float scrollY = getScrollY();
if (CurrentTargetIndex.UPSTAIRS == mCurrentViewIndex) {
if (scrollY <= 0) {
scrollY = 0;
} else if (scrollY <= threshold) {
if (needFlingToToggleView()) {
scrollY = pHeight - getScrollY();
mCurrentViewIndex = CurrentTargetIndex.DOWNSTAIRS;
} else
scrollY = -getScrollY();
} else {
scrollY = pHeight - getScrollY();
mCurrentViewIndex = CurrentTargetIndex.DOWNSTAIRS;
}
} else if (CurrentTargetIndex.DOWNSTAIRS == mCurrentViewIndex) {
if (pHeight - scrollY <= threshold) {
if (needFlingToToggleView()) {
scrollY = -getScrollY();
mCurrentViewIndex = CurrentTargetIndex.UPSTAIRS;
} else
scrollY = pHeight - scrollY;
} else if (scrollY < pHeight) {
scrollY = -getScrollY();
mCurrentViewIndex = CurrentTargetIndex.UPSTAIRS;
}
}
mScroller.startScroll(0, getScrollY(), 0, (int) scrollY, mDuration);
if (mOnSlideDetailsListener != null) {
mOnSlideDetailsListener.onStatueChanged(mCurrentViewIndex);
}
postInvalidate();
}
以上就是常用商品詳情黏滞布局的實作。最後附上GitHub連結 歡迎 star DragScrollDetailsLayout GitHub連結