Custom View - подробно

Жизненный цикл кастомных View

Как и у любого элемента, кастомные View проходят несколько фаз жизненного цикла. Важно понимать эти фазы, чтобы избежать утечек памяти и потенциальных ошибок:

  • onAttachedToWindow() — вызывается, когда View добавляется на экран. Здесь удобно подписываться на данные, запускать анимации или подготавливать ресурсы, которые нужны для отображения View.
  • onDetachedFromWindow() — вызывается, когда View удаляется из иерархии. На этом этапе стоит освобождать ресурсы, отменять подписки, чтобы избежать утечек памяти.
  • onMeasure() — метод, отвечающий за определение размеров View. Если кастомная View имеет фиксированные размеры или зависит от определенных значений, этот метод стоит переопределить.
  • onLayout() — метод, в котором определяется, где будет расположена View относительно родителя.
  • onDraw() — непосредственно рендеринг View.

Обратите внимание, что при сложных анимациях или большом количестве графики работа в onDraw() может привести к лагам и излишнему потреблению ресурсов.

Пример использования жизненного цикла

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    // Подписка на изменения данных
    startObservingData()
}

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    // Отмена подписок и очистка ресурсов
    stopObservingData()
}

Важные моменты: onMeasure и onLayout

onMeasure() и onLayout() — это методы, отвечающие за размеры и положение вьюшек.

В onMeasure() стоит реализовать логику для установки ширины и высоты кастомного компонента.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // Получаем рекомендованные размеры от родительского View
    val desiredWidth = paddingStart + paddingEnd + circleRadius.toInt() * 2
    val desiredHeight = paddingTop + paddingBottom + circleRadius.toInt() * 2

    // Результирующие размеры
    val width = resolveSize(desiredWidth, widthMeasureSpec)
    val height = resolveSize(desiredHeight, heightMeasureSpec)

    setMeasuredDimension(width, height)
}

В onLayout() задается расположение вложенных элементов для сложных кастомных View, состоящих из нескольких элементов.

Возможные сайд-эффекты и оптимизация

Утечки памяти

Кастомные View часто подписываются на данные и могут вызывать утечки памяти, если подписка не отменяется в onDetachedFromWindow(). Особенно это актуально при использовании LiveData или Flow.

Проблемы с производительностью

Если кастомная View требует частой перерисовки, стоит оптимизировать код в onDraw(). Используйте:

  • Кэширование — избегайте создания новых объектов во время отрисовки, особенно Bitmap.
  • Canvas API — старайтесь минимизировать количество операций с Canvas.

Поддержка всех экранов

Размеры и цвета должны быть адаптивными, поддерживать режимы nightMode, DPI, чтобы кастомные View выглядели корректно на всех устройствах.

onMeasure

Метод onMeasure в Android управляет размерами вьюшки и принимает два параметра: widthMeasureSpec и heightMeasureSpec. Эти параметры представляют собой спецификации размеров (specs), которые сообщают вьюшке, какие размеры ей можно занять. Спеки помогают определить размер вьюшки в зависимости от ее содержимого, родителя и других ограничений.

Спецификации размеров: что это и для чего они нужны?

Каждая спецификация размера (MeasureSpec) включает в себя два параметра:

  1. Режим измерения (MeasureSpec mode).
  2. Значение измерения (точный размер или максимальное ограничение).

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

Режимы измерения (MeasureSpec Mode)

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

  1. EXACTLY — Вьюшке задается точный размер. Этот режим обычно используется, когда у View ширина или высота заданы явно (match_parent или точное значение в dp).
  2. AT_MOST — Вьюшке задается максимальный размер. Здесь она может быть меньше или равна указанному значению, но не больше. Этот режим обычно используется, когда размер View задан как wrap_content, но родитель накладывает ограничения.
  3. UNSPECIFIED — Вьюшка может быть любого размера, который ей нужен. Этот режим встречается реже, но используется, когда родитель не имеет специальных ограничений на размер вьюшки (например, внутри ScrollView).

