天天看點

自定義商品價格AmountTextView

前言

在電商項目中,通常會遇到商品價格多樣化的需求;如:顯示±符号,貨币符号,金額數字要使用千分位,小數位要四舍五入,貨币符号與金額數字要大小不一樣等需求,使用 TextView + Spanned 也可以實作預期效果,但是相對來說處理起來比較麻煩,要靈活多變,個人覺得還是自定義一個View比較友善。

效果圖

自定義商品價格AmountTextView

自定義AmountTextView

  • AmountTextView 組合:±符号+貨币符号+整數位+小數位
  • 使用DecimalFormat進行格式化及四舍五入計算
  • 提供 ***fun setCurrency(locale: Locale)***函數,可根據國家自動擷取對象貨币符号
class AmountTextView : View {

    companion object {

        // 使用正則去驗證傳入的金額是否有效,防止格式化非數字意外的内容
        private const val NUMBER_CONSTRAINTS = "^[-+]?(([0-9]+)([.]([0-9]+))?|([.]([0-9]+))?)$"

        private const val MIN_PADDING = 2f
    }

    private var totalWidth = 0
    private var totalHeight = 0
    private var textPaintRoomSize = 0f

    private val symbolSection by lazy { Section() }
    private val integerSection by lazy { Section() }
    private val decimalSection by lazy { Section() }
    private val positiveNegativeSection by lazy { Section() }

    private val linePaint by lazy { Paint() }
    private val textPaint by lazy { TextPaint(Paint.ANTI_ALIAS_FLAG) }

    /**
     * 設定金額
     */
    var amount = 0.0
        set(value) {
            field = if (checkAmountValid("$value")) value else 0.0
            requestLayout()
        }

    @ColorInt
    var textColor = Color.parseColor("#8A000000")
        set(value) {
            field = value
            integerSection.color = value
            postInvalidate()
        }

    var textSize = dpToPx(14f)
        set(value) {
            field = value
            integerSection.textSize = value
            requestLayout()
        }

    /**
     * 是否使用 ± 符号,預設不使用
     */
    var positiveNegativeEnable = false
        set(value) {
            field = value
            requestLayout()
        }

    var positiveNegativePadding = 4f
        set(value) {
            field = value
            requestLayout()
        }

    var positiveNegativeTextSize = dpToPx(14f)
        set(value) {
            field = value
            positiveNegativeSection.textSize = value
            requestLayout()
        }

    var positiveNegativeTextColor = textColor
        set(value) {
            field = value
            positiveNegativeSection.color = value
            postInvalidate()
        }

    /**
     * 是否使用千分位标記
     */
    var groupingUsed = false
        set(value) {
            field = value
            requestLayout()
        }

    /**
     * 四舍五入計算,預設隻舍不入
     */
    var roundingMode = RoundingMode.DOWN
        set(value) {
            field = value
            requestLayout()
        }

    /**
     * 貨币符号, 預設使用 ¥ 符号
     * 這裡做個适配,直接寫 ¥ 符号,在國産部分手機上 ¥ 符号中間的橫杠會顯示一條
     */
    var symbol = "${fromHtml("&yen")}"
        set(value) {
            field = value
            symbolSection.text = value
            requestLayout()
        }

    /**
     * 符号位置
     */
    var symbolGravity = Gravity.START
        set(value) {
            field = value
            postInvalidate()
        }

    /**
     * 貨币符号顔色
     */
    @ColorInt
    var symbolTextColor = textColor
        set(value) {
            field = value
            symbolSection.color = value
            postInvalidate()
        }

    /**
     * 貨币符号字型尺寸
     */
    var symbolTextSize = textSize
        set(value) {
            field = value
            symbolSection.textSize = value
            requestLayout()
        }

    var symbolPadding = 4f
        set(value) {
            field = value
            requestLayout()
        }

    /**
     * 設定保留的小數位數
     */
    var decimalDigits = 0
        set(value) {
            field = value
            requestLayout()
        }

    /**
     * 小數位位置,支援 top 與 bottom
     */
    var decimalGravity = Gravity.BOTTOM
        set(value) {
            field = value
            postInvalidate()
        }

    /**
     * 小數位與整數部分邊距
     */
    var decimalPadding = 4f
        set(value) {
            field = value
            requestLayout()
        }

    /**
     * 小數位文本大小
     */
    var decimalTextSize = textSize
        set(value) {
            field = value
            decimalSection.textSize = value
            requestLayout()
        }

    /**
     * 小數位文本顔色
     */
    var decimalTextColor = textColor
        set(value) {
            field = value
            decimalSection.color = value
            postInvalidate()
        }

    /**
     * 删除線
     */
    var strikeThroughLineEnable = false
        set(value) {
            field = value
            postInvalidate()
        }

    var strikeThroughLineColor = Color.BLACK
        set(value) {
            field = value
            postInvalidate()
        }

    var strikeThroughLineSize = 1f
        set(value) {
            field = value
            requestLayout()
        }

    /**
     * 下劃線
     */
    var underlineEnable = false
        set(value) {
            field = value
            postInvalidate()
        }

    var underlineColor = Color.BLACK
        set(value) {
            field = value
            postInvalidate()
        }

    var underlineSize = 1f
        set(value) {
            field = value
            requestLayout()
        }

    constructor(@NonNull context: Context) : this(context, null)
    constructor(@NonNull context: Context, @Nullable attrs: AttributeSet?) : this(context, attrs, 0)
    constructor(
        @NonNull context: Context,
        @Nullable attrs: AttributeSet?,
        defStyleAttr: Int
    ) : this(context, attrs, defStyleAttr, 0)

    constructor(
        @NonNull context: Context,
        @Nullable attrs: AttributeSet?,
        defStyleAttr: Int,
        defStyleRes: Int
    ) : super(
        context, attrs, defStyleAttr, defStyleRes
    ) {
        initAttribute(context, attrs, defStyleAttr, defStyleRes)
    }

