仿照網易嚴選商品詳情頁面,整個頁面分為兩個部分,上面一部分是Native的ScrollView,下面一部分則是WebView,其目的是為了可以進行分步加載。滑動到ScrollView底部時,繼續向上拖動,可以加載下面的WebView部分。反之,滑動到WebView頂部時,繼續向下拖動,可以展示上面的ScrollView部分。其中,在向上或者向下拖動的時候,增加了一些阻力。另外,還使用了自定義控件輔助神器ViewDragHelper,可以使滑動比較流暢。
一、自定義View
總體的實現思路是對ScrollView和WebView的dispatchTouchEvent 方法進行重寫,當在ScrollView的頂部并且向上拉,或者是在WebView的底部向下拉時,自身不消費事件,讓父容器攔截事件并處理,父容器Touch事件的攔截與處理都交給ViewDragHelper來處理。
1.自定義ViewGroup
public class GoodsDetailVerticalSlideView extends ViewGroup {
private static final int VEL_THRESHOLD = 6000;// 滑動速度的閾值,超過這個絕對值認為是上下
private int DISTANCE_THRESHOLD = 75;// 單位是dp,當上下滑動速度不夠時,通過這個閾值來判定是應該粘到頂部還是底部
private OnPullListener onPullListener;// 頁面上拉或者下拉監聽器
private OnShowPreviousPageListener onShowPreviousPageListener;// 手指松開是否加載上一頁的監聽器
private OnShowNextPageListener onShowNextPageListener; // 手指松開是否加載下一頁的監聽器
private ViewDragHelper mDragHelper;
private GestureDetectorCompat mGestureDetector;// 手勢識別,處理手指在觸摸屏上的滑動
private View view1;
private View view2;
private int viewHeight;
private int currentPage;// 當前第幾頁
private int pageIndex;// 頁碼標記
/**
* 設置頁面上拉或者下拉監聽
* @param onPullListener
*/
public void setOnPullListener(OnPullListener onPullListener) {
this.onPullListener = onPullListener;
}
/**
* 設置加載上一頁監聽
* @param onShowPreviousPageListener
*/
public void setOnShowPreviousPageListener(OnShowPreviousPageListener onShowPreviousPageListener) {
this.onShowPreviousPageListener = onShowPreviousPageListener;
}
/**
* 設置加載下一頁監聽
* @param onShowNextPageListener
*/
public void setOnShowNextPageListener(OnShowNextPageListener onShowNextPageListener) {
this.onShowNextPageListener = onShowNextPageListener;
}
public GoodsDetailVerticalSlideView(Context context) {
this(context, null);
}
public GoodsDetailVerticalSlideView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public GoodsDetailVerticalSlideView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
DISTANCE_THRESHOLD = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DISTANCE_THRESHOLD, getResources().getDisplayMetrics());
// 在自定義ViewGroup時,ViewDragHelper可以用來拖拽和設置子View的位置(在ViewGroup范圍內)。
mDragHelper = ViewDragHelper.create(this, 10.0f, new DragCallBack());
mDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_BOTTOM);
mGestureDetector = new GestureDetectorCompat(getContext(), new YScrollDetector());
currentPage = 1;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
measureChildren(widthMeasureSpec, heightMeasureSpec);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (view1 == null) view1 = getChildAt(0);
if (view2 == null) view2 = getChildAt(1);
//當滑到第二頁時,第二頁的top為0,第一頁為負數。
if (view1.getTop() == 0) {
view1.layout(0, 0, r, b);
view2.layout(0, 0, r, b);
viewHeight = view1.getMeasuredHeight();
view2.offsetTopAndBottom(viewHeight);// view2向下移動到view1的底部
} else {
view1.layout(view1.getLeft(), view1.getTop(), view1.getRight(), view1.getBottom());
view2.layout(view2.getLeft(), view2.getTop(), view2.getRight(), view2.getBottom());
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
}
// Touch事件的攔截與處理都交給mDragHelper來處理
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (view1.getBottom() > 0 && view1.getTop() < 0) {
// view粘到頂部或底部,正在動畫中的時候,不處理Touch事件
return false;
}
boolean shouldIntercept = false;
boolean yScroll = mGestureDetector.onTouchEvent(ev);
try {
shouldIntercept = mDragHelper.shouldInterceptTouchEvent(ev);
//修復導致OnTouchEvent中pointerIndex out of range的異常
int action = ev.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
mDragHelper.processTouchEvent(ev);
}
} catch (Exception e) {
e.printStackTrace();
}
return shouldIntercept && yScroll;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
try {
mDragHelper.processTouchEvent(event);
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
private class DragCallBack extends ViewDragHelper.Callback {
@Override
public boolean tryCaptureView(View child, int pointerId) {
// 兩個子View都需要跟蹤,返回true
return true;
}
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
// 由于拖拽導致被捕獲View的位置發生改變時進行回調
if (changedView == view1) {
view2.offsetTopAndBottom(dy);
if (onPullListener != null && currentPage == 1) {
onPullListener.onPull(1, top);
}
}
if (changedView == view2) {
view1.offsetTopAndBottom(dy);
if (onPullListener != null && currentPage == 2) {
onPullListener.onPull(2, top);
}
}
// 如果不重繪,拖動的時候,其他View會不顯示
ViewCompat.postInvalidateOnAnimation(GoodsDetailVerticalSlideView.this);
}
@Override
public int getViewVerticalDragRange(View child) {
// 這個用來控制拖拽過程中松手后,自動滑行的速度
return child.getHeight();
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
// 滑動松開后,需要向上或者向下粘到特定的位置, 默認是粘到最頂端
int finalTop = 0;
if (releasedChild == view1) {
// 拖動view1松手
if (yvel < -VEL_THRESHOLD || releasedChild.getTop() < -DISTANCE_THRESHOLD) {
// 向上的速度足夠大或者向上滑動的距離超過某個閾值,就滑動到view2頂端
finalTop = -viewHeight;
}
} else {
// 拖動view2松手
if (yvel > VEL_THRESHOLD || releasedChild.getTop() > DISTANCE_THRESHOLD) {
// 向下的速度足夠大或者向下滑動的距離超過某個閾值,就滑動到view1頂端
finalTop = viewHeight;
}
}
//觸發緩慢滾動
//將給定子View平滑移動到給定位置,會回調continueSettling(boolean)方法,在內部是用的ScrollerCompat來實現滑動的。
//如果返回true,表明動畫應該繼續,所以調用者應該調用continueSettling(boolean)在每個后續幀繼續動作,直到它返回false。
if (mDragHelper.smoothSlideViewTo(releasedChild, 0, finalTop)) {
ViewCompat.postInvalidateOnAnimation(GoodsDetailVerticalSlideView.this);
}
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
// 限制被拖動的子View在垂直方向的移動,可以用作邊界約束
// 阻尼滑動,讓滑動位移變為1/2,除數越大阻力越大
return child.getTop() + dy / 2;
}
}
@Override
public void computeScroll() {
// 判斷smoothSlideViewTo觸發的continueSettling(boolean)的返回值
if (mDragHelper.continueSettling(true)) {
// 如果當前被捕獲的子View還需要繼續移動,則進行重繪直到它返回false,返回false表示不用后續操作就能完成這個動作了。
ViewCompat.postInvalidateOnAnimation(this);
if (view2.getTop() == 0) {
currentPage = 2;
if (onShowNextPageListener != null && pageIndex != 2) {
onShowNextPageListener.onShowNextPage();
pageIndex =2;
}
} else if (view1.getTop() == 0) {
currentPage = 1;
if (onShowPreviousPageListener != null && pageIndex != 1) {
onShowPreviousPageListener.onShowPreviousPage();
pageIndex =1;
}
}
}
}
/** 滾動到view1頂部 */
public void smoothSlideToFirstPageTop() {
if (currentPage == 2) {
//觸發緩慢滾動
if (mDragHelper.smoothSlideViewTo(view2, 0, viewHeight)) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
}
/** 滾動到view2頂部 */
public void smoothSlideToSecondPageTop() {
if (currentPage == 1) {
//觸發緩慢滾動
if (mDragHelper.smoothSlideViewTo(view1, 0, -viewHeight)) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
}
private class YScrollDetector extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) {
// 垂直滑動時dy>dx,才被認定是上下拖動
return Math.abs(dy) > Math.abs(dx);
}
}
public interface OnPullListener{
void onPull(int currentPage, int top);
}
public interface OnShowPreviousPageListener{
void onShowPreviousPage();
}
public interface OnShowNextPageListener {
void onShowNextPage();
}
}
其中,有一些回調監聽Listener,在具體業務邏輯處理的時候,可以跟Activity進行相應的交互,其余部分基本都有代碼注釋了。
2.自定義ScrollView
public class GoodsDetailScrollView extends ScrollView {
private float downX;
private float downY;
public GoodsDetailScrollView(Context context) {
this(context, null);
}
public GoodsDetailScrollView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public GoodsDetailScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = ev.getX();
downY = ev.getY();
//如果滑動到了最底部,就允許繼續向上滑動加載下一頁,否者不允許
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
float dx = ev.getX() - downX;
float dy = ev.getY() - downY;
boolean allowParentTouchEvent;
if (Math.abs(dy) > Math.abs(dx)) {
if (dy > 0) {
//ScrollView頂部下拉時需要放大圖片,自身消費事件
allowParentTouchEvent = false;
} else {
//位于底部時上拉,讓父View消費事件
allowParentTouchEvent = isBottom();
}
} else {
//水平方向滑動,自身消費事件
allowParentTouchEvent = false;
}
getParent().requestDisallowInterceptTouchEvent(!allowParentTouchEvent);
}
return super.dispatchTouchEvent(ev);
}
public boolean isTop() {
return !canScrollVertically(-1);
}
public boolean isBottom() {
return !canScrollVertically(1);
}
public void goTop() {
scrollTo(0, 0);
}
}
其中,可以根據自身業務邏輯的需要,對dispatchTouchEvent事件分發做相應的調整。
3.自定義WebView
public class GoodsDetailWebView extends WebView {
private float downX;
private float downY;
public GoodsDetailWebView(Context context) {
this(context, null);
}
public GoodsDetailWebView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public GoodsDetailWebView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = ev.getX();
downY = ev.getY();
//如果滑動到了最頂部,就允許繼續向下滑動加載上一頁,否者不允許
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
float dx = ev.getX() - downX;
float dy = ev.getY() - downY;
boolean allowParentTouchEvent;
if (Math.abs(dy) > Math.abs(dx)) {
if (dy > 0) {
//位于頂部時下拉,讓父View消費事件
allowParentTouchEvent = isTop();
} else {
//向上滑動,自身消費事件
allowParentTouchEvent = false;
}
} else {
//水平方向滑動,自身消費事件
allowParentTouchEvent = false;
}
getParent().requestDisallowInterceptTouchEvent(!allowParentTouchEvent);
}
return super.dispatchTouchEvent(ev);
}
public boolean isTop() {
return getScrollY() <= 0;
}
public boolean isBottom() {
return getHeight() + getScrollY() >= getContentHeight() * getScale();
}
public void goTop() {
scrollTo(0, 0);
}
}
同樣的,可以根據自身業務邏輯的需要,對dispatchTouchEvent事件分發做相應的調整。
二、如何使用
1.在Activity中使用GoodsDetailVerticalSlideView控件
(1)使用GoodsDetailVerticalSlideView控件,內部包含兩個子View,分別表示第一部分ScrollView和第二部分WebView,可以先使用FrameLayout占位,然后在代碼中使用Fragment替換。
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
android:id="@+id/goods_detail_vertical_slide_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:id="@+id/layout_goods_scrollview"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
android:id="@+id/layout_goods_webview"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
(2)當然,也可以直接使用GoodsDetailScrollView和GoodsDetailWebView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
android:id="@+id/goods_detail_vertical_slide_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_width="match_parent"
android:layout_height="match_parent">
......
android:layout_width="match_parent"
android:layout_height="match_parent">
......
2.在Fragment中使用GoodsDetailScrollView和GoodsDetailWebView
這邊有個注意點就是上拉或者下拉的時候,我們一般都會給用戶展示一個文字和圖片的指示器來提示用戶如何操作,我們只需要把指示器放在上面一部分的ScrollView布局里面即可,然后根據目前正在展示哪一部分進行顯示/隱藏以及文字圖片變化就可以了,這樣可以使我們的整個拖動效果看起來比較流暢。
(1)Fragment中使用GoodsDetailScrollView
android:id="@+id/goods_detail_scrollview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
......