Custom 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
.Размеры и цвета должны быть адаптивными, поддерживать режимы nightMode
, DPI, чтобы кастомные View выглядели корректно на всех устройствах.
Метод onMeasure
в Android управляет размерами вьюшки и принимает два параметра: widthMeasureSpec
и heightMeasureSpec
. Эти параметры представляют собой спецификации размеров (specs), которые сообщают вьюшке, какие размеры ей можно занять. Спеки помогают определить размер вьюшки в зависимости от ее содержимого, родителя и других ограничений.
Каждая спецификация размера (MeasureSpec
) включает в себя два параметра:
MeasureSpec mode
).Спецификации размеров устанавливаются родительским контейнером, и он передает их в onMeasure
. Эти спецификации помогают вьюшке "понять", какой размер ей требуется занять или какой размер она должна выбрать, исходя из установленных ограничений.
Режимы измерения указывают на то, как вьюшке следует вести себя по отношению к родителю и ограниченному пространству. В Android есть три режима измерения:
match_parent
или точное значение в dp
).wrap_content
, но родитель накладывает ограничения.ScrollView
).Каждый из этих режимов может быть применен в методе 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)
}
В этом примере создадим 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
в Android отвечает за позиционирование дочерних элементов внутри кастомного View или ViewGroup. Он вызывается после onMeasure
и используется для того, чтобы задать положение (координаты) каждого дочернего элемента внутри ViewGroup, а также саму вьюшку внутри родительского контейнера.
onLayout
позволяет:
onLayout
Сигнатура метода:
override fun onLayout(
changed: Boolean,
left: Int,
top: Int,
right: Int,
bottom: Int
) {
// Позиционирование дочерних View
}
Аргументы left
, top
, right
и bottom
указывают на границы текущей 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
в этом примере:left
и right
для каждого элемента, обновляя значение currentLeft
.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
}
}
}
onMeasure
мы измеряем каждый элемент, используя половину ширины родителя в качестве columnWidth
.onLayout
мы размещаем элементы в два столбца, используя ширину columnWidth
для каждого столбца.onLayout
Избежание наложений: Если у элементов нет достаточно пространства для размещения, onLayout
помогает управлять их положением и подстраивать расстояние между ними.
Учет отступов и маргинов: При ручном управлении позиционированием часто требуется учитывать отступы и маргины. Это особенно важно для создания "плотных" или симметричных макетов.
Гибкое позиционирование: В onLayout
можно программно управлять положением элементов, например, в зависимости от ориентации устройства или количества элементов.
Создание сложных макетов: Когда нужен нестандартный макет (например, круговое расположение или адаптивное поведение в зависимости от размеров экрана), onLayout
предоставляет гибкость для реализации.
Работа с атрибутами в кастомных вьюшках Android позволяет задавать параметры, такие как цвета, размеры и другие свойства, прямо в XML-разметке. Это делает ваши компоненты более гибкими и настраиваемыми. Давайте разберём, как это делается на примере задания цвета для кастомной вьюшки.
Предположим, что мы создаем кастомную вьюшку, которая будет отображать круг с цветом, заданным через атрибуты. Для этого нам понадобятся:
Первым шагом будет создание файла атрибутов, если его еще нет. Файл attrs.xml
обычно находится в каталоге res/values/
. В этом файле мы определим атрибуты, которые будут использоваться в нашей кастомной вьюшке.
<resources>
<declare-styleable name="ColorCircleView">
<attr name="circleColor" format="color" />
</declare-styleable>
</resources>
Здесь мы создаем атрибут circleColor
, который будет принимать значение цвета.
Теперь создадим класс для нашей кастомной вьюшки, в котором мы будем использовать этот атрибут.
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)
}
}
Конструктор: Мы принимаем Context
, AttributeSet
и defStyleAttr
. AttributeSet
позволяет нам получить доступ к пользовательским атрибутам.
Чтение атрибутов: В блоке init
мы используем obtainStyledAttributes
для получения атрибутов, определённых в attrs.xml
. Мы передаем AttributeSet
, styleable
(имя нашего набора атрибутов), а также начальные значения.
Получение значения: Мы используем метод getColor
для получения цвета, заданного в XML, с запасным значением Color.RED
.
Метод onDraw: В этом методе мы рисуем круг с заданным цветом.
Теперь, когда наша кастомная вьюшка готова, мы можем использовать её в XML-разметке нашего приложения.
<your.package.name.ColorCircleView
android:layout_width="100dp"
android:layout_height="100dp"
app:circleColor="#FF5722" /> <!-- Задаем цвет круга -->
Проблемы с типами: Убедитесь, что вы правильно указываете типы атрибутов в attrs.xml
, иначе возникнут ошибки при чтении значений.
Неосвобожденные ресурсы: После получения атрибутов важно всегда вызывать recycle()
для освобождения ресурсов.
Отсутствующие атрибуты: Всегда имейте запасные значения для атрибутов, чтобы избежать 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)
}
}```