一、可行性分析
需求可行性:一般运用于地图页面的上滑,比如地图类 app,打车类 app,外卖类 app。
技术可行性:我们知道 Android View 的滑动有 2 个大类,一个是 ViewGroup 滑动(ScrollXXX)子 View 静止,最终修改的是RenderNode的图像偏移TranslationXXX; 另一类是子 View 滑动 但ViewGroup 不动,最终修改的是left\right\top\bottom实际布局位置 ,实际上两种方式都可以实现如下效果,相比来说第一类比较容易实现。本次使用的是第二类,借助 ViewDragHelper 实现,因为其本身提供了很多工具。
当然 ViewDragHelper 存在很多问题:
- 1、不适合多个子 View 联动,因为 API 存在很多限制,使用不当容易产生丢帧问题,从而导致 View 错乱,需要多次矫正 View 位置
- 2、不适合深入定制多个View联动的滑动效果,虽然本文克服了此问题
- 3、无法捕获可滑动的 ViewGroup
简单来说,ViewDragHelper 更适合单个不可滑动 View 的操作,复杂联动显得有些鸡肋,本次开发中深有体会,如果非要实现复杂的 offset 联动效果(多个View一起滑动),建议舍弃 ViewDragHelper,直接使用事件处理或者 NestedScrolling 机制可能会会更好,直接事件处理可参考 ListView/RecyclerView,后者可参考 NestedScrollView。

