Створюємо кастомний Android View з гнучкою зміною розміру

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Вітаю! Мене звати Роман, я працюю Android Engineer у Smiss Ltd. Це моя перша стаття на DOU, у якій я хочу поділитися досвідом створення кастомного, функціонального XML View в Android у своєму пет-проєкті для створенню Телеграм тем.

Ця стаття буде корисна Android розробникам з базовими знаннями, які хочуть краще зрозуміти світ кастомних XML в’юшок та навчитися робити свої компоненти які можна масштабувати та не переживати за те, що елементи в’юшки з’їдуть в сторону та не будуть відповідати дизайну.

Проблема

Мені потрібно було створити мінімалістичну версію Телеграма, щоб використати її як прев’ю для теми, яку користувач створює змінюючи стиль, основний колір, світлу/темну тему та інші властивості.

  • Ліворуч список всіх чатів, який складається з AppBar, папок та самих чатів, які можна конфігурувати, наприклад прибрати значок непрочитаних чи добавити кружечок онлайн.
  • Праворуч відкритий чат із повідомленнями, який містить той самий AppBar (але у іншій конфігурації), панель для написання повідомлення внизу, панель плеєра вгорі та повідомлення, які теж можна налаштувати, наприклад вказати вхідне воно чи вихідне або це файлове повідомлення чи відповідь на інше.

Отож, згідно задуму, це прев’ю мені потрібно відобразити у двох випадках:

  1. невелике прев’ю на головному екрані, яке змінює колір та інші властивості відповідно до того, що обирає користувач;
  2. повноекранне прев’ю на екрані редагування, з яким користувач може взаємодіяти, наприклад вибрати один з елементів та змінити його колір.

Так як обидва прев’ю є інтерактивними, то варіант з Drawable картинкою можна відразу відкинути і залишається тільки розробка власного View. Хоча тут не все так просто, як мені здалося спочатку...

На зображенні видно першу спробу кастомного прев’ю, але після зміни розмірів прев’ю, його внутрішні елементи або перекриваються, або знаходяться занадто далеко один від одного. Тому, я вирішив використати підхід, де розмір і відстані внутрішніх компонентів (кружечки та прямокутники) будуть пропорційно залежати від зовнішнього компонента (фон з товстим контуром).

Рішення

Першим кроком є створення основи для кастомного View з фіксованою шириною та висотою (ці значення знадобляться пізніше для розрахунку пропорційних розмірів). У моєму випадку це PreviewBackground з висотою 560dp та шириною 280dp. Я рекомендую обирати більший розмір, оскільки так простіше добавляти та дебажити внутрішні елементи, поки вюшка не буде повністю готовою. Потім фіксовані ширина та висота прибереться і в’юшка буде автоматично масштабуватися з динамічними розмірми.

<com.therxmv.preview.components.PreviewBackground
    android:layout_width="280dp"
    android:layout_height="560dp"
    app:layout_constraintDimensionRatio="1:2"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

// Implementation
class PreviewBackground(
    context: Context,
    attrs: AttributeSet? = null,
) : RelativeLayout(context, attrs) {

    private val _cornerRadius: Float get() = 20.dpToPx(context)
    private val _strokeWidth: Float get() = 8.dpToPx(context)
    private val _edge: Float get() = _strokeWidth / 2 // to make stroke inside

    private var backgroundColor = Color.BLACK
    private var strokeColor = Color.WHITE

    private val backgroundPaint: Paint
        get() = Paint().apply {
            isAntiAlias = true
            color = backgroundColor
            style = Paint.Style.FILL
        }
    private val strokePaint: Paint
        get() = Paint().apply {
            isAntiAlias = true
            color = strokeColor
            style = Paint.Style.STROKE
            strokeWidth = _strokeWidth
        }

    init {
        setWillNotDraw(false)
    }

    override fun onDraw(canvas: Canvas) {
        canvas.drawRoundRect(
            /* left = */ _edge,
            /* top = */ _edge,
            /* right = */ width - _edge,
            /* bottom = */ height - _edge,
            /* rx = */ _cornerRadius,
            /* ry = */ _cornerRadius,
            /* paint = */ backgroundPaint,
        )
        canvas.drawRoundRect(
            /* left = */ _edge,
            /* top = */ _edge,
            /* right = */ width - _edge,
            /* bottom = */ height - _edge,
            /* rx = */ _cornerRadius,
            /* ry = */ _cornerRadius,
            /* paint = */ strokePaint,
        )
        super.onDraw(canvas)
    }
}

Тепер наш батьківський компонент можна використати для обрахунку числа масштабування (scaleFactor).

Важливо! У мене вказано app:layout_constraintDimensionRatio="1:2" для того щоб зберегти пропорції та полегшити обрахунок scaleFactor. Якщо вам не потрібна дана властивість, то не забудьте обрахувати scaleFactor як для ширини так і для висоти та обрати менший з них.

