整理下Android开发中列表点击反馈的一些知识点,就是点击Item会出现背景阴影的效果。
目前最常用的两个列表控件ListView和RecyclerView,可以说RecyclerView作为ListView的升级版有着更强大的功能,现在也基本都使用RecyclerView居多。当然了,RecyclerView还是有些属性设置没有但ListView有,就是下面要说的点击反馈。
ListView
先来说说ListView,ListView默认就带有点击反馈效果,不需要特意去设置。当然了,你如果不想要默认的点击反馈效果或想替换别的样式,都可以通过设置一下属性来实现:
<!-- 去除ListView的点击反馈效果 -->
<ListView
android:listSelector="@android:color/transparent"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<!-- 自定义ListView的点击反馈效果 -->
<ListView
android:id="@+id/lv_list"
android:listSelector="@drawable/sel_common_bg_press"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
其中 sel_common_bg_press.xml 文件如下:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="#88ff5722"/>
</shape>
</item>
<item android:drawable="@android:color/transparent"/>
</selector>
这个很简单,就是定义了按压和没按压时的颜色状态。
基本上ListView的点击反馈都是通过这个属性来处理,不过在一些特殊情况下会出现点击反馈无效的情况:
- 设置了Item布局的android:background属性,并且设置为不透明的背景色,在这种情况下就会看不到点击反馈效果,但实际上android:listSelector属性还是处于作用的状态,只不过被覆盖了看不到。你可以用半透明的背景色设置android:background属性,这种情况还是能看到点击反馈的;
- Item布局里的子视图占满了整个布局,比如ImageView的图片占满整个Item,这时也看不到点击反馈;
- 还有一个不算点击反馈无效的情况就是,如果Item里面的控件接收并处理了点击事件,点击反馈效果也看不到,因为点击事件被别人消耗了;
其实上面前两情况类似,都是点击效果被覆盖了,因为默认的点击反馈响应的是Item的背景而不是前景,这个和后面要提到的
android:foreground相对应。不过ListView还有一个属性来处理这种情况:
<!-- android:drawSelectorOnTop="true"让点击反馈为Item的最顶部 -->
<ListView
android:id="@+id/lv_list"
android:drawSelectorOnTop="true"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
设置了android:drawSelectorOnTop="true"属性后,即便Item布局被其它东西覆盖了也能看到点击反馈效果。
RecyclerView
RecyclerView就没有ListView这么方便了,默认是没有点击反馈的,需要我们自己设置。简单的话可以设置Item布局的android:background属性,这个效果就和ListView的android:listSelector属性效果类似,但和ListView还是会面临同样的点击看不到效果的情况,而且RecyclerView并没有android:drawSelectorOnTop这些相关的属性,所以要另外想办法处理。
这个时候就要用前面提到的android:foreground设置前景色属性啦,不过这个属性只有在android 6.0版本以上或者FrameLayout控件布局上使用才有效果,所以为了兼容低版本,只能选择使用FrameLayout布局来作为Item的顶层布局(该方法同样适用ListView)。使用如下:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:foreground="@drawable/sel_common_bg_press">
<ImageView
android:id="@+id/iv_icon"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:scaleType="centerCrop"
android:src="@mipmap/ic_pic"/>
</FrameLayout>
实际也挺简单的,就是让FrameLayout作为最外层布局就行了。有一点需要注意下,我对FrameLayout设置了android:clickable="true"属性,这么做是为了说明FrameLayout要能接收到点击事件才会有点击反馈效果!正常情况我们并不需要在这里设置,因为我们通常会给列表的Item添加点击监听,这样也能看到点击反馈效果。如果你要使用系统默认的点击反馈样式,可以用这个android:foreground="?android:attr/selectableItemBackground"。
到此RecyclerView的点击反馈也能够处理了,下面在讲下自定义布局的点击反馈效果,就拿经典的开源项目RippleEffect来介绍,这个同样适用ListView。
RippleView
注意,我对RippleEffect源码做了些修改,来让效果更贴近平时的使用。我直接贴源码,因为这里主要讲点击反馈,我只在源码的基础上增加了些触摸的判断。
public class RippleView extends RelativeLayout {
// 点击加速度
private static final int RIPPLE_ACCELERATE = 20;
// 5种触摸状态
private static final int RIPPLE_NORMAL = 0;
private static final int RIPPLE_SINGLE = 1;
private static final int RIPPLE_LONG_PRESS = 2;
private static final int RIPPLE_ACTION_MOVE = 3;
private static final int RIPPLE_ACTION_UP = 4;
// 触摸状态
private int rippleStatus = RIPPLE_NORMAL;
// 是否为列表模式,默认为否
private boolean isListMode;
// 用户是否滑动的判别距离
private int touchSlop;
// 长按时的坐标
private int lastLongPressX;
private int lastLongPressY;
private int WIDTH;
private int HEIGHT;
private int frameRate = 10;
private int rippleDuration = 400;
private int rippleAlpha = 90;
private Handler canvasHandler;
private float radiusMax = 0;
private boolean animationRunning = false;
private int timer = 0;
private int timerEmpty = 0;
private int durationEmpty = -1;
private float x = -1;
private float y = -1;
private int zoomDuration;
private float zoomScale;
private ScaleAnimation scaleAnimation;
private Boolean hasToZoom;
private Boolean isCentered;
private Integer rippleType;
private Paint paint;
private Bitmap originBitmap;
private int rippleColor;
private int ripplePadding;
private GestureDetector gestureDetector;
private final Runnable runnable = new Runnable() {
@Override
public void run() {
invalidate();
}
};
private OnRippleCompleteListener onCompletionListener;
public RippleView(Context context) {
super(context);
}
public RippleView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context, attrs);
}
public RippleView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(context, attrs);
}
/**
* Method that initializes all fields and sets listeners
*
* @param context Context used to create this view
* @param attrs Attribute used to initialize fields
*/
private void init(final Context context, final AttributeSet attrs) {
if (isInEditMode())
return;
final TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.RippleView);
rippleColor = typedArray.getColor(R.styleable.RippleView_rv_color, Color.parseColor("#33626262"));
rippleType = typedArray.getInt(R.styleable.RippleView_rv_type, 0);
hasToZoom = typedArray.getBoolean(R.styleable.RippleView_rv_zoom, false);
isCentered = typedArray.getBoolean(R.styleable.RippleView_rv_centered, false);
rippleDuration = typedArray.getInteger(R.styleable.RippleView_rv_rippleDuration, rippleDuration);
frameRate = typedArray.getInteger(R.styleable.RippleView_rv_framerate, frameRate);
rippleAlpha = typedArray.getInteger(R.styleable.RippleView_rv_alpha, rippleAlpha);
ripplePadding = typedArray.getDimensionPixelSize(R.styleable.RippleView_rv_ripplePadding, 0);
canvasHandler = new Handler();
zoomScale = typedArray.getFloat(R.styleable.RippleView_rv_zoomScale, 1.03f);
zoomDuration = typedArray.getInt(R.styleable.RippleView_rv_zoomDuration, 200);
isListMode = typedArray.getBoolean(R.styleable.RippleView_rv_listMode, false);
typedArray.recycle();
paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.FILL);
paint.setColor(rippleColor);
paint.setAlpha(rippleAlpha);
this.setWillNotDraw(false);
gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
@Override
public void onLongPress(MotionEvent event) {
super.onLongPress(event);
animateRipple(event);
sendClickEvent(true);
lastLongPressX = (int) event.getX();
lastLongPressY = (int) event.getY();
rippleStatus = RIPPLE_LONG_PRESS;
}
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
return true;
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
return true;
}
});
this.setDrawingCacheEnabled(true);
this.setClickable(true);
this.touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
if (animationRunning) {
if (isListMode && (rippleStatus == RIPPLE_SINGLE
|| rippleStatus == RIPPLE_ACTION_MOVE || rippleStatus == RIPPLE_ACTION_UP)) {
doRippleWork(canvas, RIPPLE_ACCELERATE);
} else {
doRippleWork(canvas, 1);
}
}
}
private void doRippleWork(Canvas canvas, int offect) {
canvas.save();
if (rippleDuration <= timer * frameRate) {
// There is problem on Android M where canvas.restore() seems to be called automatically
// For now, don't call canvas.restore() manually on Android M (API 23)
canvas.drawCircle(x, y, (radiusMax * (((float) timer * frameRate) / rippleDuration)), paint);
if (onCompletionListener != null && rippleStatus != RIPPLE_ACTION_MOVE && rippleStatus != RIPPLE_LONG_PRESS) {
onCompletionListener.onComplete(this);
}
if (rippleStatus != RIPPLE_LONG_PRESS) {
animationRunning = false;
rippleStatus = RIPPLE_NORMAL;
timer = 0;
durationEmpty = -1;
timerEmpty = 0;
if (Build.VERSION.SDK_INT != 23) {
canvas.restore();
}
}
invalidate();
return;
} else
canvasHandler.postDelayed(runnable, frameRate);
if (timer == 0)
canvas.save();
canvas.drawCircle(x, y, (radiusMax * (((float) timer * frameRate) / rippleDuration)), paint);
paint.setColor(Color.parseColor("#ffff4444"));
if (rippleType == 1 && originBitmap != null && (((float) timer * frameRate) / rippleDuration) > 0.4f) {
if (durationEmpty == -1)
durationEmpty = rippleDuration - timer * frameRate;
timerEmpty++;
final Bitmap tmpBitmap = getCircleBitmap((int) ((radiusMax) * (((float) timerEmpty * frameRate) / (durationEmpty))));
canvas.drawBitmap(tmpBitmap, 0, 0, paint);
tmpBitmap.recycle();
}
paint.setColor(rippleColor);
if (!isListMode) {
if (rippleType == 1) {
if ((((float) timer * frameRate) / rippleDuration) > 0.6f)
paint.setAlpha((int) (rippleAlpha - ((rippleAlpha) * (((float) timerEmpty * frameRate) / (durationEmpty)))));
else
paint.setAlpha(rippleAlpha);
} else
paint.setAlpha((int) (rippleAlpha - ((rippleAlpha) * (((float) timer * frameRate) / rippleDuration))));
}
timer += offect;
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
WIDTH = w;
HEIGHT = h;
scaleAnimation = new ScaleAnimation(1.0f, zoomScale, 1.0f, zoomScale, w / 2, h / 2);
scaleAnimation.setDuration(zoomDuration);
scaleAnimation.setRepeatMode(Animation.REVERSE);
scaleAnimation.setRepeatCount(1);
}
/**
* Launch Ripple animation for the current view with a MotionEvent
*
* @param event MotionEvent registered by the Ripple gesture listener
*/
public void animateRipple(MotionEvent event) {
createAnimation(event.getX(), event.getY());
}
/**
* Launch Ripple animation for the current view centered at x and y position
*
* @param x Horizontal position of the ripple center
* @param y Vertical position of the ripple center
*/
public void animateRipple(final float x, final float y) {
createAnimation(x, y);
}
/**
* Create Ripple animation centered at x, y
*
* @param x Horizontal position of the ripple center
* @param y Vertical position of the ripple center
*/
private void createAnimation(final float x, final float y) {
if (this.isEnabled() && !animationRunning) {
if (hasToZoom)
this.startAnimation(scaleAnimation);
radiusMax = Math.max(WIDTH, HEIGHT);
if (rippleType != 2)
radiusMax /= 2;
radiusMax -= ripplePadding;
if (isCentered || rippleType == 1) {
this.x = getMeasuredWidth() / 2;
this.y = getMeasuredHeight() / 2;
} else {
this.x = x;
this.y = y;
}
animationRunning = true;
if (rippleType == 1 && originBitmap == null)
originBitmap = getDrawingCache(true);
invalidate();
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (gestureDetector.onTouchEvent(event)) {
animateRipple(event);
sendClickEvent(false);
rippleStatus = RIPPLE_SINGLE;
}
if (rippleStatus == RIPPLE_LONG_PRESS) {
if (event.getAction() == MotionEvent.ACTION_UP) {
rippleStatus = RIPPLE_ACTION_UP;
} else if (Math.abs(event.getX() - lastLongPressX) >= touchSlop ||
Math.abs(event.getY() - lastLongPressY) >= touchSlop) {
rippleStatus = RIPPLE_ACTION_MOVE;
}
}
return super.onTouchEvent(event);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
this.onTouchEvent(event);
return super.onInterceptTouchEvent(event);
}
/**
* Send a click event if parent view is a Listview instance
*
* @param isLongClick Is the event a long click ?
*/
private void sendClickEvent(final Boolean isLongClick) {
if (getParent() instanceof AdapterView) {
final AdapterView adapterView = (AdapterView) getParent();
final int position = adapterView.getPositionForView(this);
final long id = adapterView.getItemIdAtPosition(position);
if (isLongClick) {
if (adapterView.getOnItemLongClickListener() != null)
adapterView.getOnItemLongClickListener().onItemLongClick(adapterView, this, position, id);
} else {
if (adapterView.getOnItemClickListener() != null)
adapterView.getOnItemClickListener().onItemClick(adapterView, this, position, id);
}
}
}
private Bitmap getCircleBitmap(final int radius) {
final Bitmap output = Bitmap.createBitmap(originBitmap.getWidth(), originBitmap.getHeight(), Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(output);
final Paint paint = new Paint();
final Rect rect = new Rect((int) (x - radius), (int) (y - radius), (int) (x + radius), (int) (y + radius));
paint.setAntiAlias(true);
canvas.drawARGB(0, 0, 0, 0);
canvas.drawCircle(x, y, radius, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
canvas.drawBitmap(originBitmap, rect, rect, paint);
return output;
}
/**
* Set Ripple color, default is #FFFFFF
*
* @param rippleColor New color resource
*/
public void setRippleColor(@ColorRes int rippleColor) {
this.rippleColor = ContextCompat.getColor(getContext(), rippleColor);
}
public int getRippleColor() {
return rippleColor;
}
public RippleType getRippleType() {
return RippleType.values()[rippleType];
}
/**
* Set Ripple type, default is RippleType.SIMPLE
*
* @param rippleType New Ripple type for next animation
*/
public void setRippleType(final RippleType rippleType) {
this.rippleType = rippleType.ordinal();
}
public Boolean isCentered() {
return isCentered;
}
/**
* Set if ripple animation has to be centered in its parent view or not, default is False
*
* @param isCentered
*/
public void setCentered(final Boolean isCentered) {
this.isCentered = isCentered;
}
public int getRipplePadding() {
return ripplePadding;
}
/**
* Set Ripple padding if you want to avoid some graphic glitch
*
* @param ripplePadding New Ripple padding in pixel, default is 0px
*/
public void setRipplePadding(int ripplePadding) {
this.ripplePadding = ripplePadding;
}
public Boolean isZooming() {
return hasToZoom;
}
/**
* At the end of Ripple effect, the child views has to zoom
*
* @param hasToZoom Do the child views have to zoom ? default is False
*/
public void setZooming(Boolean hasToZoom) {
this.hasToZoom = hasToZoom;
}
public float getZoomScale() {
return zoomScale;
}
/**
* Scale of the end animation
*
* @param zoomScale Value of scale animation, default is 1.03f
*/
public void setZoomScale(float zoomScale) {
this.zoomScale = zoomScale;
}
public int getZoomDuration() {
return zoomDuration;
}
/**
* Duration of the ending animation in ms
*
* @param zoomDuration Duration, default is 200ms
*/
public void setZoomDuration(int zoomDuration) {
this.zoomDuration = zoomDuration;
}
public int getRippleDuration() {
return rippleDuration;
}
/**
* Duration of the Ripple animation in ms
*
* @param rippleDuration Duration, default is 400ms
*/
public void setRippleDuration(int rippleDuration) {
this.rippleDuration = rippleDuration;
}
public int getFrameRate() {
return frameRate;
}
/**
* Set framerate for Ripple animation
*
* @param frameRate New framerate value, default is 10
*/
public void setFrameRate(int frameRate) {
this.frameRate = frameRate;
}
public int getRippleAlpha() {
return rippleAlpha;
}
/**
* Set alpha for ripple effect color
*
* @param rippleAlpha Alpha value between 0 and 255, default is 90
*/
public void setRippleAlpha(int rippleAlpha) {
this.rippleAlpha = rippleAlpha;
}
public void setOnRippleCompleteListener(OnRippleCompleteListener listener) {
this.onCompletionListener = listener;
}
/**
* Defines a callback called at the end of the Ripple effect
*/
public interface OnRippleCompleteListener {
void onComplete(RippleView rippleView);
}
public enum RippleType {
SIMPLE(0),
DOUBLE(1),
RECTANGLE(2);
int type;
RippleType(int type) {
this.type = type;
}
}
}
使用也很简单,把RippleView作为最外层布局就行了。
<?xml version="1.0" encoding="utf-8"?>
<com.dl7.listclickfeedback.RippleView
android:id="@+id/item_ripple"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:rv_listMode="true"
app:rv_color="#88ff5722"
app:rv_rippleDuration="800"
app:rv_type="rectangle">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="30dp"
android:src="@mipmap/ic_face_funny"/>
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"/>
</LinearLayout>
</com.dl7.listclickfeedback.RippleView>
最后是点击事件的处理,需要让RippleView来接收处理点击事件。
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, final int position) {
// ......
((ViewHolder) holder).mItemRipple.setOnRippleCompleteListener(new RippleView.OnRippleCompleteListener() {
@Override
public void onComplete(RippleView rippleView) {
ToastUtils.showToast(mDatas.get(position));
}
});
}
这样就OK了,最后贴点图来说明反馈效果。
背景色反馈:
前景色反馈:
波纹反馈: