Недавно я столкнулся с проблемой создания вот такой вьюшки:

Это фигурка из игры 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)


  1. Sazonov
    10.10.2021 10:31
    +2

    О чём ваша статья? Тут нет ничего кроме ссылок на play market и GitHub. Мне кажется вы забыли поставить тэг «я пиарюсь».


    1. KiberneticWorm Автор
      10.10.2021 15:53

      Я лишь хотел показать какие возможности предоставляет кастомная отрисовка компонентов, что можно получить в результате кастомной отрисовки компонентов элементов, я не пиару приложения, потому что это чисто мой pet проект.


      1. Sazonov
        11.10.2021 00:58
        +1

        Так если вы хотели что-то показать, то вам стоит написать про это статью. Пока же у вас никакой статьи нет.

        Чтобы правильно рассчитать координаты нужна базовая математика. Тут особо нечего расписывать. Разве что вы придумали новые и интересные трюки, как делать хорошее позиционирование на экранах с разным соотношением сторон.

        Лень лазить по коду, но текстуры вы процедурно генерируете? Если да - то это тоже можно расписать в статье.


  1. trokhymchuk
    10.10.2021 15:50

    1. Выглядит страшно. Я не дизайнер, но даже я понимаю, что это очень плохо.

    2. А в чём ценность статьи? Кто-то сможет подчеркнуть для себя что-то новое? Нет. Комментатор выше правильно подчеркнул, это чистой води пиар.


    1. KiberneticWorm Автор
      10.10.2021 15:52

      Я просто хотел показать, какие возможности предоставляет кастомная отрисовка компонентов


      1. trokhymchuk
        10.10.2021 17:49

        Написали бы статью, где объяснили что это, зачем нужно и показали бы пример.


        1. KiberneticWorm Автор
          11.10.2021 16:17

          Хорошо, я понял, распишу более подробно


      1. dopusteam
        10.10.2021 18:06
        -1

        Все игрушки расширяют класс AbsoluteLayout и поэтому размещение кружков происходит по двум координатам x и y, которые рассчитываются математически

        Границы между элементами также рассчитываются по формулам, которые я выводил путем проб и ошибок

        Также для элементов добавлены тени и затемнения

        Вы про это? В этом суть статьи?


        1. trokhymchuk
          10.10.2021 20:51

          Если я правильно понял, то статья на уровне "учимся рисовать в турбопаскале". К слову, не совсем понятно, как и зачем ТС выводил формулы, всё равно ведь выглядит криво.


          1. KiberneticWorm Автор
            11.10.2021 17:27

            Спасибо за коммент, я изменил статью