Створюємо кастомний Android View з гнучкою зміною розміру
Вітаю! Мене звати Роман, я працюю Android Engineer у Smiss Ltd. Це моя перша стаття на DOU, у якій я хочу поділитися досвідом створення кастомного, функціонального XML View в Android у своєму пет-проєкті для створенню Телеграм тем.
Ця стаття буде корисна Android розробникам з базовими знаннями, які хочуть краще зрозуміти світ кастомних XML в’юшок та навчитися робити свої компоненти які можна масштабувати та не переживати за те, що елементи в’юшки з’їдуть в сторону та не будуть відповідати дизайну.
Проблема
Мені потрібно було створити мінімалістичну версію Телеграма, щоб використати її як прев’ю для теми, яку користувач створює змінюючи стиль, основний колір, світлу/темну тему та інші властивості.
- Ліворуч список всіх чатів, який складається з
AppBar
, папок та самих чатів, які можна конфігурувати, наприклад прибрати значок непрочитаних чи добавити кружечок онлайн. - Праворуч відкритий чат із повідомленнями, який містить той самий
AppBar
(але у іншій конфігурації), панель для написання повідомлення внизу, панель плеєра вгорі та повідомлення, які теж можна налаштувати, наприклад вказати вхідне воно чи вихідне або це файлове повідомлення чи відповідь на інше.
Отож, згідно задуму, це прев’ю мені потрібно відобразити у двох випадках:
- невелике прев’ю на головному екрані, яке змінює колір та інші властивості відповідно до того, що обирає користувач;
- повноекранне прев’ю на екрані редагування, з яким користувач може взаємодіяти, наприклад вибрати один з елементів та змінити його колір.
Так як обидва прев’ю є інтерактивними, то варіант з 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) для створення свого Телеграма з стрічкою новин.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів