Недавно я столкнулся с проблемой создания вот такой вьюшки:
Это фигурка из игры Pop It
Начнем с реализации отдельных кружков:
class PopItView @JvmOverloads constructor(
ctx: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
private val fillColor: Int = Color.RED // цвет кружка
): View(ctx, attrs, defStyleAttr) {
// небольшой внутренний отступ
private val pad = 8f
// PaintWrapper - небольшая обертка над Paint
// здесь мы указываем внутренний blur-эффект и небольшую тень
private val fillPaint = PaintWrapper().fill(fillColor)
.blurMaskFilter(10f, BlurMaskFilter.Blur.INNER)
.shadowLayer(0.1f, 0f, 0f, 0x80000000.toInt())
.softwareLayerType(this)
// используется для отрисовки почти прозрачной границы вокруг кружка
// для придания реалистичности
private val strokePaint = PaintWrapper().stroke(
8f, Color.argb(30, 0, 0, 0)).paint()
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val myBlack = Color.argb(50, 11, 11, 11)
// создаем градиент для нашего кружочка,
// который добавляет сходства с 3D фигуркой
fillPaint.makeLinearGradient(
x1 = width.toFloat(), y1 = 0f, colors = intArrayOf(fillColor, fillColor, myBlack),
positions = floatArrayOf(0f, 0.3f, 1f)
)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// отрисовываем сам кружок и его границу с отступом
canvas.drawCircle(width / 2f, height / 2f, width / 2f - pad, fillPaint.paint())
canvas.drawCircle(width / 2f, height / 2f, width / 2f - pad, strokePaint)
}
}
Вот как выглядит класс PaintWrapper
под капотом:
// при каждом создании экземпляра PaintWrapper создается новый Paint
// или передается как аргумент
class PaintWrapper(private val paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)) {
// обратите внимание я использую Kotlin-функцию apply для удобства
// все методы являются лишь обертками над оригинальными Paint объекта
fun color(color: Int) = PaintWrapper(
paint.apply {
this.color = color
}
)
fun stroke(width: Float, color: Int) = PaintWrapper(
paint.apply {
style = Paint.Style.STROKE
strokeWidth = width
}
).color(color)
fun fill(color: Int) = PaintWrapper(paint.apply {
style = Paint.Style.FILL
}).color(color)
fun softwareLayerType(view: View) = PaintWrapper(paint.apply {
view.setLayerType(View.LAYER_TYPE_SOFTWARE, this)
})
fun shadowLayer(radius: Float, dx: Float, dy: Float, color: Int) = PaintWrapper(paint.apply {
setShadowLayer(radius, dx, dy, color)
})
fun blurMaskFilter(radius: Float, type: BlurMaskFilter.Blur) = PaintWrapper(paint.apply {
maskFilter = BlurMaskFilter(radius, type)
})
fun makeLinearGradient(
x0: Float = 0f, y0: Float = 0f,
x1: Float, y1: Float,
colors: IntArray, positions: FloatArray,
mode: Shader.TileMode = Shader.TileMode.MIRROR
) {
paint.run {
shader = LinearGradient(x0, y0, x1, y1, colors, positions, mode)
}
}
fun paint() = paint
}
Приступим к размещению кружков по окружности.
override fun updatePops(popItemCallback: PopItemCallback) {
// важное замечание: фигурка квадратная и поэтому я использую
// только width для расчетов
// очищаем наш container
removeAllViews()
// устанавливаем отступы
setPadding(pad, pad, pad, pad)
// определим размер одного кружка в зависимости от размера самой View
// width - размер view без оступов
// здесь мы также учитываем отступы pad
val size = (width - 2 * pad) / 5
// мы расширяемся от AbsoluteLayout, поэтому указываем
// размер кружка и его координаты
// здесь мы отрисовываем центральный кружок учитывая отступы
val absoluteViewParams = AbsoluteLayout.LayoutParams(size, size,
width / 2 - (size / 2) - pad,
height / 2 - (size / 2) - pad,)
// добавляем кружок в container
this.addView(PopItView(context).apply {
layoutParams = absoluteViewParams
})
// далее добавляем кружки, между которыми угол составит 60 градусов
for (i in 0 until 360 step 60) {
// используем формулы из тригонометрии расчитываем
// x и y координаты кружков
// size - размер кружка, i - угол
// дополнительные слагаемые width / 2 - (size / 2) - pad
// используются для смещения центра кружков
val x = ((size) * sin(Math.toRadians(i.toDouble()))).toInt()
+ width / 2 - (size / 2) - pad
val y = ((size) * cos(Math.toRadians(i.toDouble()))).toInt()
+ width / 2 - (size / 2) - pad
val absoluteViewParams = AbsoluteLayout.LayoutParams(size, size, x, y)
this.addView(PopItView(context).apply {
layoutParams = absoluteViewParams
})
}
// аналогично для кружков, угол между которыми составляет 30 градусов
for (i in 0 until 360 step 30) {
val x = ((size * 2f) * sin(Math.toRadians(i.toDouble()))).roundToInt() + width / 2 - (size / 2) - pad
val y = ((size * 2f) * cos(Math.toRadians(i.toDouble()))).roundToInt() + width / 2 - (size / 2) - pad
val absoluteViewParams = AbsoluteLayout.LayoutParams(size, size, x, y)
this.addView(PopItView(context).apply {
layoutParams = absoluteViewParams
})
}
}
Ненужную часть кода я пропустил и решил показать только самую важную.
Для абсолютного позиционирования я использую AbsoluteLayout
:
// размещает элементы согласно их ширине, высоте и двум координатам x и y
open class AbsoluteLayout @JvmOverloads constructor(
ctx: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(ctx, attrs, defStyleAttr) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val count = childCount
var maxHeight = 0
var maxWidth = 0
measureChildren(widthMeasureSpec, heightMeasureSpec)
for (i in 0 until count) {
val child = getChildAt(i)
if (child.visibility != GONE) {
var childRight: Int
var childBottom: Int
val lp = child.layoutParams as LayoutParams
childRight = lp.x + child.measuredWidth
childBottom = lp.y + child.measuredHeight
maxWidth = max(maxWidth, childRight)
maxHeight = max(maxHeight, childBottom)
}
}
// Account for padding too
maxWidth += paddingLeft + paddingRight
maxHeight += paddingTop + paddingBottom
// Check against minimum height and width
maxHeight = max(maxHeight, suggestedMinimumHeight)
maxWidth = max(maxWidth, suggestedMinimumWidth)
setMeasuredDimension(
resolveSizeAndState(maxWidth, widthMeasureSpec, 0),
resolveSizeAndState(maxHeight, heightMeasureSpec, 0)
)
}
override fun generateDefaultLayoutParams(): ViewGroup.LayoutParams? {
return LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
0,
0
)
}
override fun onLayout(
changed: Boolean, l: Int, t: Int,
r: Int, b: Int
) {
val count = childCount
for (i in 0 until count) {
val child = getChildAt(i)
if (child.visibility != GONE) {
val lp = child.layoutParams as LayoutParams
val childLeft: Int = paddingLeft + lp.x
val childTop: Int = paddingTop + lp.y
child.layout(
childLeft, childTop,
childLeft + child.measuredWidth,
childTop + child.measuredHeight
)
}
}
}
override fun generateLayoutParams(attrs: AttributeSet?): ViewGroup.LayoutParams {
return LayoutParams(context, attrs)
}
override fun checkLayoutParams(p: ViewGroup.LayoutParams?): Boolean {
return p is LayoutParams
}
override fun generateLayoutParams(p: ViewGroup.LayoutParams?): ViewGroup.LayoutParams? {
return LayoutParams(p)
}
override fun shouldDelayChildPressedState(): Boolean {
return false
}
class LayoutParams : ViewGroup.LayoutParams {
var x = 0
var y = 0
constructor(width: Int, height: Int, x: Int, y: Int) : super(width, height) {
this.x = x
this.y = y
}
constructor(c: Context, attrs: AttributeSet?) : super(c, attrs)
constructor(source: ViewGroup.LayoutParams?) : super(source)
}
}
Подведем итоги:
В
Android
разработке бывают ситуации, когда необходимо углубляться в небольшие математические расчетыAbsoluteLayout
позволяет свободно размещать компоненты, используя математические расчетыx
иy
координатГрадиент, тень и
blur
-эффект добавляют реалистичности и позволяют иммитировать 3D фигурыИспользуя
width
иheight
свойства мы можем создавать адаптивныеview
компоненты, которые при разных размерах экрана будут отображаться корректно
Ссылка по полный код приложения.
Комментарии (10)
trokhymchuk
10.10.2021 15:50Выглядит страшно. Я не дизайнер, но даже я понимаю, что это очень плохо.
А в чём ценность статьи? Кто-то сможет подчеркнуть для себя что-то новое? Нет. Комментатор выше правильно подчеркнул, это чистой води пиар.
KiberneticWorm Автор
10.10.2021 15:52Я просто хотел показать, какие возможности предоставляет кастомная отрисовка компонентов
trokhymchuk
10.10.2021 17:49Написали бы статью, где объяснили что это, зачем нужно и показали бы пример.
dopusteam
10.10.2021 18:06-1Все игрушки расширяют класс AbsoluteLayout и поэтому размещение кружков происходит по двум координатам x и y, которые рассчитываются математически
Границы между элементами также рассчитываются по формулам, которые я выводил путем проб и ошибок
Также для элементов добавлены тени и затемнения
Вы про это? В этом суть статьи?
trokhymchuk
10.10.2021 20:51Если я правильно понял, то статья на уровне "учимся рисовать в турбопаскале". К слову, не совсем понятно, как и зачем ТС выводил формулы, всё равно ведь выглядит криво.
Sazonov
О чём ваша статья? Тут нет ничего кроме ссылок на play market и GitHub. Мне кажется вы забыли поставить тэг «я пиарюсь».
KiberneticWorm Автор
Я лишь хотел показать какие возможности предоставляет кастомная отрисовка компонентов, что можно получить в результате кастомной отрисовки компонентов элементов, я не пиару приложения, потому что это чисто мой pet проект.
Sazonov
Так если вы хотели что-то показать, то вам стоит написать про это статью. Пока же у вас никакой статьи нет.
Чтобы правильно рассчитать координаты нужна базовая математика. Тут особо нечего расписывать. Разве что вы придумали новые и интересные трюки, как делать хорошее позиционирование на экранах с разным соотношением сторон.
Лень лазить по коду, но текстуры вы процедурно генерируете? Если да - то это тоже можно расписать в статье.