diff --git a/README.md b/README.md index fae930b8..5bf2f3ef 100644 --- a/README.md +++ b/README.md @@ -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 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..f7551fdf 100644 --- a/app/src/main/java/otus/homework/customview/MainActivity.kt +++ b/app/src/main/java/otus/homework/customview/MainActivity.kt @@ -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 { + val jsonString = resources.openRawResource(R.raw.payload).bufferedReader().use { it.readText() } + val type = object : TypeToken>() {}.type + return Gson().fromJson(jsonString, type) } -} \ No newline at end of file +} diff --git a/app/src/main/java/otus/homework/customview/chart/PieChartSavedState.kt b/app/src/main/java/otus/homework/customview/chart/PieChartSavedState.kt new file mode 100644 index 00000000..946c222e --- /dev/null +++ b/app/src/main/java/otus/homework/customview/chart/PieChartSavedState.kt @@ -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, + val selectedCategory: String? +) : BaseSavedState(sourceState) diff --git a/app/src/main/java/otus/homework/customview/chart/PieChartView.kt b/app/src/main/java/otus/homework/customview/chart/PieChartView.kt new file mode 100644 index 00000000..f84759b1 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/chart/PieChartView.kt @@ -0,0 +1,327 @@ +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 = 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 defaultSizePx = (DEFAULT_SIZE_DP * resources.displayMetrics.density).toInt() + private val selectionOffsetPx = SELECTION_OFFSET_DP * resources.displayMetrics.density + // ------------------------------------------- + + 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) { + val categoryMap = expenses.groupBy { it.category } + .mapValues { entry -> entry.value.sumOf { it.amount } } + + totalAmount = categoryMap.values.sum() + + if (totalAmount <= 0) { + data = emptyList() + updateCenterTextCache() + invalidate() + return + } + + var startAngle = 0f + + data = categoryMap.entries.mapIndexed { index, entry -> + val sweepAngle = (entry.value.toFloat() / totalAmount) * FULL_CIRCLE_DEGREES + 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) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + val size = min(widthSize, heightSize).let { if (it == 0) defaultSizePx 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 - selectionOffsetPx, + viewPadding - selectionOffsetPx, + width - viewPadding + selectionOffsetPx, + height - viewPadding + selectionOffsetPx + ) + 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 = if (totalAmount > 0) (amount.toFloat() / totalAmount * 100).toInt() else 0 + + 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 = if (totalAmount > 0) (pieData.amount.toFloat() / totalAmount * 100).toInt() else 0 + 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_DP = 150 + private const val CHART_PADDING_RATIO = 0.12f + private const val SELECTION_OFFSET_DP = 8f + 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%%)" + } +} diff --git a/app/src/main/java/otus/homework/customview/chart/PieData.kt b/app/src/main/java/otus/homework/customview/chart/PieData.kt new file mode 100644 index 00000000..64eb26ef --- /dev/null +++ b/app/src/main/java/otus/homework/customview/chart/PieData.kt @@ -0,0 +1,22 @@ +package otus.homework.customview.chart + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Внутренняя модель данных для одного сектора диаграммы. + * + * @property category Название категории трат. + * @property amount Сумма трат в данной категории. + * @property startAngle Начальный угол сектора в градусах. + * @property sweepAngle Угловой размер сектора в градусах. + * @property color Цвет для отрисовки сектора. + */ +@Parcelize +internal data class PieData( + val category: String, + val amount: Int, + val startAngle: Float, + val sweepAngle: Float, + val color: Int +) : Parcelable diff --git a/app/src/main/java/otus/homework/customview/models/Expense.kt b/app/src/main/java/otus/homework/customview/models/Expense.kt new file mode 100644 index 00000000..a62d2124 --- /dev/null +++ b/app/src/main/java/otus/homework/customview/models/Expense.kt @@ -0,0 +1,20 @@ +package otus.homework.customview.models + +import com.google.gson.annotations.SerializedName + +/** + * Модель данных для расхода, загружаемого из внешнего источника. + * + * @property id Уникальный идентификатор расхода. + * @property name Название товара или услуги. + * @property amount Сумма расхода. + * @property category Категория, к которой относится расход. + * @property time Время совершения покупки (Unix timestamp). + */ +data class Expense( + @SerializedName("id") val id: Int, + @SerializedName("name") val name: String, + @SerializedName("amount") val amount: Int, + @SerializedName("category") val category: String, + @SerializedName("time") val time: Long +) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79ae6993..e5d32cf9 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -5,15 +5,31 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:padding="16dp" tools:context=".MainActivity"> - \ No newline at end of file + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127d..e3db0a76 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,17 @@ #FF018786 #FF000000 #FFFFFFFF + + #FF5722 + #E91E63 + #9C27B0 + #673AB7 + #3F51B5 + #2196F3 + #03A9F4 + #00BCD4 + #009688 + #4CAF50 + #8BC34A + #CDDC39 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9213c339..d9040215 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ Custom View + Всего + %1$d ₽ \ No newline at end of file diff --git a/art/nothing_select.png b/art/nothing_select.png new file mode 100644 index 00000000..d05d810f Binary files /dev/null and b/art/nothing_select.png differ diff --git a/art/screen_rotate.png b/art/screen_rotate.png new file mode 100644 index 00000000..1f447b87 Binary files /dev/null and b/art/screen_rotate.png differ diff --git a/art/select_sector.png b/art/select_sector.png new file mode 100644 index 00000000..a2859b8b Binary files /dev/null and b/art/select_sector.png differ