    private fun initAttribute(
        context: Context,
        attrs: AttributeSet?,
        defStyleAttr: Int,
        defStyleRes: Int
    ) {
        textPaintRoomSize = TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            textPaint.density,
            resources.displayMetrics
        )
        val typedArray = context.theme.obtainStyledAttributes(
            attrs,
            R.styleable.AmountTextView,
            defStyleAttr,
            defStyleRes
        )
        try {
            // positive negative
            positiveNegativeEnable = typedArray.getBoolean(R.styleable.AmountTextView_positiveNegative, positiveNegativeEnable)
            positiveNegativePadding = typedArray.getDimensionPixelSize(R.styleable.AmountTextView_positiveNegativePadding, positiveNegativePadding.toInt()).toFloat()
            positiveNegativeSection.textSize = typedArray.getDimension(R.styleable.AmountTextView_positiveNegativeTextSize, positiveNegativeTextSize)
            positiveNegativeSection.color = typedArray.getColor(R.styleable.AmountTextView_positiveNegativeTextColor, positiveNegativeTextColor)
            // symbol
            symbol = typedArray.getString(R.styleable.AmountTextView_symbol) ?: symbol
            symbolGravity = typedArray.getInt(R.styleable.AmountTextView_symbolGravity, symbolGravity)
            symbolPadding = typedArray.getDimensionPixelSize(R.styleable.AmountTextView_symbolPadding, symbolPadding.toInt()).toFloat()
            symbolSection.textSize = typedArray.getDimension(R.styleable.AmountTextView_symbolTextSize, symbolTextSize)
            symbolSection.color = typedArray.getInt(R.styleable.AmountTextView_symbolTextColor, symbolTextColor)
            // amount
            if (typedArray.hasValue(R.styleable.AmountTextView_android_text)) {
                val text = typedArray.getString(R.styleable.AmountTextView_android_text) ?: "0"
                amount = if (checkAmountValid(text)) text.toDouble() else 0.0
            }
            integerSection.textSize = typedArray.getDimension(R.styleable.AmountTextView_android_textSize, textSize)
            integerSection.color = typedArray.getInt(R.styleable.AmountTextView_android_textColor, textColor)
            groupingUsed = typedArray.getBoolean(R.styleable.AmountTextView_groupingUsed, groupingUsed)
            roundingMode = getRoundingMode(typedArray.getInt(R.styleable.AmountTextView_roundingMode, 0))
            // decimal
            decimalDigits = typedArray.getInteger(R.styleable.AmountTextView_decimalDigits, decimalDigits)
            decimalGravity = typedArray.getInt(R.styleable.AmountTextView_decimalGravity, decimalGravity)
            decimalPadding = typedArray.getDimensionPixelSize(R.styleable.AmountTextView_decimalPadding, decimalPadding.toInt()).toFloat()
            decimalSection.textSize = typedArray.getDimension(R.styleable.AmountTextView_decimalTextSize, decimalTextSize)
            decimalSection.color = typedArray.getInt(R.styleable.AmountTextView_decimalTextColor, decimalTextColor)
            // style
            val textStyle = typedArray.getInt(R.styleable.AmountTextView_android_textStyle, Typeface.DEFAULT.style)
            textPaint.typeface = Typeface.defaultFromStyle(textStyle)
            // line
            strikeThroughLineEnable = typedArray.getBoolean(R.styleable.AmountTextView_strikeThroughLine, strikeThroughLineEnable)
            strikeThroughLineColor = typedArray.getColor(R.styleable.AmountTextView_strikeThroughLineColor, strikeThroughLineColor)
            strikeThroughLineSize = typedArray.getDimension(R.styleable.AmountTextView_strikeThroughLineSize, strikeThroughLineSize)
            underlineEnable = typedArray.getBoolean(R.styleable.AmountTextView_underline, underlineEnable)
            underlineColor = typedArray.getColor(R.styleable.AmountTextView_underlineColor, underlineColor)
            underlineSize = typedArray.getDimension(R.styleable.AmountTextView_underlineSize, underlineSize)
        } finally {
            typedArray.recycle()
        }
        if (strikeThroughLineEnable || underlineEnable) {
            linePaint.isDither = true
            linePaint.isAntiAlias = true
            linePaint.style = Paint.Style.FILL_AND_STROKE
        }
    }

    /**
     * 檢測數字是否有效
     */
    fun checkAmountValid(text: String): Boolean {
        return Pattern.compile(NUMBER_CONSTRAINTS).matcher(text).matches()
    }

    /**
     * @return 傳回四舍五入計算模式
     */
    private fun getRoundingMode(mode: Int): RoundingMode {
        return when(mode) {
            1 -> RoundingMode.UP
            2 -> RoundingMode.DOWN
            3 -> RoundingMode.CEILING
            4 -> RoundingMode.FLOOR
            5 -> RoundingMode.HALF_UP
            6 -> RoundingMode.HALF_DOWN
            7 -> RoundingMode.HALF_EVEN
            8 -> RoundingMode.UNNECESSARY
            else -> RoundingMode.DOWN
        }
    }

    /**
     * 設定 Currency 後 symbol 會跟随設定的國家變化。
     */
    fun setCurrency(locale: Locale) {
        val currency = Currency.getInstance(locale)
        this.symbol = currency.symbol
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        setPadding(
            getMinPadding(paddingStart), getMinVerticalPadding(paddingTop),
            getMinPadding(paddingEnd), getMinVerticalPadding(paddingBottom)
        )
        createTextFromAmount()
        calculateBounds(widthMeasureSpec, heightMeasureSpec)
        calculatePositions()
        setMeasuredDimension(totalWidth, totalHeight)
    }

    private fun getMinPadding(padding: Int): Int {
        val density = resources.displayMetrics.density
        return if (padding == 0) (MIN_PADDING * density).toInt() else padding
    }

    private fun getMinVerticalPadding(padding: Int): Int {
        val maxTextSize = max(positiveNegativeSection.textSize, max(symbolSection.textSize, max(integerSection.textSize, decimalSection.textSize)))
        textPaint.textSize = maxTextSize
        val maximumDistanceLowestGlyph = textPaint.fontMetrics.bottom
        return if (padding < maximumDistanceLowestGlyph) maximumDistanceLowestGlyph.toInt() else padding
    }

    private fun createTextFromAmount() {
        val positiveNegative = if (positiveNegativeEnable) if (amount > -1) "+" else "-" else ""
        positiveNegativeSection.text = positiveNegative
        symbolSection.text = symbol
        // 獎金額格式化,添加千分符,進行四舍五入計算。
        val amountFormat = formatAmount(abs(amount), decimalDigits, groupingUsed, roundingMode)
        integerSection.text = amountFormat.split(".").firstOrNull() ?: "0"
        val decimalValue = (amountFormat.split(".").lastOrNull() ?: "0").toLong()
        val decimalFormat = if (decimalDigits > 0) ".${decimalValue}" else ""
        decimalSection.text = decimalFormat
        decimalPadding = if (decimalValue > 0) decimalPadding else 0f
    }

    /**
     * @return 傳回格式化後的金額文本
     */
    fun getAmountText(): String {
        val positiveNegative = if (positiveNegativeEnable) if (amount > -1) "+" else "-" else ""
        val amountFormat = formatAmount(abs(amount), decimalDigits, groupingUsed, roundingMode)
        val amountValue = amountFormat.split(".").firstOrNull() ?: "0"
        val decimalValue = (amountFormat.split(".").lastOrNull() ?: "0").toLong()
        val decimalFormat = if (decimalDigits > 0) ".${decimalValue}" else ""
        return positiveNegative + symbol + amountValue + decimalFormat
    }

    private fun calculateBounds(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        val heightSize = MeasureSpec.getSize(heightMeasureSpec)
        symbolSection.calculateBounds(textPaint)
        integerSection.calculateBounds(textPaint)
        decimalSection.calculateBounds(textPaint)
        positiveNegativeSection.calculateBounds(textPaint)
        decimalSection.calculateNumbersHeight(textPaint)
        integerSection.calculateNumbersHeight(textPaint)
        positiveNegativeSection.calculateNumbersHeight(textPaint)
        when (widthMode) {
            MeasureSpec.EXACTLY -> totalWidth = widthSize
            MeasureSpec.AT_MOST, MeasureSpec.UNSPECIFIED -> {
                val positiveNegativeWidth = if (positiveNegativeEnable) positiveNegativeSection.width + positiveNegativePadding.toInt() else 0
                val symbolWidth = symbolSection.width + symbolPadding
                val amountWidth = integerSection.width
                val decimalWidth = decimalSection.width + decimalPadding
                totalWidth = (paddingStart + positiveNegativeWidth + symbolWidth + amountWidth + decimalWidth + paddingEnd).toInt()
            }
        }
        when (heightMode) {
            MeasureSpec.EXACTLY -> totalHeight = heightSize
            MeasureSpec.AT_MOST, MeasureSpec.UNSPECIFIED -> {
                totalHeight = paddingTop + paddingBottom + max(integerSection.height, max(decimalSection.height, symbolSection.height))
            }
        }
    }

    /**
     * 内容排版
     */
    private fun calculatePositions() {
        val positiveNegativeWidth = if (positiveNegativeEnable) positiveNegativeSection.width + positiveNegativePadding.toInt() else 0
        // ±
        if (positiveNegativeEnable) {
            positiveNegativeSection.x = paddingStart
            positiveNegativeSection.y = calculatePositiveNegativeX()
        } else {
            positiveNegativeSection.x = 0
            positiveNegativeSection.y = 0
        }
        // symbol
        symbolSection.x = calculateSymbolX(positiveNegativeWidth)
        symbolSection.y = calculateSymbolY()
        // amount
        integerSection.x = calculateAmountX(positiveNegativeWidth)
        integerSection.y = totalHeight - paddingBottom
        // decimal
        decimalSection.x = calculateDecimalX(positiveNegativeWidth)
        decimalSection.y = calculateDecimalY()
    }

    private fun calculatePositiveNegativeX(): Int {
        val textPaintHeight = textPaint.fontMetrics.bottom - textPaint.fontMetrics.top
        return (totalHeight.div(2f) + textPaintHeight.div(2f) - textPaint.fontMetrics.bottom - textPaintRoomSize.div(2f)).toInt()
    }

    private fun calculateSymbolX(positiveNegativeWidth: Int): Int {
        return if (symbolGravity == Gravity.END
            || symbolGravity == Gravity.END or Gravity.TOP
            || symbolGravity == Gravity.END or Gravity.BOTTOM
        ) {
            (paddingStart + positiveNegativeWidth + integerSection.width + decimalPadding + decimalSection.width + symbolPadding).toInt()
        } else paddingStart + positiveNegativeWidth
    }

    private fun calculateSymbolY(): Int {
        return when(symbolGravity) {
            Gravity.START or Gravity.TOP,
            Gravity.END or Gravity.TOP -> paddingTop + symbolSection.height
            Gravity.START or Gravity.BOTTOM, Gravity.END or Gravity.BOTTOM -> totalHeight - paddingBottom
            else -> {
                val maxHeight = max(positiveNegativeSection.height, max(symbolSection.height, max(integerSection.height, decimalSection.height)))
                totalHeight.div(2) + maxHeight.div(2) - textPaintRoomSize.div(2f).toInt()
            }
        }
    }

    private fun calculateAmountX(positiveNegativeWidth: Int): Int {
        return if (symbolGravity == Gravity.END
            || symbolGravity == Gravity.END or Gravity.TOP
            || symbolGravity == Gravity.END or Gravity.BOTTOM
        ) {
            paddingStart + positiveNegativeWidth
        } else paddingStart + positiveNegativeWidth + symbolSection.width + symbolPadding.toInt()
    }

    private fun calculateDecimalX(positiveNegativeWidth: Int): Int {
        return if (symbolGravity == Gravity.START
            || symbolGravity == Gravity.START or Gravity.TOP
            || symbolGravity == Gravity.START or Gravity.BOTTOM
        ) {
            paddingStart + positiveNegativeWidth + symbolSection.width + symbolPadding.toInt() + integerSection.width + decimalPadding.toInt()
        } else if (symbolGravity == Gravity.END
            || symbolGravity == Gravity.END or Gravity.TOP
            || symbolGravity == Gravity.END or Gravity.BOTTOM
        ) {
            paddingStart + positiveNegativeWidth + integerSection.width + decimalPadding.toInt()
        } else {
            paddingStart + positiveNegativeWidth + symbolSection.width + symbolPadding.toInt() + integerSection.width + decimalPadding.toInt()
        }
    }

    private fun calculateDecimalY(): Int {
        val baseline = if (groupingUsed && abs(amount) > 1000) textPaint.fontMetrics.descent.toInt() else 0
        return if(decimalGravity == Gravity.TOP) paddingTop + decimalSection.height + baseline else totalHeight - paddingBottom
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        drawSection(canvas, positiveNegativeSection)
        drawSection(canvas, symbolSection)
        drawSection(canvas, integerSection)
        drawSection(canvas, decimalSection)
        drawDecorationLine(canvas)
    }

    private fun drawSection(canvas: Canvas, section: Section) {
        textPaint.textSize = section.textSize
        textPaint.color = section.color
        canvas.drawText(section.text, section.x - textPaintRoomSize.times(2f), section.y - textPaintRoomSize.div(2f), textPaint)
    }

    /**
     * 繪制裝飾線條
     */
    private fun drawDecorationLine(canvas: Canvas) {
        val lineMaxWidth = positiveNegativeSection.width + positiveNegativePadding + symbolSection.width + symbolPadding + integerSection.width + decimalPadding + decimalSection.width
        // 删除線
        if (strikeThroughLineEnable) {
            linePaint.color = strikeThroughLineColor
            linePaint.strokeWidth = strikeThroughLineSize
            val strikeThroughLineY = totalHeight.div(2f)
            canvas.drawLine(paddingStart.toFloat(), strikeThroughLineY, lineMaxWidth, strikeThroughLineY, linePaint)
        }
        // 下劃線
        if (underlineEnable) {
            linePaint.color = underlineColor
            linePaint.strokeWidth = underlineSize
            val underlineY = (totalHeight - paddingBottom).toFloat()
            canvas.drawLine(paddingStart.toFloat(), underlineY, lineMaxWidth, underlineY, linePaint)
        }
    }

    private fun fromHtml(content: String): Spanned {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            Html.fromHtml(content, Html.FROM_HTML_MODE_LEGACY)
        } else {
            Html.fromHtml(content)
        }
    }

    private fun formatAmount(
        number: Number,
        decimalDigits: Int = 0,
        groupingUsed: Boolean = true,
        rounding: RoundingMode = RoundingMode.DOWN
    ): String {
        return DecimalFormat.getNumberInstance().apply {
            maximumFractionDigits = decimalDigits
            isGroupingUsed = groupingUsed
            roundingMode = rounding
        }.format(number)
    }

    private inner class Section {

        var x = 0
        var y = 0
        var bounds: Rect = Rect()
        var text = ""
        var textSize = 0f
        var color = Color.BLACK
        var width = 0
        var height = 0

        fun calculateBounds(paint: TextPaint) {
            paint.textSize = textSize
            paint.getTextBounds(text, 0, text.length, bounds)
            width = bounds.width()
            height = bounds.height()
        }

        fun calculateNumbersHeight(paint: TextPaint) {
            val numbers = text.replace("[^0-9]", "")
            paint.textSize = textSize
            paint.getTextBounds(numbers, 0, numbers.length, bounds)
            height = bounds.height()
        }
    }
}
           

