一.背景
正准备出去抽根烟的你突然被产品经理叫住了,“快来,快来,我告诉你一个好消息,前几天我不是说要给 app 的菜单入口这一截 UI 美化一下嘛,我昨天突发灵感,已经想好了一个很有特色的样式,这下我们的 UI 一定和别人的不一样,我们要让产品富有品牌表现力”。
这看起来是挺有特别的,不过感觉有违 android UI 排列常理呀,形状倒是没什么问题,关键是这无缝衔接就有点奇怪了,这形状最简单的就是直接切几个梯形图当背景就实现了,但是想让控件边缘两两完美契合就得让两个控件重叠一部分,那点击时不就会错乱了,那肯定不行。
二.思路分析
实际布局的宽是以较长边决定的,例如上图中,第二个控件和第一个控件有一部分是重叠的,这是为了让控件两两之间看上去是完美契合的。这里就要考虑一个点击区域问题了,上图中红圈部分实际上全部是第二个控件,但是当用户点击 1 的那个梯形右下角区域,应该让第一个控件响应,点击 2 的那个梯形左上角,应该让第二个控件响应。
不仅要控制点击和契合图形,最好还要能支持设置这个梯形控件是正梯形还是倒梯形(下边更宽还是上边更宽),能设置宽度数值,能设置左边或是右边是否垂直;所以总结如下:
1.支持设置宽度数值,支持设置两边是否垂直,这样用固定图片就不太合适了,干脆通过自定义 View 来实现;
2.实现两控件间完美契合,这可以在自定义 View 时让截好的梯形以外的部分透明显示,然后让右边的控件往左 margin 负的上边和下边宽度相差的一半就正好契合了。
3.支持代码动态创建布局,设置背景图片,设置文字。
三、具体实现
第一步先实现这个自定义形状的控件,大概需要这几个参数,上宽,下宽,高度,左边是否垂直,右边是否垂直
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.PaintView, defStyleAttr, 0);
topWidth = ta.getDimensionPixelSize(R.styleable.PaintView_topWidth, 100);
bottomWidth = ta.getDimensionPixelSize(R.styleable.PaintView_bottomWidth, 100);
mHeight = ta.getDimensionPixelSize(R.styleable.PaintView_height, 100);
leftRect = ta.getBoolean(R.styleable.PaintView_leftRect, false);
rightRect = ta.getBoolean(R.styleable.PaintView_rightRect, false);
然后规划梯形路径
paint.setStyle(Paint.Style.FILL_AND_STROKE); //设置边框
paint.setStrokeWidth(strokWidth);
paint.setStrokeJoin(Paint.Join.ROUND); //设置圆角
mDrawPath = new Path();
int offset = strokWidth / 2;
if (topWidth > bottomWidth) {
mDrawPath.moveTo(offset, offset);
mDrawPath.rLineTo(topWidth, 0);
mDrawPath.lineTo(bottomWidth + (topWidth - bottomWidth) / (rightRect ? 1 : 2) + offset, mHeight + offset);
mDrawPath.lineTo((leftRect ? 0 : (topWidth - bottomWidth) / 2) + offset, mHeight + offset);
mDrawPath.close();
} else {
mDrawPath.moveTo((leftRect ? 0 : (bottomWidth - topWidth) / 2) + offset, offset);
mDrawPath.lineTo((bottomWidth - topWidth) / (rightRect ? 1 : 2) + topWidth + offset, offset);
mDrawPath.lineTo(bottomWidth + offset, mHeight + offset);
mDrawPath.lineTo(offset, mHeight + offset);
mDrawPath.close();
}
路径规划好后就开始测量绘制了
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(Math.max(topWidth, bottomWidth) + strokWidth, mHeight + strokWidth);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mDrawPath, paint);
}
下面是布局文件
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/activity_main" android:layout_width="match_parent" android:layout_height="match_parent">
<com.lcp.customeview.widget.PaintView android:id="@+id/ldder1" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:layout_marginLeft="20dp" android:layout_marginBottom="20dp" android:focusableInTouchMode="true" android:focusable="true" app:bottomWidth="80dp" app:height="100dp" app:pcolor="#d78f8f" app:topWidth="50dp" app:leftRect="true"/>
<com.lcp.customeview.widget.PaintView android:id="@+id/ldder2" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:layout_marginBottom="20dp" android:focusableInTouchMode="true" android:focusable="true" android:layout_marginLeft="-14dp" android:layout_toRightOf="@id/ldder1" app:bottomWidth="50dp" app:height="100dp" app:pcolor="#bc9747" app:topWidth="80dp" />
<com.lcp.customeview.widget.PaintView android:id="@+id/ldder3" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:layout_marginBottom="20dp" android:focusableInTouchMode="true" android:focusable="true" android:layout_marginLeft="-14dp" android:layout_toRightOf="@id/ldder2" app:bottomWidth="80dp" app:height="100dp" app:pcolor="#d78f8f" app:topWidth="50dp" />
<com.lcp.customeview.widget.PaintView android:id="@+id/ldder4" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:layout_marginBottom="20dp" android:focusableInTouchMode="true" android:focusable="true" android:layout_marginLeft="-14dp" android:layout_toRightOf="@id/ldder3" app:topWidth="80dp" app:bottomWidth="50dp" app:height="100dp" app:rightRect="true" app:pcolor="#bc9747" />
RelativeLayout>
可以看到 xml 文件中有个属性是 android:layout_marginLeft="-14dp" 这就是上宽和下宽相减的绝对值的一半得到的,这里 -14 而不是 -15 是为了让控件间留一条缝。
然后再设置点击区域,这个其实有现成的 api 帮我们计算,比想象中要简单的多:
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (!isInRect(event)) {
return false;
}
}
return super.dispatchTouchEvent(event);
}
public boolean isInRect(MotionEvent event) {
RectF rectF = new RectF();
mDrawPath.computeBounds(rectF, true);
mRegion.setPath(mDrawPath, new Region((int) rectF.left, (int) rectF.top, (int) rectF.right, (int) rectF.bottom));
return mRegion.contains((int) event.getX(), (int) event.getY());
}
当手指按下时,判断是否是按在 mDrawPath内,如果不在,就不分发事件,这样边缘点击的需求就被完美的实现了。
上面就是先搞定这个不规则 UI 的核心部分逻辑,最后实现一下设置背景和文字的逻辑,可以直接让上面的自定义 View 继承自 RelativeLayout,这样可以直接在布局文件里加图片和文字控件了,就像原生写法一样:
<com.lcp.customeview.widget.LadderLayout android:id="@+id/ldder1" style="@style/ladder" android:layout_marginLeft="20dp" app:bottomWidth="90dp" app:leftRect="true" app:topWidth="60dp">
<ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@mipmap/ic1" android:scaleType="centerCrop" />
<TextView android:id="@+id/textview" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:text="西湖风光" android:textColor="#333333" />
com.lcp.customeview.widget.LadderLayout>
然后这里有几个较上面需要修改的逻辑:
1.把画笔裁剪模式设置成 DST_IN,把整个子 View 裁切成目标路径的样式;
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
2.如果根 View 不设置背景属性,draw() 方法不会运行,除非主动设置下面的属性:
setWillNotDraw(false);
3.测量子控件,修改 ImageView 宽高并赋值 imageView 和 textView,以方便外面修改,例如把这个 ImageView 提供到外面让 Glide 去给它加载图片(此逻辑是专门针对自己的业务逻辑,其它实际场景不一定要这样处理):
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measuredWidth = Math.max(topWidth, bottomWidth) ;
int measuredHeight = mHeight ;
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
// 为每一个子控件测量大小
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
if (childView instanceof ImageView) {
imageView = (ImageView) childView;
ViewGroup.LayoutParams layoutParams = childView.getLayoutParams();
layoutParams.width = measuredWidth;
layoutParams.height = measuredHeight;
}
if (childView instanceof TextView) {
textView = (TextView) childView;
}
}
setMeasuredDimension(measuredWidth, measuredHeight);
}
public ImageView getImageView() {
return imageView;
}
public TextView getTextView() {
return textView;
}
4.Xfermode 模式需要设置离屏缓冲,不然效果不会是你预期的:
@Override
public void draw(Canvas canvas) {
int saved = canvas.saveLayer(null, null, Canvas.ALL_SAVE_FLAG);
super.draw(canvas);
canvas.drawPath(mDrawPath, paint);
canvas.restoreToCount(saved);
}
5.api 27以上修改了绘制方式,PorterDuff.Mode.DST_IN 不是预期的方式了,有多种方式处理,例如:
a: 用 PorterDuff.Mode.DST_OUT 模式画,同时把 path 的 FillType 反一下,注意在别的用到 path 处要反转回来
@Override
public void draw(Canvas canvas) {
int saved = canvas.saveLayer(null, null, Canvas.ALL_SAVE_FLAG);
super.draw(canvas);
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) {
paint.setXfermode(porterDuffXfermodeIn);
} else {
paint.setXfermode(porterDuffXfermodeOut);
if (!mDrawPath.isInverseFillType()) {
mDrawPath.toggleInverseFillType();
}
}
canvas.drawPath(mDrawPath, paint);
canvas.restoreToCount(saved);
}
b: 创建一个空的 path, 然后和目标切一下,发现不少人这样解决
paint.setStyle(Paint.Style.FILL);
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) {
paint.setXfermode(porterDuffXfermodeIn);
} else {
paint.setXfermode(porterDuffXfermodeOut);
final Path path = new Path();
path.addRect(0, 0, getWidth(), getHeight(), Path.Direction.CW);
path.op(mDrawPath, Path.Op.DIFFERENCE);
canvas.drawPath(path, paint);
}
这种处理方式也能达到效果,但是 paint 不能设置成 Paint.Style.FILL_AND_STROKE 模式,这样圆角就没了,如果不要求圆角可以用这种方式处理,注意这个 path 尽量别在 onDraw() 里创建。
好了,就这几条需要注意的,然后运行一把就是下面的效果了:
下面是布局示例和代码创建示例:
<RelativeLayout android:layout_width="match_parent" android:layout_height="match_parent">
<com.lcp.customeview.widget.LadderLayout android:id="@+id/ldder1" style="@style/ladder" android:layout_marginLeft="20dp" app:bottomWidth="90dp" app:leftRect="true" app:topWidth="60dp">
<ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@mipmap/ic1" android:scaleType="centerCrop" />
<TextView android:id="@+id/textview" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:text="西湖风光" android:textColor="#333333" />
com.lcp.customeview.widget.LadderLayout>
<com.lcp.customeview.widget.LadderLayout android:id="@+id/ldder2" style="@style/ladder" android:layout_marginLeft="-14dp" android:layout_toRightOf="@id/ldder1" app:bottomWidth="60dp" app:topWidth="90dp">
<ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@mipmap/ic2" android:scaleType="centerCrop" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:text="印象杭州" android:textColor="#333333" android:textSize="12sp" />
com.lcp.customeview.widget.LadderLayout>
<com.lcp.customeview.widget.LadderLayout android:id="@+id/ldder3" style="@style/ladder" android:layout_marginLeft="-14dp" android:layout_toRightOf="@id/ldder2" app:bottomWidth="80dp" app:topWidth="50dp">
<ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@mipmap/ic3" android:scaleType="centerCrop" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:text="世界时钟" android:textColor="#333333" android:textSize="12sp" />
com.lcp.customeview.widget.LadderLayout>
<com.lcp.customeview.widget.LadderLayout android:id="@+id/ldder4" style="@style/ladder" android:layout_marginLeft="-14dp" android:layout_toRightOf="@id/ldder3" app:bottomWidth="50dp" app:rightRect="true" app:topWidth="80dp">
<ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@mipmap/ic4" android:scaleType="centerCrop" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:text="天气" android:textColor="#333333" android:textSize="12sp" />
com.lcp.customeview.widget.LadderLayout>
RelativeLayout>
代码创建:
private void addView() {
LadderLayout ladderLayout = getLadderLayout(150, 240, 300, true, false);
RelativeLayout relativeLayout = new RelativeLayout(getActivity());
relativeLayout.setFocusableInTouchMode(true);
relativeLayout.setOnFocusChangeListener(this);
relativeLayout.setClickable(true);
RelativeLayout.LayoutParams layoutParams2 = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 300);
relativeLayout.addView(ladderLayout, layoutParams2);
RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
layoutParams.leftMargin = 200;
layoutParams.topMargin = 600;
rootview.addView(relativeLayout, layoutParams);
LadderLayout ladderLayout11 = getLadderLayout(240, 150, 300, false, false);
RelativeLayout relativeLayout11 = new RelativeLayout(getActivity());
relativeLayout11.setFocusableInTouchMode(true);
relativeLayout11.setOnFocusChangeListener(this);
relativeLayout11.setClickable(true);
RelativeLayout.LayoutParams layoutParams12 = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 300);
relativeLayout11.addView(ladderLayout11, layoutParams12);
RelativeLayout.LayoutParams layoutParams13 = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
layoutParams13.leftMargin = 440 - (240 - 150) / 2 + 5;
layoutParams13.topMargin = 600;
rootview.addView(relativeLayout11, layoutParams13);
LadderLayout ladderLayout21 = getLadderLayout(150, 240, 300, false, true);
RelativeLayout relativeLayout21 = new RelativeLayout(getActivity());
relativeLayout21.setFocusableInTouchMode(true);
relativeLayout21.setOnFocusChangeListener(this);
relativeLayout21.setClickable(true);
RelativeLayout.LayoutParams layoutParams22 = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 300);
relativeLayout21.addView(ladderLayout21, layoutParams22);
RelativeLayout.LayoutParams layoutParams23 = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
layoutParams23.leftMargin = 680 - ((240 - 150) / 2) * 2 + 5 * 2;
layoutParams23.topMargin = 600;
rootview.addView(relativeLayout21, layoutParams23);
}
完整自定义 View:
/** * Created by Aislli on 2017/12/26 0026. */
public class LadderLayout extends RelativeLayout {
private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private Region mRegion;
private Path mDrawPath;
private int topWidth;
private int bottomWidth;
private int mHeight;
private final int strokWidth = 20;
private boolean leftRect;
private boolean rightRect;
private PorterDuffXfermode porterDuffXfermodeIn;
private PorterDuffXfermode porterDuffXfermodeOut;
public void setViewSize(int topWidth, int bottomWidth, int mHeight) {
this.topWidth = topWidth;
this.bottomWidth = bottomWidth;
this.mHeight = mHeight;
}
public void setLeftRect(boolean leftRect) {
this.leftRect = leftRect;
}
public void setRightRect(boolean rightRect) {
this.rightRect = rightRect;
}
public LadderLayout(Context context) {
this(context, null);
}
public LadderLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public LadderLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.LadderLayout, defStyleAttr, 0);
topWidth = ta.getDimensionPixelSize(R.styleable.LadderLayout_topWidth, 100);
bottomWidth = ta.getDimensionPixelSize(R.styleable.LadderLayout_bottomWidth, 100);
// mHeight = ta.getLayoutDimension(R.styleable.LadderLayout_android_layout_height, "layout_height");
mHeight = ta.getLayoutDimension(R.styleable.LadderLayout_android_layout_height, 100);
leftRect = ta.getBoolean(R.styleable.LadderLayout_leftRect, false);
rightRect = ta.getBoolean(R.styleable.LadderLayout_rightRect, false);
ta.recycle();
init();
}
public void init() {
mRegion = new Region();
mDrawPath = new Path();
int offset = strokWidth / 2;//偏移stroke的一半以正好显示出圆角
int tTopWidth = topWidth - strokWidth;//归还偏移的尺寸
int tBottomWidth = bottomWidth - strokWidth;
int tHeight = mHeight - strokWidth;
if (topWidth > bottomWidth) {
mDrawPath.moveTo(offset, offset);
mDrawPath.rLineTo(tTopWidth, 0);
mDrawPath.lineTo(tBottomWidth + (tTopWidth - tBottomWidth) / (rightRect ? 1 : 2) + offset, tHeight + offset);
mDrawPath.lineTo((leftRect ? 0 : (tTopWidth - tBottomWidth) / 2) + offset, tHeight + offset);
mDrawPath.close();
} else {
mDrawPath.moveTo((leftRect ? 0 : (tBottomWidth - tTopWidth) / 2) + offset, offset);
mDrawPath.lineTo((tBottomWidth - tTopWidth) / (rightRect ? 1 : 2) + tTopWidth + offset, offset);
mDrawPath.lineTo(tBottomWidth + offset, tHeight + offset);
mDrawPath.lineTo(offset, tHeight + offset);
mDrawPath.close();
}
}
{
porterDuffXfermodeIn = new PorterDuffXfermode(PorterDuff.Mode.DST_IN);
porterDuffXfermodeOut = new PorterDuffXfermode(PorterDuff.Mode.DST_OUT);
setWillNotDraw(false);
paint.setStyle(Paint.Style.FILL_AND_STROKE);//设置STROKE才能设置成圆角
paint.setStrokeWidth(strokWidth);
paint.setStrokeJoin(Paint.Join.ROUND);
}
ImageView imageView;
TextView textView;
public ImageView getImageView() {
return imageView;
}
public TextView getTextView() {
return textView;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measuredWidth = Math.max(topWidth, bottomWidth);
int measuredHeight = mHeight;
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
// 为每一个子控件测量大小
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
if (childView instanceof ImageView) {
imageView = (ImageView) childView;
ViewGroup.LayoutParams layoutParams = childView.getLayoutParams();
layoutParams.width = measuredWidth;
layoutParams.height = measuredHeight;
}
if (childView instanceof TextView) {
textView = (TextView) childView;
}
}
setMeasuredDimension(measuredWidth, measuredHeight);
}
@Override
public void draw(Canvas canvas) {
int saved = canvas.saveLayer(null, null, Canvas.ALL_SAVE_FLAG);
super.draw(canvas);
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) {
paint.setXfermode(porterDuffXfermodeIn);
} else {
paint.setXfermode(porterDuffXfermodeOut);
if (!mDrawPath.isInverseFillType()) {
mDrawPath.toggleInverseFillType();
}
}
canvas.drawPath(mDrawPath, paint);
canvas.restoreToCount(saved);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (!isInRect(event)) {
return false;
}
}
return super.dispatchTouchEvent(event);
}
public boolean isInRect(MotionEvent event) {
if (mDrawPath.isInverseFillType()) {
mDrawPath.toggleInverseFillType();
}
RectF rectF = new RectF();
mDrawPath.computeBounds(rectF, true);
mRegion.setPath(mDrawPath, new Region((int) rectF.left, (int) rectF.top, (int) rectF.right, (int) rectF.bottom));
return mRegion.contains((int) event.getX(), (int) event.getY());
}
}
源码