Android使用Canvas 2D实现循环菜单效果
作者:时光少年
一、前言
循环菜单有很多种自定义方式,我们可以利用ViewPager或者RecyclerView + CarouselLayoutManager 或者RecyclerView + PageSnapHelper来实现这种效果,今天我们使用Canvas 2D来实现这种效果。
二、实现
LoopView 是常见的循环 View,一般应用于循环展示菜单项目,本次实现的是一组循环菜单,按照垂直方向,实际上,如果把某些变量互换,可以实现轮播图效果。
最终目标
- 在滑动过程中记录偏移的位置,将画出界面的从列表中移除,分别向两端添加。
- 离中心点越近,半径就会越大
- 模仿Recyler机制,偏移到界面以外的item回收利用
2.1 定义菜单项
首先这里定义一下菜单Item,主要标记颜色和文本内容
public static class LoopItem { private int color; private String text; public LoopItem(String text, int color) { this.color = color; this.text = text; } public int getColor() { return color; } public void setColor(int color) { this.color = color; } public String getText() { return text; } public void setText(String text) { this.text = text; } }
接下来需要定义绘制任务,将菜单数据和绘制任务解耦。
我们这里需要
- 半径
- x,y坐标
- 半径缩放增量
public static class DrawTask<T extends LoopItem> { private T loopItem; private float radius; //半径,定值 private float x; private float y; private float scaleOffset = 0; // 半径缩放偏移量,离中心越远,此值就会越小 public DrawTask(float x, float y, float radius) { this.radius = radius; this.x = x; this.y = y; } public void setLoopItem(T loopItem) { this.loopItem = loopItem; } public void draw(Canvas canvas, TextPaint textPaint) { if (loopItem == null) return; textPaint.setColor(loopItem.getColor()); textPaint.setStyle(Paint.Style.FILL); textPaint.setShadowLayer(10, 0, 5, 0x99444444); //绘制圆 canvas.drawCircle(x, y, radius + scaleOffset, textPaint); textPaint.setColor(Color.WHITE); textPaint.setStyle(Paint.Style.FILL); //绘制文本 String text = loopItem.getText(); float textWidth = textPaint.measureText(text); float baseline = getTextPaintBaseline(textPaint); canvas.drawText(text, -textWidth / 2, y + baseline, textPaint); } public T getLoopItem() { return loopItem; } }
2.2 半径计算
半径计算其实只需要按默的最小边的一半除以要展示的数量,为什么要这样计算呢?因为这样可以保证圆心等距,我们这里实现的效果其实是放大圆而不是缩小圆的方式,因此,默认情况
int MAX_VISIBLE_COUNT = 5 //这个值建议是奇数 circleRadius = Math.min(w / 2F, h / 2F) / MAX_VISIBLE_COUNT;
2.3 通过位置偏移进行复用和回收
这里主要是模仿Recycler机制,对DrawTask回收和复用
//回收前处理,保证偏移连续 private void recyclerBefore(int height) { if (isTouchEventUp) { float centerOffset = getMinYOffset(); resetItemYOffset(height, centerOffset); } else { resetItemYOffset(height, offsetY); } isTouchEventUp = false; } //回收后处理,保证Item连续 private void recyclerAfter(int height) { if (isTouchEventUp) { float centerOffset = getMinYOffset(); resetItemYOffset(height, centerOffset); } else { resetItemYOffset(height, 0); } } //进行回收和复用,用head和tail指针对两侧外的Item移除和复用 private void recycler() { if (drawTasks.size() < (MAX_VISIBLE_COUNT - 2)) return; Collections.sort(drawTasks, drawTaskComparatorY); DrawTask head = drawTasks.get(0); //head 指针 DrawTask tail = drawTasks.get(drawTasks.size() - 1); //尾指针 int height = getHeight(); if (head.y < -(height / 2F + circleRadius)) { drawTasks.remove(head); addToCachePool(head); head.setLoopItem(null); //回收 } else { DrawTask drawTask = getCachePool(); //复用 LoopItem loopItem = head.getLoopItem(); LoopItem preLoopItem = getPreLoopItem(loopItem); drawTask.setLoopItem(preLoopItem); drawTask.y = head.y - circleRadius * 2; drawTasks.add(0, drawTask); } if (tail.y > (height / 2F + circleRadius)) { drawTasks.remove(tail); addToCachePool(tail); tail.setLoopItem(null); } else { DrawTask drawTask = getCachePool(); LoopItem loopItem = tail.getLoopItem(); LoopItem nextLoopItem = getNextLoopItem(loopItem); drawTask.setLoopItem(nextLoopItem); drawTask.y = tail.y + circleRadius * 2; drawTasks.add(drawTask); } }
2.4 防止靠近中心的View被绘制
远离中心的Item要先绘制,意味着靠近边缘的要优先绘制,防止盖住中心的Item,因此每次都需要排序 这里的outOffset半径偏移值,半径越小的就会排在前面
Collections.sort(drawTasks, new Comparator<DrawTask>() { @Override public int compare(DrawTask left, DrawTask right) { float dx = Math.abs(left.y) - Math.abs(right.y); if (dx > 0) { return 1; } if (dx == 0) { return 0; } return -1; } });
2.5 获取离中心点最近的Item的y值
scaleOffset越大,离圆心越近,通过这种方式就能筛选出靠近圆心的Item Y坐标
private float getMinYOffset() { float minY = 0; float offset = 0; for (int i = 0; i < drawTasks.size(); i++) { DrawTask drawTask = drawTasks.get(i); if (Math.abs(drawTask.scaleOffset) > offset) { minY = -drawTask.y; offset = drawTask.scaleOffset; } } return minY; }
2.6 根据滑动方向重新计算每个item的偏移
Item是需要移动的,因此在事件处理的时候一定要进行偏移处理,因此滑动过程需要对Y值进行有效处理,当然要避免为1,防止View出现缩小而不是滑动的效果。
private void resetItemYOffset(int height, float centerOffset) { for (int i = 0; i < drawTasks.size(); i++) { DrawTask task = drawTasks.get(i); task.y = (task.y + centerOffset); float ratio = Math.abs(task.y) / (height / 2F); if (ratio > 1f) { ratio = 1f; } task.outOffset = ((10 + circleRadius) * 3 / 4f) * (1 - ratio); } }
2.7 事件处理
我们要支持Item移动,因此必然要处理TouchEvent,首先我们需要在ACTION_DOWN时拦截事件,其次需要处理ACTION_MOVE事件和ACTION_UP事件中产生的位置偏移。
另外,我们保留系统内默认View对事件处理的方式,具体原理就是在onTouchEvent返回之前调用super.onTouchEvent方法
super.onTouchEvent(event); return true;
下面是事件处理完整的方法,基本是常规操作
@Override public boolean onTouchEvent(MotionEvent event) { int action = event.getActionMasked(); isTouchEventUp = false; switch (action) { case MotionEvent.ACTION_DOWN: offsetY = 0; startEventX = event.getX() - getWidth() / 2F; startEventY = event.getY() - getHeight() / 2F; super.onTouchEvent(event); return true; case MotionEvent.ACTION_MOVE: float eventX = event.getX(); float eventY = event.getY(); if (eventY < 0) { eventY = 0; } if (eventX < 0) { eventX = 0; } if (eventY > getWidth()) { eventX = getWidth(); } if (eventY > getHeight()) { eventY = getHeight(); } float currentX = eventX - getWidth() / 2F; float currentY = eventY - getHeight() / 2F; float dx = currentX - startEventX; float dy = currentY - startEventY; if (Math.abs(dx) < Math.abs(dy) && Math.abs(dy) >= slopTouch) { isTouchMove = true; } if (!isTouchMove) { break; } offsetY = dy; startEventX = currentX; startEventY = currentY; postInvalidate(); super.onTouchEvent(event); return true; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_OUTSIDE: case MotionEvent.ACTION_UP: isTouchMove = false; isTouchEventUp = true; offsetY = 0; Log.d("eventup", "offsetY=" + offsetY); postInvalidate(); break; } return super.onTouchEvent(event); }
三、使用方法
使用方法
LoopView loopView = findViewById(R.id.loopviews); final List<LoopView.LoopItem> loopItems = new ArrayList<>(); int[] colors = { Color.RED, Color.CYAN, Color.GRAY, Color.GREEN, Color.BLACK, Color.MAGENTA, 0xffff9922, 0xffFF4081, 0xffFFEAC4 }; String[] items = { "新闻", "科技", "历史", "军事", "小说", "娱乐", "电影", "电视剧", }; for (int i = 0; i < items.length; i++) { LoopView.LoopItem loopItem = new LoopView.LoopItem(items[i], colors[i % colors.length]); loopItems.add(loopItem); } loopView.setLoopItems(loopItems); } LoopView loopView = new LoopView(this); loopView.setLoopItems(loopItems); FrameLayout frameLayout = new FrameLayout(this); FrameLayout.MarginLayoutParams layoutParams = new FrameLayout.MarginLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,720); layoutParams.topMargin = 100; layoutParams.leftMargin = 50; layoutParams.rightMargin = 50; frameLayout.addView(loopView,layoutParams); setContentView(frameLayout);
四、总结
4.1 整体效果
其实效果上还是可以的,本质上和ListView和RecyclerView思想类似,但是循环这一块儿其实和WheelView 思想类似。
4.2 点击事件处理
实际上本篇的View市支持点击事件的,当时点击区域没有判断,不过也是比较好处理,只要对DrawTask排序,保证最中间的Item可以点击即可,篇幅有限,这里就不处理了。
4.3 全部代码
public class LoopView extends View { private static final int MAX_VISIBLE_COUNT = 5; private TextPaint mTextPaint = null; private DisplayMetrics displayMetrics = null; private int mLineWidth = 1; private int mTextSize = 14; private int slopTouch = 0; private float circleRadius; private final List<DrawTask> drawTasks = new ArrayList<>(); private final List<DrawTask> cacheDrawTasks = new ArrayList<>(); private final List<LoopItem> loopItems = new ArrayList<>(); boolean isInit = false; private float startEventX = 0; private float startEventY = 0; private boolean isTouchMove = false; private float offsetY = 0; boolean isTouchEventUp = false; public LoopView(Context context) { this(context, null); } public LoopView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public LoopView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); setClickable(true); setFocusable(true); setFocusableInTouchMode(true); displayMetrics = context.getResources().getDisplayMetrics(); mTextPaint = createPaint(); slopTouch = ViewConfiguration.get(context).getScaledTouchSlop(); setLayerType(LAYER_TYPE_SOFTWARE, null); initDesignEditMode(); } private void initDesignEditMode() { if (!isInEditMode()) return; int[] colors = { Color.RED, Color.CYAN, Color.YELLOW, Color.GRAY, Color.GREEN, Color.BLACK, Color.MAGENTA, 0xffff9922, }; String[] items = { "新闻", "科技", "历史", "军事", "小说", "娱乐", "电影", "电视剧", }; for (int i = 0; i < items.length; i++) { LoopItem loopItem = new LoopItem(items[i], colors[i % colors.length]); loopItems.add(loopItem); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); if (widthMode != MeasureSpec.EXACTLY) { widthSize = displayMetrics.widthPixels; } int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); if (heightMode != MeasureSpec.EXACTLY) { heightSize = (int) (displayMetrics.widthPixels * 0.9f); } setMeasuredDimension(widthSize, heightSize); } private TextPaint createPaint() { // 实例化画笔并打开抗锯齿 TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG); paint.setAntiAlias(true); paint.setStrokeWidth(dpTopx(mLineWidth)); paint.setTextSize(dpTopx(mTextSize)); return paint; } private float dpTopx(float dp) { return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics()); } /** * 基线到中线的距离=(Descent+Ascent)/2-Descent * 注意,实际获取到的Ascent是负数。公式推导过程如下: * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。 */ public static float getTextPaintBaseline(Paint p) { Paint.FontMetrics fontMetrics = p.getFontMetrics(); return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent; } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); circleRadius = Math.min(w / 2F, h / 2F) / MAX_VISIBLE_COUNT; } Comparator<DrawTask> drawTaskComparator = new Comparator<DrawTask>() { @Override public int compare(DrawTask left, DrawTask right) { float dx = Math.abs(right.y) - Math.abs(left.y); if (dx > 0) { return 1; } if (dx == 0) { return 0; } return -1; } }; Comparator<DrawTask> drawTaskComparatorY = new Comparator<DrawTask>() { @Override public int compare(DrawTask left, DrawTask right) { float dx = left.y - right.y; if (dx > 0) { return 1; } if (dx == 0) { return 0; } return -1; } }; @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int width = getWidth(); int height = getHeight(); int id = canvas.save(); canvas.translate(width / 2F, height / 2F); initCircle(); //前期重置,以便recycler复用 recyclerBefore(height); //复用和移除 recycler(); //再次处理,防止view复用之后产生其他位移 recyclerAfter(height); Collections.sort(drawTasks, drawTaskComparator); for (int i = 0; i < drawTasks.size(); i++) { drawTasks.get(i).draw(canvas, mTextPaint); } drawGuideline(canvas, width); canvas.restoreToCount(id); } private float getMinYOffset() { float minY = 0; float offset = 0; for (int i = 0; i < drawTasks.size(); i++) { DrawTask drawTask = drawTasks.get(i); if (Math.abs(drawTask.scaleOffset) > offset) { minY = -drawTask.y; offset = drawTask.scaleOffset; } } return minY; } private void recyclerAfter(int height) { if (isTouchEventUp) { float centerOffset = getMinYOffset(); resetItemYOffset(height, centerOffset); } else { resetItemYOffset(height, 0); } } private void recyclerBefore(int height) { if (isTouchEventUp) { float centerOffset = getMinYOffset(); resetItemYOffset(height, centerOffset); } else { resetItemYOffset(height, offsetY); } isTouchEventUp = false; } private void recycler() { if (drawTasks.size() < (MAX_VISIBLE_COUNT - 2)) return; Collections.sort(drawTasks, drawTaskComparatorY); DrawTask head = drawTasks.get(0); DrawTask tail = drawTasks.get(drawTasks.size() - 1); int height = getHeight(); if (head.y < -(height / 2F + circleRadius)) { drawTasks.remove(head); addToCachePool(head); head.setLoopItem(null); } else { DrawTask drawTask = getCachePool(); LoopItem loopItem = head.getLoopItem(); LoopItem preLoopItem = getPreLoopItem(loopItem); drawTask.setLoopItem(preLoopItem); drawTask.y = head.y - circleRadius * 2; drawTasks.add(0, drawTask); } if (tail.y > (height / 2F + circleRadius)) { drawTasks.remove(tail); addToCachePool(tail); tail.setLoopItem(null); } else { DrawTask drawTask = getCachePool(); LoopItem loopItem = tail.getLoopItem(); LoopItem nextLoopItem = getNextLoopItem(loopItem); drawTask.setLoopItem(nextLoopItem); drawTask.y = tail.y + circleRadius * 2; drawTasks.add(drawTask); } } private void resetItemYOffset(int height, float scaleOffset) { for (int i = 0; i < drawTasks.size(); i++) { DrawTask task = drawTasks.get(i); task.y = (task.y + scaleOffset); float ratio = Math.abs(task.y) / (height / 2F); if (ratio > 1f) { ratio = 1f; } task.scaleOffset = ((10 + circleRadius) * 3 / 4f) * (1 - ratio); } } RectF guideRect = new RectF(); private void drawGuideline(Canvas canvas, int width) { if (!isInEditMode()) return; mTextPaint.setColor(Color.BLACK); mTextPaint.setStyle(Paint.Style.FILL); int i = 0; int counter = 0; while (counter < MAX_VISIBLE_COUNT) { float topY = i * 2 * circleRadius; guideRect.left = -width / 2f; guideRect.right = width / 2f; guideRect.top = topY - 0.5f; guideRect.bottom = topY + 0.5f; canvas.drawRect(guideRect, mTextPaint); counter++; float bottomY = -i * 2 * circleRadius; if (topY == bottomY) { i++; continue; } guideRect.top = bottomY - 0.5f; guideRect.bottom = bottomY + 0.5f; canvas.drawRect(guideRect, mTextPaint); counter++; i++; } } private LoopItem getNextLoopItem(LoopItem loopItem) { int index = loopItems.indexOf(loopItem); if (index < loopItems.size() - 1) { return loopItems.get(index + 1); } return loopItems.get(0); } private LoopItem getPreLoopItem(LoopItem loopItem) { int index = loopItems.indexOf(loopItem); if (index > 0) { return loopItems.get(index - 1); } return loopItems.get(loopItems.size() - 1); } private DrawTask getCachePool() { if (cacheDrawTasks.size() > 0) { return cacheDrawTasks.remove(0); } DrawTask drawTask = createDrawTask(); return drawTask; } private void addToCachePool(DrawTask top) { cacheDrawTasks.add(top); } private void initCircle() { if (isInit) { return; } isInit = true; List<DrawTask> drawTaskList = new ArrayList<>(); int i = 0; while (drawTaskList.size() < MAX_VISIBLE_COUNT) { float topY = i * 2 * circleRadius; DrawTask drawTask = new DrawTask(0, topY, circleRadius); drawTaskList.add(drawTask); float bottomY = -i * 2 * circleRadius; if (topY == bottomY) { i++; continue; } drawTask = new DrawTask(0, bottomY, circleRadius); drawTaskList.add(drawTask); i++; } Collections.sort(drawTaskList, new Comparator<DrawTask>() { @Override public int compare(DrawTask left, DrawTask right) { float dx = left.y - right.y; if (dx > 0) { return 1; } if (dx == 0) { return 0; } return -1; } }); drawTasks.clear(); if (loopItems.size() == 0) return; for (int j = 0; j < drawTaskList.size(); j++) { drawTaskList.get(j).setLoopItem(loopItems.get(j % loopItems.size())); } drawTasks.addAll(drawTaskList); } private DrawTask createDrawTask() { DrawTask drawTask = new DrawTask(0, 0, circleRadius); return drawTask; } @Override public boolean onTouchEvent(MotionEvent event) { int action = event.getActionMasked(); isTouchEventUp = false; switch (action) { case MotionEvent.ACTION_DOWN: offsetY = 0; startEventX = event.getX() - getWidth() / 2F; startEventY = event.getY() - getHeight() / 2F; return true; case MotionEvent.ACTION_MOVE: float eventX = event.getX(); float eventY = event.getY(); if (eventY < 0) { eventY = 0; } if (eventX < 0) { eventX = 0; } if (eventY > getWidth()) { eventX = getWidth(); } if (eventY > getHeight()) { eventY = getHeight(); } float currentX = eventX - getWidth() / 2F; float currentY = eventY - getHeight() / 2F; float dx = currentX - startEventX; float dy = currentY - startEventY; if (Math.abs(dx) < Math.abs(dy) && Math.abs(dy) >= slopTouch) { isTouchMove = true; } if (!isTouchMove) { break; } offsetY = dy; startEventX = currentX; startEventY = currentY; postInvalidate(); return true; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_OUTSIDE: case MotionEvent.ACTION_UP: isTouchMove = false; isTouchEventUp = true; offsetY = 0; Log.d("eventup", "offsetY=" + offsetY); invalidate(); break; } return super.onTouchEvent(event); } public void setLoopItems(List<LoopItem> loopItems) { this.loopItems.clear(); this.drawTasks.clear(); this.cacheDrawTasks.clear(); this.isInit = false; if (loopItems != null) { this.loopItems.addAll(loopItems); } postInvalidate(); } public static class DrawTask<T extends LoopItem> { private T loopItem; private float radius; private float x; private float y; private float scaleOffset = 0; public DrawTask(float x, float y, float radius) { this.radius = radius; this.x = x; this.y = y; } public void setLoopItem(T loopItem) { this.loopItem = loopItem; } public void draw(Canvas canvas, TextPaint textPaint) { if (loopItem == null) return; textPaint.setColor(loopItem.getColor()); textPaint.setStyle(Paint.Style.FILL); textPaint.setShadowLayer(10, 0, 5, 0x99444444); canvas.drawCircle(x, y, radius + scaleOffset, textPaint); textPaint.setColor(Color.WHITE); textPaint.setStyle(Paint.Style.FILL); String text = loopItem.getText(); float textWidth = textPaint.measureText(text); float baseline = getTextPaintBaseline(textPaint); textPaint.setShadowLayer(0, 0, 0, Color.TRANSPARENT); canvas.drawText(text, -textWidth / 2, y + baseline, textPaint); } public T getLoopItem() { return loopItem; } } public static class LoopItem { private int color; private String text; public LoopItem(String text, int color) { this.color = color; this.text = text; } public int getColor() { return color; } public void setColor(int color) { this.color = color; } public String getText() { return text; } public void setText(String text) { this.text = text; } } }
以上就是Android使用Canvas 2D实现循环菜单效果的详细内容,更多关于Android Canvas 2D循环菜单的资料请关注脚本之家其它相关文章!