Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Custom View

### Реализация

#### Сектор не выбран
![Nothing Selected](art/nothing_select.png)
#### Сектор выбран
![Sector Selected](art/select_sector.png)
#### Повторот экрана с сохранением состояния
![Screen Rotate](art/screen_rotate.png)

### Задание. Реализовать кастом View - график Pie Chart, на котором будем визуализировать траты по категориям:

1. Обязательно реализуйте метод onMeasure и учтите все возможные MeasureSpecs
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
id 'kotlin-parcelize'
}

android {
Expand Down
31 changes: 28 additions & 3 deletions app/src/main/java/otus/homework/customview/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
package otus.homework.customview

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import otus.homework.customview.databinding.ActivityMainBinding
import otus.homework.customview.models.Expense

/**
* Главный экран приложения, отображающий круговую диаграмму расходов.
*/
class MainActivity : AppCompatActivity() {

private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

val expenses = loadExpenses()
binding.pieChart.setData(expenses)
}

/**
* Загружает список расходов из сырого ресурса payload.json.
*
* @return Список объектов [Expense].
*/
private fun loadExpenses(): List<Expense> {
val jsonString = resources.openRawResource(R.raw.payload).bufferedReader().use { it.readText() }
val type = object : TypeToken<List<Expense>>() {}.type
return Gson().fromJson(jsonString, type)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package otus.homework.customview.chart

import android.os.Parcelable
import android.view.View.BaseSavedState
import kotlinx.parcelize.Parcelize

/**
* Класс для сохранения состояния [PieChartView].
*/
@Parcelize
internal class PieChartSavedState(
private val sourceState: Parcelable?,
val data: List<PieData>,
val selectedCategory: String?
) : BaseSavedState(sourceState)
316 changes: 316 additions & 0 deletions app/src/main/java/otus/homework/customview/chart/PieChartView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
package otus.homework.customview.chart

import android.content.Context
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 androidx.core.content.ContextCompat
import otus.homework.customview.R
import otus.homework.customview.models.Expense
import java.util.Locale
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt

/**
* Кастомная View для отображения круговой диаграммы (Pie Chart).
* Позволяет визуализировать траты по категориям и обрабатывать клики по секторам.
*/
class PieChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

/** Список данных для отрисовки каждого сектора. */
private var data: List<PieData> = emptyList()

/** Общая сумма всех трат. */
private var totalAmount: Int = 0

/** Идентификатор выбранной категории. */
private var selectedCategory: String? = null

// --- Кешированные значения для отрисовки ---
private var viewCenterX = 0f
private var viewCenterY = 0f
private var viewPadding = 0f
private var viewHoleRadius = 0f
private var currentTitleText = ""
private var currentAmountText = ""
private var currentPercentText = ""

private val labelTotal by lazy { context.getString(R.string.pie_total) }
private val amountFormat by lazy { context.getString(R.string.pie_amount_format) }
// -------------------------------------------

private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}

/** Кисть для отрисовки текста (%). */
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textAlign = Paint.Align.CENTER
}

/** Кисть для отрисовки центрального заголовка (Всего/Категория). */
private val centerTitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.GRAY
textAlign = Paint.Align.CENTER
}

/** Кисть для отрисовки центрального значения (Сумма). */
private val centerValuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textAlign = Paint.Align.CENTER
isFakeBoldText = true
}

/** Прямоугольник, в который вписывается диаграмма. */
private val rectF = RectF()
private val selectedRectF = RectF()

/** Список цветов для секторов, загружаемый из ресурсов. */
private val colors = listOf(
R.color.pie_color_1, R.color.pie_color_2, R.color.pie_color_3,
R.color.pie_color_4, R.color.pie_color_5, R.color.pie_color_6,
R.color.pie_color_7, R.color.pie_color_8, R.color.pie_color_9,
R.color.pie_color_10, R.color.pie_color_11, R.color.pie_color_12
).map { ContextCompat.getColor(context, it) }

