探探的滑动选择妹子的功能,算是一个很经典的交互方式。自从出来以后可以说是备受关注,渐渐地很多类似功能的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,请参考源码。