Android仿网易严选商品详情页
仿照网易严选商品详情页面,整个页面分为两个部分,上面一部分是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替换。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
<com.my.widgets.GoodsDetailVerticalSlideView
android:id="@+id/goods_detail_vertical_slide_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/layout_goods_scrollview"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<FrameLayout
android:id="@+id/layout_goods_webview"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</com.my.widgets.GoodsDetailVerticalSlideView>
</RelativeLayout>
(2)当然,也可以直接使用GoodsDetailScrollView和GoodsDetailWebView
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
<com.my.widgets.GoodsDetailVerticalSlideView
android:id="@+id/goods_detail_vertical_slide_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.my.widgets.GoodsDetailScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
......
</com.my.widgets.GoodsDetailScrollView>
<com.my.widgets.GoodsDetailWebView
android:layout_width="match_parent"
android:layout_height="match_parent">
......
</com.my.widgets.GoodsDetailWebView>
</com.my.widgets.GoodsDetailVerticalSlideView>
</RelativeLayout>
2.在Fragment中使用GoodsDetailScrollView和GoodsDetailWebView
这边有个注意点就是上拉或者下拉的时候,我们一般都会给用户展示一个文字和图片的指示器来提示用户如何操作,我们只需要把指示器放在上面一部分的ScrollView布局里面即可,然后根据目前正在展示哪一部分进行显示/隐藏以及文字图片变化就可以了,这样可以使我们的整个拖动效果看起来比较流畅。
(1)Fragment中使用GoodsDetailScrollView
<?xml version="1.0" encoding="utf-8"?>
<com.my.widgets.GoodsDetailScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/goods_detail_scrollview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
......
<!-- 此处应有上拉/下拉指示器 --!>
</LinearLayout>
</com.my.widgets.GoodsDetailScrollView>
(2)Fragment中使用GoodsDetailWebView
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.my.widgets.GoodsDetailWebView
android:layout_width="match_parent"
android:layout_height="match_parent">
</com.my.widgets.GoodsDetailWebView>
</RelativeLayout>