/**
* Устанавливает данные для отображения.
*
* @param expenses Список объектов расхода [Expense].
*/
fun setData(expenses: List<Expense>) {
val categoryMap = expenses.groupBy { it.category }
.mapValues { entry -> entry.value.sumOf { it.amount } }

totalAmount = categoryMap.values.sum()
var startAngle = 0f

data = categoryMap.entries.mapIndexed { index, entry ->
val sweepAngle = (entry.value.toFloat() / totalAmount) * FULL_CIRCLE_DEGREES

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

вот тут можем получить деление на 0, если список categoryMap пустой, нужна проверка

val pieData = PieData(
category = entry.key,
amount = entry.value,
startAngle = startAngle,
sweepAngle = sweepAngle,
color = colors[index % colors.size]
)
startAngle += sweepAngle
pieData
}
updateCenterTextCache()
invalidate()
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthSize = MeasureSpec.getSize(widthMeasureSpec)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

тут не будут корректно обработаны все режимы, лучше использовать готовые методы

    val width = resolveSize(desiredSize, widthMeasureSpec)
    val height = resolveSize(desiredSize, heightMeasureSpec)
    val size = min(width, height)

val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val size = min(widthSize, heightSize).let { if (it == 0) DEFAULT_SIZE_PX else it }

setMeasuredDimension(size, size)
textPaint.textSize = size / PERCENT_TEXT_SIZE_RATIO
centerTitlePaint.textSize = size / CENTER_TITLE_TEXT_SIZE_RATIO
centerValuePaint.textSize = size / CENTER_VALUE_TEXT_SIZE_RATIO
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateGeometryCache()
}

/** Обновляет расчеты геометрии, зависящие от размеров View. */
private fun updateGeometryCache() {
viewCenterX = width / 2f
viewCenterY = height / 2f
viewPadding = width * CHART_PADDING_RATIO

rectF.set(viewPadding, viewPadding, width - viewPadding, height - viewPadding)
selectedRectF.set(
viewPadding - SELECTION_OFFSET_PX,
viewPadding - SELECTION_OFFSET_PX,
width - viewPadding + SELECTION_OFFSET_PX,
height - viewPadding + SELECTION_OFFSET_PX
)
viewHoleRadius = (width / 2f - viewPadding) * HOLE_RADIUS_RATIO
}

/** Обновляет текстовый кеш для центрального блока. */
private fun updateCenterTextCache() {
if (selectedCategory != null) {
val selectedData = data.find { it.category == selectedCategory }
val amount = selectedData?.amount ?: 0
val percentage = (amount.toFloat() / totalAmount * 100).toInt()

currentTitleText = selectedCategory!!
currentAmountText = String.format(Locale.getDefault(), amountFormat, amount)
currentPercentText = String.format(Locale.getDefault(), PERCENT_FORMAT_PARENTHESES, percentage)
} else {
currentTitleText = labelTotal
currentAmountText = String.format(Locale.getDefault(), amountFormat, totalAmount)
currentPercentText = ""
}
}

override fun onDraw(canvas: Canvas) {
if (data.isEmpty()) return

drawSectors(canvas)
drawHole(canvas)
drawCenterText(canvas)
}

/** Отрисовывает сектора диаграммы и текстовые проценты внутри них. */
private fun drawSectors(canvas: Canvas) {
// Измеряем ширину самого широкого возможного текста для расчета порога
val minTextWidth = textPaint.measureText(MAX_PERCENT_LABEL)
val textRadius = (viewCenterX - viewPadding) * PERCENT_TEXT_RADIUS_RATIO

data.forEach { pieData ->
paint.color = pieData.color

val targetRect = if (pieData.category == selectedCategory) selectedRectF else rectF
canvas.drawArc(targetRect, pieData.startAngle, pieData.sweepAngle, true, paint)

// Вычисляем длину дуги на радиусе отрисовки текста s = r * theta(rad)
val arcLength = textRadius * Math.toRadians(pieData.sweepAngle.toDouble()).toFloat()

// Рисуем только если текст помещается с небольшим запасом
if (arcLength > minTextWidth * TEXT_MIN_MARGIN_RATIO) {
drawPercentage(canvas, pieData, textRadius)
}
}
}

/** Рисует процентное значение внутри конкретного сектора. */
private fun drawPercentage(canvas: Canvas, pieData: PieData, radius: Float) {
// Перевод медианного угла в радианы для sin/cos
val medianAngleRad = Math.toRadians((pieData.startAngle + pieData.sweepAngle / 2f).toDouble())
val x = (viewCenterX + radius * cos(medianAngleRad)).toFloat()
val y = (viewCenterY + radius * sin(medianAngleRad)).toFloat()

val percentage = (pieData.amount.toFloat() / totalAmount * 100).toInt()
canvas.drawText(String.format(Locale.getDefault(), PERCENT_FORMAT, percentage), x, y + textPaint.textSize / 3f, textPaint)
}

