Обзор возможностей кастомных View с примерами кода на Kotlin. В конце статьи вас ждет бонус в виде ссылок на полезные статьи и обучающие видео по данной теме.

  1. Введение.
    Когда может понадобиться реализация собственного View?
    Способы создания Custom View.
    Иерархия View в Android.

  2. Жизненный цикл View.

  3. Конструкторы View.

  4. Методы View.
    onAttachToWindow()
    onMeasure()
    onLayout()
    onDraw()
    onSizeChanged()
    onSaveInstanceState() и onRestoreInstanceState()
    onTouchEvent()

  5. Обновление View.
    invalidate()
    requestLayout()

  6. Атрибуты для Custom View.

  7. Дополнительные материалы для изучения.

Разработчик на Android создает сложный экран с использованием Custom View и в итоге решает уйти в iOS разработку
Разработчик на Android создает сложный экран с использованием Custom View и в итоге решает уйти в iOS разработку

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)


  1. CrashLogger
    11.04.2023 10:23

    В целом неплохо, но выглядит как пересказ официальной документации ) Не хватает примера, как вы разрабатывали свой нетривиальный View, как преодолевали трудности и что в итоге получилось. Ну и ссылки на гитхаб )


    1. dmt_ovs Автор
      11.04.2023 10:23
      +1

      Была мысль включить пример с интересным Custom View из практики, но там по объему материала тянет на отдельную статью, если будет свободное время - выложу)


  1. Rusrst
    11.04.2023 10:23

    requestlayout не гарантирует вызов invalidate. Гугл в своих примерах вызывает оба.

    https://developer.android.com/develop/ui/views/layout/custom-views/create-view#addprop