Android 送礼-绘制轨迹-手写签名效果

1、最近接到一个送礼需求,需要用户用礼物图片画一个效果,然后保存送出去,重新绘制出来


import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.animation.LinearInterpolator
import androidx.core.animation.doOnEnd
import kotlin.math.hypot

class BrushSignatureView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : View(context, attrs) {

    /** 所有笔画 */
    private val strokes = mutableListOf<MutableList<StrokePoint>>()
    private var currentStroke: MutableList<StrokePoint>? = null

    /** 是否允许用户手绘 */
    var enableDrawing: Boolean = true

    /** 回放速度(ms/点) */
    var mSpeed: Long = 20L

    /** 笔刷图片 */
    private var brushBitmap: Bitmap? = null
    private var originalBrushBitmap: Bitmap? = null

    /** 点间隔(外部 dp) */
    var stampDistanceDp: Float = 2f
        set(value) {
            field = value
            stampDistancePx = dp2px(value)
        }
    private var stampDistancePx: Float = dp2px(2f)

    /** 图片大小(外部 dp) */
    var brushSizeDp: Float = 24f
        set(value) {
            field = value
            brushSizePx = dp2px(value).toInt()
            resizeBrush()
        }
    private var brushSizePx: Int = dp2px(24f).toInt()

    /** 动画相关 */
    private var replayAnimator: ValueAnimator? = null
    private var replayTempStrokes: MutableList<MutableList<StrokePoint>>? = null
    private var isReplaying = false

    private var blinkingAnimator: ValueAnimator? = null
    private var isFlashing = false
    private var flashAlpha = 255

    /** 回放结束回调 */
    var onReplayEnd: (() -> Unit)? = null

    private var accumulatedDistance = 0f
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    init {
        isClickable = true
    }

    /** 设置笔刷图片 */
    fun setBrushBitmap(bitmap: Bitmap) {
        originalBrushBitmap?.takeIf { !it.isRecycled }?.recycle()
        brushBitmap?.takeIf { !it.isRecycled }?.recycle()
        originalBrushBitmap = bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, true)
        resizeBrush()
    }

    /** 调整笔刷大小 */
    private fun resizeBrush() {
        val src = originalBrushBitmap ?: return
        brushBitmap?.takeIf { !it.isRecycled }?.recycle()
        brushBitmap = Bitmap.createScaledBitmap(src, brushSizePx, brushSizePx, true)
        invalidate()
    }

    /** ========================
     *   用户手绘
     * ======================== */
    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (!enableDrawing || isReplaying || isFlashing) return false

        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                currentStroke = mutableListOf()
                strokes.add(currentStroke!!)
                addPoint(event.x, event.y)
                accumulatedDistance = 0f
            }
            MotionEvent.ACTION_MOVE -> {
                addInterpolatedPoints(event.x, event.y)
            }
            MotionEvent.ACTION_UP -> {}
        }
        return true
    }

    /** 插值补点保证均匀间隔 */
    private fun addInterpolatedPoints(x: Float, y: Float) {
        val last = currentStroke?.lastOrNull() ?: run {
            addPoint(x, y)
            return
        }

        var dx = x - last.x
        var dy = y - last.y
        var dist = hypot(dx, dy)
        if (dist < 0.1f) return

        var offsetX = last.x
        var offsetY = last.y
        var lastPoint = last

        accumulatedDistance += dist
        while (accumulatedDistance >= stampDistancePx) {
            val ratio = stampDistancePx / dist
            offsetX = lastPoint.x + dx * ratio
            offsetY = lastPoint.y + dy * ratio
            val newPoint = StrokePoint(offsetX, offsetY)
            addPoint(newPoint.x, newPoint.y)

            lastPoint = newPoint
            dx = x - lastPoint.x
            dy = y - lastPoint.y
            dist = hypot(dx, dy)
            accumulatedDistance -= stampDistancePx
        }
    }

    private fun addPoint(x: Float, y: Float) {
        currentStroke?.add(StrokePoint(x, y))
        invalidate()
    }

    /** ========================
     *   绘制
     * ======================== */
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val toDraw = if (isReplaying) replayTempStrokes else strokes
        toDraw?.forEach { drawStroke(canvas, it) }
    }

    private fun drawStroke(canvas: Canvas, stroke: List<StrokePoint>) {
        val brush = brushBitmap ?: return
        val half = brushSizePx / 2f
        if (stroke.isEmpty()) return

        val oldAlpha = paint.alpha
        if (isFlashing) paint.alpha = flashAlpha

        for (p in stroke) {
            canvas.drawBitmap(brush, p.x - half, p.y - half, paint)
        }

        paint.alpha = oldAlpha
    }

    /** ========================
     *   导出 / 导入
     * ======================== */
    fun exportStrokes(): MutableList<MutableList<StrokePoint>> =
        strokes.map { it.toMutableList() }.toMutableList()

    fun importStrokes(list: MutableList<MutableList<StrokePoint>>, autoReplay: Boolean = false, speed: Long = mSpeed) {
        strokes.clear()
        list.forEach { strokes.add(it.toMutableList()) }
        invalidate()
        if (autoReplay) replay(speed)
    }

    /** ========================
     *   撤回 / 清空
     * ======================== */
    fun undo() {
        if (strokes.isNotEmpty()) {
            strokes.removeAt(strokes.size - 1)
            invalidate()
        }
    }

    fun clearAll() {
        strokes.clear()
        currentStroke = null
        invalidate()
    }

    /** ========================
     *   回放动画
     * ======================== */
    fun replay(speed: Long = mSpeed) {
        if (strokes.isEmpty()) { onReplayEnd?.invoke(); return }

        replayAnimator?.cancel()
        replayAnimator = null
        isReplaying = true

        val full = strokes.map { it.toMutableList() }.toMutableList()
        replayTempStrokes = mutableListOf()

        val totalPoints = full.sumOf { it.size }

        replayAnimator = ValueAnimator.ofInt(0, totalPoints - 1).apply {
            duration = totalPoints * speed
            interpolator = LinearInterpolator()
            addUpdateListener { animator ->
                val index = animator.animatedValue as Int
                updateReplay(full, index)
            }
            doOnEnd {
                isReplaying = false
                replayTempStrokes = null
                startFlash() // 回放结束后闪烁
            }
            start()
        }
    }

    private fun updateReplay(full: MutableList<MutableList<StrokePoint>>, index: Int) {
        var count = 0
        replayTempStrokes?.clear()
        for (stroke in full) {
            val newStroke = mutableListOf<StrokePoint>()
            for (p in stroke) {
                if (count <= index) newStroke.add(p)
                count++
            }
            if (newStroke.isNotEmpty()) replayTempStrokes?.add(newStroke)
        }
        invalidate()
    }

    /** ========================
     *   闪烁效果
     * ======================== */
    private fun startFlash(times: Int = 2, duration: Long = 500L) {
        if (strokes.isEmpty()) return
        blinkingAnimator?.cancel()
        blinkingAnimator = null
        isFlashing = true

        // 每次闪烁包含一次亮->暗->亮
        val values = mutableListOf<Int>()
        for (i in 0 until times) {
            values.addAll(listOf(255, 0))
        }
        values.add(255) // 最后回到全亮

        blinkingAnimator = ValueAnimator.ofInt(*values.toIntArray()).apply {
            this.duration = duration * values.size / 2 // 每个过渡时间为 duration
            interpolator = LinearInterpolator()
            addUpdateListener {
                flashAlpha = it.animatedValue as Int
                invalidate()
            }
            doOnEnd {
                isFlashing = false
                flashAlpha = 255
                invalidate()
                onReplayEnd?.invoke()

            }
            start()
        }
    }

    private fun dp2px(dp: Float): Float = dp * resources.displayMetrics.density

    /** ========================
     *   内存清理
     * ======================== */
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        release()
    }

    fun release() {
        // 取消动画
        replayAnimator?.cancel()
        blinkingAnimator?.cancel()
        replayAnimator = null
        blinkingAnimator = null

        // 回收 Bitmap
        brushBitmap?.takeIf { !it.isRecycled }?.recycle()
        brushBitmap = null
        originalBrushBitmap?.takeIf { !it.isRecycled }?.recycle()
        originalBrushBitmap = null

        // 清理临时数据
        currentStroke = null
        replayTempStrokes = null
        strokes.clear()
    }

}