Так як у мене висота пропорційно залежить від ширини, то я обраховую зміну тільки відносно ширини. Та створюю об’єкт DpValues, з якого я буду брати коректні, масштабовані dp значення.

Для обрахунку scaleFactor потрібно внести нові зміни у PreviewBackground: приватна та публічна змінні dpValues, onLayout для обрахунку dpValues.

class PreviewBackground {
    private var _dpValues: DpValues? = null
    val dpValues: DpValues get() = requireNotNull(_dpValues) // Provides scalable values for children

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        if (_dpValues == null) {
            val scaleFactor = width / 280.dpToPx(context) // <-- Обрахунок
            _dpValues = DpValues(context, scaleFactor)
        }
        setPadding(dpValues.dp20)
        
        super.onLayout(changed, l, t, r, b)
    }
}

DpValues — це єдине місце для отримання правильних (пропорційних) значень. Тут я маю метод Int.pxOf(), який приймає dp та повертає його у пікселях, та помноженим на scaleFactor, який 1 за замовчуванням. Для зручності, я додав кілька заготовлених, найпопулярніших значень із вже масштабованими значеннями.

Обов’язково користуйтеся тільки DpValues для вказання розмірів (і не тільки) внутрішніх елементів!

/* Extension function */
fun Int.dpToPx(context: Context, scaleFactor: Float = 1f): Float {
    val density = context.resources.displayMetrics.density
    return this * density * scaleFactor
}
class DpValues(
    private val context: Context,
    private val scaleFactor: Float = 1f,
) {
    fun pxOf(dp: Int) = dp.dpToPx(context, scaleFactor).toInt()
    val dp4: Int
        get() = pxOf(4)
    val dp7: Int
        get() = pxOf(7)
    val dp8: Int
        get() = pxOf(8)
    val dp10: Int
        get() = pxOf(10)
/* Rest of the values... */

Тепер, замість фіксованих значень для товщини контуру та радіусу кутів у PreviewBackground я використовую створені dpValues:

private val cornerRadius: Float get() = dpValues.dp20.toFloat()
private val strokeWidth: Float get() = dpValues.dp8.toFloat()
private val edge: Float get() = strokeWidth / 2 // to make stroke inside

Ліворуч PreviewBackground без використання dpValues, праворуч з використанням, саме те, що потрібно :)

У PreviewBackground я зробив публічну змінну dpValues щоб мати доступ до динамічних значень із дочірніх елементів.

// Приклад використання PreviewBackground та внутрішніх елементів для прев'ю списка всіх чатів
class ChatListPreview {
    init {
        val background = attachBackground()
        doOnLayout {
            with(background) {
                drawAppbar()
                drawTabs()
                drawChatItems()
                drawActionButton()
            }
        }
    }
    private fun attachBackground() =
        PreviewBackground(context).apply {
            id = backgroundId
            attachViewToParent(
                /* child = */ this@apply,
                /* index = */ 0,
                /* params = */ LayoutParams(
                    LayoutParams.MATCH_PARENT,
                    LayoutParams.MATCH_PARENT,
                )
            )
        }
    private fun PreviewBackground.drawAppbar() {
        PreviewAppbar(dpValues = dpValues, isInChat = false, context = context).apply {
            id = appbarId
            layoutParams = LayoutParams(
                LayoutParams.MATCH_PARENT,
                LayoutParams.WRAP_CONTENT,
            ).apply {
                addRule(ALIGN_PARENT_TOP)
            }
            [email protected](this@apply)
        }
    }
    /* Rest of the elements... */
}

dpValues передається у конструктор PreviewAppbar, у якому ці значення будуть використанні для малювання кружечків та прямокутників.

    private fun drawLeftIcon() {
        RoundedRectangleView.create(
            context = context,
            id = leftIconId,
            width = dpValues.dp30,
            height = dpValues.dp30,
            setUpLayoutParams = {
                addRule(ALIGN_PARENT_TOP)
                addRule(ALIGN_PARENT_START)
            }
        ).also { addView(it) }
    }
    private fun drawRightIcon() {
        RoundedRectangleView.create(
            context = context,
            id = rightIconId,
            width = dpValues.dp14,
            height = dpValues.dp30,
            setUpLayoutParams = {
                addRule(ALIGN_PARENT_TOP)
                addRule(ALIGN_PARENT_END)
            }
        ).also { addView(it) }
    }

Праворуч маємо гарний AppBar з dpValues, а ліворуч використовуються статичні значення і все з’їхало.

У результаті маємо повністю кастомну в’юшку, яка виконує свої функції (зміна кольору та стилю) і залишається однаковою попри зміну розміру.

P.S. Повноекранна в’юшка ще не дійшла до продакшена, так як ще потрібно прив’язати кнопки до теми, але це вже справа часу ;)

Також, якщо вам буде цікаво, пізніше спробую детальніше розповісти, як саме створюються теми для Телеграма або як користуватися Telegram API (Telegram Database Library) для створення свого Телеграма з стрічкою новин.

Залишайте свої побажання чи критику та до зустрічі ;)

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі