Наверное, каждый Android-разработчик на этапе обучения или в процессе выполнения задач в коммерческом проекте задумывался о создании своей CustomView без использования сторонних библиотек, с собственной отрисовкой, анимацией, а также хорошей оптимизацией, чтобы CustomView адаптировалась под переданные ей размеры, правильно отображалась в независимости от экрана пользователя.

Самый верный и действенный способ постичь Дзен в реализации CustomView является работа над диаграммами и графиками. Чем сложнее будет задуманная диаграмма, тем больше этапов будет пройдено в её реализации: начиная от собственного расчета размеров, заканчивая многоступенчатой анимацией при отрисовке. Каждый из вас способен сделать что-то своё, но порог входа для этого, я соглашусь, достаточно высокий. Поэтому необходим некоторый пример, который поможет разобраться в этом темном лихолесье неизвестности. 

Что ж, в этой статье мы пройдем все этапы реализации своей CustomView, с подробным описанием шагов, чтобы каждый смог повторить такое в своих проектах. Мы будем реализовывать кольцевую диаграмму для отображения какой-либо статистики, добавим много возможностей кастомизации диаграммы под любые виды задач, а также приправим это дело красивой анимацией отрисовки и сохранением состояния. Все будет написано на языке программирования Kotlin.

Для тех, кто хочет посмотреть на эту красоту сразу, поиграться в Android Studio самостоятельно, исходный код с подробным описанием выложен в публичный доступ на моем GitHub - AnalyticalPieChart


Постановка задачи

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

 «Да, да, вы не удивляйтесь»

Наша задача - создание CustomView (AnalyticalPieChart) кольцевой диаграммы, которая должна отображать процентное соотношение значений на круге в виде дуг разного цвета, а также показывать статистику в виде списка пар текстовых значений: название данных - числовое значение.

Теперь мы понимает нашу конечную цель, но присутствует ещё необходимость в некоторых требованиях к данной цели, чтобы достичь максимального результата для понимания в реализации. 

Список требований к нашей CustomView: 

  1. Адаптивность в отрисовке к любым, переданным значениям размеров.

  2. Самостоятельный расчет необходимого размера нашей CustomView.

  3. Возможность изменения ширины круга.

  4. Возможность изменения цветовой палитры.

  5. Возможность добавления расстояния между дугами на круге.

  6. Возможность изменения закругления концов дуг диаграммы.

  7. Возможность изменения размеров круговой диаграммы.

  8. Возможность изменения размеров текста для названия данных и его числового значения.

  9. Возможность изменения цвета текста для названия данных и его числового значения.

  10. Возможность изменения расстояния между текстом.

  11. Возможность изменения радиуса круга рядом с числовым значением данных.

  12. Собственная анимация.

  13. Собственная реализация сохранения состояния CustomView при configuration changes.

Требований получилось достаточно много, но это дает огромные возможности адаптировать и переиспользовать получившуюся CustomView под свою конкретную задачу.

Подготовительный этап

Для того, чтобы перейти к реализации нашей CustomView, необходим подготовительный этап, в котором мы добавим в наш проект некоторые хелперы - extensions и ресурсные файлы.

Extensions

Во всех расчетах View мы будем использовать значение единицы px. Для правильного отображения есть необходимость в переводе dp и sp в px. Поэтому добавляем следующие extension функции для Context.

app/src/main/java/com/example/analyticalpiechart/extension/Context.kt

/**
 * Context Extension для конвертирования значения в пиксели.
 * @property dp - значение density-independent pixels
 */
fun Context.dpToPx(dp: Int): Float {
    return dp.toFloat() * this.resources.displayMetrics.density
}

/**
 * Context Extension для конвертирования значения размера шрифта в пиксели.
 * @property sp - значение scale-independent pixels
 */
fun Context.spToPx(sp: Int): Float {
    return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp.toFloat(), this.resources.displayMetrics);
}

Далее добавим extension функцию хелпер в класс StaticLayout для отрисовки текста на нашей View.

app/src/main/java/com/example/analyticalpiechart/extension/StaticLayout.kt 

/**
 * StaticLayout Extension для удобства отрисовки текста.
 */
fun StaticLayout.draw(canvas: Canvas, x: Float, y: Float) {
    canvas.withTranslation(x, y) {
        draw(this)
    }
}

Model

Отрисовка круговой диаграммы состоит в отображении дуг. При отрисовке необходимо знать: начало дуги на круге, длину самой дуги. Поэтому нам необходима модель "темная лошадка", которая представляла бы собой модель дуги, которая уже в свою очередь могла рассчитать в процентном соотношении свое положение и длину от переданных значений, а также хранить дополнительную информацию: ширину, цвет, закругление углов и т.д. 
 
Создадим модель AnalyticalPieChartModel

app/src/main/java/com/example/analyticalpiechart/model/AnalyticalPieChartModel.kt

/**
 * Представляет собой модель хранения смежной информации о рисуемом объекте диаграммы.
 *
 * После добавления переменных в primary constructor, вызывается блок init {},
 * в котором переданные в модель значения ампроксимируются к значениям процента круговой диаграммы.
 *
 * Модель состоит и следующих параметров:
 * @property percentOfCircle - значение занимаемого процента круговой диаграммы.
 * @property percentToStartAt - значение положения на круговой диаграмме,
 * откуда должен начать отрисовываться объект.
 * @property colorOfLine - значение цвета для отрисовки линии объекта.
 * @property stroke - значение ширины линии объекта.
 * @property paint - объект кисти отрисовки
 * @property paintRound - закругление концов линии объекта.
 */
data class AnalyticalPieChartModel(
    var percentOfCircle: Float = 0F,
    var percentToStartAt: Float = 0F,
    var colorOfLine: Int = 0,
    var stroke: Float = 0F,
    var paint: Paint = Paint(),
    var paintRound: Boolean = true
) {
    /**
     * Блок, в котором значения преобразуются к приближенным значениям круговой диаграммы.
     * То есть в модель передается процент (от 0 до 100).
     */
    init {
        // Проверка на корректность переданного процента.
        if (percentOfCircle < 0 || percentOfCircle > 100) {
            percentOfCircle = 100F
        }

        // Расчет переданного значения на круговой диаграмме.
        percentOfCircle = 360 * percentOfCircle / 100

        // Проверка на корректность переданного процента.
        if (percentToStartAt < 0 || percentToStartAt > 100) {
            percentToStartAt = 0F
        }

        // Расчет переданного значения на круговой диаграмме.
        percentToStartAt = 360 * percentToStartAt / 100

        /** Установка своего цвета в случаи пропуска [colorOfLine]  */
        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);
        }
    }
}

SavedState

Кроме этого, мы в требованиях к нашей CustomView указали, что наша View должна самостоятельно сохранять свое состояние. Поэтому нам необходим свой класс, который будет наследоваться от BaseSavedState и имплементировать интерфейс Parcelable. Этот класс как раз-таки будет отвечать за сохранение и получение наших данных в onSaveInstanceState() и методе жизненного цикла View - onRestoreInstanceState()
 
Создадим класс AnalyticalPieChartState

app/src/main/java/com/example/analyticalpiechart/model/AnalyticalPieChartState.kt

/**
 * Собственный state для сохранения и восстановления данных
 */
class AnalyticalPieChartState(
    private val superSavedState: Parcelable?,
    val dataList: List<Pair<Int, String>>
    ) : BaseSavedState(superSavedState), Parcelable {
}

Добавление AnalyticalPieChart

Сперва необходимо создать интерфейс взаимодействия со View. Добавим интерфейс AnalyticalPieChartInterface, в котором будут написаны основные функции взаимодействия: setDataChart – для добавления данных, startAnimation – для старта анимации.

app/src/main/java/com/example/analyticalpiechart/customView/AnalyticalPieChartInterface.kt

/**
 * Интерфейс для взаимодействия с CustomView AnalyticalPieChart
 */
interface AnalyticalPieChartInterface {

    /**
     * Метод для добавления списка данных для отображения на графике.
     * @property list - список данных, тип которого мы можете поменять
     * на свою определенную модель.
     */
    fun setDataChart(list: List<Pair<Int, String>>)

    /**
     * Метод для активирования анимации прорисовки.
     */
    fun startAnimation()
}

Создадим нашу CustomView, которую будем в дальнейшем дополнять кодом, и имплементируем ранее созданный интерфейс AnalyticalPieChartInterface.

app/src/main/java/com/example/analyticalpiechart/customView/AnalyticalPieChart.kt

/**
 * CustomView AnalyticalPieChart
 *
 * Кольцевая диаграмма для отображения статистики объектов в процентном соотношении.
 */
class AnalyticalPieChart @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) :  View(context, attrs, defStyleAttr), AnalyticalPieChartInterface {

    /**
     * Имплиментируемый метод интерфейса взаимодействия [AnalyticalPieChartInterface].
     * Добавление данных в View.
     */
    override fun setDataChart(list: List<Pair<Int, String>>) {}

    /**
     * Имплиментируемый метод интерфейса взаимодействия [AnalyticalPieChartInterface].
     * Запуск анимации отрисовки View.
     */
    override fun startAnimation() {}
    
}

Вниманию тут нужно уделить лишь аннотации @JvmOverloads. Она информирует компилятор, что следует создать конструктор на основе предыдущего с дополнительным параметром со значением по умолчанию. Этим мы избегаем бойлерплейт кода

Файлы ресурсов

Добавим массив цветов для отображения данных.

app/src/main/res/values/strings.xml

<resources>
    <string name="app_name">Analytical Pie Chart</string>

    <string name="analyticalPieChartAmount">Всего</string>
  
    <string-array name="analyticalPieChartColorArray">
        <item>#E480F4</item>
        <item>#6CC3F3</item>
        <item>#7167ED</item>
        <item>#D9455F</item>
        <item>#6054EA</item>
    </string-array>
</resources>

Внутри конструкции <declare-styleable> задекларируем наши кастомные Attrs, которые позволят нам взаимодействовать с полями класса AnalyticalPieChart сразу в xml разметке. О том, что значит каждый из атрибутов, мы рассмотрим дальше, когда будем добавлять поля в CustomView.

app/src/main/res/values/attr.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="AnalyticalPieChart">
        <attr name="pieChartColors" format="reference"/>

        <attr name="pieChartMarginTextFirst" format="dimension"/>
        <attr name="pieChartMarginTextSecond" format="dimension"/>
        <attr name="pieChartMarginTextThird" format="dimension"/>
        <attr name="pieChartMarginSmallCircle" format="dimension"/>

        <attr name="pieChartCircleStrokeWidth" format="dimension"/>
        <attr name="pieChartCirclePadding" format="dimension"/>
        <attr name="pieChartCirclePaintRoundSize" format="boolean"/>
        <attr name="pieChartCircleSectionSpace" format="float"/>

        <attr name="pieChartTextCircleRadius" format="dimension"/>
        <attr name="pieChartTextAmountSize" format="dimension"/>
        <attr name="pieChartTextNumberSize" format="dimension"/>
        <attr name="pieChartTextDescriptionSize" format="dimension"/>

        <attr name="pieChartTextAmountColor" format="color"/>
        <attr name="pieChartTextNumberColor" format="color"/>
        <attr name="pieChartTextDescriptionColor" format="color"/>

        <attr name="pieChartTextAmount" format="string"/>
    </declare-styleable>
</resources>

Добавим нашу View в xml разметку Activity.

app/src/main/res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:orientation="vertical"
        android:layout_marginHorizontal="20dp">
        <com.example.analyticalpiechart.customView.AnalyticalPieChart
            android:id="@+id/analyticalPieChart_1"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:pieChartColors="@array/analyticalPieChartColorArray"
            app:pieChartTextAmountColor="?android:attr/textColor"
            app:pieChartTextNumberColor="?android:attr/textColor"
            app:pieChartTextDescriptionColor="?android:attr/textColorHint"
            app:pieChartTextNumberSize="16sp"
            app:pieChartTextDescriptionSize="14sp"
            app:pieChartTextAmount="@string/analyticalPieChartAmount"
            app:pieChartCircleStrokeWidth="6dp"
            app:pieChartCircleSectionSpace="2"
            android:layout_marginTop="12dp"/>
    </LinearLayout>
</ScrollView>

Напоминаю, кто хочет разобраться в проекте самостоятельно, всегда прошу, исходники в репозитории GitHub - AnalyticalPieChart

Реализация

Добавление полей в AnalyticalPieChart. Init блок.

CustomView по требованиям должна быть максимально адапативной и кастомизируемой под любой тип значит. Это означает, что нам необходимо достаточно много полей, которые бы отвечали за то или иное изменение при отображении. Добавим поля в наш класс AnalyticalPieChart.

app/src/main/java/com/example/analyticalpiechart/customView/AnalyticalPieChart.kt

/**
 * CustomView AnalyticalPieChart
 *
 * Кольцевая диаграмма для отображения статистики объектов в процентном соотношении.
 *
 * AnalyticalPieChart адаптируется под любое значение высоты и ширины, которое передается
 * данной View от parent.
 * Проверено на всех возможных разрешениях экрана (ldpi, mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi).
 *
 * Для удобства использования и сборки в DI, класс имплементирует интерфейс взаимодействия [AnalyticalPieChartInterface]
 *
 *
 * AnalyticalPieChart обладает огромным количество настроек отображения.
 * Все расчеты производятся в пикселях, необходимо это учитывать при изменении кода.
 *
 * @property marginTextFirst   - значение отступа между числом и его описанием.
 * @property marginTextSecond  - значение отступа между объектами, где объект - число и описание.
 * @property marginTextThird   - значение отступа между числом и его описанием общего результата.
 * @property marginSmallCircle - значение отступа между числом и маленьким кругом.
 * @property marginText        - значение суммы отступов [marginTextFirst] и [marginTextSecond].
 * @property circleRect        - объект отрисовки круговой диаграммы.
 * @property circleStrokeWidth - значение толщины круговой диаграммы.
 * @property circleRadius      - значение радиуса круговой диаграммы.
 * @property circlePadding     - padding для всех сторон круговой диаграммы.
 * @property circlePaintRoundSize - значение округления концов линий объектов круга.
 * @property circleSectionSpace   - значение расстояние-процент между линиями круга.
 * @property circleCenterX        - значение координаты X центра круговой диаграммы.
 * @property circleCenterY        - значение координаты Y центра круговой диаграммы.
 * @property numberTextPaint      - объект кисти отрисовки текста чисел.
 * @property descriptionTextPain  - объект кисти отрисовки текста описания.
 * @property amountTextPaint      - объект кисти отрисовки текста результата.
 * @property textStartX           - значение координаты X, откуда отрисовывается текст.
 * @property textStartY           - значение координаты Y, откуда отрисовывается текст.
 * @property textHeight           - значение высоты текста.
 * @property textCircleRadius     - значение радиуса малого круга около текста числа.
 * @property textAmountStr        - строка результата.
 * @property textAmountY          - значение координаты Y, откуда отрисовывается результирующий текст.
 * @property textAmountXNumber    - значение координаты X, откуда отрисовывается результирующий текст числа.
 * @property textAmountXDescription     - значение координаты X, откуда отрисовывается описание результата.
 * @property textAmountYDescription     - значение координаты Y, откуда отрисовывается описание результата.
 * @property totalAmount          - итоговый результат - сумма значений Int в [dataList].
 * @property pieChartColors       - список цветов круговой диаграммы в виде текстового представления.
 * @property percentageCircleList - список моделей для отрисовки.
 * @property textRowList          - список строк, которые необходимо отобразить.
 * @property dataList             - исходный список данных.
 * @property animationSweepAngle  - переменная для анимации.
 */
class AnalyticalPieChart @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) :  View(context, attrs, defStyleAttr), AnalyticalPieChartInterface {

    /**
     * Базовые значения для полей и самой [AnalyticalPieChart]
     */
    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 = "AnalyticalPieChartArrayData"

        /* Процент ширины для отображения текста от общей ширины View */
        private const val TEXT_WIDTH_PERCENT = 0.40

        /* Процент ширины для отображения круговой диаграммы от общей ширины View */
        private const val CIRCLE_WIDTH_PERCENT = 0.50

        /* Базовые значения ширины и высоты View */
        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<String> = listOf()
    private var percentageCircleList: List<AnalyticalPieChartModel> = listOf()
    private var textRowList: MutableList<StaticLayout> = mutableListOf()
    private var dataList: List<Pair<Int, String>> = listOf()
    private var animationSweepAngle: Int = 0

    /**
     * В INIT блоке инициализируются все необходимые поля и переменные.
     * Необходимые значения вытаскиваются из специальных Attr тегов
     * (<declare-styleable name="AnalyticalPieChart">).
     */
    init {
        // Задаем базовые значения и конвертируем в px
        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

        // Инициализируем поля View, если Attr присутствуют
        if (attrs != null) {
            val typeArray = context.obtainStyledAttributes(attrs, R.styleable.AnalyticalPieChart)

            // Секция списка цветов
            val colorResId = typeArray.getResourceId(R.styleable.AnalyticalPieChart_pieChartColors, 0)
            pieChartColors = typeArray.resources.getStringArray(colorResId).toList()

            // Секция отступов
            marginTextFirst = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartMarginTextFirst, marginTextFirst)
            marginTextSecond = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartMarginTextSecond, marginTextSecond)
            marginTextThird = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartMarginTextThird, marginTextThird)
            marginSmallCircle = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartMarginSmallCircle, marginSmallCircle)

            // Секция круговой диаграммы
            circleStrokeWidth = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartCircleStrokeWidth, circleStrokeWidth)
            circlePadding = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartCirclePadding, circlePadding)
            circlePaintRoundSize = typeArray.getBoolean(R.styleable.AnalyticalPieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize)
            circleSectionSpace = typeArray.getFloat(R.styleable.AnalyticalPieChart_pieChartCircleSectionSpace, circleSectionSpace)

            // Секция текста
            textCircleRadius = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartTextCircleRadius, textCircleRadius)
            textAmountSize = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartTextAmountSize, textAmountSize)
            textNumberSize = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartTextNumberSize, textNumberSize)
            textDescriptionSize = typeArray.getDimension(R.styleable.AnalyticalPieChart_pieChartTextDescriptionSize, textDescriptionSize)
            textAmountColor = typeArray.getColor(R.styleable.AnalyticalPieChart_pieChartTextAmountColor, textAmountColor)
            textNumberColor = typeArray.getColor(R.styleable.AnalyticalPieChart_pieChartTextNumberColor, textNumberColor)
            textDescriptionColor = typeArray.getColor(R.styleable.AnalyticalPieChart_pieChartTextDescriptionColor, textDescriptionColor)
            textAmountStr = typeArray.getString(R.styleable.AnalyticalPieChart_pieChartTextAmount) ?: ""

            typeArray.recycle()
        }

        circlePadding += circleStrokeWidth

        // Инициализация кистей View
        initPains(amountTextPaint, textAmountSize, textAmountColor)
        initPains(numberTextPaint, textNumberSize, textNumberColor)
        initPains(descriptionTextPain, textDescriptionSize, textDescriptionColor, true)
    }

    /**
     * Имплиментируемый метод интерфейса взаимодействия [AnalyticalPieChartInterface].
     * Добавление данных в View.
     */
    override fun setDataChart(list: List<Pair<Int, String>>) {
        dataList = list
        calculatePercentageOfData()
    }

    /**
     * Имплиментируемый метод интерфейса взаимодействия [AnalyticalPieChartInterface].
     * Запуск анимации отрисовки View.
     */
    override fun startAnimation() {}

    /**
     * Метод инициализации переданной TextPaint
     */
    private fun initPains(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)
    }
}

Комментарий к каждому полю вынесен перед классом для того, чтобы было удобнее копировать. Также стоит пояснить, что в начале Init блока значения размеров и цвета текста заданы с дефолтными значениями. Это сделано для того, чтобы объекты TextPaint были точно проинициализированы. В случае если attr будут отсутствовать - никакой текст не отобразиться на экране.

В блоке If мы вытаскиваем значения переданные View из xml разметки, не забывая при этом очищать объект TypeArray. После этого блока стоит обратить внимание на то, что к circlePadding мы прибавляем circleStrokeWidth (толщина круга). Это сделано для того, чтобы учитывать в отступах сразу толщину круга. Если бы этого не было, то круговая диаграмма, при нулевом circlePadding, выходила бы за края.

Специально для вас я подготовил шаблон CustomView, в котором отображается: какое полей и за что отвечает. На этой схеме указаны не все поля, которые добавлены в нашу View, так как некоторые поля нужны лишь для сохранения расчетов и отображения. Кроме того, изменения цвета и ширины текста тоже не указаны на данном шаблоне, поскольку они достаются в Init блоке и записываются сразу в объекты TextPaint.

OnMeasure()

Метод OnMeasure() — это метод, в котором View задает свои размеры, являясь важной частью контракта между view и layout. onMeasure() вызывается после вызова метода measure().  Данный метод вызывается layout-ом для всех дочерних view, чтобы определить их размеры.

Мы поставили себе задачу, что наша CustomView должна адаптироваться под любой размер и самостоятельно рассчитывать под себя место. Поэтому необходимо разобраться в том, какие могут быть ситуации при отрисовке.

Специально для вас я подготовил пример, на котором мы можем заметить, что данных может быть очень много. Для отрисовки этих данных соответственно необходимо место на нашей View. Поэтому высоту CustomView мы будем рассчитывать с помощью измерения высоты каждого текста, не забывая при этом учитывать отступы. Диаграмма же будет подстраиваться под рассчитанную нами высоту, что позволит отображать сам круг в любом размере, в независимости от ширины.

Расчет размеров

Перед тем, как реализовывать расчет размеров нашей View в методе onMeasure(), нам необходимы дополнительные методы, которые позволят нам совместить наши расчеты вместе и не дублировать код. Будем собирать метод onMeasure() по кусочкам.

Сперва добавим метод resolveDefaultSize. Данный метод служит для поверки режима, с которым передается значение, в котором закодирована информация о предпочтениях layout к размерам View, и установки базового значения View в случаи, если у parent layout нет предпочтений к размерам нашей CustomView. В остальных случаях мы беспрекословно слушаемся наш parent layout, и возвращаем размер, который от нас требуется.

/**
 * Метод получения размера View по переданному Mode.
 */
private fun resolveDefaultSize(spec: Int, defValue: Int): Int {
  return when(MeasureSpec.getMode(spec)) {
    MeasureSpec.UNSPECIFIED -> context.dpToPx(defValue).toInt() // Размер не определен parent layout
    else -> MeasureSpec.getSize(spec) // Слушаемся parent layout
  }
}

Так как мы будет отрисовывать текст самостоятельно, нам необходим некоторый контейнер, который позволил бы нам корректно отображать текст. Для такого случая существует класс StaticLayout, который создаёт разметку для текста, который не будет редактироваться после создания. С помощью такого контейнера мы сможем вычислить высоту текста.

Особенностью StaticLayout является и то, что данный класс самостоятельно позаботится о размещении текса с переносами строк в заданной ширине. Пример того, как это работает можно посмотреть на следующей схеме.

Добавим метод getMultilineText, который будет нам создавать и возвращать объекты класса StaticLayout с заданными параметрами. Более подробно о возможностях данного класса вы сможете найти в официальной документации.

/**
 * Метод создания [StaticLayout] с переданными значениями
 */
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()
}

Озаботившись получением высоты какого-либо текста, мы теперь можем посчитать высоту для всего текста View, который необходимо отобразить. Добавим метод getTextViewHeight, в который мы передаем максимальную ширину, которую текст может занимать на экране.

Кроме того, текст необходимо и отрисовать. Поэтому объекты StaticLayout мы будем сохранять в список textRowList, который будет использоваться при отрисовки. Если же мы созданные объекты не сохраняли бы в список, то при отрисовке нам нужно было бы опять их создавать, а при отображении нам ни в коем случае нельзя создавать какие-либо объекты. Ответ на вопрос «Почему?» будет дальше в секции про отрисовку.

/**
 * Метод расчёта высоты объектов, где объект - это число и его описание.
 * Добавление объекта в список строк для отрисовки [textRowList].
 */
private fun getTextViewHeight(maxWidth: Int): Int {
    var textHeight = 0
    // Проходимся по всем данным, которые передали в View
    dataList.forEach {
        // Создаем объект StaticLayout для значения данных
        val textLayoutNumber = getMultilineText(
            text = it.first.toString(),
            textPaint = numberTextPaint,
            width = maxWidth
        )
        // Создаем объект StaticLayout для описания значения данных
        val textLayoutDescription = getMultilineText(
            text = it.second,
            textPaint = descriptionTextPain,
            width = maxWidth
        )
        // Сохраняем объекты в список для отрисовки
        textRowList.apply {
            add(textLayoutNumber)
            add(textLayoutDescription)
        }
        // Складываем высоты текстов
        textHeight += textLayoutNumber.height + textLayoutDescription.height
    }

    return textHeight
}

Теперь мы можем рассчитать высоту AnalyticalPieChart с помощью метода calculateViewHeight. Сначала мы получаем размеры высота View из созданного ранее метода resolveDefaultSize. Далее находим высоту всего текста, учитывая отступы между разными строками. И уже высчитываем высоту самого View, учитывая padding.

В конце мы делаем проверку, если рассчитанная нами высота View с padding и margin больше, чем высота, которую нам дает наш parent, то мы будем возвращать именно нами рассчитанную высоту. Данная проверка делается для того, чтобы избежать случаев, когда у нас немного текста, и он сможет поместиться во View без дополнительного размера.

/**
 * Метод расчёта высоты всего текста, включая отступы.
 */
private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int {
    // Получаем высоту, которую нам предлагает parent layout
    val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT)
    // Высчитываем высоту текста с учетом отступов
    textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt()

    // Добавляем к значению высоты вертикальные padding View
    val textHeightWithPadding = textHeight + paddingTop + paddingBottom
    return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight
}

Заключительным этапом будет как раз-таки переопределение метода onMeasure. Добавим его в AnalyticalPieChart.

/**
 * Метод жизненного цикла View.
 * Расчет необходимой ширины и высоты View.
 */
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

    // Очищаем список строк для текста
    textRowList.clear()

    // Получаем ширину View
    val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH)
    
    // Высчитываем ширину, которую будет занимать текст
    val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT)
    
    // Вычисляем необходимую высоту для нашей View
    val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt())

    // Координаты X и Y откуда будет происходить отрисовка текста
    textStartX = initSizeWidth - textTextWidth.toFloat()
    textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2

    calculateCircleRadius(initSizeWidth, initSizeHeight)

    setMeasuredDimension(initSizeWidth, 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
    }

    // Установка расположения круговой диаграммы на View 
    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
}

/**
 * Метод обертки текста в класс [Rect]
 */
private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect {
    val bounds = Rect()
    textPaint.getTextBounds(text, 0, text.length, bounds)
    return bounds
}

Сначала мы отчищаем список строк для отображения. Получаем ширину View через метод resolveDefaultSize, берем процент от этой ширины под наш текст и рассчитываем высоту нашей View. Здесь же находим X и Y координаты откуда будет выводиться текст. И обязательно вызываем setMeasuredDimension, в который передаем размеры нашей View. Если не вызывать этот метод, будет вызвана ошибка IllegalStateException.

Метод calculateCircleRadius() - рассчитывает размеры круговой диаграммы, учитывая размеры AnalyticalPieChart и отступы, координаты для вывода результата в центре диаграммы, а также сразу устанавливает расположение диаграммы на View.

OnDraw() - отрисовка AnalyticalPieChart

Как я говорил ранее, при отрисовке (метод onDraw()) нам необходимо избегать создание объектов и сложных вычислений. Так как создание новых объектов может спровоцировать сборку мусора, что приводит к паузам и лагам пользовательского UI. Именно поэтому все необходимые вычисления и инициализации объектов мы выполняли до отрисовки самой View.

Безотлагательно переходим к отображению нашей View на экране пользователя. Нам необходимо отображать две различные области: диаграмма, данные. Поэтому мы вынесем отрисовку этих областей в разные методы, чтобы код был более читабельным и удобным для изменений.

Отрисовка диаграммы

Отображение диаграммы тесно связана с анимацией, поэтому добавим сначала ValueAnimator, который будет проходить значения от 0 до 360 (полный оборот круга). Для более идеальной анимации воспользуемся интерпретатором FastOutSlowInInterpolator, который ускоряет анимацию в начале и замедляет в конце. Значения, которые будет проходить ValueAnimator, мы будем запоминать в переменной animationSweepAngle.

Важно отметить, что в нашем ValueAnimator кроме запоминания значения, мы также должны вызвать метод invalidate(). Это метод, который инициирует принудительную перерисовку определенного представления. Проще говоря, метод invalidate() следует вызывать в случае, когда требуется изменение внешнего вида представления. У нас изменилось значение - необходима перерисовка.

Вообще каждый из вас может поэкспериментировать с анимацией, подобрать те значения, которые вы считаете нужным. 

Метод startAnimation будет выглядеть следующим образом.

/**
 * Имплиментируемый метод интерфейса взаимодействия [AnalyticalPieChartInterface].
 * Запуск анимации отрисовки View.
 */
override fun startAnimation() {
    // Проход значений от 0 до 360 (целый круг), с длительностью - 1.5 секунды
    val animator = ValueAnimator.ofInt(0, 360).apply {
        duration = 1500 // длительность анимации в миллисекундах
        interpolator = FastOutSlowInInterpolator() // интерпретатор анимации
        addUpdateListener { valueAnimator ->
            // Обновляем значение для отрисовки диаграммы
            animationSweepAngle = valueAnimator.animatedValue as Int
            // Принудительная перерисовка
            invalidate()
        }
    }
    animator.start()
}

После того, как мы разобрались с анимацией отображения, перейдем непосредственно к самому отображению диаграммы. Так как ранее мы уже рассчитали все значения для каждой дуги, остается лишь пройтись по этим дугам и отобразить на экране. Особенность будет заключаться в том, что отображение дуг зависит от переменной animationSweepAngle, которая, повторюсь, проходит значение от 0 до 360. По мере того, как animationSweepAngle будет увеличиваться, дуги, процент которых попадает под это значение, будут отображаться на экране.

Добавим в AnalyticalPieChart метод drawCircle(canvas: Canvas), в который мы передаем Canvas для отображения. Метод будет выглядеть следующим образом.

/**
 * Метод отрисовки круговой диаграммы на Canvas.
 */
private fun drawCircle(canvas: Canvas) {
    // Проходимся по дугам круга
    for(percent in percentageCircleList) {
        // Если процент дуги попадает под угол отрисовки (animationSweepAngle)
        // Отображаем эту дугу на Canvas
        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)
        }
    }
}

Отрисовка данных

Отображение же данных более сложная задача, чем отображение круговой диаграммы. Сперва надо понять, что мы уже создали объекты текста и записали в список textRowList. Но по нашей задумке, нам необходимо отобразить со значением данных маленький круг с цветом этих данных. Кроме того, данные у нас разделяются таким образом, что каждый второй элемент в списке textRowList является как раз-таки значением данных. Нам необходимо отслеживать на какой высоте отображать наш текст и круг, поэтому мы будем увеличивать textBuffY (хранит координату Y для отображения текста).

В конце нам также необходимо отобразить результат в центре круговой диаграммы. Благо сделать это будет просто, так как центр круга мы уже рассчитали, необходимо лишь учесть отступ между значением и текстом результата. 

Добавим метод drawText(canvas: Canvas), и он будет выглядеть следующим образом.

/**
 * Метод отрисовки всего текста диаграммы на Canvas.
 */
private fun drawText(canvas: Canvas) {
    // Отслеживаем Y координату при отображении текста
    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]) }
            )
            // Прибавляем высоту и отступ к координате Y
            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)
}

Мы уже движемся к финалу отображения нашей View на экране. Нам необходимо просто вызвать наши созданные ранее методы в onDraw(). Поэтому метод onDraw() будет выглядеть следующим образом.

/**
 * Метод жизненного цикла View.
 * Отрисовка всех необходимых компонентов на Canvas.
 */
override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    drawCircle(canvas)
    drawText(canvas)
}

Чтобы увидеть результат, нам остается лишь в методе onStart() нашей Activity отправить данные в AnalyticalPieChart и вызвать старт анимации. Я делаю вызовы для примера, где и как вызывать их решайте сами - как вам будет удобно.

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

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

    override fun onStart() {
        super.onStart()

        binding.analyticalPieChart1.setDataChart(
            listOf(Pair(4, "Свои проекты"), Pair(6, "Соместные проекты"), Pair(6, "Проекты поддержанные группой людей"), Pair(2, "Неизвестные проекты"))
        )
    }
}

Запускаем наше приложение и получаем красивый результат.

Сохранение состояния

Осталось совсем немного для завершения нашей CustomView. Нам необходимо сохранять состояние нашей View при тех случаях, когда происходит configuration changes (поворот экрана, смена языковой темы и т. д.).

Ранее мы уже сделали базу для сохранения нашего состояния в виде класса AnalyticalPieChartState. Сохранение состояния будет заключаться в запоминании первичных данных, то есть список dataList.  

Переопределим методы onSaveInstanceState() и onRestoreInstanceState() следующим образом. 

/**
 * Восстановление данных из [AnalyticalPieChartState]
 */
override fun onRestoreInstanceState(state: Parcelable?) {
    val analyticalPieChartState = state as? AnalyticalPieChartState
    super.onRestoreInstanceState(analyticalPieChartState?.superState ?: state)

    dataList = analyticalPieChartState?.dataList ?: listOf()
}

/**
 * Сохранение [dataList] в собственный [AnalyticalPieChartState]
 */
override fun onSaveInstanceState(): Parcelable {
    val superState = super.onSaveInstanceState()
    return AnalyticalPieChartState(superState, dataList)
}

Если же состояние вдруг не пришло или отсутствует, то в dataList мы записываем пустой список.

Заключение

Что ж, я очень надеюсь, что данная статья дала кому-то понимание того, как можно создавать свои CustomView. Всегда необходимо начинать с постановки задачи, если будет хорошо расписан результат, то и разработка будет вестись проще и быстрее.

В итоге мы получили CustomView - AnalyticalPieChart, которая соответствует всем требованиям, которые мы выдвинули с самого начала. Она адаптируется самостоятельно под любой размер, отображает и диаграмму, и данные; позволяет изменить очень много параметров, которые влияют на итоговый результат; дает возможность использовать её в разных типах задач. 

Всем спасибо, кто уделил внимания к данной статье. Материал получился достаточно объемный, так как необходимо объяснять все, из-за чего могут возникнуть вопросы. Если было что-то непонятно или вы не уловили суть того или иного действия, всегда прошу в комментарии к данной статье, постараюсь отписаться каждому.

Комментарии (5)


  1. Rusrst
    22.04.2023 17:39
    +1

    Уууу, это просто огонь!!! Спасибо, а то однотипный шлак который в последнее время здесь выдают за кастомные вью немного поднадоел. Единственное сам текст (список) наверное было бы проще рисовать не внутри кастомной вью, а руками самостоятельно разработчику - не всем нужны текста, кому-то нужна только диаграмма, кто-то текст захочет кастомизировать. С другой стороны это вью, а не compose, можно и отнаследоваться при желании (но тот же drawtext private а не protected)

    Для полноты картины только touchevents не хватило, но и так очень круто!!!


    1. Alex-tech-it Автор
      22.04.2023 17:39

      Благодарю за лесный комментарий. Я сам устал от того, что на ру сегменте мало материала по отрисовке чего-то своего. Наверное, поэтому возникло огромное желание написать статью, чтобы каждый смог это повторить без каких-либо проблем.

      По поводу touch ивентов я тоже задумывался: показать как можно их использовать, но тогда статья была бы точно на час чтения. Думаю, что выложу продолжение с более углубленной информацией по теме.


      1. Rusrst
        22.04.2023 17:39

        Ну в touchevents там то ничего сложного по сути, но вот математики будет много - градус в который попали, расстояние.

        Если планируете ещё писать, можно про кастомные view group, про них информации ещё меньше. А там ведь всякие dispatchdraw, dispatchtouchevent, layout larams свои, то же есть о чем рассказать. Да и позволяют они тоже очень много.


  1. quaer
    22.04.2023 17:39
    +1

    paint.strokeCap = Paint.Cap.ROUND;

    Это свойство не работает на некоторых мобильниках, линии не закругляются.

    Как вы подбирали цвета?


    1. Alex-tech-it Автор
      22.04.2023 17:39

      Не знал, что данное свойство не работает на некоторых экранах, хотя тестил вроде бы на всех возможных. Благодарю, что заметили и указали на это, постараюсь исправить так, чтобы можно использовать во всех случаях.

      По поводу цветов, я подбирал цвета самостоятельно. Главное в выборе цветов ориентироваться либо на контраст, либо на монотоность.

      Спасибо, что прокомментировали статью. Надеюсь, что ответил на ваш вопрос сполна.