Android使用Canvas?2D實現(xiàn)循環(huán)菜單效果
一、前言
循環(huán)菜單有很多種自定義方式,我們可以利用ViewPager或者RecyclerView + CarouselLayoutManager 或者RecyclerView + PageSnapHelper來實現(xiàn)這種效果,今天我們使用Canvas 2D來實現(xiàn)這種效果。

二、實現(xiàn)
LoopView 是常見的循環(huán) View,一般應用于循環(huán)展示菜單項目,本次實現(xiàn)的是一組循環(huán)菜單,按照垂直方向,實際上,如果把某些變量互換,可以實現(xiàn)輪播圖效果。
最終目標
- 在滑動過程中記錄偏移的位置,將畫出界面的從列表中移除,分別向兩端添加。
- 離中心點越近,半徑就會越大
- 模仿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;
}
}
接下來需要定義繪制任務,將菜單數(shù)據(jù)和繪制任務解耦。
我們這里需要
- 半徑
- 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 半徑計算
半徑計算其實只需要按默的最小邊的一半除以要展示的數(shù)量,為什么要這樣計算呢?因為這樣可以保證圓心等距,我們這里實現(xiàn)的效果其實是放大圓而不是縮小圓的方式,因此,默認情況
int MAX_VISIBLE_COUNT = 5 //這個值建議是奇數(shù) circleRadius = Math.min(w / 2F, h / 2F) / MAX_VISIBLE_COUNT;
2.3 通過位置偏移進行復用和回收
這里主要是模仿Recycler機制,對DrawTask回收和復用
//回收前處理,保證偏移連續(xù)
private void recyclerBefore(int height) {
if (isTouchEventUp) {
float centerOffset = getMinYOffset();
resetItemYOffset(height, centerOffset);
} else {
resetItemYOffset(height, offsetY);
}
isTouchEventUp = false;
}
//回收后處理,保證Item連續(xù)
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要先繪制,意味著靠近邊緣的要優(yōu)先繪制,防止蓋住中心的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 根據(jù)滑動方向重新計算每個item的偏移
Item是需要移動的,因此在事件處理的時候一定要進行偏移處理,因此滑動過程需要對Y值進行有效處理,當然要避免為1,防止View出現(xiàn)縮小而不是滑動的效果。
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事件中產生的位置偏移。
另外,我們保留系統(tǒng)內默認View對事件處理的方式,具體原理就是在onTouchEvent返回之前調用super.onTouchEvent方法
super.onTouchEvent(event); return true;
下面是事件處理完整的方法,基本是常規(guī)操作
@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思想類似,但是循環(huán)這一塊兒其實和WheelView 思想類似。
4.2 點擊事件處理
實際上本篇的View市支持點擊事件的,當時點擊區(qū)域沒有判斷,不過也是比較好處理,只要對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是負數(shù)。公式推導過程如下:
* 中線到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實現(xiàn)循環(huán)菜單效果的詳細內容,更多關于Android Canvas 2D循環(huán)菜單的資料請關注腳本之家其它相關文章!
相關文章
Android 自定義View實現(xiàn)多節(jié)點進度條功能
這篇文章主要介紹了Android 自定義View實現(xiàn)多節(jié)點進度條,本文通過實例代碼給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-05-05
Android實現(xiàn)iPhone晃動撤銷輸入功能 Android仿微信搖一搖功能
這篇文章主要為大家詳細介紹了Android實現(xiàn)iPhone晃動撤銷輸入功能,Android仿微信搖一搖功能,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-07-07

