From 91928d058baa6a0366b6e79083b6e9fa66667bcb Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Sat, 1 Jul 2023 23:43:38 +0300 Subject: [PATCH] Pie chart test --- .../kotatsu/core/ui/widgets/PieChart.kt | 387 ++++++++++++++++++ .../koitharu/kotatsu/core/util/ext/Android.kt | 9 + .../kotatsu/core/util/ext/StaticLayout.kt | 11 + .../kotatsu/settings/tools/ToolsFragment.kt | 9 + app/src/main/res/layout/fragment_tools.xml | 28 +- app/src/main/res/values/attrs.xml | 25 ++ app/src/main/res/values/constants.xml | 7 + 7 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/StaticLayout.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt new file mode 100644 index 000000000..822dd4f26 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt @@ -0,0 +1,387 @@ +package org.koitharu.kotatsu.core.ui.widgets + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.CornerPathEffect +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.os.Build +import android.os.Parcelable +import android.text.Layout +import android.text.StaticLayout +import android.text.TextDirectionHeuristic +import android.text.TextDirectionHeuristics +import android.text.TextPaint +import android.util.AttributeSet +import android.view.View +import androidx.annotation.RequiresApi +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.dpToPx +import org.koitharu.kotatsu.core.util.ext.draw +import org.koitharu.kotatsu.core.util.ext.getAnimationDuration +import org.koitharu.kotatsu.core.util.ext.spToPx + +class PieChart @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr), PieChartInterface { + + companion object { + private const val DEFAULT_MARGIN_TEXT_1 = 2 + private const val DEFAULT_MARGIN_TEXT_2 = 10 + private const val DEFAULT_MARGIN_TEXT_3 = 2 + private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12 + private const val ANALYTICAL_PIE_CHART_KEY = "PieChartArrayData" + + private const val TEXT_WIDTH_PERCENT = 0.40 + private const val CIRCLE_WIDTH_PERCENT = 0.50 + + const val DEFAULT_VIEW_SIZE_HEIGHT = 150 + const val DEFAULT_VIEW_SIZE_WIDTH = 250 + } + + private var marginTextFirst: Float = context.dpToPx(DEFAULT_MARGIN_TEXT_1) + private var marginTextSecond: Float = context.dpToPx(DEFAULT_MARGIN_TEXT_2) + private var marginTextThird: Float = context.dpToPx(DEFAULT_MARGIN_TEXT_3) + private var marginSmallCircle: Float = context.dpToPx(DEFAULT_MARGIN_SMALL_CIRCLE) + private val marginText: Float = marginTextFirst + marginTextSecond + private val circleRect = RectF() + private var circleStrokeWidth: Float = context.dpToPx(6) + private var circleRadius: Float = 0F + private var circlePadding: Float = context.dpToPx(8) + private var circlePaintRoundSize: Boolean = true + private var circleSectionSpace: Float = 3F + private var circleCenterX: Float = 0F + private var circleCenterY: Float = 0F + private var numberTextPaint: TextPaint = TextPaint() + private var descriptionTextPain: TextPaint = TextPaint() + private var amountTextPaint: TextPaint = TextPaint() + private var textStartX: Float = 0F + private var textStartY: Float = 0F + private var textHeight: Int = 0 + private var textCircleRadius: Float = context.dpToPx(4) + private var textAmountStr: String = "" + private var textAmountY: Float = 0F + private var textAmountXNumber: Float = 0F + private var textAmountXDescription: Float = 0F + private var textAmountYDescription: Float = 0F + private var totalAmount: Int = 0 + private var pieChartColors: List = listOf() + private var percentageCircleList: List = listOf() + private var textRowList: MutableList = mutableListOf() + private var dataList: List> = listOf() + private var animationSweepAngle: Int = 0 + + init { + var textAmountSize: Float = context.spToPx(22) + var textNumberSize: Float = context.spToPx(20) + var textDescriptionSize: Float = context.spToPx(14) + var textAmountColor: Int = Color.WHITE + var textNumberColor: Int = Color.WHITE + var textDescriptionColor: Int = Color.GRAY + + if (attrs != null) { + val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart) + + val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0) + pieChartColors = typeArray.resources.getStringArray(colorResId).toList() + + marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst) + marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond) + marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird) + marginSmallCircle = typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle) + + circleStrokeWidth = typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth) + circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding) + circlePaintRoundSize = typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize) + circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace) + + textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius) + textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize) + textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize) + textDescriptionSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize) + textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor) + textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor) + textDescriptionColor = typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor) + textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: "" + + typeArray.recycle() + } + + circlePadding += circleStrokeWidth + + // Инициализация кистей View + initPaints(amountTextPaint, textAmountSize, textAmountColor) + initPaints(numberTextPaint, textNumberSize, textNumberColor) + initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true) + } + + @RequiresApi(Build.VERSION_CODES.M) + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + textRowList.clear() + + val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH) + + val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT) + val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt()) + + textStartX = initSizeWidth - textTextWidth.toFloat() + textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2 + + calculateCircleRadius(initSizeWidth, initSizeHeight) + + setMeasuredDimension(initSizeWidth, initSizeHeight) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + drawCircle(canvas) + drawText(canvas) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + val pieChartState = state as? PieChartState + super.onRestoreInstanceState(pieChartState?.superState ?: state) + + dataList = pieChartState?.dataList ?: listOf() + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return PieChartState(superState, dataList) + } + + override fun setDataChart(list: List>) { + dataList = list + calculatePercentageOfData() + } + + override fun startAnimation() { + val animator = ValueAnimator.ofInt(0, 360).apply { + duration = context.getAnimationDuration(android.R.integer.config_longAnimTime) + interpolator = FastOutSlowInInterpolator() + addUpdateListener { valueAnimator -> + animationSweepAngle = valueAnimator.animatedValue as Int + invalidate() + } + } + animator.start() + } + + private fun drawCircle(canvas: Canvas) { + for(percent in percentageCircleList) { + if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle){ + canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint) + } else if (animationSweepAngle > percent.percentToStartAt) { + canvas.drawArc(circleRect, percent.percentToStartAt, animationSweepAngle - percent.percentToStartAt, false, percent.paint) + } + } + } + + private fun drawText(canvas: Canvas) { + var textBuffY = textStartY + textRowList.forEachIndexed { index, staticLayout -> + if (index % 2 == 0) { + staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY) + canvas.drawCircle( + textStartX + marginSmallCircle / 2, + textBuffY + staticLayout.height / 2 + textCircleRadius / 2, + textCircleRadius, + Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) } + ) + textBuffY += staticLayout.height + marginTextFirst + } else { + staticLayout.draw(canvas, textStartX, textBuffY) + textBuffY += staticLayout.height + marginTextSecond + } + } + + canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint) + canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain) + } + + private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) { + textPaint.color = textColor + textPaint.textSize = textSize + textPaint.isAntiAlias = true + + if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + + private fun resolveDefaultSize(spec: Int, defValue: Int): Int { + return when(MeasureSpec.getMode(spec)) { + MeasureSpec.UNSPECIFIED -> context.dpToPx(defValue).toInt() + else -> MeasureSpec.getSize(spec) + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int { + val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT) + textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt() + + val textHeightWithPadding = textHeight + paddingTop + paddingBottom + return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight + } + + private fun calculateCircleRadius(width: Int, height: Int) { + val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT) + circleRadius = if (circleViewWidth > height) { + (height.toFloat() - circlePadding) / 2 + } else { + circleViewWidth.toFloat() / 2 + } + + with(circleRect) { + left = circlePadding + top = height / 2 - circleRadius + right = circleRadius * 2 + circlePadding + bottom = height / 2 + circleRadius + } + + circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2 + circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2 + + textAmountY = circleCenterY + + val sizeTextAmountNumber = getWidthOfAmountText( + totalAmount.toString(), + amountTextPaint + ) + + textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2 + textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2 + textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun getTextViewHeight(maxWidth: Int): Int { + var textHeight = 0 + dataList.forEach { + val textLayoutNumber = getMultilineText( + text = it.first.toString(), + textPaint = numberTextPaint, + width = maxWidth + ) + val textLayoutDescription = getMultilineText( + text = it.second, + textPaint = descriptionTextPain, + width = maxWidth + ) + textRowList.apply { + add(textLayoutNumber) + add(textLayoutDescription) + } + textHeight += textLayoutNumber.height + textLayoutDescription.height + } + + return textHeight + } + + private fun calculatePercentageOfData() { + totalAmount = dataList.fold(0) { res, value -> res + value.first} + + var startAt = circleSectionSpace + percentageCircleList = dataList.mapIndexed { index, pair -> + var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace + percent = if (percent < 0F) 0F else percent + + val resultModel = PieChartModel( + percentOfCircle = percent, + percentToStartAt = startAt, + colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]), + stroke = circleStrokeWidth, + paintRound = circlePaintRoundSize + ) + if (percent != 0F) startAt += percent + circleSectionSpace + resultModel + } + } + + private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect { + val bounds = Rect() + textPaint.getTextBounds(text, 0, text.length, bounds) + return bounds + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun getMultilineText( + text: CharSequence, + textPaint: TextPaint, + width: Int, + start: Int = 0, + end: Int = text.length, + alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR, + spacingMult: Float = 1f, + spacingAdd: Float = 0f) : StaticLayout { + + return StaticLayout.Builder + .obtain(text, start, end, textPaint, width) + .setAlignment(alignment) + .setTextDirection(textDir) + .setLineSpacing(spacingAdd, spacingMult) + .build() + } +} + +interface PieChartInterface { + + fun setDataChart(list: List>) + + fun startAnimation() +} + +data class PieChartModel( + var percentOfCircle: Float = 0F, + var percentToStartAt: Float = 0F, + var colorOfLine: Int = 0, + var stroke: Float = 0F, + var paint: Paint = Paint(), + var paintRound: Boolean = true +) { + + init { + if (percentOfCircle < 0 || percentOfCircle > 100) { + percentOfCircle = 100F + } + + percentOfCircle = 360 * percentOfCircle / 100 + + if (percentToStartAt < 0 || percentToStartAt > 100) { + percentToStartAt = 0F + } + + percentToStartAt = 360 * percentToStartAt / 100 + + if (colorOfLine == 0) { + colorOfLine = Color.parseColor("#000000") + } + + paint = Paint() + paint.color = colorOfLine + paint.isAntiAlias = true + paint.style = Paint.Style.STROKE + paint.strokeWidth = stroke + paint.isDither = true; + + if (paintRound){ + paint.strokeJoin = Paint.Join.ROUND; + paint.strokeCap = Paint.Cap.ROUND; + paint.pathEffect = CornerPathEffect(8F); + } + } +} + +class PieChartState( + private val superSavedState: Parcelable?, + val dataList: List> +) : View.BaseSavedState(superSavedState), Parcelable { +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt index 92403178d..7d016aed0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt @@ -20,6 +20,7 @@ import android.os.Build import android.os.Bundle import android.os.PowerManager import android.provider.Settings +import android.util.TypedValue import android.view.View import android.view.ViewPropertyAnimator import android.view.Window @@ -209,3 +210,11 @@ inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean { } } } + +fun Context.dpToPx(dp: Int): Float { + return dp.toFloat() * this.resources.displayMetrics.density +} + +fun Context.spToPx(sp: Int): Float { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp.toFloat(), this.resources.displayMetrics); +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/StaticLayout.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/StaticLayout.kt new file mode 100644 index 000000000..43e5a3d1e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/StaticLayout.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.core.util.ext + +import android.graphics.Canvas +import android.text.StaticLayout +import androidx.core.graphics.withTranslation + +fun StaticLayout.draw(canvas: Canvas, x: Float, y: Float) { + canvas.withTranslation(x, y) { + draw(this) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt index f0ecacc4d..b8fa37327 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tools/ToolsFragment.kt @@ -43,6 +43,15 @@ class ToolsFragment : binding.switchIncognito.setOnCheckedChangeListener(this) binding.memoryUsageView.setManageButtonOnClickListener(this) + binding.chart?.setDataChart( + listOf( + Pair(5, "Категория 1"), + Pair(3, "Категория 2"), + Pair(7, "Категория 3"), + ) + ) + binding.chart?.startAnimation() + viewModel.isIncognitoModeEnabled.observe(viewLifecycleOwner) { binding.switchIncognito.setChecked(it, false) } diff --git a/app/src/main/res/layout/fragment_tools.xml b/app/src/main/res/layout/fragment_tools.xml index f9f93cb0e..353104b92 100644 --- a/app/src/main/res/layout/fragment_tools.xml +++ b/app/src/main/res/layout/fragment_tools.xml @@ -24,8 +24,34 @@ + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 70b3e5841..2a97982c8 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -128,4 +128,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 536b16a5e..0e796d4f9 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -55,4 +55,11 @@ HTTP SOCKS + + #E480F4 + #6CC3F3 + #7167ED + #D9455F + #6054EA +