# android_guide **Repository Path**: luoluostudent/android_guide ## Basic Information - **Project Name**: android_guide - **Description**: ReycyclerView 可拖动布局,展示新人指引动画效果,item拖动动画指引 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-12-07 - **Last Updated**: 2026-01-18 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # RecyclerView拖拽排序新手引导动画 ## 需求介绍 在日常开发中,经常需要对一个`RecyclerView`的item实现可拖动排序的功能,并且为了提升用户体验,还需要制作相应的动画指引,也就是新手引导动画。 ### 开发实际效果 箭头动画效果 ![图片item拖动动画指引.jpg](%E5%9B%BE%E7%89%87item%E6%8B%96%E5%8A%A8%E5%8A%A8%E7%94%BB%E6%8C%87%E5%BC%95.jpg) item动画效果 ![item拖动.jpg](item%E6%8B%96%E5%8A%A8.jpg) 视频效果见文件: [item拖动动画指引.mp4](item%E6%8B%96%E5%8A%A8%E5%8A%A8%E7%94%BB%E6%8C%87%E5%BC%95.mp4) [item布局拖动动画效果.mp4](item%E5%B8%83%E5%B1%80%E6%8B%96%E5%8A%A8%E5%8A%A8%E7%94%BB%E6%95%88%E6%9E%9C.mp4) ### 难点: 1. 界面蒙版 2. 选择前两个item,画出透明区域,做动画效果 3. 展示气泡视图 4. 循环播放动画 5. 移除指引视图 ### 流程 #### 1. 实现RecyclerView item长按拖动效果 核心代码: ```java // ItemMoveCallback.java public class ItemMoveCallback extends ItemTouchHelper.Callback { private final ItemTouchHelperAdapter adapter; public ItemMoveCallback(ItemTouchHelperAdapter adapter) { this.adapter = adapter; } @Override public boolean isLongPressDragEnabled() { return true; // 启用长按拖拽 } @Override public boolean isItemViewSwipeEnabled() { return false; // 禁用滑动 } @Override public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; // 设置拖拽方向为上下 return makeMovementFlags(dragFlags, 0); } @Override public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { adapter.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition()); return true; } @Override public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { // 不需要处理滑动删除 } @Override public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) { if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) { if (viewHolder instanceof SubjectAdapter.SubjectViewHolder) { SubjectAdapter.SubjectViewHolder itemViewHolder = (SubjectAdapter.SubjectViewHolder) viewHolder; itemViewHolder.itemView.setAlpha(0.6f); // 拖拽时设置透明度 } } super.onSelectedChanged(viewHolder, actionState); } @Override public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); viewHolder.itemView.setAlpha(1.0f); // 恢复透明度 } } /// SubjectAdapter.java public void moveItem(int fromPosition, int toPosition) { if (fromPosition < 0 || toPosition < 0 || fromPosition >= subjectList.size() || toPosition >= subjectList.size()) { return; } Collections.swap(subjectList, fromPosition, toPosition); notifyItemMoved(fromPosition, toPosition); } ``` 在[MainActivity](file:///E:/android_student/MyGuide/app/src/main/java/com/luoyang/myguide/MainActivity.java#L22-L154)中使用: ```java private void setupRecyclerView() { // 创建学科数据 subjectList = new ArrayList<>(); subjectList.add(new Subject("数学")); subjectList.add(new Subject("语文")); subjectList.add(new Subject("英语")); subjectList.add(new Subject("物理")); subjectList.add(new Subject("化学")); // 设置布局管理器和适配器 recyclerView.setLayoutManager(new LinearLayoutManager(this)); adapter = new SubjectAdapter(subjectList); recyclerView.setAdapter(adapter); // 添加拖拽功能 ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new ItemMoveCallback(this)); itemTouchHelper.attachToRecyclerView(recyclerView); } ``` #### 2. 自定义指引视图,创建蒙版view 核心代码在[GuideView](file:///E:/android_student/MyGuide/app/src/src/main/java/com/luoyang/myguide/GuideView.java#L26-L385)中: ```java public class GuideView extends View { private Paint mBgPaint; // 背景画笔 private Paint mClearPaint; // 清除画笔 private void init() { // 初始化背景画笔(半透明黑色蒙版) mBgPaint = new Paint(); mBgPaint.setColor(Color.parseColor("#80000000")); // 半透明黑色 mBgPaint.setStyle(Paint.Style.FILL); mBgPaint.setAntiAlias(true); // 初始化清除画笔(用于挖空高亮区域) mClearPaint = new Paint(); mClearPaint.setColor(Color.TRANSPARENT); mClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); mClearPaint.setStyle(Paint.Style.FILL); mClearPaint.setAntiAlias(true); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (mFirstItemRect != null && mSecondItemRect != null) { // 保存图层 int layerId = canvas.saveLayer(0, 0, getWidth(), getHeight(), null); // 先绘制半透明黑色蒙版 canvas.drawRect(0, 0, getWidth(), getHeight(), mBgPaint); // 清除第一个item区域(设为透明) canvas.drawRect(mFirstItemRect, mClearPaint); // 清除第二个item区域(设为透明) canvas.drawRect(mSecondItemRect, mClearPaint); // 恢复图层 canvas.restoreToCount(layerId); // 绘制其他元素... } else { // 如果还没有设置高亮区域,则绘制全屏半透明蒙版 canvas.drawRect(0, 0, getWidth(), getHeight(), mBgPaint); } } } ``` #### 3. 实现两个不同布局间的交换动画 现在我们实现了两个不同布局(item_subject_temp1.xml和item_subject_temp2.xml)之间的交换动画效果: ```java /** * 创建模拟的item布局bitmap */ public void createItemBitmaps(int itemWidth, int itemHeight) { // 创建第一个布局的bitmap mItemTemp1Bitmap = Bitmap.createBitmap(itemWidth, itemHeight, Bitmap.Config.ARGB_8888); Canvas temp1Canvas = new Canvas(mItemTemp1Bitmap); // 绘制背景 Paint bgPaint1 = new Paint(); bgPaint1.setColor(Color.parseColor("#FFE4B5")); // 浅黄色背景 temp1Canvas.drawRect(0, 0, itemWidth, itemHeight, bgPaint1); // 绘制图标和文字,模拟item_subject_temp1.xml布局 // 创建第二个布局的bitmap mItemTemp2Bitmap = Bitmap.createBitmap(itemWidth, itemHeight, Bitmap.Config.ARGB_8888); Canvas temp2Canvas = new Canvas(mItemTemp2Bitmap); // 绘制背景 Paint bgPaint2 = new Paint(); bgPaint2.setColor(Color.parseColor("#87CEEB")); // 天蓝色背景 temp2Canvas.drawRect(0, 0, itemWidth, itemHeight, bgPaint2); // 绘制图标和文字,模拟item_subject_temp2.xml布局 } /** * 绘制交换动画 */ private void drawExchangeAnimation(Canvas canvas) { if (mItemTemp1Bitmap == null || mItemTemp2Bitmap == null) { return; } // 根据动画进度计算位置 float firstTop, secondTop; float firstAlpha, secondAlpha; // 计算交换动画的位置 if (mExchangeProgress <= 0.5f) { // 第一阶段:第一个item向下移动,第二个item向上移动 float progress = mExchangeProgress / 0.5f; // 0 to 1 firstTop = mFirstItemRect.top + (mSecondItemRect.top - mFirstItemRect.top) * progress; secondTop = mSecondItemRect.top - (mSecondItemRect.top - mFirstItemRect.top) * progress; // 透明度变化 firstAlpha = 255 * (1 - progress * 0.5f); // 逐渐变透明 secondAlpha = 255 * (1 - progress * 0.5f); // 逐渐变透明 } else { // 第二阶段:交换完成,然后回到原位 float progress = (mExchangeProgress - 0.5f) / 0.5f; // 0 to 1 firstTop = mSecondItemRect.top - (mSecondItemRect.top - mFirstItemRect.top) * progress; secondTop = mFirstItemRect.top + (mSecondItemRect.top - mFirstItemRect.top) * progress; // 透明度变化 firstAlpha = 255 * (0.5f + progress * 0.5f); // 逐渐恢复不透明 secondAlpha = 255 * (0.5f + progress * 0.5f); // 逐渐恢复不透明 } // 绘制第一个item(temp1) Paint paint1 = new Paint(); paint1.setAlpha((int) firstAlpha); canvas.drawBitmap(mItemTemp1Bitmap, mFirstItemRect.left, firstTop, paint1); // 绘制第二个item(temp2) Paint paint2 = new Paint(); paint2.setAlpha((int) secondAlpha); canvas.drawBitmap(mItemTemp2Bitmap, mSecondItemRect.left, secondTop, paint2); } ``` #### 4. RecyclerView新建完毕后,获取第一个item,第二个item,并设置透明区域 在[MainActivity](file:///E:/android_student/MyGuide/app/src/main/java/com/luoyang/myguide/MainActivity.java#L22-L154)中实现: ```java /** * 显示新手引导 */ private void showGuide() { // 确保前两个item已经创建并布局 recyclerView.post(() -> { // 获取前两个数据项对应的视图 View firstItemView = null; View secondItemView = null; // 遍历所有子视图,找到对应前两个数据项的视图 for (int i = 0; i < recyclerView.getChildCount(); i++) { View child = recyclerView.getChildAt(i); int position = recyclerView.getChildAdapterPosition(child); if (position == 0) { firstItemView = child; } else if (position == 1) { secondItemView = child; } // 如果找到了两个视图就退出循环 if (firstItemView != null && secondItemView != null) { break; } } if (firstItemView != null && secondItemView != null) { // 获取item在屏幕上的位置 int[] firstLocation = new int[2]; int[] secondLocation = new int[2]; firstItemView.getLocationOnScreen(firstLocation); secondItemView.getLocationOnScreen(secondLocation); // 转换为相对于GuideView的本地坐标 int statusBarHeight = getStatusBarHeight(); int firstLocalX = firstLocation[0]; int firstLocalY = firstLocation[1] - statusBarHeight; int secondLocalX = secondLocation[0]; int secondLocalY = secondLocation[1] - statusBarHeight; // 创建透明区域(前两个item区域) Rect firstRect = new Rect( firstLocalX, firstLocalY, firstLocalX + firstItemView.getWidth(), firstLocalY + firstItemView.getHeight() ); Rect secondRect = new Rect( secondLocalX, secondLocalY, secondLocalX + secondItemView.getWidth(), secondLocalY + secondItemView.getHeight() ); // 设置透明区域 guideView.setHighlightItems(firstRect, secondRect); // 创建item bitmap用于动画 if (firstItemView.getWidth() > 0 && firstItemView.getHeight() > 0) { guideView.createItemBitmaps(firstItemView.getWidth(), firstItemView.getHeight()); } } }); } ``` #### 5. 画动画箭头视图,画气泡提示信息视图 在[GuideView](file:///E:/android_student/MyGuide/app/src/src/main/java/com/luoyang/myguide/GuideView.java#L26-L385)中绘制箭头和气泡: ```java @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (mFirstItemRect != null && mSecondItemRect != null) { // ... 上面提到的蒙版绘制代码 ... // 绘制交换动画 drawExchangeAnimation(canvas); // 添加箭头指示移动方向 Paint arrowPaint = new Paint(); arrowPaint.setColor(Color.GRAY); arrowPaint.setStyle(Paint.Style.FILL_AND_STROKE); arrowPaint.setAntiAlias(true); arrowPaint.setStrokeWidth(5f); arrowPaint.setStrokeCap(Paint.Cap.ROUND); arrowPaint.setStrokeJoin(Paint.Join.ROUND); // 计算item前面偏右位置 (10dp) float offsetPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10, getResources().getDisplayMetrics()); float firstItemLeft = Math.min(firstLeft, firstRight); float secondItemLeft = Math.min(secondLeft, secondRight); // 在第一个item前方偏右10dp的正中心位置绘制向上箭头(带长条尾部) Path upArrow = new Path(); float firstArrowBottom = mFirstItemRect.top + (mFirstItemRect.height() / 2) + mArrowOffset; upArrow.moveTo(firstItemLeft + offsetPx, firstArrowBottom); // 底部 upArrow.lineTo(firstItemLeft + offsetPx - 15, firstArrowBottom - 30); // 左上点 upArrow.lineTo(firstItemLeft + offsetPx - 5, firstArrowBottom - 30); // 左竖线顶部 upArrow.lineTo(firstItemLeft + offsetPx - 5, firstArrowBottom - 55); // 长条尾部底部 upArrow.lineTo(firstItemLeft + offsetPx + 5, firstArrowBottom - 55); // 长条尾部顶部 upArrow.lineTo(firstItemLeft + offsetPx + 5, firstArrowBottom - 30); // 右竖线顶部 upArrow.lineTo(firstItemLeft + offsetPx + 15, firstArrowBottom - 30); // 右上点 upArrow.close(); canvas.drawPath(upArrow, arrowPaint); // 在第二个item前方偏右10dp的正中心位置绘制向下箭头(带长条尾部) Path downArrow = new Path(); float secondArrowTop = mSecondItemRect.top + (mSecondItemRect.height() / 2) - mArrowOffset; downArrow.moveTo(secondItemLeft + offsetPx, secondArrowTop); // 顶部 downArrow.lineTo(secondItemLeft + offsetPx - 15, secondArrowTop + 30); // 左下点 downArrow.lineTo(secondItemLeft + offsetPx - 5, secondArrowTop + 30); // 左竖线底部 downArrow.lineTo(secondItemLeft + offsetPx - 5, secondArrowTop + 55); // 长条尾部顶部 downArrow.lineTo(secondItemLeft + offsetPx + 5, secondArrowTop + 55); // 长条尾部底部 downArrow.lineTo(secondItemLeft + offsetPx + 5, secondArrowTop + 30); // 右竖线底部 downArrow.lineTo(secondItemLeft + offsetPx + 15, secondArrowTop + 30); // 右下点 downArrow.close(); canvas.drawPath(downArrow, arrowPaint); // 绘制气泡 drawBubble(canvas, mSecondItemRect); } } ``` #### 6. 如何添加箭头动画 添加动画循环播放 使用ValueAnimator实现循环动画: ```java private void init() { // ... 其他初始化代码 ... // 使用ValueAnimator替换ArrowAnimation mArrowAnimator = ValueAnimator.ofFloat(0f, 1f); mArrowAnimator.setDuration(1000); mArrowAnimator.setRepeatCount(ValueAnimator.INFINITE); mArrowAnimator.setRepeatMode(ValueAnimator.REVERSE); mArrowAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { mArrowProgress = (float) animation.getAnimatedValue(); // 根据进度值计算箭头偏移量 (-15 到 15 像素之间) 基于最佳实践 mArrowOffset = (mArrowProgress - 0.5f) * 30; invalidate(); // 重绘View } }); mArrowAnimator.start(); // 初始化交换动画 mExchangeAnimator = ValueAnimator.ofFloat(0f, 1f); mExchangeAnimator.setDuration(1500); mExchangeAnimator.setRepeatCount(ValueAnimator.INFINITE); mExchangeAnimator.setRepeatMode(ValueAnimator.RESTART); mExchangeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { mExchangeProgress = (float) animation.getAnimatedValue(); invalidate(); } }); mExchangeAnimator.start(); } ``` #### 7. 主页添加添加指引视图 在[MainActivity](file:///E:/android_student/MyGuide/app/src/main/java/com/luoyang/myguide/MainActivity.java#L22-L154)中添加引导视图: ```java private void showGuide() { recyclerView.post(() -> { // ... 获取item视图的代码 ... if (firstItemView != null && secondItemView != null) { // 创建GuideView guideView = new GuideView(this); // 获取 WindowManager WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE); // 设置 WindowManager 参数 WindowManager.LayoutParams params = new WindowManager.LayoutParams( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT ); // 添加到窗口 windowManager.addView(guideView, params); // 设置透明区域 guideView.setHighlightItems(firstRect, secondRect); // 创建item bitmap用于动画 if (firstItemView.getWidth() > 0 && firstItemView.getHeight() > 0) { guideView.createItemBitmaps(firstItemView.getWidth(), firstItemView.getHeight()); } // 设置点击监听 guideView.setOnGuideClickListener(() -> { // 移除GuideView if (guideView.getParent() != null) { dismissGuideView(); } }); } }); } ``` #### 8. 点击屏幕任意点,移除指引视图 ```java private void dismissGuideView() { if (guideView != null) { try { WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE); windowManager.removeView(guideView); guideView = null; } catch (Exception e) { e.printStackTrace(); } } } @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent event) { // 直接消费所有触摸事件 if (mListener != null && event.getAction() == MotionEvent.ACTION_UP) { mListener.onGuideClick(); } return true; } @Override public boolean dispatchTouchEvent(MotionEvent ev) { onTouchEvent(ev); // 消费事件,不让它传递给下层视图 return true; } ``` ### 扩展需求 这里只是做了一个界面的一个位置的动画效果,还可以扩展为多个位置的指引动画: - 方式1:点击后循环找位置添加蒙版视图 ### 遇到问题 1. **如何做item拖动效果** - 有官方API,不需要自定义动画,只需要设置拖动的item透明度,以及确认交换的item,保存数据 2. **如何实现蒙版** - 自定义View,使用PorterDuffXfermode模式清除特定区域 3. **如何做循环动画** - 自己画布局,做布局变化,使用ValueAnimator 4. **移除模板视图** - 传统的rootView界面添加动画视图,移除动画视图不了,需要WindowManager搭配使用ValueAnimator ### demo代码仓库 https://gitee.com/luoluostudent/android_guide