From 066038b25acab7b477e2f81459aa1af48a6d297d Mon Sep 17 00:00:00 2001 From: AleksVira Date: Mon, 15 Dec 2025 00:36:02 +0300 Subject: [PATCH 1/5] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../otus/homework/customview/PieChartView.kt | 300 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 19 +- app/src/main/res/values/attrs.xml | 22 ++ 3 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/otus/homework/customview/PieChartView.kt create mode 100644 app/src/main/res/values/attrs.xml diff --git a/app/src/main/java/otus/homework/customview/PieChartView.kt b/app/src/main/java/otus/homework/customview/PieChartView.kt new file mode 100644 index 00000000..a0396cc8 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/PieChartView.kt @@ -0,0 +1,300 @@ +package otus.homework.customview + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.util.TypedValue +import android.view.View +import androidx.annotation.RawRes +import org.json.JSONArray +import java.text.NumberFormat +import java.util.Locale +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin + +class PieChartView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : View(context, attrs, defStyleAttr) { + + enum class ColorMode { MONO, COLOR } + enum class TextMode { NONE, INNER, OUTER } + enum class WidthMode { CONST, VAR } + + private data class Slice( + val value: Float, + val percent: Float, + ) + + private val arcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.BUTT + } + private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textAlign = Paint.Align.CENTER + textSize = sp(12f) + } + private val centerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textAlign = Paint.Align.CENTER + textSize = sp(18f) + isFakeBoldText = true + } + private val centerTitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textAlign = Paint.Align.CENTER + textSize = sp(14f) + } + + private val arcBounds = RectF() + + private var colorMode: ColorMode = ColorMode.COLOR + private var textMode: TextMode = TextMode.OUTER + private var widthMode: WidthMode = WidthMode.CONST + + private var slices: List = emptyList() + private var totalAmount: Long = 0L + + private val palette = intArrayOf( + 0xFF8DD3C7.toInt(), 0xFFFFFFB3.toInt(), 0xFFBEBADA.toInt(), 0xFFFB8072.toInt(), + 0xFF80B1D3.toInt(), 0xFFFDB462.toInt(), 0xFFB3DE69.toInt(), 0xFFFCCDE5.toInt(), + 0xFFD9D9D9.toInt(), 0xFFBC80BD.toInt(), 0xFFCCEBC5.toInt(), 0xFFFFED6F.toInt() + ) + + init { + if (attrs != null) { + val a = context.obtainStyledAttributes(attrs, R.styleable.PieChartView, defStyleAttr, 0) + try { + colorMode = when (a.getInt(R.styleable.PieChartView_colorMode, 1)) { + 0 -> ColorMode.MONO + else -> ColorMode.COLOR + } + textMode = when (a.getInt(R.styleable.PieChartView_textMode, 2)) { + 0 -> TextMode.NONE + 1 -> TextMode.INNER + else -> TextMode.OUTER + } + widthMode = when (a.getInt(R.styleable.PieChartView_widthMode, 0)) { + 1 -> WidthMode.VAR + else -> WidthMode.CONST + } + } finally { + a.recycle() + } + } + + if (isInEditMode) { + setSlices( + listOf( + Slice(27.4f, 27.4f), + Slice(18.9f, 18.9f), + Slice(35.6f, 35.6f), + Slice(18.1f, 18.1f), + ), + total = 100, + ) + } else { + loadFromRaw(R.raw.payload) + } + } + + fun reload() { + loadFromRaw(R.raw.payload) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val desired = dp(240f) + + val wMode = MeasureSpec.getMode(widthMeasureSpec) + val wSize = MeasureSpec.getSize(widthMeasureSpec) + val hMode = MeasureSpec.getMode(heightMeasureSpec) + val hSize = MeasureSpec.getSize(heightMeasureSpec) + + val maxW = if (wMode == MeasureSpec.UNSPECIFIED) desired else wSize + val maxH = if (hMode == MeasureSpec.UNSPECIFIED) desired else hSize + + val size = min(maxW, maxH) + setMeasuredDimension(size, size) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (slices.isEmpty() || totalAmount <= 0) { + return + } + + val contentLeft = paddingLeft.toFloat() + val contentTop = paddingTop.toFloat() + val contentRight = (width - paddingRight).toFloat() + val contentBottom = (height - paddingBottom).toFloat() + val contentW = (contentRight - contentLeft).coerceAtLeast(0f) + val contentH = (contentBottom - contentTop).coerceAtLeast(0f) + val diameter = min(contentW, contentH) + + val cx = contentLeft + contentW / 2f + val cy = contentTop + contentH / 2f + val outerLabelReserve = if (textMode == TextMode.OUTER) dp(16f).toFloat() + labelPaint.textSize else 0f + + val availableOuterEdgeRadius = (diameter / 2f - outerLabelReserve).coerceAtLeast(0f) + val baseThickness = availableOuterEdgeRadius / 3f + if (baseThickness <= 0f) return + + val ringCenterRadius = (availableOuterEdgeRadius - baseThickness / 2f).coerceAtLeast(0f) + arcBounds.set( + cx - ringCenterRadius, + cy - ringCenterRadius, + cx + ringCenterRadius, + cy + ringCenterRadius, + ) + + var startAngle = -90f + slices.forEachIndexed { index, slice -> + val thicknessMultiplier = if (widthMode == WidthMode.VAR && index % 2 == 1) 0.75f else 1f + val thickness = baseThickness * thicknessMultiplier + val halfStroke = thickness / 2f + + arcPaint.strokeWidth = thickness + arcPaint.color = sliceColor(index, slices.size) + val sweep = 360f * (slice.value / totalAmount.toFloat()) + canvas.drawArc(arcBounds, startAngle, sweep, false, arcPaint) + + if (textMode != TextMode.NONE) { + val outerEdgeRadius = ringCenterRadius + halfStroke + drawLabel(canvas, cx, cy, ringCenterRadius, outerEdgeRadius, startAngle, sweep, slice) + } + + startAngle += sweep + } + + drawCenterTotal(canvas, cx, cy) + } + + private fun drawCenterTotal(canvas: Canvas, cx: Float, cy: Float) { + val title = "Всего" + val totalText = NumberFormat.getIntegerInstance().format(totalAmount) + + val titleFm = centerTitlePaint.fontMetrics + val totalFm = centerPaint.fontMetrics + val titleHeight = titleFm.descent - titleFm.ascent + val totalHeight = totalFm.descent - totalFm.ascent + val spacing = dp(4f).toFloat() + val blockHeight = titleHeight + spacing + totalHeight + val blockTop = cy - blockHeight / 2f + + val titleBaseline = blockTop - titleFm.ascent + val totalBaseline = titleBaseline + titleHeight + spacing - totalFm.ascent + + canvas.drawText(title, cx, titleBaseline, centerTitlePaint) + canvas.drawText(totalText, cx, totalBaseline, centerPaint) + } + + private fun drawLabel( + canvas: Canvas, + cx: Float, + cy: Float, + ringCenterRadius: Float, + outerEdgeRadius: Float, + startAngle: Float, + sweepAngle: Float, + slice: Slice, + ) { + val locale = Locale.getDefault() + val percentText = String.format(locale, "%.1f", slice.percent) + val text = "$percentText%" + + val midAngleRad = Math.toRadians((startAngle + sweepAngle / 2f).toDouble()) + + val labelRadius = when (textMode) { + TextMode.INNER -> ringCenterRadius + TextMode.OUTER -> outerEdgeRadius + dp(16f).toFloat() + TextMode.NONE -> return + } + + val x = (cx + cos(midAngleRad) * labelRadius).toFloat() + val y = (cy + sin(midAngleRad) * labelRadius).toFloat() + + val fm = labelPaint.fontMetrics + canvas.drawText(text, x, y - (fm.ascent + fm.descent) / 2f, labelPaint) + } + + private fun sliceColor(index: Int, sliceCount: Int): Int { + return when (colorMode) { + ColorMode.MONO -> monoColor(index, sliceCount) + ColorMode.COLOR -> { + val n = sliceCount.coerceAtLeast(1) + val idx = floor((index + 0.5) * palette.size.toDouble() / n.toDouble()).toInt() + palette[idx.coerceIn(0, palette.lastIndex)] + } + } + } + + private fun monoColor(index: Int, sliceCount: Int): Int { + val n = sliceCount.coerceAtLeast(1) + val evenCount = (n + 1) / 2 + val pos = if (index % 2 == 0) index / 2 else evenCount + (index - 1) / 2 + + val start = 0.95 + val end = 0.05 + val t = if (n == 1) 0.0 else pos.toDouble() / (n - 1).toDouble() + val level = (start + (end - start) * t).coerceIn(end, start) + val gray = (255.0 * level).roundToInt().coerceIn(0, 255) + return Color.rgb(gray, gray, gray) + } + + private fun loadFromRaw(@RawRes rawRes: Int) { + val json = resources.openRawResource(rawRes).bufferedReader().use { it.readText() } + val array = JSONArray(json) + val byCategory = linkedMapOf() + var total = 0L + + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val category = obj.optString("category", "") + val amount = obj.optLong("amount", 0L) + if (category.isBlank() || amount <= 0L) continue + byCategory[category] = (byCategory[category] ?: 0L) + amount + total += amount + } + + val sliceList = byCategory + .entries + .sortedByDescending { it.value } + .map { (_, amount) -> + val percent = if (total > 0L) amount.toDouble() * 100.0 / total.toDouble() else 0.0 + Slice(amount.toFloat(), percent.toFloat()) + } + + setSlices(sliceList, total) + } + + private fun setSlices(slices: List, total: Long) { + this.slices = slices + this.totalAmount = total + invalidate() + } + + private fun dp(value: Float): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value, + resources.displayMetrics, + ).toInt() + } + + private fun sp(value: Float): Float { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + value, + resources.displayMetrics, + ) + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79ae6993..3ab92ebf 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,22 @@ - - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:textMode="inner" + app:widthMode="var_mode" /> \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 00000000..a7a5d11b --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + From 30bf3f63259d9af521023f0ae813c5629ec4de8f Mon Sep 17 00:00:00 2001 From: AleksVira Date: Mon, 15 Dec 2025 01:11:04 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=D0=92=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../otus/homework/customview/MainActivity.kt | 2 + .../{PieChartView.kt => ViraPieChartView.kt} | 131 ++++++++++++++---- app/src/main/res/layout/activity_main.xml | 5 +- app/src/main/res/raw/payload_1.json | 9 ++ app/src/main/res/raw/payload_2.json | 16 +++ app/src/main/res/raw/payload_4.json | 30 ++++ app/src/main/res/raw/payload_7.json | 51 +++++++ app/src/main/res/values/attrs.xml | 6 +- app/src/main/res/values/strings.xml | 1 + 9 files changed, 219 insertions(+), 32 deletions(-) rename app/src/main/java/otus/homework/customview/{PieChartView.kt => ViraPieChartView.kt} (70%) create mode 100644 app/src/main/res/raw/payload_1.json create mode 100644 app/src/main/res/raw/payload_2.json create mode 100644 app/src/main/res/raw/payload_4.json create mode 100644 app/src/main/res/raw/payload_7.json diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 78cb9448..979661a9 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -7,5 +7,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + findViewById(R.id.pieChart).setDataSource(R.raw.payload_4) } } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/PieChartView.kt b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt similarity index 70% rename from app/src/main/java/otus/homework/customview/PieChartView.kt rename to app/src/main/java/otus/homework/customview/ViraPieChartView.kt index a0396cc8..b75038ee 100644 --- a/app/src/main/java/otus/homework/customview/PieChartView.kt +++ b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt @@ -18,18 +18,27 @@ import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sin -class PieChartView @JvmOverloads constructor( +class ViraPieChartView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr) { + private companion object { + private const val ERROR_DATA_SOURCE_NOT_SET = + "ViraPieChartView: dataSource is not set. " + + "Provide app:dataSource=\"@raw/...\" in XML or call setDataSource(@RawRes) from Activity/Fragment." + } + enum class ColorMode { MONO, COLOR } enum class TextMode { NONE, INNER, OUTER } enum class WidthMode { CONST, VAR } private data class Slice( - val value: Float, + val name: String, + val category: String, + val time: Long, + val amount: Long, val percent: Float, ) @@ -60,6 +69,9 @@ class PieChartView @JvmOverloads constructor( private var textMode: TextMode = TextMode.OUTER private var widthMode: WidthMode = WidthMode.CONST + private var titleText: String = "Title" + private var dataSourceResId: Int = 0 + private var slices: List = emptyList() private var totalAmount: Long = 0L @@ -71,43 +83,57 @@ class PieChartView @JvmOverloads constructor( init { if (attrs != null) { - val a = context.obtainStyledAttributes(attrs, R.styleable.PieChartView, defStyleAttr, 0) + val a = context.obtainStyledAttributes(attrs, R.styleable.ViraPieChartView, defStyleAttr, 0) try { - colorMode = when (a.getInt(R.styleable.PieChartView_colorMode, 1)) { + colorMode = when (a.getInt(R.styleable.ViraPieChartView_colorMode, 1)) { 0 -> ColorMode.MONO else -> ColorMode.COLOR } - textMode = when (a.getInt(R.styleable.PieChartView_textMode, 2)) { + textMode = when (a.getInt(R.styleable.ViraPieChartView_textMode, 2)) { 0 -> TextMode.NONE 1 -> TextMode.INNER else -> TextMode.OUTER } - widthMode = when (a.getInt(R.styleable.PieChartView_widthMode, 0)) { + widthMode = when (a.getInt(R.styleable.ViraPieChartView_widthMode, 0)) { 1 -> WidthMode.VAR else -> WidthMode.CONST } + + titleText = a.getString(R.styleable.ViraPieChartView_titleText) ?: titleText + + dataSourceResId = a.getResourceId(R.styleable.ViraPieChartView_dataSource, 0) + .takeIf { it != 0 } + ?: resolveRawResId(a.getString(R.styleable.ViraPieChartView_dataSource)) + ?: 0 } finally { a.recycle() } } if (isInEditMode) { - setSlices( - listOf( - Slice(27.4f, 27.4f), - Slice(18.9f, 18.9f), - Slice(35.6f, 35.6f), - Slice(18.1f, 18.1f), - ), - total = 100, - ) + val (previewSlices, previewTotal) = buildPreviewData() + setSlices(previewSlices, previewTotal) } else { - loadFromRaw(R.raw.payload) + if (dataSourceResId != 0) { + loadFromRaw(dataSourceResId) + } else { + post { + requireDataSourceResId() + } + } } } fun reload() { - loadFromRaw(R.raw.payload) + loadFromRaw(requireDataSourceResId()) + } + + fun setDataSource(@RawRes rawResId: Int) { + require(rawResId != 0) { "ViraPieChartView: rawResId must be non-zero" } + dataSourceResId = rawResId + if (!isInEditMode) { + loadFromRaw(dataSourceResId) + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -127,6 +153,7 @@ class PieChartView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) + requireDataSourceResId() if (slices.isEmpty() || totalAmount <= 0) { return @@ -164,7 +191,7 @@ class PieChartView @JvmOverloads constructor( arcPaint.strokeWidth = thickness arcPaint.color = sliceColor(index, slices.size) - val sweep = 360f * (slice.value / totalAmount.toFloat()) + val sweep = 360f * (slice.amount.toFloat() / totalAmount.toFloat()) canvas.drawArc(arcBounds, startAngle, sweep, false, arcPaint) if (textMode != TextMode.NONE) { @@ -179,7 +206,7 @@ class PieChartView @JvmOverloads constructor( } private fun drawCenterTotal(canvas: Canvas, cx: Float, cy: Float) { - val title = "Всего" + val title = titleText val totalText = NumberFormat.getIntegerInstance().format(totalAmount) val titleFm = centerTitlePaint.fontMetrics @@ -253,29 +280,75 @@ class PieChartView @JvmOverloads constructor( private fun loadFromRaw(@RawRes rawRes: Int) { val json = resources.openRawResource(rawRes).bufferedReader().use { it.readText() } val array = JSONArray(json) - val byCategory = linkedMapOf() var total = 0L + val items = ArrayList(array.length()) + for (i in 0 until array.length()) { val obj = array.getJSONObject(i) + val name = obj.optString("name", "") val category = obj.optString("category", "") + val time = obj.optLong("time", 0L) val amount = obj.optLong("amount", 0L) - if (category.isBlank() || amount <= 0L) continue - byCategory[category] = (byCategory[category] ?: 0L) + amount + if (amount <= 0L) continue + + items.add( + Slice( + name = name, + category = category, + time = time, + amount = amount, + percent = 0f, + ) + ) total += amount } - val sliceList = byCategory - .entries - .sortedByDescending { it.value } - .map { (_, amount) -> - val percent = if (total > 0L) amount.toDouble() * 100.0 / total.toDouble() else 0.0 - Slice(amount.toFloat(), percent.toFloat()) - } + val sliceList = items + .sortedByDescending { it.amount } + .withPercent(total) setSlices(sliceList, total) } + private fun buildPreviewData(): Pair, Long> { + val items = listOf( + Slice(name = "A", category = "C1", time = 0L, amount = 274, percent = 0f), + Slice(name = "B", category = "C2", time = 0L, amount = 189, percent = 0f), + Slice(name = "C", category = "C3", time = 0L, amount = 356, percent = 0f), + Slice(name = "D", category = "C4", time = 0L, amount = 181, percent = 0f), + ) + val total = items.sumOf { it.amount } + return items.withPercent(total) to total + } + + private fun resolveRawResId(rawName: String?): Int? { + val cleaned = rawName + ?.trim() + ?.removePrefix("@raw/") + ?.removeSuffix(".json") + ?.takeIf { it.isNotBlank() } + ?: return null + + val id = resources.getIdentifier(cleaned, "raw", context.packageName) + return id.takeIf { it != 0 } + } + + private fun requireDataSourceResId(): Int { + if (!isInEditMode && dataSourceResId == 0) { + throw IllegalStateException(ERROR_DATA_SOURCE_NOT_SET) + } + return dataSourceResId + } + + private fun List.withPercent(total: Long): List { + if (total <= 0L) return this + return map { slice -> + val percent = slice.amount.toDouble() * 100.0 / total.toDouble() + slice.copy(percent = percent.toFloat()) + } + } + private fun setSlices(slices: List, total: Long) { this.slices = slices this.totalAmount = total diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 3ab92ebf..a629266c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -6,17 +6,18 @@ android:layout_height="match_parent" tools:context=".MainActivity"> - \ No newline at end of file diff --git a/app/src/main/res/raw/payload_1.json b/app/src/main/res/raw/payload_1.json new file mode 100644 index 00000000..d65e7bfb --- /dev/null +++ b/app/src/main/res/raw/payload_1.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "name": "Truffo", + "amount": 4541, + "category": "Кафе и рестораны", + "time": 1623326031 + } +] \ No newline at end of file diff --git a/app/src/main/res/raw/payload_2.json b/app/src/main/res/raw/payload_2.json new file mode 100644 index 00000000..412ea998 --- /dev/null +++ b/app/src/main/res/raw/payload_2.json @@ -0,0 +1,16 @@ +[ + { + "id": 1, + "name": "Truffo", + "amount": 4541, + "category": "Кафе и рестораны", + "time": 1623326031 + }, + { + "id": 2, + "name": "Стоматология", + "amount": 8000, + "category": "Здоровье", + "time": 1623419811 + } +] \ No newline at end of file diff --git a/app/src/main/res/raw/payload_4.json b/app/src/main/res/raw/payload_4.json new file mode 100644 index 00000000..03fbd3af --- /dev/null +++ b/app/src/main/res/raw/payload_4.json @@ -0,0 +1,30 @@ +[ + { + "id": 1, + "name": "Truffo", + "amount": 4541, + "category": "Кафе и рестораны", + "time": 1623326031 + }, + { + "id": 2, + "name": "Simple Wine", + "amount": 1600, + "category": "Алкоголь", + "time": 1623329631 + }, + { + "id": 3, + "name": "Азбука Вкуса Экспресс", + "amount": 1841, + "category": "Доставка еды", + "time": 1623322371 + }, + { + "id": 4, + "name": "Стоматология", + "amount": 8000, + "category": "Здоровье", + "time": 1623419811 + } +] \ No newline at end of file diff --git a/app/src/main/res/raw/payload_7.json b/app/src/main/res/raw/payload_7.json new file mode 100644 index 00000000..a255b2b0 --- /dev/null +++ b/app/src/main/res/raw/payload_7.json @@ -0,0 +1,51 @@ +[ + { + "id": 1, + "name": "Азбука Вкуса", + "amount": 1580, + "category": "Продукты", + "time": 1623318531 + }, + { + "id": 2, + "name": "Truffo", + "amount": 4541, + "category": "Кафе и рестораны", + "time": 1623326031 + }, + { + "id": 3, + "name": "Simple Wine", + "amount": 1600, + "category": "Алкоголь", + "time": 1623329631 + }, + { + "id": 4, + "name": "Азбука Вкуса Экспресс", + "amount": 1841, + "category": "Доставка еды", + "time": 1623322371 + }, + { + "id": 5, + "name": "Стоматология", + "amount": 8000, + "category": "Здоровье", + "time": 1623419811 + }, + { + "id": 6, + "name": "Пятерочка", + "amount": 809, + "category": "Продукты", + "time": 1623419934 + }, + { + "id": 7, + "name": "Бассейн", + "amount": 1000, + "category": "Спорт", + "time": 1623419934 + } +] \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index a7a5d11b..0d754296 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -1,7 +1,7 @@ - + @@ -17,6 +17,10 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9213c339..af1c3efc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Custom View + Всего \ No newline at end of file From 7c38270b73341ed9f526ecbce151409832262f26 Mon Sep 17 00:00:00 2001 From: AleksVira Date: Mon, 15 Dec 2025 03:05:31 +0300 Subject: [PATCH 3/5] =?UTF-8?q?=D0=A2=D1=80=D0=B5=D1=82=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF,=20=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D0=B1=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../otus/homework/customview/MainActivity.kt | 2 - .../otus/homework/customview/ViewUtils.kt | 22 ++ .../homework/customview/ViraPieChartView.kt | 226 +++++++++++++----- app/src/main/res/layout/activity_main.xml | 5 +- 4 files changed, 186 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/otus/homework/customview/ViewUtils.kt diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 979661a9..78cb9448 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -7,7 +7,5 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - - findViewById(R.id.pieChart).setDataSource(R.raw.payload_4) } } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/ViewUtils.kt b/app/src/main/java/otus/homework/customview/ViewUtils.kt new file mode 100644 index 00000000..8d99d9b6 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/ViewUtils.kt @@ -0,0 +1,22 @@ +package otus.homework.customview + +import android.content.Context +import android.util.TypedValue + +object ViewUtils { + fun dp(context: Context, value: Float): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value, + context.resources.displayMetrics, + ).toInt() + } + + fun sp(context: Context, value: Float): Float { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + value, + context.resources.displayMetrics, + ) + } +} diff --git a/app/src/main/java/otus/homework/customview/ViraPieChartView.kt b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt index b75038ee..0e863fbf 100644 --- a/app/src/main/java/otus/homework/customview/ViraPieChartView.kt +++ b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt @@ -6,17 +6,14 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.util.AttributeSet -import android.util.TypedValue +import android.view.MotionEvent import android.view.View +import android.widget.Toast import androidx.annotation.RawRes -import org.json.JSONArray +import androidx.core.content.ContextCompat import java.text.NumberFormat -import java.util.Locale -import kotlin.math.cos -import kotlin.math.floor -import kotlin.math.min -import kotlin.math.roundToInt -import kotlin.math.sin +import kotlin.math.* +import org.json.JSONArray class ViraPieChartView @JvmOverloads constructor( context: Context, @@ -27,7 +24,10 @@ class ViraPieChartView @JvmOverloads constructor( private companion object { private const val ERROR_DATA_SOURCE_NOT_SET = "ViraPieChartView: dataSource is not set. " + - "Provide app:dataSource=\"@raw/...\" in XML or call setDataSource(@RawRes) from Activity/Fragment." + "Provide app:dataSource=\"@raw/...\" in XML or call setDataSource(@RawRes) from Activity/Fragment." + + // Кешируемые raw ресурсы для ускорения + private val rawResourceCache = mutableMapOf() } enum class ColorMode { MONO, COLOR } @@ -40,6 +40,13 @@ class ViraPieChartView @JvmOverloads constructor( val time: Long, val amount: Long, val percent: Float, + // Геометрическая информация для кликов (заполняется в onDraw) + var startAngle: Float = 0f, + var sweepAngle: Float = 0f, + var innerRadius: Float = 0f, + var outerRadius: Float = 0f, + var centerX: Float = 0f, + var centerY: Float = 0f, ) private val arcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -49,18 +56,18 @@ class ViraPieChartView @JvmOverloads constructor( private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK textAlign = Paint.Align.CENTER - textSize = sp(12f) + textSize = ViewUtils.sp(context, 12f) } private val centerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK textAlign = Paint.Align.CENTER - textSize = sp(18f) + textSize = ViewUtils.sp(context, 18f) isFakeBoldText = true } private val centerTitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK textAlign = Paint.Align.CENTER - textSize = sp(14f) + textSize = ViewUtils.sp(context, 14f) } private val arcBounds = RectF() @@ -75,13 +82,39 @@ class ViraPieChartView @JvmOverloads constructor( private var slices: List = emptyList() private var totalAmount: Long = 0L + // Кешируемые измерения для производительности + private var cachedOuterLabelReserve: Float = 0f + + // Кешируемые font metrics для отрисовки текста + private var labelFontHeight: Float = 0f + private var centerTitleFontHeight: Float = 0f + private var centerFontHeight: Float = 0f + private val palette = intArrayOf( 0xFF8DD3C7.toInt(), 0xFFFFFFB3.toInt(), 0xFFBEBADA.toInt(), 0xFFFB8072.toInt(), 0xFF80B1D3.toInt(), 0xFFFDB462.toInt(), 0xFFB3DE69.toInt(), 0xFFFCCDE5.toInt(), 0xFFD9D9D9.toInt(), 0xFFBC80BD.toInt(), 0xFFCCEBC5.toInt(), 0xFFFFED6F.toInt() ) + private fun initCacheValues() { + // Кешируем частые измерения (будет вызываться при изменении textMode) + updateCacheValues() + } + + private fun updateCacheValues() { + cachedOuterLabelReserve = if (textMode == TextMode.OUTER) { + ViewUtils.dp(context, 16f).toFloat() + labelPaint.textSize + } else { + 0f + } + + labelFontHeight = labelPaint.fontMetrics.run { descent - ascent } + centerTitleFontHeight = centerTitlePaint.fontMetrics.run { descent - ascent } + centerFontHeight = centerPaint.fontMetrics.run { descent - ascent } + } + init { + isSaveEnabled = true if (attrs != null) { val a = context.obtainStyledAttributes(attrs, R.styleable.ViraPieChartView, defStyleAttr, 0) try { @@ -104,7 +137,7 @@ class ViraPieChartView @JvmOverloads constructor( dataSourceResId = a.getResourceId(R.styleable.ViraPieChartView_dataSource, 0) .takeIf { it != 0 } ?: resolveRawResId(a.getString(R.styleable.ViraPieChartView_dataSource)) - ?: 0 + ?: 0 } finally { a.recycle() } @@ -122,33 +155,34 @@ class ViraPieChartView @JvmOverloads constructor( } } } - } - fun reload() { - loadFromRaw(requireDataSourceResId()) - } - - fun setDataSource(@RawRes rawResId: Int) { - require(rawResId != 0) { "ViraPieChartView: rawResId must be non-zero" } - dataSourceResId = rawResId - if (!isInEditMode) { - loadFromRaw(dataSourceResId) - } + // Инициализируем кешируемые значения + initCacheValues() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val desired = dp(240f) - val wMode = MeasureSpec.getMode(widthMeasureSpec) val wSize = MeasureSpec.getSize(widthMeasureSpec) val hMode = MeasureSpec.getMode(heightMeasureSpec) val hSize = MeasureSpec.getSize(heightMeasureSpec) - val maxW = if (wMode == MeasureSpec.UNSPECIFIED) desired else wSize - val maxH = if (hMode == MeasureSpec.UNSPECIFIED) desired else hSize + val width = when (wMode) { + MeasureSpec.EXACTLY -> wSize + MeasureSpec.AT_MOST -> wSize + else -> wSize // UNSPECIFIED - используем размер окна + } + + val height = when (hMode) { + MeasureSpec.EXACTLY -> hSize + MeasureSpec.AT_MOST -> { + val squareHeight = width - paddingLeft - paddingRight + min(squareHeight.coerceAtLeast(0), hSize) + } - val size = min(maxW, maxH) - setMeasuredDimension(size, size) + else -> width - paddingLeft - paddingRight + } + + setMeasuredDimension(width, height) } override fun onDraw(canvas: Canvas) { @@ -169,7 +203,7 @@ class ViraPieChartView @JvmOverloads constructor( val cx = contentLeft + contentW / 2f val cy = contentTop + contentH / 2f - val outerLabelReserve = if (textMode == TextMode.OUTER) dp(16f).toFloat() + labelPaint.textSize else 0f + val outerLabelReserve = cachedOuterLabelReserve val availableOuterEdgeRadius = (diameter / 2f - outerLabelReserve).coerceAtLeast(0f) val baseThickness = availableOuterEdgeRadius / 3f @@ -183,8 +217,19 @@ class ViraPieChartView @JvmOverloads constructor( cy + ringCenterRadius, ) + slices.forEach { + it.apply { + startAngle = 0f + sweepAngle = 0f + innerRadius = 0f + outerRadius = 0f + centerX = 0f + centerY = 0f + } + } + var startAngle = -90f - slices.forEachIndexed { index, slice -> + slices = slices.mapIndexed { index, slice -> val thicknessMultiplier = if (widthMode == WidthMode.VAR && index % 2 == 1) 0.75f else 1f val thickness = baseThickness * thicknessMultiplier val halfStroke = thickness / 2f @@ -194,12 +239,20 @@ class ViraPieChartView @JvmOverloads constructor( val sweep = 360f * (slice.amount.toFloat() / totalAmount.toFloat()) canvas.drawArc(arcBounds, startAngle, sweep, false, arcPaint) - if (textMode != TextMode.NONE) { - val outerEdgeRadius = ringCenterRadius + halfStroke - drawLabel(canvas, cx, cy, ringCenterRadius, outerEdgeRadius, startAngle, sweep, slice) + slice.copy( + startAngle = startAngle, + sweepAngle = sweep, + innerRadius = ringCenterRadius - halfStroke, + outerRadius = ringCenterRadius + halfStroke, + centerX = cx, + centerY = cy + ).also { updatedSlice -> + if (textMode != TextMode.NONE) { + val outerEdgeRadius = ringCenterRadius + halfStroke + drawLabel(canvas, cx, cy, ringCenterRadius, outerEdgeRadius, startAngle, sweep, updatedSlice) + } + startAngle += sweep } - - startAngle += sweep } drawCenterTotal(canvas, cx, cy) @@ -209,16 +262,12 @@ class ViraPieChartView @JvmOverloads constructor( val title = titleText val totalText = NumberFormat.getIntegerInstance().format(totalAmount) - val titleFm = centerTitlePaint.fontMetrics - val totalFm = centerPaint.fontMetrics - val titleHeight = titleFm.descent - titleFm.ascent - val totalHeight = totalFm.descent - totalFm.ascent - val spacing = dp(4f).toFloat() - val blockHeight = titleHeight + spacing + totalHeight + val spacing = ViewUtils.dp(context, 4f) + val blockHeight = centerTitleFontHeight + spacing + centerFontHeight val blockTop = cy - blockHeight / 2f - val titleBaseline = blockTop - titleFm.ascent - val totalBaseline = titleBaseline + titleHeight + spacing - totalFm.ascent + val titleBaseline = blockTop - centerTitlePaint.fontMetrics.ascent + val totalBaseline = titleBaseline + centerTitleFontHeight + spacing - centerPaint.fontMetrics.ascent canvas.drawText(title, cx, titleBaseline, centerTitlePaint) canvas.drawText(totalText, cx, totalBaseline, centerPaint) @@ -234,23 +283,20 @@ class ViraPieChartView @JvmOverloads constructor( sweepAngle: Float, slice: Slice, ) { - val locale = Locale.getDefault() - val percentText = String.format(locale, "%.1f", slice.percent) - val text = "$percentText%" + val percentText = "%.1f%%".format(slice.percent) val midAngleRad = Math.toRadians((startAngle + sweepAngle / 2f).toDouble()) val labelRadius = when (textMode) { TextMode.INNER -> ringCenterRadius - TextMode.OUTER -> outerEdgeRadius + dp(16f).toFloat() + TextMode.OUTER -> outerEdgeRadius + ViewUtils.dp(context, 16f).toFloat() TextMode.NONE -> return } val x = (cx + cos(midAngleRad) * labelRadius).toFloat() val y = (cy + sin(midAngleRad) * labelRadius).toFloat() - val fm = labelPaint.fontMetrics - canvas.drawText(text, x, y - (fm.ascent + fm.descent) / 2f, labelPaint) + canvas.drawText(percentText, x, y - (labelPaint.fontMetrics.ascent + labelPaint.fontMetrics.descent) / 2f, labelPaint) } private fun sliceColor(index: Int, sliceCount: Int): Int { @@ -330,8 +376,25 @@ class ViraPieChartView @JvmOverloads constructor( ?.takeIf { it.isNotBlank() } ?: return null - val id = resources.getIdentifier(cleaned, "raw", context.packageName) - return id.takeIf { it != 0 } + // Проверяем кеш сначала + rawResourceCache[cleaned]?.let { return it } + + // Вычисляем и кешируем результат + return try { + // Быстрая компиляция-time проверка через рефлексию + val clazz = R.raw::class.java + val field = clazz.getDeclaredField(cleaned) + val resId = field.getInt(null) + rawResourceCache[cleaned] = resId + resId + } catch (ex: Exception) { + // Fallback на стандартный метод (только если ресурс не найден) + val resId = context.resources.getIdentifier(cleaned, "raw", context.packageName) + if (resId != 0) { + rawResourceCache[cleaned] = resId + resId + } else null + } } private fun requireDataSourceResId(): Int { @@ -355,19 +418,52 @@ class ViraPieChartView @JvmOverloads constructor( invalidate() } - private fun dp(value: Float): Int { - return TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - value, - resources.displayMetrics, - ).toInt() + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + val x = event.x + val y = event.y + + for (slice in slices) { + if (isPointInSector(x, y, slice)) { + performClick() + Toast.makeText(context, slice.category, Toast.LENGTH_SHORT).show() + return true + } + } + } + + MotionEvent.ACTION_UP -> { + return true + } + } + return super.onTouchEvent(event) } - private fun sp(value: Float): Float { - return TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, - value, - resources.displayMetrics, - ) + private fun isPointInSector(x: Float, y: Float, slice: Slice): Boolean { + val dx = x - slice.centerX + val dy = y - slice.centerY + val distance = sqrt(dx.pow(2) + dy.pow(2)) + + if (distance < slice.innerRadius || distance > slice.outerRadius) { + return false + } + + val angleRad = atan2(dy, dx) + var angleDeg = Math.toDegrees(angleRad.toDouble()).toFloat() + if (angleDeg < 0) angleDeg += 360f + + var adjustedAngle = angleDeg + 90f + if (adjustedAngle >= 360f) adjustedAngle -= 360f + + val startAngle = slice.startAngle + 90f + val endAngle = startAngle + slice.sweepAngle + + return if (startAngle <= endAngle) { + adjustedAngle in startAngle..endAngle + } else { + adjustedAngle >= startAngle || adjustedAngle <= endAngle + } } + } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a629266c..80f7df78 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -11,13 +11,14 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:padding="16dp" - app:colorMode="mono" + app:colorMode="color" + app:dataSource="@raw/payload_7" app:titleText="@string/view_title" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:textMode="outer" + app:textMode="inner" app:widthMode="var_mode" /> \ No newline at end of file From 0268ffa7e89432f9cba7c97448f5e5c74833b3d4 Mon Sep 17 00:00:00 2001 From: AleksVira Date: Mon, 15 Dec 2025 03:47:57 +0300 Subject: [PATCH 4/5] =?UTF-8?q?=D0=A7=D0=B5=D1=82=D0=B2=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 1 + .../otus/homework/customview/MainActivity.kt | 35 +++- .../homework/customview/ViraPieChartView.kt | 177 +++++++++++++----- app/src/main/res/layout/activity_main.xml | 1 - gradle.properties | 5 +- 5 files changed, 168 insertions(+), 51 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f22e7497..c7b19cd0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinAndroid) + id 'kotlin-parcelize' } android { diff --git a/app/src/main/java/otus/homework/customview/MainActivity.kt b/app/src/main/java/otus/homework/customview/MainActivity.kt index 78cb9448..0aba3073 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -1,11 +1,44 @@ package otus.homework.customview -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import org.json.JSONArray class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + val pieChart = findViewById(R.id.pieChart) + + pieChart.setOnSectorClickListener { category -> + Toast.makeText(this, category, Toast.LENGTH_SHORT).show() + } + + val entries = loadEntriesFromRaw(R.raw.payload_7) + pieChart.setData(entries) + } + + private fun loadEntriesFromRaw(rawResId: Int): List { + val json = resources.openRawResource(rawResId).bufferedReader().use { it.readText() } + val array = JSONArray(json) + val result = ArrayList(array.length()) + + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val amount = obj.optLong("amount", 0L) + if (amount <= 0L) continue + + result.add( + ViraPieChartView.Entry( + name = obj.optString("name", ""), + category = obj.optString("category", ""), + time = obj.optLong("time", 0L), + amount = amount, + ) + ) + } + return result } } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/customview/ViraPieChartView.kt b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt index 0e863fbf..9cbd2604 100644 --- a/app/src/main/java/otus/homework/customview/ViraPieChartView.kt +++ b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt @@ -5,12 +5,12 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.RectF +import android.os.Parcelable import android.util.AttributeSet import android.view.MotionEvent import android.view.View -import android.widget.Toast import androidx.annotation.RawRes -import androidx.core.content.ContextCompat +import kotlinx.parcelize.Parcelize import java.text.NumberFormat import kotlin.math.* import org.json.JSONArray @@ -34,6 +34,17 @@ class ViraPieChartView @JvmOverloads constructor( enum class TextMode { NONE, INNER, OUTER } enum class WidthMode { CONST, VAR } + data class Entry( + val name: String, + val category: String, + val time: Long, + val amount: Long, + ) + + fun interface OnSectorClickListener { + fun onSectorClick(category: String) + } + private data class Slice( val name: String, val category: String, @@ -82,6 +93,9 @@ class ViraPieChartView @JvmOverloads constructor( private var slices: List = emptyList() private var totalAmount: Long = 0L + private var onSectorClickListener: OnSectorClickListener? = null + private var pressedSlice: Slice? = null + // Кешируемые измерения для производительности private var cachedOuterLabelReserve: Float = 0f @@ -96,11 +110,6 @@ class ViraPieChartView @JvmOverloads constructor( 0xFFD9D9D9.toInt(), 0xFFBC80BD.toInt(), 0xFFCCEBC5.toInt(), 0xFFFFED6F.toInt() ) - private fun initCacheValues() { - // Кешируем частые измерения (будет вызываться при изменении textMode) - updateCacheValues() - } - private fun updateCacheValues() { cachedOuterLabelReserve = if (textMode == TextMode.OUTER) { ViewUtils.dp(context, 16f).toFloat() + labelPaint.textSize @@ -148,16 +157,11 @@ class ViraPieChartView @JvmOverloads constructor( setSlices(previewSlices, previewTotal) } else { if (dataSourceResId != 0) { - loadFromRaw(dataSourceResId) - } else { - post { - requireDataSourceResId() - } + setDataSource(dataSourceResId) } } - // Инициализируем кешируемые значения - initCacheValues() + updateCacheValues() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -187,8 +191,6 @@ class ViraPieChartView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - requireDataSourceResId() - if (slices.isEmpty() || totalAmount <= 0) { return } @@ -323,38 +325,24 @@ class ViraPieChartView @JvmOverloads constructor( return Color.rgb(gray, gray, gray) } - private fun loadFromRaw(@RawRes rawRes: Int) { + private fun loadEntriesFromRaw(@RawRes rawRes: Int): List { val json = resources.openRawResource(rawRes).bufferedReader().use { it.readText() } val array = JSONArray(json) - var total = 0L - - val items = ArrayList(array.length()) - + val parsed = ArrayList(array.length()) for (i in 0 until array.length()) { val obj = array.getJSONObject(i) - val name = obj.optString("name", "") - val category = obj.optString("category", "") - val time = obj.optLong("time", 0L) val amount = obj.optLong("amount", 0L) if (amount <= 0L) continue - - items.add( - Slice( - name = name, - category = category, - time = time, + parsed.add( + Entry( + name = obj.optString("name", ""), + category = obj.optString("category", ""), + time = obj.optLong("time", 0L), amount = amount, - percent = 0f, ) ) - total += amount } - - val sliceList = items - .sortedByDescending { it.amount } - .withPercent(total) - - setSlices(sliceList, total) + return parsed } private fun buildPreviewData(): Pair, Long> { @@ -397,13 +385,6 @@ class ViraPieChartView @JvmOverloads constructor( } } - private fun requireDataSourceResId(): Int { - if (!isInEditMode && dataSourceResId == 0) { - throw IllegalStateException(ERROR_DATA_SOURCE_NOT_SET) - } - return dataSourceResId - } - private fun List.withPercent(total: Long): List { if (total <= 0L) return this return map { slice -> @@ -418,6 +399,49 @@ class ViraPieChartView @JvmOverloads constructor( invalidate() } + fun setDataSource(@RawRes rawRes: Int) { + if (rawRes == 0) { + throw IllegalArgumentException(ERROR_DATA_SOURCE_NOT_SET) + } + dataSourceResId = rawRes + setDataInternal(loadEntriesFromRaw(rawRes), keepDataSourceResId = rawRes) + } + + fun setData(entries: List) { + dataSourceResId = 0 + setDataInternal(entries, keepDataSourceResId = 0) + } + + private fun setDataInternal(entries: List, keepDataSourceResId: Int) { + dataSourceResId = keepDataSourceResId + + val filtered = entries.filter { it.amount > 0L } + val total = filtered.sumOf { it.amount } + if (total <= 0L) { + setSlices(emptyList(), 0L) + return + } + + val sliceList = filtered + .sortedByDescending { it.amount } + .map { + Slice( + name = it.name, + category = it.category, + time = it.time, + amount = it.amount, + percent = 0f, + ) + } + .withPercent(total) + + setSlices(sliceList, total) + } + + fun setOnSectorClickListener(listener: OnSectorClickListener?) { + onSectorClickListener = listener + } + override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -426,20 +450,39 @@ class ViraPieChartView @JvmOverloads constructor( for (slice in slices) { if (isPointInSector(x, y, slice)) { - performClick() - Toast.makeText(context, slice.category, Toast.LENGTH_SHORT).show() + pressedSlice = slice return true } } + pressedSlice = null + return false } MotionEvent.ACTION_UP -> { - return true + val slice = pressedSlice + pressedSlice = null + + if (slice != null && isPointInSector(event.x, event.y, slice)) { + performClick() + onSectorClickListener?.onSectorClick(slice.category) + return true + } + return false + } + + MotionEvent.ACTION_CANCEL -> { + pressedSlice = null + return false } } return super.onTouchEvent(event) } + override fun performClick(): Boolean { + super.performClick() + return true + } + private fun isPointInSector(x: Float, y: Float, slice: Slice): Boolean { val dx = x - slice.centerX val dy = y - slice.centerY @@ -466,4 +509,42 @@ class ViraPieChartView @JvmOverloads constructor( } } + override fun onSaveInstanceState(): Parcelable { + return PieSavedState( + superState = super.onSaveInstanceState(), + colorModeName = colorMode.name, + textModeName = textMode.name, + widthModeName = widthMode.name, + titleText = titleText, + ) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state !is PieSavedState) { + super.onRestoreInstanceState(state) + return + } + + super.onRestoreInstanceState(state.superState) + + colorMode = state.colorModeName?.let { runCatching { ColorMode.valueOf(it) }.getOrNull() } ?: colorMode + textMode = state.textModeName?.let { runCatching { TextMode.valueOf(it) }.getOrNull() } ?: textMode + widthMode = state.widthModeName?.let { runCatching { WidthMode.valueOf(it) }.getOrNull() } ?: widthMode + titleText = state.titleText ?: titleText + + updateCacheValues() + + // Данные восстанавливаются снаружи (Activity/VM) через setData(...) + invalidate() + } + + @Parcelize + internal data class PieSavedState( + val superState: Parcelable?, + val colorModeName: String?, + val textModeName: String?, + val widthModeName: String?, + val titleText: String?, + ) : Parcelable + } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 80f7df78..f7ea0053 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -12,7 +12,6 @@ android:layout_height="wrap_content" android:padding="16dp" app:colorMode="color" - app:dataSource="@raw/payload_7" app:titleText="@string/view_title" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/gradle.properties b/gradle.properties index 98bed167..38960496 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,4 +18,7 @@ android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official + +# Gradle 8.x/Groovy can break on very new JDK classfile versions; pin Gradle JVM to a supported JDK. +org.gradle.java.home=/Library/Java/JavaVirtualMachines/zulu-21.jdk/Contents/Home \ No newline at end of file From 12a201d88c479a06d909f83c7f61633f99298bed Mon Sep 17 00:00:00 2001 From: AleksVira Date: Mon, 15 Dec 2025 03:58:42 +0300 Subject: [PATCH 5/5] =?UTF-8?q?=D0=9F=D1=8F=D1=82=D1=8B=D0=B9=20=D1=8D?= =?UTF-8?q?=D1=82=D0=B0=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../homework/customview/ViraPieChartView.kt | 87 ++----------------- app/src/main/res/values/attrs.xml | 2 - 2 files changed, 8 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/otus/homework/customview/ViraPieChartView.kt b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt index 9cbd2604..314c2caf 100644 --- a/app/src/main/java/otus/homework/customview/ViraPieChartView.kt +++ b/app/src/main/java/otus/homework/customview/ViraPieChartView.kt @@ -9,11 +9,9 @@ import android.os.Parcelable import android.util.AttributeSet import android.view.MotionEvent import android.view.View -import androidx.annotation.RawRes import kotlinx.parcelize.Parcelize import java.text.NumberFormat import kotlin.math.* -import org.json.JSONArray class ViraPieChartView @JvmOverloads constructor( context: Context, @@ -22,12 +20,8 @@ class ViraPieChartView @JvmOverloads constructor( ) : View(context, attrs, defStyleAttr) { private companion object { - private const val ERROR_DATA_SOURCE_NOT_SET = - "ViraPieChartView: dataSource is not set. " + - "Provide app:dataSource=\"@raw/...\" in XML or call setDataSource(@RawRes) from Activity/Fragment." - - // Кешируемые raw ресурсы для ускорения - private val rawResourceCache = mutableMapOf() + private const val ERROR_DATA_NOT_SET = + "ViraPieChartView: data is not set. Provide data via setData(entries)." } enum class ColorMode { MONO, COLOR } @@ -88,7 +82,6 @@ class ViraPieChartView @JvmOverloads constructor( private var widthMode: WidthMode = WidthMode.CONST private var titleText: String = "Title" - private var dataSourceResId: Int = 0 private var slices: List = emptyList() private var totalAmount: Long = 0L @@ -142,11 +135,6 @@ class ViraPieChartView @JvmOverloads constructor( } titleText = a.getString(R.styleable.ViraPieChartView_titleText) ?: titleText - - dataSourceResId = a.getResourceId(R.styleable.ViraPieChartView_dataSource, 0) - .takeIf { it != 0 } - ?: resolveRawResId(a.getString(R.styleable.ViraPieChartView_dataSource)) - ?: 0 } finally { a.recycle() } @@ -155,10 +143,6 @@ class ViraPieChartView @JvmOverloads constructor( if (isInEditMode) { val (previewSlices, previewTotal) = buildPreviewData() setSlices(previewSlices, previewTotal) - } else { - if (dataSourceResId != 0) { - setDataSource(dataSourceResId) - } } updateCacheValues() @@ -191,6 +175,10 @@ class ViraPieChartView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) + if (!isInEditMode && slices.isEmpty()) { + // Данные должны прийти извне через setData(...) + throw IllegalStateException(ERROR_DATA_NOT_SET) + } if (slices.isEmpty() || totalAmount <= 0) { return } @@ -325,26 +313,6 @@ class ViraPieChartView @JvmOverloads constructor( return Color.rgb(gray, gray, gray) } - private fun loadEntriesFromRaw(@RawRes rawRes: Int): List { - val json = resources.openRawResource(rawRes).bufferedReader().use { it.readText() } - val array = JSONArray(json) - val parsed = ArrayList(array.length()) - for (i in 0 until array.length()) { - val obj = array.getJSONObject(i) - val amount = obj.optLong("amount", 0L) - if (amount <= 0L) continue - parsed.add( - Entry( - name = obj.optString("name", ""), - category = obj.optString("category", ""), - time = obj.optLong("time", 0L), - amount = amount, - ) - ) - } - return parsed - } - private fun buildPreviewData(): Pair, Long> { val items = listOf( Slice(name = "A", category = "C1", time = 0L, amount = 274, percent = 0f), @@ -356,34 +324,6 @@ class ViraPieChartView @JvmOverloads constructor( return items.withPercent(total) to total } - private fun resolveRawResId(rawName: String?): Int? { - val cleaned = rawName - ?.trim() - ?.removePrefix("@raw/") - ?.removeSuffix(".json") - ?.takeIf { it.isNotBlank() } - ?: return null - - // Проверяем кеш сначала - rawResourceCache[cleaned]?.let { return it } - - // Вычисляем и кешируем результат - return try { - // Быстрая компиляция-time проверка через рефлексию - val clazz = R.raw::class.java - val field = clazz.getDeclaredField(cleaned) - val resId = field.getInt(null) - rawResourceCache[cleaned] = resId - resId - } catch (ex: Exception) { - // Fallback на стандартный метод (только если ресурс не найден) - val resId = context.resources.getIdentifier(cleaned, "raw", context.packageName) - if (resId != 0) { - rawResourceCache[cleaned] = resId - resId - } else null - } - } private fun List.withPercent(total: Long): List { if (total <= 0L) return this @@ -399,22 +339,11 @@ class ViraPieChartView @JvmOverloads constructor( invalidate() } - fun setDataSource(@RawRes rawRes: Int) { - if (rawRes == 0) { - throw IllegalArgumentException(ERROR_DATA_SOURCE_NOT_SET) - } - dataSourceResId = rawRes - setDataInternal(loadEntriesFromRaw(rawRes), keepDataSourceResId = rawRes) - } - fun setData(entries: List) { - dataSourceResId = 0 - setDataInternal(entries, keepDataSourceResId = 0) + setDataInternal(entries) } - private fun setDataInternal(entries: List, keepDataSourceResId: Int) { - dataSourceResId = keepDataSourceResId - + private fun setDataInternal(entries: List) { val filtered = entries.filter { it.amount > 0L } val total = filtered.sumOf { it.amount } if (total <= 0L) { diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 0d754296..c8d3b903 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -19,8 +19,6 @@ - -