Pinterest中有一个非常棒的交互,大概的操作就是在列表页可以通过长按单个Item来讲Item选中并且让周围的item变透明白,然后弹出选项菜单,可以通过手指的移动来选择菜单,我们来看个图,对比下简物中的实现以及Pinterest的效果
右边是Pinterest,左边是Jianwoo,图标的红色是我app的主题红,和Pinterest的是有一点不一样,其它的仿制程度和Pinterest没有多少差异,下面放一个Jianwoo中实现后的交互动画图
这种交互效果是不是很棒!下面我们来讨论一下具体实现思路,会遇到什么问题,要怎么解决,
做事,思路最重要!
分析
要实现的效果有哪几个?
- 1、长按item后,要弹出菜单
- 2、弹出菜单后要可以通过触摸屏幕选择菜单
- 3、菜单到了选择区域,图标要有变大的动画并且显示当前菜单的标题文字
- 4、除了选中区域外,均要变成白色
- 5、长按后Title栏和底部Tab栏有退出动画,松手后有回来的动画
既然已经确定了问题,那我们就来解决问题
长按Item后弹出菜单
问题:长按后弹出菜单,菜单出现在哪儿?(菜单所在的容器控件应该是什么)是当前的Fragment/Activity上吗?
这是第一个问题,要弹出菜单,菜单不能凭空出现,首先要确定承载它的容器,用当前Activity或者Fragment的根布局去添加,行不行?行,但应不应该这样去做,这样做好不好?答案是不好,why?
试想一下:如果弹出的菜单是在当前的的Activity/Fragment的布局之上,那就意味着你Activity/Fragment的布局就得为这个交互做适应,LineaLayout肯定就不能作为Activity/Fragment的根布局了,而且最重要的是:一旦你决定用Activity/Fragment的布局去添加这些View元素,那就意味着你这个交互就和Activity/Fragment绑定在一起了,要复用将会非常麻烦!这是绝对不应该采取的方案,开始错了,后面将会错一大片
那我们应该用什么,我最开始想到的是WindowManager,为什么?因为WindowManager不需要Activity作为宿主,自由方便,通过合理的设置WindowManager.LayoutParams.type还可以使弹出的悬浮窗不需要权限,这很美好,但是后面遇到了一个世纪性的bug:小米机型上如果用户设置了不允许悬浮窗弹出,那将意味着WindowManager无法使用,这种情况下连Toast也无法使用了!这不得不说是一个产商为了用户而做的一个半阉割,于是我实现了Dialog的实现方案,但是其实,不管用WIndowManger还是Dialog,其实都不影响你去实现主要功能,因为WindowManager/Dialog只需要你提供一个View给它,以及Dialog再需要一个宿主Activity而已
既然已经确定了弹出的View所出现的容器,那我们就进行下一步问题解决,如何确定弹出菜单的位置?
这是一个长按弹出的菜单,那首先对Item view设置OnTouchListener监听,通过监听ACTION_DOWN可以获取用户按下的在屏幕中的坐标x,y
在Adapter中
getHolder(holder).mImage.setOnTouchListener(listener);
public TouchToSelector onTouch(View v, MotionEvent event){
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
/**
* 获取按下的x,y坐标
*/
setTouchX(event.getRawX());
setTouchY(event.getRawY());
/**
* 记录按下时间
*/
setDownTime(System.currentTimeMillis());
create(v);
break;
case MotionEvent.ACTION_CANCEL:
....
break;
case MotionEvent.ACTION_UP:
....
break;
}
return this;
}
注意获取一个Item的监听事件在屏幕中的x,y坐标是getRawX() && getRawY()
我们已经获取到了按下的坐标,那现在可以对弹出菜单的位置进行计算了,Pinterest中的图标出现策略不仅仅是在按下手指周边出现图标,并且会根据按下坐标的x,y确定图标出现的方向,也就是当手指在屏幕上边时,图标是朝下的,当手指的屏幕右边是,图标是朝左的,也就是
- 1、将屏幕分成四个区域,通过x,y坐标确定区域,确定图标出现方向
- 2、根据图标出现的方向,以及x,y坐标,基于每个图标之间间隔角度为45°计算出图标的中心点坐标,这里既可以用二次函数,也可以用三角函数,我这里用的是三角函数
第一步:根据按下屏幕中的x,y坐标,获取象限区域(这里和数学上的象限顺序并不一致)
/**
* 按下在屏幕中的x,y坐标
* getScreenWidth() 屏幕宽度
* getScreenHeight() 屏幕高度
* @param x x坐标
* @param y y坐标
* @return
*/
private int getQuadrant(int x, int y){
/**
* 第一区域
*/
if(x < getScreenWidth() / 2 && y < getScreenHeight() / 2){
return 1;
}
/**
* 第二区域
*/
if(x < getScreenWidth() && x > getScreenWidth() / 2 && y < getScreenHeight() / 2){
return 2;
}
/**
* 第三区域
*/
if(x < getScreenWidth() && x > getScreenWidth() / 2 && y > getScreenHeight() / 2 && y < getScreenHeight()){
return 3;
}
/**
* 第四区域
*/
if(x < getScreenWidth() / 2 && y > getScreenHeight() / 2 && y < getScreenHeight()){
return 4;
}
return 3;
}
获取到了象限区域后,我们可以知道图标出现的位置与按下点所形成的角度,通过角度以及确定出现的距离长度,可以计算出图标出现的x,y坐标,然后通过设置图标的LayoutParams可以让图标出现在指定的位置(以下相关代码并非在同一个类)
/**
* 图标位置和按下点和x轴形成三角形的 x 所在的直角边宽度
*/
protected int mWidth;
/**
* 图标位置和按下点和x轴形成三角形的 y 所在的直角边高度
*/
protected int mHeight;
/**
* 图标所在位置左上角x
*/
protected int mIndexX;
/**
* 图标所在位置左上角y
*/
protected int mIndexY;
public void initParams(){
setVisibility(View.GONE);
mBaseDegree = 0;
setItemId(mITouchView.getItemId());
mNormalResId = mITouchView.getImageResNormal();
mPressResId = mITouchView.getImageResPress();
mTitle = mITouchView.getImageTitle();
mIndex = mITouchView.getImageIndex();
setImageResource(getNormalResId());
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(getIconWidth(),getIconHeight());
/**
* 高
*/
mHeight = Math.abs((int)(Math.sin(getAngle(getAngle())) * getR()));
/**
* 宽
*/
mWidth = Math.abs((int)(Math.cos(getAngle(getAngle())) * getR()));
mIndexX = getTouchX() - mWidth - getIconWidth() / 2;
mIndexY = getTouchY() - mHeight - getIconHeight() / 2 - getStatusBarHeight();
params.setMargins(getIndexX(), getIndexY(), 0, 0);
setLayoutParams(params);
}
/**
* 根据index顺序确定以及象限区域应该出现的角度
* getFactor() 图标与按下坐标连线之间的角度 这里是45°
* getSingleAngle()最终返回的是根据象限计算的基本角度
* @return
*/
public int getAngle(){
return getIndex() * getFactor() + getSingleAngle();
}
/**
* 根据传入的角度计算真实的角度(用于Math中的三角函数计算)
* @param angle
* @return
*/
public double getAngle(int angle){
return angle * Math.PI / 180;
}
/**
* 两个图标的角度
* @return
*/
public int getSingleAngle(){
return getBaseDegree();
}
public int getBaseDegree() {
switch (getQuadrant(getTouchX(), getTouchY())){
case 1:
return mBaseDegree + 135;
case 2:
return mBaseDegree + 225;
case 3:
return mBaseDegree + 0;
case 4:
return mBaseDegree + 45;
}
int degree = 180 - (1 - getQuadrant(getTouchX(), getTouchY())) * 90;
degree = degree > 360 ? degree - 360 : degree;
return degree + mBaseDegree;
}
知道了图标的x,y坐标,也知道图标的角度,那现在可以开始做一个出现和消失的动画了
public void show(){
setVisibility(View.VISIBLE);
invalidate();
TranslateAnimation animation = new TranslateAnimation(getShowAndHideXY()[0], 0, getShowAndHideXY()[1], 0);
animation.setDuration(200);
animation.setStartOffset(TouchToSelecttorConfig.ANIMATION_START_OFFSET);
animation.setInterpolator(new LinearOutSlowInInterpolator());
startAnimation(animation);
}
public void hide(){
TranslateAnimation animation = new TranslateAnimation(0, getShowAndHideXY()[0], 0, getShowAndHideXY()[1]);
animation.setDuration(200);
animation.setInterpolator(new LinearOutSlowInInterpolator());
animation.setFillAfter(true);
startAnimation(animation);
}
/**
* getW() 图标位置和按下点和x轴形成三角形的 x 所在的直角边宽度
* getH() 图标位置和按下点和x轴形成三角形的 y 所在的直角边高度
* 按下去时的移动动画的fromX 和 fromY
* @return
*/
private int[] getShowAndHideXY(){
int[] showAndHideXY = new int[2];
switch (getQuadrant(getTouchX(), getTouchY())){
case 1:
showAndHideXY[0] = getW() * -1;
showAndHideXY[1] = getH() * -1;
break;
case 2:
showAndHideXY[0] = getW();
showAndHideXY[1] = getH() * -1;
break;
case 3:
showAndHideXY[0] = getW();
showAndHideXY[1] = getH();
break;
case 4:
showAndHideXY[0] = getW() * -1;
showAndHideXY[1] = getH();
break;
}
return showAndHideXY;
}
出现的位置以及出现消失的动画都解决了,那下面就来解决如何在Adapter Item view设置OnTouchListener后将触摸事件扩散到整个屏幕?
我们刚刚对Adapter Item view设置了一个OnTouchListener
getHolder(holder).mImage.setOnTouchListener(listener);
但是这个监听所管控的区域仅仅是view的大小区域,而弹出的窗口可能是WindowManager也可能是Dialog,也就是和Activity不在一个View层面上,那我们要怎样让弹出的View可以接收到我们的onTouch事件呢?
我们无法将view的onTouch事件分发给WindowManager/Dialog,但我们可以把事件分发给Activity所在Content或者当前Fragment的子容器ScrollView,而弹出的View只需要触摸的x,y坐标而已,我们把分发后接收到的x,y坐标传给弹出的View不就好了,seems like a good idea
那这里就涉及到View的事件分发机制了,比如界面层次依次有如下容器和View的嵌套:Activity容器A,Fragment容器B,ScrollView容器C,Recyclerview容器D,Adapter View E,如果你了解Android的事件分发机制的话你会知道,事件分发顺序依次是从A -> B -> C -> D -> E,如果中间某个容器重写了onTouchEvent或者设置了onTouchListener并且设置返回值为true,那当前层次ViewGroup将会消费掉事件,并且不会再往下分发,当然如果你在当前容器设置onIntercepteTouchEvent返回true拦截掉事件也会阻止事件往下分发,那我们在这里其实没有必要让父容器拦截掉事件
因为本身这不是处理滑动冲突,我们只需要拿到当前View的一个操作区域更大的父容器并且给其设置一个onTouchListener监听返回值为true就可以把事件消费并且不再往下分发了,这是重点
那我们怎么做呢
1、因为我们是长按后才弹出菜单,也就是要求应该是弹出菜单后才开始给父容器设置onTouchListener,那这件事情是在Adapter view的onTouch方法中的ACTION_DOWN做的,如何处理呢?我们可以启用一个计时器,这个计时器等待的时间就是我们长按的时间,时间到了我们就开始后面的操作
public TouchToSelector onTouch(View v, MotionEvent event){
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
/**
* 获取按下的x,y坐标
*/
setTouchX(event.getRawX());
setTouchY(event.getRawY());
/**
* 记录按下时间
*/
setDownTime(System.currentTimeMillis());
create(v);
break;
case MotionEvent.ACTION_CANCEL:
...
break;
case MotionEvent.ACTION_UP:
....
break;
}
return this;
}
private TouchToSelector create(View view){
this.mView = view;
this.mContext = view.getContext();
setCanLongClick(true);
initTimer();
return this;
}
private void initTimer(){
mTimer = new Timer();
TimerTask task = new TimerTask() {
@Override
public void run() {
if(isCanLongClick()){
mHandler.sendEmptyMessage(LONG_CLICK);
}
}
};
mTimer.schedule(task, TouchToSelecttorConfig.LONG_CLICK);
}
这就是长按时间的实现方式以及流程,那我们在收到了计时器到时间的消息后,我们就应该拿到父容器并且设置onTouchListener,将事件消费并且不往下分发,handleMessage方法就不写了
private void handleOnLongClick(){
dispatchTouchEvent();
initWindowManager();
initBg();
dispatchEvent();
if(null != onLongClickListener){
onLongClickListener.onLongClick(mView);
}
}
private void dispatchTouchEvent(){
getScrollView().setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if(null != mBg){
mBg.onTouchEvent(event);
}
switch (event.getAction()){
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
return getScrollView().onTouchEvent(event);
}
return true;
}
});
}
private ViewGroup getScrollView(){
return (ViewGroup) findViewById(mScrollViewId != 0 ? mScrollViewId : android.R.id.content);
}
注意了,我在onTouch监听里面将事件传递给了mBg,mBg是啥呢,就是我们弹出菜单的的父容器,那我们只需要在mBg里面做坐标判断,就可以知道,是否触碰到了哪个图标了,下面代码节选mBg.onTouchEvent()->ACTION_MOVE以及图标ImageView内部的方法判断,通过距离和角度的判断来响应是哪个图标被选中,选中的图标将结果通过监听返回出去
public void handleActionMove(MotionEvent event){
for(PinterestSelectorImageView pinterestSelectorImageView:mPinterestSelectorImageViews){
pinterestSelectorImageView.handleActionMove(event);
}
}
public void handleActionMove(MotionEvent event){
if(!isInDistance(event)){
if(mScaleToBig){
hideTitle();
handleItemUnSelect();
}
}
if(isInDistance(event) && isInAngle(event)){
scaleToBig();
}else {
scaleToSmall();
}
}
private boolean isInDistance(MotionEvent event){
int distance = (int)getTwoPointDistance((int)event.getRawX(), (int)event.getRawY(), getTouchX(), getTouchY() + getStatusBarHeight());
return distance > getR() / 3 && distance < (getR() + getR() / 3);
}
/**
* 得到按下的点和移动点所连成线与X轴的角度
* @return
*/
private boolean isInAngle(MotionEvent event){
int x1 = (int)event.getRawX();
int y1 = (int)event.getRawY();
int degree = getTwoPointDegreeDiffTouchXY(x1, y1);
int minDegree = getMinDegree();
int maxDegree = getMaxDegree();
return degree > minDegree && degree < maxDegree;
}
现在触摸和判断是否选中图标也解决了,还剩下一个问题,那就是周边变白是如何实现的,我们再回过头来看下图
除了ITEM所在Layout,其它区域均为白色,怎么实现呢,其实不难
- 1、设置弹出的View容器背景为透明
- 2、给背景添加一个View,这个View被切割了一款区域,即“选中item的区域”
- 3、如何切割呢,这里要用到Path,用Path来画路径,然后给View画上去,即在draw方法里面
canvas.drawPath(path, paint);
这个路径怎么画?画的路径方式有很多,最终只要把item区域隔开就行,不过这里有一个要点:按下的位置x,y不是layout view左上角的坐标,获取view在屏幕中的坐标,要用以下方法
int[] location = new int[2];
view.getLocationOnScreen(location);
画路径的代码我就不详细说了,这里我把我画路径的代码贴出来,就不描述了
public void draw(Canvas canvas) {
final int x = getViewLocaltionX(itemLayout);
final int y = getViewLocaltionY(itemLayout) - getStatusBarHeight();
float indexY = y + (dialogMode ? -BaseUtils.getStatusBarHeight() : 0);
float itemMeasureWidth = itemLayout.getMeasuredWidth();
float itemMeasureHeight = itemLayout.getMeasuredHeight();
Paint paint=new Paint();
/**
* 去锯齿
*/
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.FILL);
/**
* 设置paint的颜色
*/
paint.setColor(Color.parseColor(color));
Path path = new Path();
path.moveTo(0, 0);
path.lineTo(getScreenWidth(), 0);
path.lineTo(getScreenWidth(), indexY);
path.lineTo(x, indexY);
path.lineTo(x, indexY + itemMeasureHeight);
path.lineTo(x + itemMeasureWidth, indexY + itemMeasureHeight);
path.lineTo(x + itemMeasureWidth, indexY);
path.lineTo(getScreenWidth(), indexY);
path.lineTo(getScreenWidth(), getScreenHeight());
path.lineTo(0, getScreenHeight());
path.close();
canvas.drawPath(path, paint);
super.draw(canvas);
}
private int getViewLocaltionX(View v){
return getViewLocation(v)[0];
}
private int getViewLocaltionY(View v){
return getViewLocation(v)[1];
}
private int[] getViewLocation(View v){
int[] location = new int[2];
v.getLocationOnScreen(location);
return location;
}
注意indexY是在区分Dialog模式和WindowManager模式下,status_bar高度的加减问题,如果状态栏存在,那就需要减去这个高度
以上就是高仿Pinterest交互的基本实现原理和思路了