Примеры работы с режимами в onMeasure

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

Пример реализации onMeasure с разбором режимов

Рассмотрим пример, где мы будем использовать разные режимы измерения, чтобы задать View различные размеры в зависимости от ограничений.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // Получаем режим и размер из спецификаций для ширины и высоты
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)

    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)

    // Предпочтительный размер (в данном случае квадрат 200x200)
    val desiredSize = 200

    // Рассчитываем ширину
    val width = when (widthMode) {
        MeasureSpec.EXACTLY -> widthSize            // Точный размер, заданный родителем
        MeasureSpec.AT_MOST -> minOf(desiredSize, widthSize)  // Максимум до указанного значения
        MeasureSpec.UNSPECIFIED -> desiredSize      // Без ограничений — ставим предпочтительный
        else -> desiredSize
    }

    // Рассчитываем высоту
    val height = when (heightMode) {
        MeasureSpec.EXACTLY -> heightSize
        MeasureSpec.AT_MOST -> minOf(desiredSize, heightSize)
        MeasureSpec.UNSPECIFIED -> desiredSize
        else -> desiredSize
    }

    // Устанавливаем вычисленные размеры для View
    setMeasuredDimension(width, height)
}

Пример использования onMeasure с адаптацией под wrap_content и match_parent

В этом примере создадим View, которая будет изменять свой размер в зависимости от того, как указаны размеры у родителя:

  • Если ширина или высота установлены в wrap_content, то вьюшка выберет размеры, которые она считает "достаточными".
  • Если установлено match_parent или фиксированное значение, то она подстроится под родителя.
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val minWidth = 100
    val minHeight = 100

    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val finalWidth = when (widthMode) {
        MeasureSpec.EXACTLY -> widthSize
        MeasureSpec.AT_MOST -> minOf(minWidth, widthSize)
        MeasureSpec.UNSPECIFIED -> minWidth
        else -> minWidth
    }

    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)
    val finalHeight = when (heightMode) {
        MeasureSpec.EXACTLY -> heightSize
        MeasureSpec.AT_MOST -> minOf(minHeight, heightSize)
        MeasureSpec.UNSPECIFIED -> minHeight
        else -> minHeight
    }

    setMeasuredDimension(finalWidth, finalHeight)
}

Метод resolveSize

Для упрощения расчета конечного размера можно использовать метод resolveSize. Он учитывает режим измерения и позволяет задать предпочтительный размер для wrap_content случаев.

Пример использования resolveSize:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val desiredWidth = 200
    val desiredHeight = 200

    val width = resolveSize(desiredWidth, widthMeasureSpec)
    val height = resolveSize(desiredHeight, heightMeasureSpec)

    setMeasuredDimension(width, height)
}

onLayout

Метод onLayout в Android отвечает за позиционирование дочерних элементов внутри кастомного View или ViewGroup. Он вызывается после onMeasure и используется для того, чтобы задать положение (координаты) каждого дочернего элемента внутри ViewGroup, а также саму вьюшку внутри родительского контейнера.

onLayout позволяет:

  1. Определять и задавать координаты дочерних View внутри кастомной ViewGroup.
  2. Избегать проблем с наложением и контролировать отступы между элементами.
  3. Создавать кастомные компоновки (например, сетку, шахматное расположение, круговую компоновку).
  4. Оптимизировать производительность, особенно если требуется переопределить стандартные методы позиционирования.

Основные принципы работы onLayout

Сигнатура метода:

override fun onLayout(
    changed: Boolean,
    left: Int,
    top: Int,
    right: Int,
    bottom: Int
) {
    // Позиционирование дочерних View
}

Аргументы left, top, right и bottom указывают на границы текущей View относительно родительского контейнера.

Простой пример: расположение дочерних View в ряд

Рассмотрим, как разместить дочерние элементы в горизонтальный ряд с одинаковым расстоянием между ними:

class HorizontalLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        var totalWidth = 0
        var maxHeight = 0

        for (i in 0 until childCount) {
            val child = getChildAt(i)
            measureChild(child, widthMeasureSpec, heightMeasureSpec)
            totalWidth += child.measuredWidth
            maxHeight = maxOf(maxHeight, child.measuredHeight)
        }

        setMeasuredDimension(resolveSize(totalWidth, widthMeasureSpec), resolveSize(maxHeight, heightMeasureSpec))
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        var currentLeft = left

        for (i in 0 until childCount) {
            val child = getChildAt(i)
            val childWidth = child.measuredWidth
            val childHeight = child.measuredHeight

            // Задаем позицию для каждого дочернего элемента
            child.layout(
                currentLeft, 
                top, 
                currentLeft + childWidth, 
                top + childHeight
            )

            // Смещаемся вправо для следующего элемента
            currentLeft += childWidth
        }
    }
}

Как работает onLayout в этом примере:

  1. Мы итерируемся по каждому дочернему элементу.
  2. Определяем позицию left и right для каждого элемента, обновляя значение currentLeft.
  3. Вызываем child.layout(left, top, right, bottom), чтобы установить координаты элемента.

Пример позиционирования дочерних элементов в два столбца

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

class TwoColumnLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        var totalHeight = 0
        val columnWidth = MeasureSpec.getSize(widthMeasureSpec) / 2

        for (i in 0 until childCount step 2) {
            val firstChild = getChildAt(i)
            val secondChild = if (i + 1 < childCount) getChildAt(i + 1) else null

            measureChild(firstChild, MeasureSpec.makeMeasureSpec(columnWidth, MeasureSpec.EXACTLY), heightMeasureSpec)
            secondChild?.let {
                measureChild(it, MeasureSpec.makeMeasureSpec(columnWidth, MeasureSpec.EXACTLY), heightMeasureSpec)
            }

            totalHeight += firstChild.measuredHeight.coerceAtLeast(secondChild?.measuredHeight ?: 0)
        }

        setMeasuredDimension(resolveSize(MeasureSpec.getSize(widthMeasureSpec), widthMeasureSpec), resolveSize(totalHeight, heightMeasureSpec))
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        var currentTop = top
        val columnWidth = (right - left) / 2

        for (i in 0 until childCount step 2) {
            val firstChild = getChildAt(i)
            val secondChild = if (i + 1 < childCount) getChildAt(i + 1) else null

            val childHeight = firstChild.measuredHeight.coerceAtLeast(secondChild?.measuredHeight ?: 0)

            // Располагаем первый элемент в левом столбце
            firstChild.layout(left, currentTop, left + columnWidth, currentTop + firstChild.measuredHeight)

            // Располагаем второй элемент в правом столбце, если он есть
            secondChild?.layout(left + columnWidth, currentTop, right, currentTop + secondChild.measuredHeight)

            // Смещаемся вниз на высоту самого высокого элемента в ряду
            currentTop += childHeight
        }
    }
}

Разбор решения:

  1. В onMeasure мы измеряем каждый элемент, используя половину ширины родителя в качестве columnWidth.
  2. В onLayout мы размещаем элементы в два столбца, используя ширину columnWidth для каждого столбца.
  3. При этом каждый следующий ряд размещается ниже предыдущего на высоту самого высокого элемента в ряду, чтобы избежать наложений.

Проблемы, которые можно решать в onLayout

  1. Избежание наложений: Если у элементов нет достаточно пространства для размещения, onLayout помогает управлять их положением и подстраивать расстояние между ними.

  2. Учет отступов и маргинов: При ручном управлении позиционированием часто требуется учитывать отступы и маргины. Это особенно важно для создания "плотных" или симметричных макетов.

  3. Гибкое позиционирование: В onLayout можно программно управлять положением элементов, например, в зависимости от ориентации устройства или количества элементов.

  4. Создание сложных макетов: Когда нужен нестандартный макет (например, круговое расположение или адаптивное поведение в зависимости от размеров экрана), onLayout предоставляет гибкость для реализации.

Атрибуты

Работа с атрибутами в кастомных вьюшках Android позволяет задавать параметры, такие как цвета, размеры и другие свойства, прямо в XML-разметке. Это делает ваши компоненты более гибкими и настраиваемыми. Давайте разберём, как это делается на примере задания цвета для кастомной вьюшки.

1. Создание кастомной вьюшки

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

  • Класс кастомной вьюшки.
  • XML-атрибуты, определяющие, как можно задать цвет.

2. Определение атрибутов в res/values/attrs.xml

Первым шагом будет создание файла атрибутов, если его еще нет. Файл attrs.xml обычно находится в каталоге res/values/. В этом файле мы определим атрибуты, которые будут использоваться в нашей кастомной вьюшке.

<resources>
    <declare-styleable name="ColorCircleView">
        <attr name="circleColor" format="color" />
    </declare-styleable>
</resources>

Здесь мы создаем атрибут circleColor, который будет принимать значение цвета.

3. Реализация кастомной вьюшки

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

class ColorCircleView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private var circleColor: Int = Color.RED // Значение по умолчанию

    init {
        // Читаем атрибуты
        attrs?.let {
            val typedArray = context.obtainStyledAttributes(it, R.styleable.ColorCircleView, 0, 0)
            circleColor = typedArray.getColor(R.styleable.ColorCircleView_circleColor, Color.RED)
            typedArray.recycle() // Освобождаем ресурсы
        }
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // Рисуем круг с заданным цветом
        val paint = Paint().apply {
            color = circleColor
            isAntiAlias = true
        }
        val radius = minOf(width, height) / 2f
        canvas.drawCircle(width / 2f, height / 2f, radius, paint)
    }
}

Разбор кода:

  1. Конструктор: Мы принимаем Context, AttributeSet и defStyleAttr. AttributeSet позволяет нам получить доступ к пользовательским атрибутам.

  2. Чтение атрибутов: В блоке init мы используем obtainStyledAttributes для получения атрибутов, определённых в attrs.xml. Мы передаем AttributeSet, styleable (имя нашего набора атрибутов), а также начальные значения.

  3. Получение значения: Мы используем метод getColor для получения цвета, заданного в XML, с запасным значением Color.RED.

  4. Метод onDraw: В этом методе мы рисуем круг с заданным цветом.

4. Использование кастомной вьюшки в XML

Теперь, когда наша кастомная вьюшка готова, мы можем использовать её в XML-разметке нашего приложения.

<your.package.name.ColorCircleView
    android:layout_width="100dp"
    android:layout_height="100dp"
    app:circleColor="#FF5722" /> <!-- Задаем цвет круга -->

6. Общие проблемы при работе с атрибутами

  1. Проблемы с типами: Убедитесь, что вы правильно указываете типы атрибутов в attrs.xml, иначе возникнут ошибки при чтении значений.

  2. Неосвобожденные ресурсы: После получения атрибутов важно всегда вызывать recycle() для освобождения ресурсов.

  3. Отсутствующие атрибуты: Всегда имейте запасные значения для атрибутов, чтобы избежать NullPointerException.

Пример: механические часы