/** Рисует центральный круг («дырку»), создавая эффект пончика. */
private fun drawHole(canvas: Canvas) {
paint.color = Color.WHITE
canvas.drawCircle(viewCenterX, viewCenterY, viewHoleRadius, paint)
}

/** Рисует информационный текст (Заголовок, Сумму и Проценты) в центре диаграммы. */
private fun drawCenterText(canvas: Canvas) {
val hasPercent = currentPercentText.isNotEmpty()

// Смещение заголовка вверх
val titleY = if (hasPercent) {
viewCenterY - centerTitlePaint.textSize * 1.2f
} else {
viewCenterY - centerTitlePaint.textSize / CENTER_TITLE_VERTICAL_OFFSET_RATIO
}

canvas.drawText(currentTitleText, viewCenterX, titleY, centerTitlePaint)

// Сумма по центру (чуть ниже или ровно в центре)
val amountY = if (hasPercent) viewCenterY + centerValuePaint.textSize / 2f else {
viewCenterY + centerValuePaint.textSize * CENTER_VALUE_VERTICAL_OFFSET_RATIO
}
canvas.drawText(currentAmountText, viewCenterX, amountY, centerValuePaint)

// Проценты на третьей строке
if (hasPercent) {
val percentY = amountY + centerTitlePaint.textSize * 1.2f
canvas.drawText(currentPercentText, viewCenterX, percentY, centerTitlePaint)
}
}

override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_UP) {
val x = event.x - viewCenterX
val y = event.y - viewCenterY

val distance = sqrt(x.pow(2) + y.pow(2))

// Если клик в "дырке" - сбрасываем выделение
if (distance < viewHoleRadius) {
if (selectedCategory != null) {
selectedCategory = null
updateCenterTextCache()
invalidate()
}
return true
}

if (distance <= width / 2f) {
var angle = Math.toDegrees(atan2(y.toDouble(), x.toDouble())).toFloat()
if (angle < 0) angle += 360f

data.find { angle >= it.startAngle && angle < (it.startAngle + it.sweepAngle) }?.let {
selectedCategory = if (selectedCategory == it.category) null else it.category
updateCenterTextCache()
performClick()
invalidate()
}
}
}
return true
}

override fun performClick(): Boolean {
super.performClick()
return true
}

override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return PieChartSavedState(superState, data, selectedCategory)
}

override fun onRestoreInstanceState(state: Parcelable?) {
if (state is PieChartSavedState) {
super.onRestoreInstanceState(state.superState)
this.data = state.data
this.selectedCategory = state.selectedCategory
this.totalAmount = this.data.sumOf { it.amount }
updateCenterTextCache()
invalidate()
} else {
super.onRestoreInstanceState(state)
}
}

companion object {
private const val FULL_CIRCLE_DEGREES = 360f
private const val DEFAULT_SIZE_PX = 400

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

это и SELECTION_OFFSET_PX в идеале учиывать в dp, чтобы выглядело одинаково на разных экранах

private const val CHART_PADDING_RATIO = 0.12f
private const val SELECTION_OFFSET_PX = 20f
private const val HOLE_RADIUS_RATIO = 0.55f
private const val PERCENT_TEXT_RADIUS_RATIO = 0.75f

private const val PERCENT_TEXT_SIZE_RATIO = 22f
private const val CENTER_TITLE_TEXT_SIZE_RATIO = 25f
private const val CENTER_VALUE_TEXT_SIZE_RATIO = 15f

private const val CENTER_TITLE_VERTICAL_OFFSET_RATIO = 4f
private const val CENTER_VALUE_VERTICAL_OFFSET_RATIO = 0.8f

private const val TEXT_MIN_MARGIN_RATIO = 1.3f

private const val MAX_PERCENT_LABEL = "100%"
private const val PERCENT_FORMAT = "%d%%"
private const val PERCENT_FORMAT_PARENTHESES = "(%d%%)"
}
}
Loading