268 lines
9.5 KiB
Kotlin
268 lines
9.5 KiB
Kotlin
package uts.sdk.modules.masCodeScan
|
|||
|
|
|
||
|
|
import android.content.Context
|
||
|
|
import android.graphics.Bitmap
|
||
|
|
import android.graphics.Canvas
|
||
|
|
import android.graphics.Color
|
||
|
|
import android.graphics.Paint
|
||
|
|
import android.graphics.Rect
|
||
|
|
import android.os.SystemClock
|
||
|
|
import android.view.View
|
||
|
|
import com.google.mlkit.vision.barcode.common.Barcode
|
||
|
|
import kotlin.math.max
|
||
|
|
import kotlin.math.sin
|
||
|
|
|
||
|
|
data class HitMark(
|
||
|
|
val rawValue: String,
|
||
|
|
val format: Int,
|
||
|
|
val formatName: String,
|
||
|
|
val screenRect: Rect
|
||
|
|
) {
|
||
|
|
val centerX: Float get() = screenRect.exactCenterX()
|
||
|
|
val centerY: Float get() = screenRect.exactCenterY()
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 多码点选覆盖层:定格帧 + 圆点标记。
|
||
|
|
*/
|
||
|
|
class MultiCodePickView(context: Context) : View(context) {
|
||
|
|
|
||
|
|
private var stillFrame: Bitmap? = null
|
||
|
|
private var rawHits: List<Barcode> = emptyList()
|
||
|
|
private var hitMarks: List<HitMark> = emptyList()
|
||
|
|
private var visualCrop: Rect = Rect()
|
||
|
|
private var stillW: Int = 0
|
||
|
|
private var stillH: Int = 0
|
||
|
|
private var layerW: Int = 0
|
||
|
|
private var layerH: Int = 0
|
||
|
|
private var recognizing: Boolean = false
|
||
|
|
private var recognizingStartedAt: Long = 0L
|
||
|
|
|
||
|
|
private val density: Float = resources.displayMetrics.density
|
||
|
|
private val dotOuterR: Float = 18f * density
|
||
|
|
private val dotInnerR: Float = 6f * density
|
||
|
|
private val touchR: Float = 28f * density
|
||
|
|
private val breatheDurationMs = 1500L
|
||
|
|
|
||
|
|
private val haloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
|
|
style = Paint.Style.FILL
|
||
|
|
color = Color.argb(60, 7, 193, 96)
|
||
|
|
}
|
||
|
|
private val shellPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
|
|
style = Paint.Style.FILL
|
||
|
|
color = Color.WHITE
|
||
|
|
}
|
||
|
|
private val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
|
|
style = Paint.Style.FILL
|
||
|
|
color = Color.rgb(7, 193, 96)
|
||
|
|
}
|
||
|
|
private val shadePaint = Paint().apply {
|
||
|
|
color = Color.argb(40, 0, 0, 0)
|
||
|
|
}
|
||
|
|
private val promptBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
|
|
color = Color.argb(190, 0, 0, 0)
|
||
|
|
}
|
||
|
|
private val promptTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
|
|
color = Color.WHITE
|
||
|
|
textSize = 16f * resources.displayMetrics.scaledDensity
|
||
|
|
textAlign = Paint.Align.CENTER
|
||
|
|
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||
|
|
}
|
||
|
|
private val loadingTrackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
|
|
style = Paint.Style.STROKE
|
||
|
|
strokeWidth = 3f * density
|
||
|
|
strokeCap = Paint.Cap.ROUND
|
||
|
|
color = Color.argb(100, 255, 255, 255)
|
||
|
|
}
|
||
|
|
private val loadingArcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
|
|
style = Paint.Style.STROKE
|
||
|
|
strokeWidth = 3f * density
|
||
|
|
strokeCap = Paint.Cap.ROUND
|
||
|
|
color = Color.rgb(7, 193, 96)
|
||
|
|
}
|
||
|
|
private val loadingTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||
|
|
color = Color.WHITE
|
||
|
|
textSize = 15f * resources.displayMetrics.scaledDensity
|
||
|
|
textAlign = Paint.Align.CENTER
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||
|
|
super.onLayout(changed, left, top, right, bottom)
|
||
|
|
layerW = right - left
|
||
|
|
layerH = bottom - top
|
||
|
|
if (changed && rawHits.isNotEmpty()) {
|
||
|
|
rebuildHitMarks()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fun showRecognizing(bitmap: Bitmap) {
|
||
|
|
if (stillFrame !== bitmap) {
|
||
|
|
stillFrame?.recycle()
|
||
|
|
}
|
||
|
|
stillFrame = bitmap
|
||
|
|
rawHits = emptyList()
|
||
|
|
hitMarks = emptyList()
|
||
|
|
visualCrop.setEmpty()
|
||
|
|
stillW = bitmap.width
|
||
|
|
stillH = bitmap.height
|
||
|
|
recognizing = true
|
||
|
|
recognizingStartedAt = SystemClock.elapsedRealtime()
|
||
|
|
visibility = VISIBLE
|
||
|
|
invalidate()
|
||
|
|
}
|
||
|
|
|
||
|
|
fun presentStillFrame(bitmap: Bitmap, barcodes: List<Barcode>, visualCropRect: Rect) {
|
||
|
|
if (stillFrame !== bitmap) {
|
||
|
|
stillFrame?.recycle()
|
||
|
|
}
|
||
|
|
stillFrame = bitmap
|
||
|
|
rawHits = barcodes
|
||
|
|
visualCrop = Rect(visualCropRect)
|
||
|
|
stillW = bitmap.width
|
||
|
|
stillH = bitmap.height
|
||
|
|
recognizing = false
|
||
|
|
rebuildHitMarks()
|
||
|
|
visibility = VISIBLE
|
||
|
|
invalidate()
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun rebuildHitMarks() {
|
||
|
|
if (layerW <= 0 || layerH <= 0 || stillW <= 0 || stillH <= 0) {
|
||
|
|
hitMarks = emptyList()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
hitMarks = rawHits.mapNotNull { barcode ->
|
||
|
|
val box = barcode.boundingBox ?: return@mapNotNull null
|
||
|
|
val adjustedBox = Rect(
|
||
|
|
box.left - visualCrop.left,
|
||
|
|
box.top - visualCrop.top,
|
||
|
|
box.right - visualCrop.left,
|
||
|
|
box.bottom - visualCrop.top
|
||
|
|
)
|
||
|
|
val clippedBox = Rect(adjustedBox)
|
||
|
|
if (!clippedBox.intersect(0, 0, stillW, stillH)) {
|
||
|
|
return@mapNotNull null
|
||
|
|
}
|
||
|
|
val screenRect = mapBoxToScreen(clippedBox, stillW, stillH, layerW, layerH)
|
||
|
|
HitMark(
|
||
|
|
rawValue = barcode.rawValue ?: "",
|
||
|
|
format = barcode.format,
|
||
|
|
formatName = SymbologyUtil.nameOf(barcode.format),
|
||
|
|
screenRect = screenRect
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fun clear() {
|
||
|
|
stillFrame?.recycle()
|
||
|
|
stillFrame = null
|
||
|
|
rawHits = emptyList()
|
||
|
|
hitMarks = emptyList()
|
||
|
|
visualCrop.setEmpty()
|
||
|
|
stillW = 0
|
||
|
|
stillH = 0
|
||
|
|
recognizing = false
|
||
|
|
recognizingStartedAt = 0L
|
||
|
|
visibility = GONE
|
||
|
|
invalidate()
|
||
|
|
}
|
||
|
|
|
||
|
|
fun pickHitAt(x: Float, y: Float): HitMark? {
|
||
|
|
return hitMarks.minByOrNull {
|
||
|
|
val dx = x - it.centerX
|
||
|
|
val dy = y - it.centerY
|
||
|
|
dx * dx + dy * dy
|
||
|
|
}?.takeIf {
|
||
|
|
val dx = x - it.centerX
|
||
|
|
val dy = y - it.centerY
|
||
|
|
dx * dx + dy * dy <= touchR * touchR
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun mapBoxToScreen(
|
||
|
|
boundingBox: Rect,
|
||
|
|
sourceW: Int, sourceH: Int,
|
||
|
|
viewW: Int, viewH: Int
|
||
|
|
): Rect {
|
||
|
|
val scale = max(viewW.toFloat() / sourceW, viewH.toFloat() / sourceH)
|
||
|
|
val offsetX = (viewW - sourceW * scale) / 2f
|
||
|
|
val offsetY = (viewH - sourceH * scale) / 2f
|
||
|
|
return Rect(
|
||
|
|
(boundingBox.left * scale + offsetX).toInt(),
|
||
|
|
(boundingBox.top * scale + offsetY).toInt(),
|
||
|
|
(boundingBox.right * scale + offsetX).toInt(),
|
||
|
|
(boundingBox.bottom * scale + offsetY).toInt()
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onDraw(canvas: Canvas) {
|
||
|
|
super.onDraw(canvas)
|
||
|
|
stillFrame?.let { bmp ->
|
||
|
|
if (layerW <= 0 || layerH <= 0) return@let
|
||
|
|
val bmpW = bmp.width.toFloat()
|
||
|
|
val bmpH = bmp.height.toFloat()
|
||
|
|
val scale = max(layerW.toFloat() / bmpW, layerH.toFloat() / bmpH)
|
||
|
|
val drawW = (bmpW * scale).toInt()
|
||
|
|
val drawH = (bmpH * scale).toInt()
|
||
|
|
val left = (layerW - drawW) / 2
|
||
|
|
val top = (layerH - drawH) / 2
|
||
|
|
val destRect = Rect(left, top, left + drawW, top + drawH)
|
||
|
|
canvas.drawBitmap(bmp, null, destRect, null)
|
||
|
|
if (!recognizing) {
|
||
|
|
canvas.drawRect(destRect, shadePaint)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (recognizing) {
|
||
|
|
drawRecognizing(canvas)
|
||
|
|
postInvalidateDelayed(16)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (hitMarks.size >= 2) {
|
||
|
|
val prompt = "已识别到多个码,请选择一个"
|
||
|
|
val horizontalPadding = 18f * density
|
||
|
|
val verticalPadding = 10f * density
|
||
|
|
val textBounds = Rect()
|
||
|
|
promptTextPaint.getTextBounds(prompt, 0, prompt.length, textBounds)
|
||
|
|
val promptWidth = textBounds.width() + horizontalPadding * 2
|
||
|
|
val promptHeight = textBounds.height() + verticalPadding * 2
|
||
|
|
val promptLeft = (layerW - promptWidth) / 2f
|
||
|
|
// 放在底部操作按钮上方,避免遮挡条码和顶部关闭按钮。
|
||
|
|
val promptTop = layerH - promptHeight - 100f * density
|
||
|
|
canvas.drawRoundRect(
|
||
|
|
promptLeft,
|
||
|
|
promptTop,
|
||
|
|
promptLeft + promptWidth,
|
||
|
|
promptTop + promptHeight,
|
||
|
|
promptHeight / 2,
|
||
|
|
promptHeight / 2,
|
||
|
|
promptBackgroundPaint
|
||
|
|
)
|
||
|
|
val baseline = promptTop + verticalPadding - textBounds.top
|
||
|
|
canvas.drawText(prompt, layerW / 2f, baseline, promptTextPaint)
|
||
|
|
}
|
||
|
|
val breatheProgress = (SystemClock.elapsedRealtime() % breatheDurationMs).toFloat() / breatheDurationMs
|
||
|
|
val breatheWave = ((sin(breatheProgress * Math.PI * 2.0) + 1.0) / 2.0).toFloat()
|
||
|
|
for (mark in hitMarks) {
|
||
|
|
haloPaint.alpha = (34 + breatheWave * 70).toInt()
|
||
|
|
canvas.drawCircle(mark.centerX, mark.centerY, touchR + breatheWave * 8f * density, haloPaint)
|
||
|
|
canvas.drawCircle(mark.centerX, mark.centerY, dotOuterR, shellPaint)
|
||
|
|
canvas.drawCircle(mark.centerX, mark.centerY, dotInnerR, corePaint)
|
||
|
|
}
|
||
|
|
if (hitMarks.isNotEmpty()) {
|
||
|
|
postInvalidateDelayed(16)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun drawRecognizing(canvas: Canvas) {
|
||
|
|
val cx = layerW / 2f
|
||
|
|
val cy = layerH / 2f
|
||
|
|
val radius = 24f * density
|
||
|
|
val bounds = android.graphics.RectF(cx - radius, cy - radius, cx + radius, cy + radius)
|
||
|
|
val elapsed = SystemClock.elapsedRealtime() - recognizingStartedAt
|
||
|
|
val startAngle = (elapsed % 1000L) * 360f / 1000f - 90f
|
||
|
|
canvas.drawCircle(cx, cy, radius, loadingTrackPaint)
|
||
|
|
canvas.drawArc(bounds, startAngle, 110f, false, loadingArcPaint)
|
||
|
|
canvas.drawText("识别中", cx, cy + radius + 30f * density, loadingTextPaint)
|
||
|
|
}
|
||
|
|
}
|