class MechanicalClockView @JvmOverloads constructor(  
    context: Context,  
    attrs: AttributeSet? = null,  
    defStyleAttr: Int = 0  
) : View(context, attrs, defStyleAttr) {  
  
    private val paint = Paint()  
    private var hour = 0  
    private var minute = 0  
    private var second = 0  
  
    init {  
        paint.isAntiAlias = true  
        paint.textAlign = Paint.Align.CENTER  
        paint.textSize = 50f  
        updateClock()  
    }  
  
    private fun updateClock() {  
        val calendar = Calendar.getInstance()  
        hour = calendar.get(Calendar.HOUR_OF_DAY) % 12 // Используем 12-часовой формат  
        minute = calendar.get(Calendar.MINUTE)  
        second = calendar.get(Calendar.SECOND)  
        invalidate() // Перерисовываем вьюшку  
        postInvalidateDelayed(1000) // Запланировать повторную перерисовку через секунду  
    }  
  
    override fun onDraw(canvas: Canvas) {  
        super.onDraw(canvas)  
  
        // Рисуем фон  
        canvas.drawColor(Color.WHITE)  
  
        // Рисуем циферблат  
        drawClockFace(canvas)  
  
        // Рисуем стрелки  
        drawClockHands(canvas)  
  
        // Обновляем время  
        updateClock()  
    }  
  
    private fun drawClockFace(canvas: Canvas) {  
        val centerX = width / 2f  
        val centerY = height / 2f  
        val radius = Math.min(centerX, centerY) * 0.9f  
  
        paint.color = Color.BLACK  
        paint.style = Paint.Style.STROKE  
        paint.strokeWidth = 8f  
  
        // Рисуем круг циферблата  
        canvas.drawCircle(centerX, centerY, radius, paint)  
  
        // Рисуем деления часов  
        paint.strokeWidth = 4f  
        for (i in 0 until 12) {  
            val angle = Math.toRadians((i * 30 - 60).toDouble()) // Сдвигаем угол на 90 градусов  
            val startX = centerX + radius * 0.8f * Math.cos(angle).toFloat()  
            val startY = centerY + radius * 0.8f * Math.sin(angle).toFloat()  
            val endX = centerX + radius * 0.9f * Math.cos(angle).toFloat()  
            val endY = centerY + radius * 0.9f * Math.sin(angle).toFloat()  
            canvas.drawLine(startX, startY, endX, endY, paint)  
  
            // Рисуем цифры  
            val numberX = centerX + radius * 0.75f * Math.cos(angle).toFloat()  
            val numberY = centerY + radius * 0.75f * Math.sin(angle).toFloat() + (paint.textSize / 3)  
            canvas.drawText((i + 1).toString(), numberX, numberY, paint)  
        }  
    }  
  
    private fun drawClockHands(canvas: Canvas) {  
        val centerX = width / 2f  
        val centerY = height / 2f  
        val radius = Math.min(centerX, centerY) * 0.8f  
  
        // Рисуем часовую стрелку  
        paint.color = Color.BLACK  
        paint.strokeWidth = 10f  
        val hourAngle = Math.toRadians((hour * 30 + minute * 0.5 - 90).toDouble()) // Сдвигаем угол на 90 градусов  
        val hourX = centerX + radius * 0.5f * Math.cos(hourAngle)  
        val hourY = centerY + radius * 0.5f * Math.sin(hourAngle)  
        canvas.drawLine(centerX, centerY, hourX.toFloat(), hourY.toFloat(), paint)  
  
        // Рисуем минутную стрелку  
        paint.strokeWidth = 6f  
        val minuteAngle = Math.toRadians((minute * 6 - 90).toDouble()) // Сдвигаем угол на 90 градусов  
        val minuteX = centerX + radius * 0.7f * Math.cos(minuteAngle)  
        val minuteY = centerY + radius * 0.7f * Math.sin(minuteAngle)  
        canvas.drawLine(centerX, centerY, minuteX.toFloat(), minuteY.toFloat(), paint)  
  
        // Рисуем секундную стрелку  
        paint.color = Color.RED  
        paint.strokeWidth = 4f  
        val secondAngle = Math.toRadians((second * 6 - 90).toDouble()) // Сдвигаем угол на 90 градусов  
        val secondX = centerX + radius * 0.75f * Math.cos(secondAngle)  
        val secondY = centerY + radius * 0.75f * Math.sin(secondAngle)  
        canvas.drawLine(centerX, centerY, secondX.toFloat(), secondY.toFloat(), paint)  
    }  
}```