Обзор возможностей кастомных View
с примерами кода на Kotlin. В конце статьи вас ждет бонус в виде ссылок на полезные статьи и обучающие видео по данной теме.
Введение.
— Когда может понадобиться реализация собственного View?
— Способы создания Custom View.
— Иерархия View в Android.Методы View.
—onAttachToWindow()
—onMeasure()
—onLayout()
—onDraw()
—onSizeChanged()
—onSaveInstanceState()
иonRestoreInstanceState()
—onTouchEvent()
Обновление View.
—invalidate()
—requestLayout()
1. Введение
Обычно термин Custom View
обозначает View
, которого нет в sdk Android. Другими словами — это пользовательская реализация View
, которая может содержать собственную логику и визуальный интерфейс.
Когда может понадобиться реализация собственного View?
— Специфичный дизайн или анимация
Если в стандартных View
недостаточно функциональности, и нужно добавить новые возможности (график функции, сложная диаграмма).
— Специфичная обработка жестов
Свайп, тап, зум, поворот элемента по двойному касанию и другие комбинации жестов для взаимодействия с элементами интерфейса.
— Улучшение производительности
Например, для оптимизации отображения большого количества графических объектов (карта с большим количеством маркеров).
Способы создания Custom View.
— Расширение функциональности стандартного View
или ViewGroup
Допустим, что нам не хватает стандартных возможностей Button
. Мы создаём свой класс и наследуемся от класса android.widget.Button
. Затем переопределяем методы для отображения и обработки событий. Этот подход используется для дополнения функциональности существующих элементов View
или изменения их внешнего вида.
— Создание кастомного класса View
с нуля
Этот способ подходит, если мы хотим создать свой собственный элемент View
. Создаем класс, который наследуется от класса View
, а затем переопределяем методы для отображения и обработки событий. Этот метод пригодится для создания сложных и уникальных пользовательских интерфейсов (кастомный прогресс-бар, графики или диаграммы).
— Использовать существующие библиотеки
Можно использовать существующие библиотеки, которые предоставляют набор готовых компонентов и методов для построения UI и могут быть настроены под ваш проект. Такой подход позволяет сэкономить время на разработку, но ограничивает вас возможностями библиотеки. Пример: MPAndroidChart для создания различных графиков и диаграмм.
Иерархия View в Android.
2. Жизненный цикл View
Полная версия жизненного цикла View
:
При создании Custom View
мы используем лишь методы с приставкой On
, поэтому схему можно упростить:
3. Конструкторы View
Создание View
начинается с конструктора с различными параметрами: Context
, AttributeSet
, defStyleAttr
и defStyleRes
. View
имеет четыре конструктора, и вам нужно будет переопределить хотя бы один из них:
class TestView : View {
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
constructor(
context: Context?,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)
}
В Kotlin можно использовать @JvmOverloads
. Эта аннотация генерирует все возможные комбинации параметров конструктора, которые могут быть использованы при вызове из Java.
class TestView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defStyleRes: Int = 0
) : View(context, attrs, defStyleAttr) {
// Your code here
}
1. Конструктор с одним параметром — Context
Используется только в том случае, если View
мы хотим создавать из кода, а не из XML.
2. Конструктор с двумя параметрами — Context и AttributeSet
Используется для создания View
с использованием XML-макета. В этом конструкторе можно получить значения атрибутов View
, указанных в XML-разметке, и использовать их для настройки свойств вашего Custom View
.
3. Конструктор с тремя параметрами — Context, AttributeSet и defStyleAttr
Вызывается при создании View
с помощью XML-разметки и задании значения стиля (defStyleAttr
) из темы.
4. Конструктор с четырьмя параметрами — Context, AttributeSet, defStyleAttr и defStyleRes Также используется для создания View с использованием XML-макета, со стилем из темы и/или с ресурсом стиля.
4. Методы View
1. Метод onAttachToWindow()
После вызова данного метода, наша View
прикрепляется к нашему Activity
и знает о других элементах, которые также находятся на этом экране.
2. Метод onMeasure()
Конечная цель метода onMeasure()
— определить размер и расположение вашего View
на экране. В качестве параметров он принимает две переменные widthMeasureSpec
и heightMeasureSpec
, которые в свою очередь представляют собой требования измерения ширины и высоты вашего View
. При переопределении данного метода, необходимо указать ширину и высоту вашего View
самостоятельно, используя метод setMeasuredDimension()
.
class CustomView(context: Context, attrs: AttributeSet) : View(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(width, height)
}
}
MeasureSpec
— это класс, используемый для определения размеров View
в Android. Когда View
помещается на экран, ей нужно знать, какое место ей предоставлено, чтобы правильно расположиться и отобразиться. MeasureSpec
состоит из двух основных компонентов: размера и режима измерения.
Режим измерения может быть одним из трех типов:
EXACTLY
(точно) — размерView
должен быть задан точно (например, в dp или px). Это может быть указано в макетеView
с атрибутомandroid:layout_width
илиandroid:layout_height
со значением фиксированной ширины или высоты.AT_MOST
(не больше) —View
может быть любого размера, который не превышает указанный максимальный размер, например,layout_width="wrap_content"
. Это означает, чтоView
может иметь любой размер, пока он не превышает размер родительского контейнера.UNSPECIFIED
(неопределенный) — размерView
может быть любым, не ограниченным размером родителя.
Для каждого измерения View
, то есть для ширины и высоты, используется отдельный MeasureSpec
. Он передается в метод onMeasure()
в качестве аргумента. Для того, чтобы получить размеры View
на основе MeasureSpec
, можно использовать методы MeasureSpec.getSize()
и MeasureSpec.getMode()
:
getSize(measureSpec: Int)
— извлекает размер из заданного объектаMeasureSpec
.getMode(measureSpec: Int)
— извлекает режим из заданного объектаMeasureSpec
.
Пример использования MeasureSpec
в методе onMeasure()
:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = 100 // Предполагаемая ширина View
val desiredHeight = 100 // Предполагаемая высота View
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val width = when (widthMode) {
MeasureSpec.EXACTLY -> widthSize // Задан конкретный размер для ширины
MeasureSpec.AT_MOST -> min(desiredWidth, widthSize) // Размер не должен превышать заданный размер
else -> desiredWidth // Задать предпочтительный размер, если точного или максимального размера не задано
}
val height = when (heightMode) {
MeasureSpec.EXACTLY -> heightSize // Задан конкретный размер для высоты
MeasureSpec.AT_MOST -> min(desiredHeight, heightSize) // Размер не должен превышать заданный размер
else -> desiredHeight // Задать предпочтительный размер, если точного или максимального размера не задано
}
setMeasuredDimension(width, height) // Устанавливаем фактический размер View
}
3. Метод onLayout()
Метод onLayout()
вызывается при каждом изменении размера и позиции View
, в том числе при его создании и перерисовке. Обычно этот метод переопределяется в Custom View
только в том случае, когда в нем есть дочерние View
, которые нужно разместить в определенном порядке.
4. Метод onDraw()
Основной метод при разработке собственной View
. При переопределении метода onDraw()
используется объект Canvas
(2D-холст), на котором можно рисовать графические элементы. Также в этом методе можно использовать объекты Paint
и Path
, которые определяют стиль и форму рисуемых элементов.
1. Canvas
Canvas
предоставляет нам методы для рисования фигур, линий, текста и других элементов на экране, например:
drawColor(color: Int)
— заливает всю область цветом, указанным в аргументе.drawLine(startX: Float, startY: Float, stopX: Float, stopY: Float, paint: Paint)
— рисует линию, заданную двумя точками.drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint)
— рисует прямоугольник, заданный координатами левого верхнего и правого нижнего углов.drawCircle(cx: Float, cy: Float, radius: Float, paint: Paint)
— рисует круг, заданный координатами центра и радиусом.drawText(text: String, x: Float, y: Float, paint: Paint)
— рисует текст, заданный строкой и координатами базовой точки.
2. Paint
Объект Paint
представляет собой кисть, с помощью которой мы рисуем на Canvas
.
Примеры методов для val paint = Paint()
:
color
(цвет рисования) —paint.color = Color.RED
strokeWidth
(ширина линии рисования) —paint.strokeWidth = 10f
style
(стиль рисования) —paint.style = Paint.Style.FILL
. Принимает в качестве параметра одно из значений классаPaint.Style
:FILL
,STROKE
илиFILL_AND_STROKE
.textSize
(размер шрифта текста) —paint.textSize = 30f
Пример использования Paint
и Canvas
:
class MyCustomView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// Устанавливаем цвет и стиль для Paint
paint.color = Color.RED
paint.style = Paint.Style.FILL
// Рисуем круг на Canvas
canvas.drawCircle(width / 2f, height / 2f, 100f, paint)
// Устанавливаем цвет и стиль для Paint
paint.color = Color.BLUE
paint.style = Paint.Style.STROKE
paint.strokeWidth = 10f
// Рисуем прямоугольник на Canvas
canvas.drawRect(50f, 50f, 200f, 200f, paint)
}
}
Создание объектов в onDraw()
может привести к лишним затратам памяти и ухудшению производительности приложения. Метод onDraw()
вызывается при каждой перерисовке View
, поэтому слишком частое создание новых объектов Paint
может вызвать нагрузку на сборщик мусора. Вместо этого рекомендуется создать объект Paint
в конструкторе класса или в другом подходящем методе и переиспользовать его в методе onDraw()
.
Таким образом, вместо создания новых объектов Paint
в методе onDraw()
, лучше создать их в конструкторе класса, например:
class MyCustomView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val fillPaint = Paint().apply {
color = Color.RED
style = Paint.Style.FILL
}
private val strokePaint = Paint().apply {
color = Color.BLUE
style = Paint.Style.STROKE
strokeWidth = 10f
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// Рисуем круг на Canvas
canvas.drawCircle(width / 2f, height / 2f, 100f, fillPaint)
// Рисуем прямоугольник на Canvas
canvas.drawRect(50f, 50f, 200f, 200f, strokePaint)
}
}
В этом примере мы создаем два объекта Paint
в конструкторе класса и настраиваем их свойства один раз. Затем мы используем эти объекты Paint
в методе onDraw()
для рисования круга и прямоугольника на Canvas
. Таким образом, мы избегаем создания новых объектов Paint
при каждой перерисовке View
и уменьшаем нагрузку на сборщик мусора.
Далее еще несколько полезных методов, которые вы будете применять при разработке собственного View
.
5. Метод onSizeChanged()
Метод onSizeChanged()
вызывается при изменении размеров View
(смена ориентация устройства, изменение размера родительского контейнера) и может быть переопределен в кастомном View
для реакции на эти изменения. Метод имеет следующую сигнатуру:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// Your code here
}
w
— новая ширинаView
h
— новая высотаView
oldw
— старая ширинаView
oldh
— старая высотаView
В onSizeChanged()
мы как раз выполняем нужные расчеты, подготавливаем данные для дальнейшей отрисовки в методе onDraw()
. В идеале, мы не ведем никакие расчеты в onDraw()
, а берем конкретные подготовленные цифры и просто рисуем.
Пример использования:
class TestView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val circlePaint = Paint().apply {
color = Color.RED
style = Paint.Style.FILL
}
private val rectPaint = Paint().apply { color = Color.BLUE }
private val arcPaint = Paint().apply { color = Color.GREEN }
private val rectF = RectF()
private val arcRectF = RectF()
private var centerX = 0f
private var centerY = 0f
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
rectF.left = 20f
rectF.top = 40f
rectF.right = w - 20f
rectF.bottom = h - 40f
arcRectF.left = 20f
arcRectF.top = 40f
arcRectF.right = w - 20f
arcRectF.bottom = h - 40f
centerX = w / 2f
centerY = h / 2f
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawRect(rectF, rectPaint)
canvas.drawCircle(centerX, centerY, 100f, circlePaint)
canvas.drawArc(arcRectF, 180f, 90f, true, arcPaint)
}
}
6. Методы onSaveInstanceState() и onRestoreInstanceState()
Методы onSaveInstanceState()
и onRestoreInstanceState()
позволяют сохранять и восстанавливать состояние View
в случае, когда система уничтожает и пересоздает View
, например, при повороте экрана или при нехватке памяти.
Метод onSaveInstanceState()
вызывается перед уничтожением View
, и в нем необходимо сохранить состояние. В этом методе можно поместить все нужные нам данные в Bundle
с помощью метода put...()
, например:
override fun onSaveInstanceState(): Parcelable {
val bundle = Bundle()
bundle.putString("text", textView.text.toString())
bundle.putParcelable("instanceState", super.onSaveInstanceState())
return bundle
}
Здесь мы сохраняем текст, отображаемый в textView
в Bundle
, который затем возвращается в качестве результата. Также мы вызываем super.onSaveInstanceState()
и передаем его результат в Bundle
с помощью метода putParcelable()
, чтобы сохранить состояние базового класса.
Метод onRestoreInstanceState()
вызывается после того, как View
была пересоздана, чтобы восстановить ее состояние из Bundle
. В этом методе можно извлечь необходимые данные из Bundle
с помощью методаget...()
, например:
override fun onRestoreInstanceState(state: Parcelable?) {
val bundle = state as Bundle
textView.text = bundle.getString("text")
super.onRestoreInstanceState(bundle.getParcelable("instanceState"))
}
Здесь мы восстанавливаем текст в textView
из Bundle
, который был передан в качестве параметра. Также мы вызываем метод super.onRestoreInstanceState()
и передаем ему сохраненное состояние базового класса с помощью метода getParcelable()
.
Метод onRestoreInstanceState()
может вызываться после метода onFinishInflate()
, поэтому необходимо учитывать это при восстановлении состояния View
.
Также стоит отметить, что эти методы могут работать только с Serializable
или Parcelable
типами данных. Если вам нужно сохранить другой тип данных, например, объект вашего собственного класса, то вам необходимо реализовать его сериализацию и десериализацию в методах onSaveInstanceState()
и onRestoreInstanceState()
соответственно.
7. Метод onTouchEvent()
Метод onTouchEvent()
— это один из методов обработки событий пользовательского ввода в View
. Вызывается при каждом событии касания на View
, например при нажатии, перемещении или отпускании пальца.
Метод возвращает значение типа Boolean
, которое указывает, было ли событие обработано этим методом. Если метод возвращает true
, это означает, что событие было обработано и больше никаких действий не требуется, если он возвращает false
, событие продолжит свой путь по иерархии View
и будет обработано другими методами.
Пример использования onTouchEvent()
в кастомной View
:
class MyCustomView(context: Context, attrs: AttributeSet) : View(context, attrs) {
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// обработка нажатия пальца на экран
return true
}
MotionEvent.ACTION_MOVE -> {
// обработка перемещения пальца по экрану
return true
}
MotionEvent.ACTION_UP -> {
// обработка отпускания пальца от экрана
return true
}
}
return super.onTouchEvent(event)
}
}
В этом примере мы переопределяем метод onTouchEvent()
и используем его для обработки трех основных событий касания на экране:
ACTION_DOWN
— когда палец нажимается на экранACTION_MOVE
— когда палец перемещается по экрануACTION_UP
— когда палец отпускается от экрана.
Внутри каждого из этих блоков мы можем написать нужный нам код для обработки события, например, изменения цвета или размера View
, а также возвращаем true
, чтобы указать, что событие было обработано и больше никаких действий не требуется. Если событие не было обработано внутри блоков when
, мы вызываем метод super.onTouchEvent(event)
, чтобы передать событие на обработку другим методам в иерархии View
.
5. Обновление View
Когда данные, используемые внутри View
, изменяются, требуется обновить View
, чтобы отобразить новые значения. Из диаграммы жизненного цикла видно, что существуют два метода, которые заставляют View
перерисовываться:
invalidate()
— используется, когда нужно только перерисовать ваш элемент.requestLayout()
— используется, когда нужно изменить размеры вашегоView
.
1. Метод invalidate()
Для обновления визуальной части нашего View
, используется метод invalidate()
. Например, когда ваш View-компонент обновляет свой текст, цвет или обрабатывает прикосновение. Это значит, что View-компонент будет вызывать только метод onDraw()
, чтобы обновить своё состояние.
Пример использования:
class CustomView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint()
init {
paint.color = Color.RED
}
override fun onDraw(canvas: Canvas) {
canvas.drawCircle(width / 2f, height / 2f, width / 4f, paint)
}
fun changePaintColor(newColor: Int) {
paint.color = newColor
invalidate() // вызываем invalidate() для перерисовки Custom View
}
}
2. Метод requestLayout()
Если у нашего View
были изменены размеры и/или позиция, необходимо вызвать метод requestLayout()
, после которого последует вызов методов согласно жизненному циклу View
, т.е. onMeasure()
→ onLayout()
→ onDraw()
.
Пример использования:
class CustomView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private var myWidth = 0
private var myHeight = 0
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
myWidth = MeasureSpec.getSize(widthMeasureSpec)
myHeight = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(myWidth, myHeight)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
// располагаем элементы внутри Custom View
}
fun changeViewSize(newWidth: Int, newHeight: Int) {
layoutParams.width = newWidth
layoutParams.height = newHeight
requestLayout() // вызываем requestLayout() для перерасположения Custom View
}
}
Особенность применения этих методов заключаются в том, что частая перерисовка или пересчет размеров View
может замедлить работу приложения. Поэтому лучше использовать эти методы только в случае необходимости. Например, если изменения внешнего вида View
могут быть объединены в один вызов invalidate()
, то лучше объединить их для уменьшения количества перерисовок.
6. Атрибуты для Custom View
1. Определяем атрибуты, которые будут доступны для этого View
.
Для этого нужно создать XML-файл в папке res/values с расширением attrs.xml и описать там нужные атрибуты.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CustomView">
<attr name="customText" format="string" />
<attr name="customTextColor" format="color" />
</declare-styleable>
</resources>
2. В конструкторе View
получаем атрибуты, переданные в XML, и сохраняем их в переменных класса.
class CustomView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var customText: String? = null
private var customTextColor: Int = Color.BLACK
init {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomView, defStyleAttr, 0)
customText = typedArray.getString(R.styleable.CustomView_customText)
customTextColor = typedArray.getColor(R.styleable.CustomView_customTextColor, Color.BLACK)
typedArray.recycle()
}
// остальной код
}
Здесь мы получаем массив атрибутов typedArray
из контекста с помощью метода context.obtainStyledAttributes()
, передавая ему параметры attrs
и стиль R.styleable.CustomView
. Затем мы извлекаем значение атрибута customTextColor
из массива typedArray
с помощью метода typedArray.getColor()
. Вторым параметром мы передаем значение по умолчанию, которое будет использоваться, если атрибут не был задан. После извлечения значения мы обязательно вызываем метод typedArray.recycle()
, чтобы освободить ресурсы.
7. Дополнительные материалы
Видео:
1. Александра Серебренникова на Android Broadcast (серия из 3-х видео)
2. Roman Andrushchenko — Создание View путём компоновки нескольких существующих View
3. Roman Andrushchenko — Создание View с нуля
4. ITVDN — Custom Views в Android. Обзор функционала. Создаем CustomToolbar
5. ITVDN — Custom Views в Android. Создаем собственную View
Ссылки:
1. Create Custom View components — developer.android.com
2. Android Custom View Tutorial — kodeco.com
3. The Life Cycle of a View in Android — proandroiddev.com
4. Рисование собственных представлений (View) в Android — Хабр
Комментарии (3)
Rusrst
11.04.2023 10:23requestlayout не гарантирует вызов invalidate. Гугл в своих примерах вызывает оба.
https://developer.android.com/develop/ui/views/layout/custom-views/create-view#addprop
CrashLogger
В целом неплохо, но выглядит как пересказ официальной документации ) Не хватает примера, как вы разрабатывали свой нетривиальный View, как преодолевали трудности и что в итоге получилось. Ну и ссылки на гитхаб )
dmt_ovs Автор
Была мысль включить пример с интересным Custom View из практики, но там по объему материала тянет на отдельную статью, если будет свободное время - выложу)