You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 03:28:15