从这两张图我们可以看出,左侧的Tab是吸顶的,而右侧整个Head部份是会完全展示的,两种功能,只需要简单的设置,就能改出符合需求的UI。
二、问题
难点:
- 父子 View 事件转移机制:事件转移需要先 cancel/up 本次事件,然后重新 dispatch 一个 down 事件
- Listview/recyclerView 事件捕获,ViewDragHelper 不会捕获该类 View,因此需要手动取捕获
- 偏移计算,需要实现联动需要进行偏移计算,ViewDragHelper 只能处理单个 View,因此需要进行所有 view 的偏移计算
2.1 丢帧问题处理
主要原因是多个View级联动时,可能RecyclerView和ListView也在滑动,但是ViewDragHelper调用abort时,没发调用到RecyclerView和ListView内部的Scroller来终止滚动,因此需要手动处理,主要是滑动过程只能中需要实时补偿.
java
/**
* DragerHelper无法暴漏Scroller,settle滑动状态下abort时会出现位置偏差,
* 因此需要修复View联动时处理丢帧问题
*/
private void fixLossFrame() {
int childCount = getChildCount();
int firstChildTop = getFirstChildTop();
int firstChildHeight = getFirstChildHeight();
View firstChildView = getChildAt(0);
LayoutParams lp = (LayoutParams) firstChildView.getLayoutParams();
int offsetTop = firstChildTop + firstChildHeight + lp.topMargin + lp.bottomMargin;
for (int i = 1; i < childCount; i++) {
View child = getChildAt(i);
lp = (LayoutParams) child.getLayoutParams();
int childTop = child.getTop();
int expectTop = offsetTop + lp.topMargin;
if (childTop != expectTop) {
ViewCompat.offsetTopAndBottom(child, expectTop - childTop);
}
offsetTop += child.getHeight() + lp.topMargin + lp.bottomMargin;
}
}
2.2 无法捕获可滑动的 ViewGroup
主要原因是ListView和RecyclerView的事件捕获方式存在差异,导致优先级比较高,因此优先捕获事件。问题主要出现在下滑过程中无法捕获ListView、RecyclerView、ScrollView,上滑过程中事件被ListView、RecyclerView、ScrollView抢占。
java
float currentY = ev.getY();
float dy = currentY - startEventY;
View view = mDragHelper.findTopChildUnder((int) startEventX, (int) startEventY);
if (view == null) {
break;
}
boolean isAtTop = isAtTop(view);
if (!isAtTop) {
break;
}
//兼容向下滑动时,事件被传递给ListView,RecyclerView的问题
if (dy > 0) {
Log.d("TouchEvent", "1----isAtTop==" + isAtTop);
mDragHelper.captureChildView(view, 0);
shouldAbortDragHelper = false;
return true;
} else {
//兼容向上滑动时,事件被传递给ListView,RecyclerView的问题
int firstChildTop = getFirstChildTop();
int firstChildHeight = getFirstChildHeight();
int firstChildOffsetTop = firstChildTop;
if (allowHeaderOverScoll) {
firstChildOffsetTop += firstChildHeight;
}
if (firstChildOffsetTop > 0) {
mDragHelper.captureChildView(view, 0);
shouldAbortDragHelper = false;
return true;
}
}
2.3 全部代码
下面是NestedScrollLayout完整实现,这里我们要注意的以下几点
- 布局测量
- 布局滑动
- scrolling consume
java
public class NestedScrollLayout extends FrameLayout implements View.OnTouchListener {
private final ViewDragHelperCallback mViewDragCallback;
private ViewDragHelper mDragHelper;
private DisplayMetrics displayMetrics = null;
private boolean shouldAbortDragHelper = false;
float startEventY = 0;
float startEventX = 0;
float childStartEventX = 0f;
float childStartEventY = 0f;
//允许头部划出顶部
private boolean allowHeaderOverScoll = false;
//默认偏移基线
private int defaultOffsetTop = 0;
//是否首次布局
private boolean isFirstLayout = true;
private static final int MIN_FLING_VELOCITY = 500; // dips per second
public NestedScrollLayout(Context context) {
this(context, null);
}
public NestedScrollLayout(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public NestedScrollLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mViewDragCallback = new ViewDragHelperCallback(this);
displayMetrics = getResources().getDisplayMetrics();
mDragHelper = ViewDragHelper.create(this, mViewDragCallback);
mDragHelper.setMinVelocity(MIN_FLING_VELOCITY * displayMetrics.density);
defaultOffsetTop = (int) dp2px(300);
setWillNotDraw(false);
}
public float dp2px(float dp) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
}
public void setAllowHeaderOverScoll(boolean allowHeaderOverScoll) {
this.allowHeaderOverScoll = allowHeaderOverScoll;
}
public void setDefaultOffsetTop(int defaultOffsetTop) {
if (defaultOffsetTop < 0) {
defaultOffsetTop = 0;
}
this.defaultOffsetTop = defaultOffsetTop;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int childCount = getChildCount();
if (childCount == 0) {
return super.onInterceptTouchEvent(ev);
}
boolean shouldIntercept = mDragHelper.shouldInterceptTouchEvent(ev);
if (shouldIntercept) {
shouldAbortDragHelper = false;
return true;
}
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
startEventY = ev.getY();
startEventX = ev.getX();
break;
case MotionEvent.ACTION_MOVE:
float currentY = ev.getY();
float dy = currentY - startEventY;
View view = mDragHelper.findTopChildUnder((int) startEventX, (int) startEventY);
if (view == null) {
break;
}
boolean isAtTop = isAtTop(view);
if (!isAtTop) {
break;
}
//兼容向下滑动时,事件被传递给ListView,RecyclerView的问题
if (dy > 0) {
Log.d("TouchEvent", "1----isAtTop==" + isAtTop);
mDragHelper.captureChildView(view, 0);
shouldAbortDragHelper = false;
return true;
} else {
//兼容向上滑动时,事件被传递给ListView,RecyclerView的问题
int firstChildTop = getFirstChildTop();
int firstChildHeight = getFirstChildHeight();
int firstChildOffsetTop = firstChildTop;
if (allowHeaderOverScoll) {
firstChildOffsetTop += firstChildHeight;
}
if (firstChildOffsetTop > 0) {
mDragHelper.captureChildView(view, 0);
shouldAbortDragHelper = false;
return true;
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
shouldAbortDragHelper = true;
break;
}
return false;
}
/**
* 是否可捕获当前view,如果所有子view位置偏移都在顶部,可捕获,
* 如果类似ListView被滑动了,那么不要进行捕获
* @return
*/
private boolean shouldCaptureView() {
int childCount = getChildCount();
if (childCount == 0) return false;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (!isAtTop(childView)) {
Log.d("shouldCaptureView","false");
return false;
}
}
return true;
}
private boolean isAtTop(View view) {
if (view == null) return false;
if (!(view instanceof ViewGroup)) {
return true;
}
if (view instanceof ListView) {
int firstVisiblePosition = ((ListView) view).getFirstVisiblePosition();
if (firstVisiblePosition > 0 || firstVisiblePosition < 0) {
return false;
}
View topChild = ((ListView) view).getChildAt(0);
return topChild.getTop() >= 0;
}
if (view instanceof RecyclerView) {
//显示区域最上面一条信息的position
RecyclerView.LayoutManager manager = ((RecyclerView) view).getLayoutManager();
if (manager == null
|| !(manager instanceof LinearLayoutManager)) {
return true;
}
int visibleItemPosition = ((LinearLayoutManager) manager).findFirstVisibleItemPosition();
View topChild = getChildAt(0);//getChildAt(0)只能获得当前能看到的item的第一个
return topChild != null && visibleItemPosition <= 0 && topChild.getTop() >= 0;
}
if (view instanceof ScrollView) {
ScrollView scrollView = ((ScrollView) view);
int childCount = scrollView.getChildCount();
if (childCount == 0) {
return true;
}
return scrollView.getScrollY() <= 0;
}
if ((view instanceof ViewGroup)) {
return true;
}
return false;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int childCount = getChildCount();
if (childCount == 0) {
return super.onTouchEvent(event);
}
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_MOVE) {
if (shouldAbortDragHelper) {
event.setAction(MotionEvent.ACTION_CANCEL);
mDragHelper.processTouchEvent(event);
MotionEvent actionDown = MotionEvent.obtain(event);
actionDown.setAction(MotionEvent.ACTION_DOWN); //事件重启
super.dispatchTouchEvent(actionDown);
return true;
}
}
mDragHelper.processTouchEvent(event);
return true;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int width = 0;
if (widthMode == MeasureSpec.UNSPECIFIED) {
LayoutParams lp = (LayoutParams) getLayoutParams();
width = displayMetrics.widthPixels - lp.leftMargin - lp.rightMargin ;
} else {
width = MeasureSpec.getSize(widthMeasureSpec);
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int height = 0;
if (heightMode == MeasureSpec.UNSPECIFIED) {
LayoutParams lp = (LayoutParams) getLayoutParams();
height = displayMetrics.heightPixels - lp.topMargin - lp.bottomMargin;
} else {
height = MeasureSpec.getSize(heightMeasureSpec);
}
int childCount = getChildCount();
if (childCount == 0) return;
for (int i=0;i<childCount;i++){
View child = getChildAt(i);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
int contentWidth = width-lp.leftMargin-lp.rightMargin - getPaddingRight() - getPaddingLeft();
int childHeight = child.getMeasuredHeight();
if ((child instanceof ListView)
|| (child instanceof RecyclerView)
|| (child instanceof ScrollView)) {
child.setOnTouchListener(this);
int expectHeight = 0;
int startIndex = allowHeaderOverScoll?1:0;
int usedHeight = 0;
for (int j=i-1;j>=startIndex;j--){
View last = getChildAt(j);
LayoutParams lastlp = (LayoutParams) last.getLayoutParams();
usedHeight += last.getMeasuredHeight() +lastlp.bottomMargin+lastlp.topMargin;
}
LayoutParams childlp = (LayoutParams) child.getLayoutParams();
expectHeight = height - usedHeight-childlp.topMargin - childlp.bottomMargin;
child.measure(MeasureSpec.makeMeasureSpec(contentWidth,MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(expectHeight,MeasureSpec.EXACTLY));
}else{
child.measure(MeasureSpec.makeMeasureSpec(contentWidth,MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(childHeight,MeasureSpec.EXACTLY));
}
}
setMeasuredDimension(width, height);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
int childCount = getChildCount();
if (childCount == 0) return;
int parentHeight = bottom - top;
int maxOffsetTop = (int) (parentHeight - getChildAt(0).getMeasuredHeight() - dp2px(20) - getPaddingTop());
if (defaultOffsetTop > maxOffsetTop) {
this.defaultOffsetTop = maxOffsetTop;
}
int childTop = getPaddingTop();
int childLeft = 0;
if(isFirstLayout){
childTop = defaultOffsetTop;
isFirstLayout = true;
}
final int count = getChildCount();
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (child == null) {
childTop += 0;
} else {
final int childWidth = child.getMeasuredWidth();
final int childHeight = child.getMeasuredHeight();
final LayoutParams lp =
(LayoutParams) child.getLayoutParams();
childLeft = getPaddingLeft() + lp.leftMargin;
childTop += lp.topMargin;
child.layout(childLeft, childTop ,
childWidth, childTop+childHeight);
childTop += childHeight + lp.bottomMargin + 0;
}
}
}
private void onViewPositionChanged(View childView, int left, int top, int dx, int dy) {
int childCount = getChildCount();
if (childCount == 0) {
return;
}
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (child == childView) continue;
int destTop = child.getTop()+dy;
if(dy<0) {
if(destTop<getChildMinTop(child)){
dy = getChildMinTop(child) - child.getTop();
}
ViewCompat.offsetTopAndBottom(child, dy);
}else{
if(destTop>getChildMaxTop(child)) {
dy = getChildMaxTop(child) - child.getTop();
}
ViewCompat.offsetTopAndBottom(child, dy);
}
}
int firstChildTop = getFirstChildTop();
int firstChildHeight = getFirstChildHeight();
fixLossFrame();
if (dy <=0 && firstChildTop <= 0) {
if (allowHeaderOverScoll) {
if (Math.abs(firstChildTop) >= firstChildHeight) {
shouldAbortDragHelper = true;
}
} else {
shouldAbortDragHelper = true;
}
} else {
shouldAbortDragHelper = false;
}
Log.d("CaptureView", "top=" + top);
}
/**
* DragerHelper无法暴漏Scroller,settle滑动状态下abort时会出现位置偏差,
* 因此需要修复View联动时处理丢帧问题
*/
private void fixLossFrame() {
int childCount = getChildCount();
int firstChildTop = getFirstChildTop();
int firstChildHeight = getFirstChildHeight();
View firstChildView = getChildAt(0);
LayoutParams lp = (LayoutParams) firstChildView.getLayoutParams();
int offsetTop = firstChildTop + firstChildHeight + lp.topMargin + lp.bottomMargin;
for (int i = 1; i < childCount; i++) {
View child = getChildAt(i);
lp = (LayoutParams) child.getLayoutParams();
int childTop = child.getTop();
int expectTop = offsetTop + lp.topMargin;
if (childTop != expectTop) {
ViewCompat.offsetTopAndBottom(child, expectTop - childTop);
}
offsetTop += child.getHeight() + lp.topMargin + lp.bottomMargin;
}
}
@Override
public boolean onTouch(View v, MotionEvent event) {
if (!shouldAbortDragHelper&&!isAtTop(v)) {
return false;
}
int action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_DOWN:
childStartEventX = event.getX();
childStartEventY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
boolean isAtTop = isAtTop(v);
float cy = event.getY();
float dy = event.getY() - childStartEventY;
childStartEventY = cy;
Log.d("onTouchChildView", "isAtTop=" + isAtTop + ",dy=" + dy);
if (isAtTop && dy > 0) {
event.setAction(MotionEvent.ACTION_CANCEL);
MotionEvent actionDown = MotionEvent.obtain(event);
actionDown.setAction(MotionEvent.ACTION_DOWN);
super.dispatchTouchEvent(actionDown);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_OUTSIDE:
childStartEventX = 0f;
childStartEventY = 0f;
Log.d("onTouchChildView", "up");
break;
}
return false;
}
public static class ViewDragHelperCallback extends ViewDragHelper.Callback {
NestedScrollLayout mScrollLayout;
public ViewDragHelperCallback(NestedScrollLayout scrollLayout) {
this.mScrollLayout = scrollLayout;
}
@Override
public boolean tryCaptureView(View child, int pointerId) {
Log.d("CaptureView", "child=" + child);
return this.mScrollLayout.shouldCaptureView();
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return this.mScrollLayout.computeCaptureViewOffsetTop(child, top, dy);
}
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
this.mScrollLayout.onViewPositionChanged(changedView, left, top, dx, dy);
}
@Override
public void onViewDragStateChanged(int state) {
super.onViewDragStateChanged(state);
Log.d("DragState", "state=" + state);
this.mScrollLayout.onViewDragStateChanged(state);
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
this.mScrollLayout.onViewReleased(releasedChild, xvel, yvel);
}
}
private void onViewDragStateChanged(int state) {
}
private void onViewReleased(View releasedChild, float xvel, float yvel) {
if (releasedChild == null) return;
int firstChildTop = getFirstChildTop();
int rangY = firstChildTop - defaultOffsetTop;
if(rangY==0) return;
mDragHelper.abort();
if (rangY <= 0 && rangY > -dp2px(120) || rangY > 0) {
int firstChildMaxTop = getChildMaxTop(getChildAt(0));
mDragHelper.smoothSlideViewTo(releasedChild, 0, getChildMaxTop(releasedChild) - (firstChildMaxTop - defaultOffsetTop));
} else {
mDragHelper.smoothSlideViewTo(releasedChild, 0, getChildMinTop(releasedChild));
}
ViewCompat.postInvalidateOnAnimation(this);
Log.d("onViewReleased", "xvel=" + xvel + " yvel=" + yvel);
}
private int getChildMinTop(View child) {
int childCount = getChildCount();
int childTopOffset = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (childView == child) break;
LayoutParams lp = (LayoutParams) childView.getLayoutParams();
childTopOffset += childView.getHeight() + lp.topMargin + lp.bottomMargin;
}
if (allowHeaderOverScoll) {
return childTopOffset - getFirstChildHeight();
} else {
return childTopOffset;
}
}
private int getChildMaxTop(View child) {
int childCount = getChildCount();
int childTopOffset = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (childView == child) break;
LayoutParams lp = (LayoutParams) childView.getLayoutParams();
childTopOffset += childView.getHeight() + lp.topMargin + lp.bottomMargin;
}
return childTopOffset + (getHeight() - getFirstChildHeight());
}
public int getFirstChildHeight() {
int childCount = getChildCount();
if (childCount == 0) return 0;
return getChildAt(0).getHeight();
}
public int getFirstChildTop() {
int childCount = getChildCount();
if (childCount == 0) return 0;
return getChildAt(0).getTop();
}
@Override
public void computeScroll() {
super.computeScroll();
if (mDragHelper.continueSettling(true)) {
if (shouldAbortDragHelper) {
mDragHelper.abort();
return;
}
ViewCompat.postInvalidateOnAnimation(this);
}
}
/*
* 计算view可偏移的top数据
*/
private int computeCaptureViewOffsetTop(View captureView, int top, int dy) {
int childCount = getChildCount();
if (childCount == 0) {
return 0;
}
int childTop = getFirstChildTop();
int childHeight = getFirstChildHeight();
if (childTop <= 0 && dy < 0) {
//处于顶部
if (allowHeaderOverScoll) {
if (Math.abs(childTop) >= childHeight) {
return captureView.getTop();
} else if (Math.abs(dy + childTop) > childHeight) {
int offset = -(childHeight + childTop);
return captureView.getTop() + offset;
}
} else {
if (childTop <= 0) {
return getChildMinTop(captureView);
}
}
}
if (childTop > 0 && dy > 0 && (childTop + childHeight + dy) > getHeight()) {
//处于底部
int offset = getHeight() - childTop - childHeight;
return captureView.getTop() + offset;
}
return top;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mDragHelper.abort();
}
}
三、使用
这里我们主要需要注意的是BODY和HEAD的属性标记
java
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@mipmap/img_map"
tools:context="com.smartian.widget.layout.ScollLayoutActivity">
<com.smartian.widget.layout.NestedScrollLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="true"
android:focusableInTouchMode="true">
<TextView
android:layout_width="match_parent"
android:layout_height="100dp"
android:background="@color/colorPrimary"
android:gravity="center"
android:text="健康快乐每一天"
android:textColor="@android:color/white"
android:textSize="30sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="@color/colorAccent"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="Tab A"
android:textColor="@color/selector_tab" />
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="#ffffff" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="Tab B" />
</LinearLayout>
<ListView
android:id="@+id/listView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffffff" />
</com.smartian.widget.layout.NestedScrollLayout>
</LinearLayout>
四、总结
主要难度是事件处理和ViewDragHelper多个View联动处理,因此在这里切记,ViewDragHelper适合单个不可滑动的View操作,多个View或者可滑动View建议使用ScorllLing机制。