安卓开发自定义stackLayout实现卡片滑动效果
问题背景
安卓日常学习和开发过程中经常会涉及到卡片切换的效果,本文将介绍实现卡片滑动效果的一种方案。
问题分析
话不多说,直接上效果图:
问题解决
话不多说直接上代码:
(1)自定义StackLayoutManager.java文件如下:
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.os.Build;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import androidx.annotation.RequiresApi;
import androidx.recyclerview.widget.RecyclerView;
import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
//自定义RecyclerView.LayoutManager布局管理类,实现卡片布局
public class StackLayoutManager extends RecyclerView.LayoutManager {
private static final String TAG = "StackLayoutManager";
//the space unit for the stacked item
private int mSpace = 60;
/**
* the offset unit,deciding current position(the sum of {@link #mItemWidth} and {@link #mSpace})
*/
private int mUnit;
//item width
private int mItemWidth;
private int mItemHeight;
//the counting variable ,record the total offset including parallex
private int mTotalOffset;
//record the total offset without parallex
private int mRealOffset;
private ObjectAnimator animator;
private int animateValue;
private int duration = 300;
private RecyclerView.Recycler recycler;
private int lastAnimateValue;
//the max stacked item count;
private int maxStackCount = 4;
//initial stacked item
private int initialStackCount = 4;
private float secondaryScale = 0.8f;
private float scaleRatio = 0.4f;
private float parallex = 1f;
private int initialOffset;
private boolean initial;
private int mMinVelocityX;
private VelocityTracker mVelocityTracker = VelocityTracker.obtain();
private int pointerId;
private Align direction = LEFT;
private RecyclerView mRV;
private Method sSetScrollState;
private int mPendingScrollPosition = NO_POSITION;
public StackLayoutManager(Config config) {
this();
this.maxStackCount = 2;
this.mSpace = 100;
this.initialStackCount = config.initialStackCount;
this.secondaryScale = config.secondaryScale;
this.scaleRatio = config.scaleRatio;
this.direction = config.align;
this.parallex = config.parallex;
}
@SuppressWarnings("unused")
public StackLayoutManager() {
setAutoMeasureEnabled(true);
}
@Override
public boolean isAutoMeasureEnabled() {
return true;
}
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() <= 0) {
return;
}
this.recycler = recycler;
detachAndScrapAttachedViews(recycler);
//got the mUnit basing on the first child,of course we assume that all the item has the same size
View anchorView = recycler.getViewForPosition(0);
measureChildWithMargins(anchorView, 0, 0);
mItemWidth = anchorView.getMeasuredWidth();
mItemHeight = anchorView.getMeasuredHeight();
mSpace = (getWidth() - mItemWidth) / 2;
// if (canScrollHorizontally())
// mUnit = mItemWidth + mSpace;
// else mUnit = mItemHeight + mSpace;
// TODO: 2019/2/14 test
if (canScrollHorizontally()) {
// mItemWidth < mSpace时可能出现问题。
mUnit = mItemWidth - mSpace;
}
//because this method will be called twice
initialOffset = resolveInitialOffset();
mMinVelocityX = ViewConfiguration.get(anchorView.getContext()).getScaledMinimumFlingVelocity();
fill(recycler, 0);
}
//we need take direction into account when calc initialOffset
private int resolveInitialOffset() {
int offset = initialStackCount * mUnit;
if (mPendingScrollPosition != NO_POSITION) {
offset = mPendingScrollPosition * mUnit;
mPendingScrollPosition = NO_POSITION;
}
return offset;
}
@Override
public void onLayoutCompleted(RecyclerView.State state) {
super.onLayoutCompleted(state);
if (getItemCount()<=0) {
return;
}
if (!initial) {
fill(recycler, initialOffset, false);
initial = true;
}
}
@Override
public void onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter) {
initial = false;
mTotalOffset = mRealOffset = 0;
}
/**
* the magic function :).all the work including computing ,recycling,and layout is done here
*
* @param recycler ...
*/
private int fill(RecyclerView.Recycler recycler, int dy, boolean apply) {
int delta = direction.layoutDirection * dy;
// multiply the parallex factor
if (apply) {
delta = (int) (delta * parallex);
}
return fillFromLeft(recycler, delta);
}
public int fill(RecyclerView.Recycler recycler, int dy) {
return fill(recycler, dy, true);
}
private int fillFromLeft(RecyclerView.Recycler recycler, int dy) {
if (mTotalOffset + dy < 0 || (mTotalOffset + dy + 0f) / mUnit > getItemCount() - 1) {
return 0;
}
detachAndScrapAttachedViews(recycler);
mTotalOffset += direction.layoutDirection * dy;
int count = getChildCount();
//removeAndRecycle views
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
if (recycleHorizontally(child, dy)) {
removeAndRecycleView(child, recycler);
}
}
int currPos = mTotalOffset / mUnit;
int leavingSpace = getWidth() - (left(currPos) + mUnit);
int itemCountAfterBaseItem = leavingSpace / mUnit + 2;
int e = currPos + itemCountAfterBaseItem;
int start = currPos - maxStackCount >= 0 ? currPos - maxStackCount : 0;
int end = e >= getItemCount() ? getItemCount() - 1 : e;
//layout view
for (int i = start; i <= end; i++) {
View view = recycler.getViewForPosition(i);
float scale = scale(i);
float alpha = alpha(i);
addView(view);
measureChildWithMargins(view, 0, 0);
int left = (int) (left(i) - (1 - scale) * view.getMeasuredWidth() / 2);
int top = 0;
int right = left + view.getMeasuredWidth();
int bottom = top + view.getMeasuredHeight();
layoutDecoratedWithMargins(view, left, top, right, bottom);
view.setAlpha(alpha);
view.setScaleY(scale);
view.setScaleX(scale);
}
return dy;
}
private View.OnTouchListener mTouchListener = new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
mVelocityTracker.addMovement(event);
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (animator != null && animator.isRunning()) {
animator.cancel();
}
pointerId = event.getPointerId(0);
}
if (event.getAction() == MotionEvent.ACTION_UP) {
if (v.isPressed()) {
v.performClick();
}
mVelocityTracker.computeCurrentVelocity(1000, 14000);
float xVelocity = mVelocityTracker.getXVelocity(pointerId);
int o = mTotalOffset % mUnit;
int scrollX;
if (Math.abs(xVelocity) < mMinVelocityX && o != 0) {
if (o >= mUnit / 2) {
scrollX = mUnit - o;
} else {
scrollX = -o;
}
int dur = (int) (Math.abs((scrollX + 0f) / mUnit) * duration);
Log.i(TAG, "onTouch: ======BREW===");
brewAndStartAnimator(dur, scrollX);
}
}
return false;
}
};
private RecyclerView.OnFlingListener mOnFlingListener = new RecyclerView.OnFlingListener() {
@RequiresApi(api = Build.VERSION_CODES.KITKAT)
@Override
public boolean onFling(int velocityX, int velocityY) {
int o = mTotalOffset % mUnit;
int s = mUnit - o;
int scrollX;
int vel = absMax(velocityX, velocityY);
if (vel * direction.layoutDirection > 0) {
scrollX = s;
} else {
scrollX = -o;
}
int dur = computeSettleDuration(Math.abs(scrollX), Math.abs(vel));
brewAndStartAnimator(dur, scrollX);
setScrollStateIdle();
return true;
}
};
private int absMax(int a, int b) {
if (Math.abs(a) > Math.abs(b)) {
return a;
} else {
return b;
}
}
@Override
public void onAttachedToWindow(RecyclerView view) {
super.onAttachedToWindow(view);
mRV = view;
//check when raise finger and settle to the appropriate item
view.setOnTouchListener(mTouchListener);
view.setOnFlingListener(mOnFlingListener);
}
private int computeSettleDuration(int distance, float xvel) {
float sWeight = 0.5f * distance / mUnit;
float velWeight = xvel > 0 ? 0.5f * mMinVelocityX / xvel : 0;
return (int) ((sWeight + velWeight) * duration);
}
private void brewAndStartAnimator(int dur, int finalXorY) {
animator = ObjectAnimator.ofInt(StackLayoutManager.this, "animateValue", 0, finalXorY);
animator.setDuration(dur);
animator.start();
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
lastAnimateValue = 0;
}
@Override
public void onAnimationCancel(Animator animation) {
lastAnimateValue = 0;
}
});
}
/******************************precise math method*******************************/
private float alpha(int position) {
float alpha;
int currPos = mTotalOffset / mUnit;
float n = (mTotalOffset + .0f) / mUnit;
if (position > currPos) {
alpha = 1.0f;
} else {
//temporary linear map,barely ok
alpha = 1 - (n - position) / maxStackCount;
}
//for precise checking,oh may be kind of dummy
return alpha <= 0.001f ? 0 : alpha;
}
private float scale(int position) {
switch (direction) {
default:
case LEFT:
// case RIGHT:
return scaleDefault(position);
}
}
private float scaleDefault(int position) {
float scale;
int currPos = this.mTotalOffset / mUnit;
float n = (mTotalOffset + .0f) / mUnit;
float x = n - currPos;
// position >= currPos+1;
if (position >= currPos) {
if (position == currPos) {
scale = 1 - scaleRatio * (n - currPos) / maxStackCount;
} else if (position == currPos + 1)
//let the item's (index:position+1) scale be 1 when the item slide 1/2 mUnit,
// this have better visual effect
{
// scale = 0.8f + (0.4f * x >= 0.2f ? 0.2f : 0.4f * x);
scale = secondaryScale + (x > 0.5f ? 1 - secondaryScale : 2 * (1 - secondaryScale) * x);
} else {
scale = secondaryScale;
}
} else {//position <= currPos
if (position < currPos - maxStackCount) {
scale = 0f;
} else {
scale = 1f - scaleRatio * (n - currPos + currPos - position) / maxStackCount;
}
}
return scale;
}
/**
* @param position the index of the item in the adapter
* @return the accurate left position for the given item
*/
private int left(int position) {
int currPos = mTotalOffset / mUnit;
int tail = mTotalOffset % mUnit;
float n = (mTotalOffset + .0f) / mUnit;
float x = n - currPos;
switch (direction) {
default:
case LEFT:
// case TOP:
//from left to right or top to bottom
//these two scenario are actually same
return ltr(position, currPos, tail, x);
}
}
private int ltr(int position, int currPos, int tail, float x) {
int left;
if (position <= currPos) {
if (position == currPos) {
left = (int) (mSpace * (maxStackCount - x));
left = left - mSpace;
} else {
left = (int) (mSpace * (maxStackCount - x - (currPos - position)));
left = left - mSpace;
}
} else {
if (position == currPos + 1) {
left = mSpace * maxStackCount + mUnit - tail;
} else {
float closestBaseItemScale = scale(currPos + 1);
//调整因为scale导致的left误差
// left = (int) (mSpace * maxStackCount + (position - currPos) * mUnit - tail
// -(position - currPos)*(mItemWidth) * (1 - closestBaseItemScale));
int baseStart = (int) (mSpace * maxStackCount + mUnit - tail + closestBaseItemScale * (mUnit - mSpace) + mSpace);
left = (int) (baseStart + (position - currPos - 2) * mUnit - (position - currPos - 2) * (1 - secondaryScale) * (mUnit - mSpace));
// if (BuildConfig.DEBUG) {
// Log.i(TAG, "ltr: currPos " + currPos
// + " pos:" + position
// + " left:" + left
// + " baseStart" + baseStart
// + " currPos+1:" + left(currPos + 1));
// }
}
// left = left - 8;
left = left <= 0 ? 0 : left;
}
return left;
}
@SuppressWarnings("unused")
public void setAnimateValue(int animateValue) {
this.animateValue = animateValue;
int dy = this.animateValue - lastAnimateValue;
fill(recycler, direction.layoutDirection * dy, false);
lastAnimateValue = animateValue;
}
@SuppressWarnings("unused")
public int getAnimateValue() {
return animateValue;
}
/**
* should recycle view with the given dy or say check if the
* view is out of the bound after the dy is applied
*
* @param view ..
* @param dy ..
* @return ..
*/
private boolean recycleHorizontally(View view/*int position*/, int dy) {
return view != null && (view.getLeft() - dy < 0 || view.getRight() - dy > getWidth());
}
private boolean recycleVertically(View view, int dy) {
return view != null && (view.getTop() - dy < 0 || view.getBottom() - dy > getHeight());
}
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
return fill(recycler, dx);
}
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
return fill(recycler, dy);
}
@Override
public boolean canScrollHorizontally() {
return direction == LEFT || direction == RIGHT;
}
@Override
public boolean canScrollVertically() {
return direction == TOP || direction == BOTTOM;
}
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT);
}
/**
* we need to set scrollstate to {@link RecyclerView#SCROLL_STATE_IDLE} idle
* stop RV from intercepting the touch event which block the item click
*/
@RequiresApi(api = Build.VERSION_CODES.KITKAT)
private void setScrollStateIdle() {
try {
if (sSetScrollState == null) {
sSetScrollState = RecyclerView.class.getDeclaredMethod("setScrollState", int.class);
}
sSetScrollState.setAccessible(true);
sSetScrollState.invoke(mRV, RecyclerView.SCROLL_STATE_IDLE);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}
@Override
public void scrollToPosition(int position) {
if (position > getItemCount() - 1) {
Log.i(TAG, "position is " + position + " but itemCount is " + getItemCount());
return;
}
int currPosition = mTotalOffset / mUnit;
int distance = (position - currPosition) * mUnit;
int dur = computeSettleDuration(Math.abs(distance), 0);
brewAndStartAnimator(dur, distance);
}
@Override
public void requestLayout() {
super.requestLayout();
initial = false;
}
@SuppressWarnings("unused")
public interface CallBack {
float scale(int totalOffset, int position);
float alpha(int totalOffset, int position);
float left(int totalOffset, int position);
}
}
(2)使用到的Align.java类如下:
public enum Align {
LEFT(1),
RIGHT(-1),
TOP(1),
BOTTOM(-1);
public int layoutDirection;
Align(int sign) {
this.layoutDirection = sign;
}
}
(3)使用到的Config.java类如下:
public class Config {
public int space = 60;
public int maxStackCount = 3;
public int initialStackCount = 0;
public float secondaryScale;
public float scaleRatio;
/**
* the real scroll distance might meet requirement,
* so we multiply a factor fro parallex
*/
public float parallex = 1f;
// 布局方向
public Align align;
}
(4)布局layout文件如下(项目使用了databinding,可以直接去除最外层使用):
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_account_top"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:scaleType="fitXY"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/bg_account_top" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="31dp"
android:text="协同办公"
android:textSize="19sp"
android:textColor="@color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_marginTop="110dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:orientation="vertical"
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:overScrollMode="never"
app:layoutManager="@string/stacklayoutmanager"
android:layout_gravity="center_horizontal"
tools:listitem="@layout/layout_work_together_view"/>
</LinearLayout>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_scroll_left_right"
android:layout_marginBottom="20dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
(5)java层调用核心代码如下:
private void initView() {
Config config = new Config();
config.secondaryScale = 0.7f;
config.scaleRatio = 0.6f;
config.maxStackCount = 2;
config.initialStackCount = 1;
config.space = 100;
config.align = Align.LEFT;
layoutManager = new StackLayoutManager(config);
mDataBinding.recyclerview.setLayoutManager(layoutManager);
adapter = new TogetherWorkAdapter(this.getContext(), datas);
adapter.setOnItemClickLitener(this);
mDataBinding.recyclerview.setAdapter(adapter);
}
问题总结
本文介绍了安卓开发中实现卡片左右滑动效果的一种方案,有兴趣的同学可以进一步深入研究。