Android自定义View实现带左右底部尾巴的iOS风格聊天气泡
优化后的iOS风格聊天气泡自定义View代码
核心优化说明
- 适配View自身布局尺寸,移除硬编码的固定矩形参数,支持
wrap_content/match_parent等布局属性 - 统一尾巴尺寸管理,避免方法内重复定义参数
- 修复左侧聊天气泡尾巴的路径衔接问题,消除多余绘制线条
- 增加属性动画兼容性,参数变更时高效触发重绘
- 支持XML布局直接配置尾巴位置、颜色等属性
完整实现代码
import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.RectF; import android.util.AttributeSet; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; public class Bubble extends View { private Paint paint; private Path path; private RectF bubbleRect; // 尾巴位置枚举 public enum TailPosition { LEFT, RIGHT, NONE } private TailPosition tailPosition = TailPosition.NONE; // 可配置的尾巴大小 private float tailSize = 15f; // 圆角半径参数 private float cornerRadius = 50f; public Bubble(Context context) { super(context); init(null); } public Bubble(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(attrs); } public Bubble(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(attrs); } public Bubble(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(attrs); } private void init(@Nullable AttributeSet attrs) { // 初始化画笔 paint = new Paint(); paint.setStyle(Paint.Style.FILL); paint.setAntiAlias(true); // 读取自定义属性 if (attrs != null) { TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.Bubble); tailPosition = TailPosition.values()[ta.getInt(R.styleable.Bubble_tailPosition, TailPosition.NONE.ordinal())]; tailSize = ta.getDimension(R.styleable.Bubble_tailSize, tailSize); cornerRadius = ta.getDimension(R.styleable.Bubble_cornerRadius, cornerRadius); paint.setColor(ta.getColor(R.styleable.Bubble_bubbleColor, Color.DKGRAY)); ta.recycle(); } else { paint.setColor(Color.DKGRAY); } bubbleRect = new RectF(); } // 设置尾巴位置 public void setTailPosition(TailPosition position) { if (this.tailPosition != position) { this.tailPosition = position; rebuildPath(); invalidate(); } } // 设置气泡颜色 public void setBubbleColor(int color) { paint.setColor(color); invalidate(); } // 设置尾巴大小 public void setTailSize(float size) { if (this.tailSize != size) { this.tailSize = size; rebuildPath(); invalidate(); } } // 设置圆角半径 public void setCornerRadius(float radius) { if (this.cornerRadius != radius) { this.cornerRadius = radius; rebuildPath(); invalidate(); } } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 用View的实际尺寸构建气泡矩形(预留1px避免边缘裁切) bubbleRect.set(1f, 1f, w - 1f, h - 1f); rebuildPath(); } private void rebuildPath() { path = getPathOfRoundedRectWithTail(bubbleRect, cornerRadius, tailPosition); } @Override protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); if (path != null) { canvas.drawPath(path, paint); } } @NonNull private Path getPathOfRoundedRectWithTail( @NonNull RectF rect, float radius, TailPosition tailPosition ) { float width = rect.right - rect.left; float height = rect.bottom - rect.top; float maxRadius = Math.min(width / 2, height / 2); float finalRadius = Math.min(radius, maxRadius); Path path = new Path(); path.moveTo(rect.left + finalRadius, rect.top); // 顶部边框 path.lineTo(rect.right - finalRadius, rect.top); // 右上角圆角 path.arcTo( new RectF( rect.right - finalRadius * 2, rect.top, rect.right, rect.top + finalRadius * 2 ), -90f, 90f ); // 右侧边框 if (tailPosition != TailPosition.RIGHT) { path.lineTo(rect.right, rect.bottom - finalRadius); } else { path.lineTo(rect.right, rect.bottom - finalRadius - tailSize); } // 右下角圆角 path.arcTo( new RectF( rect.right - finalRadius * 2, rect.bottom - finalRadius * 2, rect.right, rect.bottom ), 0f, 90f ); // 底部边框 + 尾巴绘制 if (tailPosition == TailPosition.RIGHT) { // 右侧尾巴:从右下角圆角终点延伸 path.lineTo(rect.right - tailSize, rect.bottom); path.lineTo(rect.right, rect.bottom + tailSize); path.lineTo(rect.right - tailSize * 0.5f, rect.bottom); path.lineTo(rect.left + finalRadius, rect.bottom); } else if (tailPosition == TailPosition.LEFT) { // 左侧尾巴:调整底部起点,衔接左下角圆角 path.lineTo(rect.left + tailSize * 0.5f, rect.bottom); path.lineTo(rect.left, rect.bottom + tailSize); path.lineTo(rect.left + tailSize, rect.bottom); path.lineTo(rect.left + finalRadius, rect.bottom); } else { path.lineTo(rect.left + finalRadius, rect.bottom); } // 左下角圆角 path.arcTo( new RectF( rect.left, rect.bottom - finalRadius * 2, rect.left + finalRadius * 2, rect.bottom ), 90f, 90f ); // 左侧边框 if (tailPosition != TailPosition.LEFT) { path.lineTo(rect.left, rect.top + finalRadius); } else { path.lineTo(rect.left, rect.top + finalRadius + tailSize); } // 左上角圆角 path.arcTo( new RectF( rect.left, rect.top, rect.left + finalRadius * 2, rect.top + finalRadius * 2 ), 180f, 90f ); path.close(); return path; } }
自定义属性配置(res/values/attrs.xml)
<resources> <declare-styleable name="Bubble"> <attr name="tailPosition" format="enum"> <enum name="left" value="0"/> <enum name="right" value="1"/> <enum name="none" value="2"/> </attr> <attr name="tailSize" format="dimension"/> <attr name="cornerRadius" format="dimension"/> <attr name="bubbleColor" format="color"/> </declare-styleable> </resources>
使用示例
XML布局中使用
<com.yourpackage.Bubble android:layout_width="wrap_content" android:layout_height="wrap_content" android:padding="16dp" app:tailPosition="right" app:tailSize="15dp" app:cornerRadius="24dp" app:bubbleColor="#ECECEC"/>
代码中动态切换尾巴
Bubble bubble = findViewById(R.id.bubble); bubble.setTailPosition(Bubble.TailPosition.LEFT); bubble.setBubbleColor(Color.parseColor("#007AFF"));
内容的提问来源于stack exchange,提问作者Jay Dolar
相关产品推荐
相关产品推荐