data class StrokePoint(val x: Float, val y: Float)


使用


        val bitmap = BitmapFactory.decodeResource(resources, R.drawable.bg_tlp_game_queen)
        binding.brushSignatureView.setBrushBitmap(bitmap)
        binding.brushSignatureView.stampDistanceDp = 12f

        binding.brushSignatureView.onReplayEnd = {
            //播放完成回调
            binding.brushSignatureView.release()
        }

        binding.tvSave.setOnClickListener {
            //保存轨迹
            list = binding.brushSignatureView.exportStrokes()
        }

        binding.tCancel.setOnClickListener {
            //撤销
            binding.brushSignatureView.undo()
        }

        binding.tvClear.setOnClickListener {
            //清空
            binding.brushSignatureView.enableDrawing = true
            binding.brushSignatureView.clearAll()
        }

        binding.tvPlay.setOnClickListener {
            //播放轨迹
            if (!list.isNullOrEmpty()){
                binding.brushSignatureView.enableDrawing = false
                binding.brushSignatureView.importStrokes(list!!, true, 30)
            }
        }

注意只有在onDetachedFromWindow里面做了资源释放,具体请根据自己的实际情况调用 release()

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

  • 1.ios高性能编程 (1).内层 最小的内层平均值和峰值(2).耗电量 高效的算法和数据结构(3).初始化时...
    欧辰_OSR阅读 30,044评论 8 265
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 13,906评论 1 32
  • 最近在准备android面试,整理了下相关的面试题,分为如下三个部分:android部分、Java部分、算法面试题...
    JasmineBen阅读 12,077评论 10 137
  • 开发小知识(一)[https://www.jianshu.com/p/5a4ba3c165b9] 开发小知识(二)...
    ZhengYaWei阅读 4,367评论 0 2
  • 一、简历准备 1、个人技能 (1)自定义控件、UI设计、常用动画特效 自定义控件 ①为什么要自定义控件? Andr...
    lucas777阅读 10,648评论 2 54

友情链接更多精彩内容