AmountTextView屬性

<declare-styleable name="AmountTextView">
	<!--金額-->
    <attr name="android:text" />
    <attr name="android:textColor" />
    <attr name="android:textSize" />
    <attr name="android:textStyle" />
    <!--是否啟用千分符-->
    <attr name="groupingUsed" format="boolean" />
    <!--四舍五入計算模式-->
    <attr name="roundingMode" format="enum">
    	<enum name="up" value="1" />
        <enum name="down" value="2" />
        <enum name="ceiling" value="3" />
        <enum name="floor" value="4" />
        <enum name="half_up" value="5" />
        <enum name="half_down" value="6" />
        <enum name="half_even" value="7" />
        <enum name="unnecessary" value="8" />
	</attr>
    <!--金額 ± 符号,預設不使用-->
    <attr name="positiveNegative" format="boolean" />
    <attr name="positiveNegativePadding" format="dimension" />
    <attr name="positiveNegativeTextSize" format="dimension" />
    <attr name="positiveNegativeTextColor" format="color" />
    <!--貨币符号-->
    <attr name="symbol" format="string" />
    <!--貨币符号與金額間距-->
    <attr name="symbolPadding" format="dimension" />
    <!--貨币符号位置-->
    <attr name="symbolGravity">
    	<flag name="start" value="0x00800003" />
        <flag name="top" value="0x30" />
        <flag name="end" value="0x00800005" />
        <flag name="bottom" value="0x50" />
	</attr>
    <!--貨币符号字型大小-->
    <attr name="symbolTextSize" format="dimension" />
    <!--貨币符号字型顔色-->
    <attr name="symbolTextColor" format="color" />
    <!--保留的小數位數-->
    <attr name="decimalDigits" format="integer" />
    <!--小數位與整數位間距-->
    <attr name="decimalPadding" format="dimension" />
    <!--小數位字型大小-->
    <attr name="decimalTextSize" format="dimension" />
    <!--小數位字型顔色-->
    <attr name="decimalTextColor" format="color" />
    <!--小數位位置-->
    <attr name="decimalGravity" format="enum">
    	<enum name="top" value="0x30" />
        <enum name="bottom" value="0x50" />
	</attr>
    <!--删除線-->
    <attr name="strikeThroughLine" format="boolean" />
    <attr name="strikeThroughLineColor" format="color" />
    <attr name="strikeThroughLineSize" format="dimension" />
    <!--下劃線-->
    <attr name="underline" format="boolean" />
    <attr name="underlineColor" format="color" />
    <attr name="underlineSize" format="dimension" />
</declare-styleable>
           

針對商品價格存在多種實作方式,也許存在更優雅的處理方式,這裡僅分享個人開發中的一些思考,有興趣的同學可以聯系我,一起交流交流。