• Android 使用.9图 NinePatchDrawable实现动态聊天气泡


    最近一段时间,在做一个需求,需要实现一个聊天气泡的动画效果,如下图所示:

    GitHub源码demo ,建议下载demo,运行查看。

    动态聊天气泡动画

    在这里插入图片描述

    静态聊天气泡

    在这里插入图片描述

    经过一段时间调研,实现方案如下:

    实现方案

    • 从服务端下载zip文件,文件中包含配置文件和多张png图片,配置文件定义了图片的横向拉伸拉伸区域、纵向拉伸区域、padding信息等。
    • 从本地加载配置文件,加载多张png图片为bitmap。
    • 将bitmap存储在内存里。LruCache,避免多次解析。
    • 根据配置文件,将png图片转换为.9图,NinePatchDrawable。
    • 使用多张NinePatchDrawable创建一个帧动画对象AnimationDrawable
    • 将AnimationDrawable设置为控件的背景,并让AnimationDrawable播放动画,执行一定的次数后停止动画。

    其中的难点在于第3步,将png图片转换为.9图 NinePatchDrawable

    NinePatchDrawable 的构造函数

    /**
     * Create drawable from raw nine-patch data, setting initial target density
     * based on the display metrics of the resources.
     */
    public NinePatchDrawable(Resources res,Bitmap bitmap,byte[]chunk,Rect padding,String srcName){
            this(new NinePatchState(new NinePatch(bitmap,chunk,srcName),padding),res);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    其中最关键的点在于构建byte[] chunk参数。通过查看这个类NinePatchChunk.java,并参阅了许多博客,通过反向分析NinePatchChunk类的deserialize方法,得到了如何构建byte[] chunk的方法。

    // See "frameworks/base/include/utils/ResourceTypes.h" for the format of
    // NinePatch chunk.
    class NinePatchChunk {
    
        public static final int NO_COLOR = 0x00000001;
        public static final int TRANSPARENT_COLOR = 0x00000000;
        public Rect mPaddings = new Rect();
        public int mDivX[];
        public int mDivY[];
        public int mColor[];
    
        private static void readIntArray(int[] data, ByteBuffer buffer) {
            for (int i = 0, n = data.length; i < n; ++i) {
                data[i] = buffer.getInt();
            }
        }
    
        private static void checkDivCount(int length) {
            if (length == 0 || (length & 0x01) != 0) {
                throw new RuntimeException("invalid nine-patch: " + length);
            }
        }
    
        //注释1处,解析byte[]数据,构建NinePatchChunk对象
        public static NinePatchChunk deserialize(byte[] data) {
            ByteBuffer byteBuffer =
                    ByteBuffer.wrap(data).order(ByteOrder.nativeOrder());
            byte wasSerialized = byteBuffer.get();
            if (wasSerialized == 0)//第一个字节不能为0
                return null;
            NinePatchChunk chunk = new NinePatchChunk();
            chunk.mDivX = new int[byteBuffer.get()];//第二个字节为x方向上的切割线的个数
            chunk.mDivY = new int[byteBuffer.get()];//第三个字节为y方向上的切割线的个数
            chunk.mColor = new int[byteBuffer.get()];//第四个字节为颜色的个数
            checkDivCount(chunk.mDivX.length);//判断x方向上的切割线的个数是否为偶数
            checkDivCount(chunk.mDivY.length);//判断y方向上的切割线的个数是否为偶数
            // skip 8 bytes,跳过8个字节
            byteBuffer.getInt();
            byteBuffer.getInt();
    
            //注释2处,处理padding,发现都设置为0也可以。
            chunk.mPaddings.left = byteBuffer.getInt();//左边的padding
            chunk.mPaddings.right = byteBuffer.getInt();//右边的padding
            chunk.mPaddings.top = byteBuffer.getInt();//上边的padding
            chunk.mPaddings.bottom = byteBuffer.getInt();//下边的padding
            // skip 4 bytes
            byteBuffer.getInt();//跳过4个字节
            readIntArray(chunk.mDivX, byteBuffer);//读取x方向上的切割线的位置
            readIntArray(chunk.mDivY, byteBuffer);//读取y方向上的切割线的位置
            readIntArray(chunk.mColor, byteBuffer);//读取颜色
            return chunk;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53

    注释1处,解析byte[]数据,构建NinePatchChunk对象。我们添加了一些注释,意思已经很清晰了。

    然后我们根据这里类来构建byte[] chunk参数。

    private fun buildChunk(): ByteArray {
        // 横向和竖向端点的数量 = 线段数量 * 2,这里只有一个线段,所以都是2
        val horizontalEndpointsSize = 2
        val verticalEndpointsSize = 2
    
        //这里计算的 arraySize 是 int 值,最终占用的字节数是 arraySize * 4
        val arraySize = 1 + 2 + 4 + 1 + horizontalEndpointsSize + verticalEndpointsSize + COLOR_SIZE
        //这里乘以4,是因为一个int占用4个字节
        val byteBuffer = ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder())
    
        byteBuffer.put(1.toByte()) //第一个字节无意义,不等于0就行
        byteBuffer.put(horizontalEndpointsSize.toByte()) //mDivX x数组的长度
        byteBuffer.put(verticalEndpointsSize.toByte()) //mDivY y数组的长度
        byteBuffer.put(COLOR_SIZE.toByte()) //mColor数组的长度
    
        // skip 8 bytes
        byteBuffer.putInt(0)
        byteBuffer.putInt(0)
    
        //Note: 目前还没搞清楚,发现都 byteBuffer.putInt(0),也没问题。
        //左右padding
        byteBuffer.putInt(mRectPadding.left)
        byteBuffer.putInt(mRectPadding.right)
        //上下padding
        byteBuffer.putInt(mRectPadding.top)
        byteBuffer.putInt(mRectPadding.bottom)
    
        //byteBuffer.putInt(0)
        //byteBuffer.putInt(0)
        //上下padding
        //byteBuffer.putInt(0)
        //byteBuffer.putInt(0)
    
        //skip 4 bytes
        byteBuffer.putInt(0)
    
        //mDivX数组,控制横向拉伸的线段数据,目前只支持一个线段
        patchRegionHorizontal.forEach {
            byteBuffer.putInt(it.start * width / originWidth)
            byteBuffer.putInt(it.end * width / originWidth)
        }
    
        //mDivY数组,控制竖向拉伸的线段数据,目前只支持一个线段
        patchRegionVertical.forEach {
            byteBuffer.putInt(it.start * height / originHeight)
            byteBuffer.putInt(it.end * height / originHeight)
        }
    
        //mColor数组
        for (i in 0 until COLOR_SIZE) {
            byteBuffer.putInt(NO_COLOR)
        }
    
        return byteBuffer.array()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    完整的类请参考 AnimationDrawableFactory.kt

    使用

    完整的使用请查看 ChatAdapter 类。

    AnimationDrawableFactory 支持从文件构建动画,也支持从Android的资源文件夹构建动画。

    !!!注意,从文件构建动画,需要将请把工程下的bubbleframe文件夹拷贝到手机的Android/data/包名/files
    目录下val fileDir = getExternalFilesDir(null),否则会报错。

    从文件构建动画

     return AnimationDrawableFactory(context)
        .setDrawableDir(pngsDir)//图片文件所在的目录
        .setHorizontalStretchBean(PatchStretchBean(60, 61))//水平拉伸区域
        .setVerticalStretchBean(PatchStretchBean(52, 53))//垂直拉伸区域
        .setOriginSize(128, 112)//原始图片大小
        .setPadding(Rect(31, 37, 90, 75))//padding区域
        .setHorizontalMirror(isSelf)//是否水平镜像,不是必须的
        .setScaleFromFile(true)//是否从文件中读取图片的缩放比例,不是必须的
        .setFinishCount(3)//动画播放次数
        .setFrameDuration(100)//每帧动画的播放时间
        .buildFromFile()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    这里注意一下:因为文件中的图片是一倍图,所以这里需要放大,所以设置了setScaleFromFile(true)
    如果文件中的图片是3倍图,就不需要设置这个参数了。如果需要更加精细的缩放控制,后面再增加支持。

    从Android的资源文件夹构建动画

    
    private val resIdList = mutableListOf<Int>().apply {
        add(R.drawable.bubble_frame1)
        add(R.drawable.bubble_frame2)
        add(R.drawable.bubble_frame3)
        add(R.drawable.bubble_frame4)
        add(R.drawable.bubble_frame5)
        add(R.drawable.bubble_frame6)
        add(R.drawable.bubble_frame7)
        add(R.drawable.bubble_frame8)
        add(R.drawable.bubble_frame9)
        add(R.drawable.bubble_frame10)
        add(R.drawable.bubble_frame11)
        add(R.drawable.bubble_frame12)
    }
    
    /**
     * 从正常的资源文件加载动态气泡
     */
    return AnimationDrawableFactory(context)
        .setDrawableResIdList(resIdList)//图片资源id列表
        .setHorizontalStretchBean(PatchStretchBean(60, 61))//水平拉伸区域
        .setVerticalStretchBean(PatchStretchBean(52, 53))//垂直拉伸区域
        .setOriginSize(128, 112)//原始图片大小
        .setPadding(Rect(31, 37, 90, 75))//padding区域
        .setHorizontalMirror(isSelf)//是否水平镜像,不是必须的
        .setFinishCount(3)//动画播放次数,不是必须的
        .setFrameDuration(100)//每帧动画的播放时间,不是必须的
        .buildFromResource()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    有时候可能我们只需要构建静态气泡,也就是只需要一张 NinepatchDrawable,我们提供了一个类来构建静态气泡,NinePatchDrawableFactory.kt

    从文件加载

    return NinePatchDrawableFactory(context)
                .setDrawableFile(pngFile)//图片文件
                .setHorizontalStretchBean(PatchStretchBean(60, 61))//水平拉伸区域
                .setVerticalStretchBean(PatchStretchBean(52, 53))//垂直拉伸区域
                .setOriginSize(128, 112)//原始图片大小
                .setScaleFromFile(true)//是否从文件中读取图片的缩放比例,不是必须的
                .setPadding(Rect(31, 37, 90, 75))//padding区域
                .setHorizontalMirror(isSelf)//是否水平镜像,不是必须的
                .buildFromFile()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    从资源加载

    return NinePatchDrawableFactory(context)
                .setDrawableResId(R.drawable.bubble_frame1)//图片资源id
                .setHorizontalStretchBean(PatchStretchBean(60, 61))//水平拉伸区域
                .setVerticalStretchBean(PatchStretchBean(52, 53))//垂直拉伸区域
                .setOriginSize(128, 112)//原始图片大小
                .setPadding(Rect(31, 37, 90, 75))//padding区域
                .setHorizontalMirror(isSelf)//是否水平镜像,不是必须的
                .buildFromResource()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    padding 取值

    如图所示:宽高是128*112。横向padding取值为31、90,纵向padding取值为37、75。

    在这里插入图片描述

    其他

    在实现过程中发现Android 的 帧动画 AnimationDrawable无法控制动画执行的次数。最后自定义了一个类,CanStopAnimationDrawable.kt 解决。

    参考链接:

  • 相关阅读:
    Linux CentOS 安装部署 .NET Core
    【前端】HTML
    【golang】mysql默认排序无法实现 使用golang实现对时间字符串字段的排序
    不要老想着重置!当你忘记Wi-Fi密码时,可以尝试这些办法
    Python爬虫速成之路(3):下载图片
    NoSQL之redis数据库配置与优化(部署与常用命令)
    2023年05月 Python(六级)真题解析#中国电子学会#全国青少年软件编程等级考试
    「随笔」浅谈2023年云计算的发展趋势
    npm ERR! code ERESOLVE错误解决
    SpringBoot的 8 个优点
  • 原文地址:https://blog.csdn.net/leilifengxingmw/article/details/134277095