Используйте возможности пользовательского рисунка.

В Betclic мы с гордостью выпустили наш первый метагейминг в приложении. И поверьте мне, это огромный шаг вперед в букмекерской индустрии.

Бросать вызов нашим пользователям в различных ситуациях со ставками и мгновенно вознаграждать их было неслыханно среди наших конкурентов.

Чтобы достичь этого, мы хотели обеспечить наилучший пользовательский опыт, чтобы привлечь наших пользователей. Помимо многих тем, мы работали над опытом прогресса. Мы решили создать захватывающий индикатор прогресса, чтобы вознаграждать наших пользователей за их прогресс.

Когда дело доходит до создания пользовательских интерфейсов (UI), вы часто будете полагаться на доступные инструменты, предоставляемые SDK. Однако бывают случаи, когда ваши требования к пользовательскому интерфейсу не позволяют вам их использовать. Вы можете попытаться настроить свои инструменты, но это просто не будет отвечать всем требованиям.

Чтобы придать желаемый вид этому индикатору выполнения, мы решили нарисовать его сами. Оказывается, это не так сложно, как может показаться.

Создание собственного вида

Представьте художника перед доской. Ему понадобится набор кистей вместе с ведрами для краски.

В Android белая доска будет Canvas. Чтобы рисовать на нем, вам понадобятся Paint объекта вместо кистей и ведер с краской. Наконец, вы будете использовать Path объектов, чтобы направить руку художника на холст.

Мы можем манипулировать всеми вышеперечисленными объектами непосредственно внутри файла View. В частности, все волшебство происходит в обратном вызове onDraw().

Возвращаясь к индикатору выполнения, давайте начнем с его разборки.

У нас есть набор четырехугольников, отображающих разные углы. Они отделены друг от друга и имеют заполненное состояние без пробела. Наконец, у нас есть волнистая анимация, синхронизированная с процессом ее заполнения.

Прежде чем пытаться соответствовать всем этим требованиям, мы можем начать с более простой версии. Но не беспокойтесь. Мы докопаемся до сути!

Рисование односегментного индикатора выполнения

Первым шагом было бы нарисовать его самую простую версию: индикатор выполнения из одного сегмента.

Давайте пока отложим в сторону углы, интервалы и анимацию. Это влечет за собой рисование простого прямоугольника. Начнем с выделения объектов Path и Paint.

private val segmentPath: Path = Path()
private val segmentPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)

Вы никогда не должны размещать объекты внутри метода onDraw(). Оба объекта Path и Paint должны быть созданы вне его области действия. View очень часто вызывает этот обратный вызов, и в конечном итоге вам не хватит памяти. Сообщение lint предупредит вас об этом.

Чтобы получить часть рисования, мы можем выбрать метод Path drawRect() prebaked. Поскольку на следующих шагах мы будем рисовать более сложные формы, мы будем отдавать предпочтение рисованию точка за точкой.

Мы будем использовать два основных метода класса Path:

  • moveTo(): поместите кисть в определенную координату.
  • lineTo(): провести линию между двумя координатами.

Оба метода принимают значения Float в качестве аргументов. В оставшейся части статьи я буду приводить все значения координат к Float.

Начнем с верхнего левого угла, затем переместим курсор на другие координаты.

На следующем графике показан прямоугольник, который мы нарисуем, с заданной шириной (w) и высотой (h).

В Android при рисовании ось Y инвертируется. Здесь мы вычисляем сверху вниз.

Рисование такой формы подразумевает размещение курсора в верхнем левом углу, а затем проведение линии до верхнего правого угла.

path.moveTo(0f, 0f)
path.lineTo(w, 0f)

Мы повторяем этот процесс в правом нижнем углу, а затем в левом нижнем углу.

path.lineTo(w, h)
path.lineTo(0f, h)

Наконец, мы закрываем наш путь, чтобы завершить прямоугольник.

path.close()

Наша вычислительная фаза завершена. Пришло время использовать наши ведра с краской, чтобы раскрасить его!

Объект Paint обрабатывает стиль. Вы можете играть с цветом, альфа-каналом и многими другими параметрами. Перечисление Paint.Style позволяет вам решить, будет ли фигура заполненной (по умолчанию), пустой с рамкой или и тем, и другим.

В нашем примере у вас есть заполненный прямоугольник полупрозрачного серого цвета (прозрачность видна только на GIF выше).

paint.color = color
paint.alpha = alpha.toAlphaPaint()

Для альфа-свойства Paint требуется Integer от 0 до 255. Поскольку мы привыкли манипулировать Float от 0 до 1, я создал этот простой преобразователь:

fun Float.toAlphaPaint(): Int = (this * 255).toInt()

Мы готовы отобразить наш первый сегментированный индикатор выполнения. Нам нужно только положить нашу кисть на доску с вычисленными направлениями.

canvas.drawPath(path, paint)

Вот полный код для рисования нашего прямоугольника

Движение вперед с помощью многосегментного индикатора выполнения

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

var segmentCount: Int = 1 // Set wanted value here
private val segmentPaths: MutableList<Path> = mutableListOf()
private val segmentPaints: MutableList<Paint> = mutableListOf()
init {
    (0 until segmentCount).forEach { _ ->
        segmentPaths.add(Path())
        segmentPaints.add(Paint(Paint.ANTI_ALIAS_FLAG))
    }
}

Начнем с интервала. Рисование нескольких сегментов приводит к соответствующему делению ширины View и интервалов. При этом высота остается неизменной.

Как и раньше, мы будем стремиться найти четыре координаты на сегмент. Мы уже знаем координаты Y, поэтому важно найти уравнения для вычисления координат X.

Ниже находится трехсегментный индикатор выполнения. Мы аннотируем новые координаты, вводя элементы ширины сегмента (sw) и интервала (s).

Глядя на график, видно, что координаты X зависят от:

  • позиция сегмента
  • количество сегментов (count)
  • количество интервалов (s)

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

Мы уже можем вычислить ширину сегмента. Ширина View содержит столько значений ширины сегмента и интервала минус один интервал. Извлечение ширины сегмента дает:

val sw = (w - s * (count - 1)) / count

Зная это, начнем с левых координат. Для каждого сегмента координата X находится на ширине сегмента плюс интервал, в зависимости от его положения. Естественно получаем:

val topLeftX = (sw + s) * position
val bottomLeftX = (sw + s) * position

Когда мы повторяем наш список Path объектов, индекс позиции начинается с 0. Таким образом, верхняя левая координата первого сегмента правильно соответствует (0,0).

Переходя к правым углам, мы применяем ту же логику:

val topRightX = sw * (position + 1) + s * position
val bottomRightX = sw * (position + 1) + s * position

Обратите внимание, что и верхние, и нижние координаты имеют одинаковое значение X. Пока будет отличаться только координата Y — либо 0, либо h. Оно изменится, как только мы добавим в уравнение углы.

Для удобства я сгруппировал координаты X внутри класса данных SegmentCoordinates:

data class SegmentCoordinates(
    val topLeftX: Float,
    val topRightX: Float,
    val bottomLeftX: Float,
    val bottomRightX: Float
)

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

Я бы рекомендовал извлечь этот код в тестируемый класс. Все эти уравнения должны быть протестированы.

Назовем этот класс SegmentCoordinatesComputer.

Подводя итог, мы можем применить наш вычислительный механизм к нашим элементам рисования:

Вы заметили небольшое изменение в коде рисования? При рисовании каждого сегмента мы начинаем с сброса пути перед переходом к нужным координатам.

path.reset()

Поскольку теперь у нас есть несколько объектов Path, важно сбросить их, если они будут правильно использоваться в другом цикле рисования.

Рисование прогрессии

Мы нарисовали основу нашего компонента. Тем не менее, мы не можем назвать это индикатором выполнения, пока не увидим на нем какой-либо прогресс.

Вернемся к нашему предыдущему примеру. Представьте, что мы продвинулись на 2 из 3. У нас должен быть следующий график:

Здесь вы можете увидеть прогрессию как еще один сегмент поверх других. Его координаты немного отличаются, так как включают расстояние между сегментами.

Но мы можем воссоздать вычисление на основе нашего предыдущего упражнения.

  • Левые координаты всегда будут 0.
  • Правые координаты включают условие max() для предотвращения добавления отрицательного интервала, когда прогресс равен 0.
val topLeftX = 0f
val bottomLeftX = 0f
val topRight = sw * progress + s * max(0, progress - 1)
val bottomRight = sw * progress + s * max(0, progress - 1)

Обратите внимание, что мы заменили переменную position на progress, чтобы дать лучшее представление о том, чем манипулируют.

Вот класс updatedSegmentCoordinatesComputer:

Чтобы отрисовать сегмент прогресса, нам нужно объявить еще объекты Path и Paint. Мы также хотим сохранить значение progress.

var progress: Int = 0
private val progressPath: Path = Path()
private val progressPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)

Затем мы вызываем метод drawSegment() с нашим прогрессом Path, Paint и координатами.

Когда вы рисуете на доске, новый слой рисования добавляется поверх предыдущего. То же самое относится и к рисованию на Canvas. Поэтому обязательно нарисуйте сегмент прогресса в конце метода onDraw(), чтобы он отображался поверх остальных.

Попробуйте изменить значение атрибута progress на 2 и сравните его с нашим графиком выше.

Приправьте это анимацией

Как мы можем представить индикатор выполнения без анимации? В нашем метагейминговом опыте продвижение приносит радость нашим пользователям.

Для начала мы можем воспроизвести анимацию ProgressBar. Поскольку мы не могли использовать его из-за сегментированного аспекта, мы хотим воспроизвести его поведение при выполнении.

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

Мы можем разбить его на три этапа:

  1. Запуск: мы получаем координаты сегмента с учетом текущего значения progress.
  2. Продолжается: мы обновляем координаты, вычисляя линейную интерполяцию между старыми и новыми координатами.
  3. Завершено: мы получаем координаты сегмента с учетом нового значения progress.

Мы используем ValueAnimator для обновления состояния с 0 (начало) до 1 (завершение). Это позволит нам обрабатывать интерполяцию между текущей фазой.

Мы обязательно запускаем анимацию, когда View выложен с помощью обратного вызова doOnLayout().

Вы можете решить, какую продолжительность и интерполятор вы хотите. Следующие значения повторяют поведение анимации по умолчанию из ProgressBar

val progressDuration: Int = 300 // millisecondes
val progressInterpolator: Interpolator = LinearInterpolator()

Чтобы получить линейную интерполяцию (lerp), мы используем метод расширения для сравнения исходного значения (this) со значением end на определенном шаге (amount).

fun Float.lerp(
  end: Float, 
  @FloatRange(from = 0.0, to = 1.0) amount: Float
): Float = 
this * (1 - amount.coerceIn(0f, 1f)) + end * amount.coerceIn(0f, 1f)

По мере продвижения анимации мы сохраняем текущие координаты и вычисляем самые новые с учетом положения анимации (amount).

Затем происходит прогрессивное рисование благодаря методу invalidate(). Его использование заставляет View вызывать обратный вызов onDraw().

С этой анимацией теперь у вас есть компонент, воспроизводящий собственный индикатор выполнения Android, соответствующий вашим требованиям пользовательского интерфейса.

Остался еще последний штрих анимации, который я еще не рассмотрел. Если вы вспомните окончательный эффект, который мы видели в начале статьи, вы можете наблюдать что-то вроде анимации Камехамеха.

К сожалению, подробно рассказать не смогу. Он был создан нашей командой Motion Design с использованием After Effects и интерпретирован фантастической библиотекой Lottie. Хитрость, однако, заключалась в том, чтобы синхронизировать анимацию поверх нашей анимации.

Украсьте свой компонент скосами

Даже если ваш компонент уже соответствует функциональным требованиям, которые мы ожидаем от индикатора выполнения сегмента, вы можете захотеть украсить его еще на один шаг.

Чтобы отказаться от кубического дизайна, вы можете изменить форму сегментов с помощью скосов. Каждый сегмент сохраняет свое пространство между собой, но мы сгибаем внутренние сегменты под определенным углом.

Чтобы справиться с этим, я верну вас на школьную скамью. Как вы относитесь к своим навыкам тригонометрии? Может, немного ржавый?

Увеличим:

Мы контролируем и высоту, и угол. Нам нужно вычислить расстояние между пунктирным прямоугольником и треугольником.

Если вы немного помните свою тригонометрию, мы говорим о касательной треугольника. На приведенном выше графике мы вводим еще одно соединение в наше уравнение: касательный отрезок (st).

В Android метод tan() ожидает угол в радианах. Таким образом, вы должны преобразовать его в первую очередь. В Котлине это становится:

val segmentAngle = Math.toRadians(angle.toDouble())
val segmentTangent = h * tan(segmentAngle).toFloat()

С этим новейшим элементом мы должны пересчитать значение ширины сегмента:

val sw = (w - (s + st) * (count - 1)) / count

Мы могли бы продолжить модификацию наших уравнений. Но сначала нам также нужно пересмотреть, как мы рассчитываем расстояние.

Введение углов разрушает наше восприятие пространства. Мы больше не находимся в горизонтальной плоскости. Посмотрите сами.

Интервал (s), который нам нужен, больше не соответствует интервалу между сегментами (ss), который мы используем в наших уравнениях. Поэтому важно настроить способ расчета этого интервала. Немного тригонометрии в сочетании с теоремой Пифагора должно помочь:

val spacingTangent = s * tan(segmentAngle).toFloat()
val ss = sqrt(s.pow(2) + spacingTangent.pow(2))

Получаем следующие координаты базовых сегментов. В оставшейся части статьи я буду использовать переменную s для описания расстояния между сегментами.

val topLeft = (sw + st + s) * position
val bottomLeft = (sw + s) * position + st * max(0, position - 1)
val topRight = (sw + st) * (position + 1) + s * position - if (isLast) st else 0f
val bottomRight = sw * (position + 1) + (st + s) * position

где isLast = position == count — 1

Из этих уравнений следуют две вещи.

  1. Нижняя левая координата имеет условие max(), чтобы избежать рисования за ее пределами для первого сегмента.
  2. Верхний правый имеет ту же проблему для последнего сегмента и не должен добавлять дополнительный сегмент.

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

val topLeft = 0f
val bottomLeft = 0f
val topRight = (sw + st) * progress + s * max(0, progress - 1) - if (isLast) st else 0f
val bottomRight = sw * progress + (st + s) * max(0, progress - 1)

Если вы извлекли вычислительный код в соответствии с рекомендациями, вы можете напрямую запустить свой код!

Заключительные мысли

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

Мы могли бы нарисовать полудуги и край индикатора выполнения. И мы были бы готовы справиться с этим.

В заключение, вы должны предоставить общедоступный API для вашего пользовательского View. Вы даже можете принудительно задать некоторые значения, чтобы предотвратить ошибочное поведение.

Например, я решил ограничить угол от 0° до 60°. Выход за пределы растянет ваш скос, чтобы в конечном итоге сломать ваш четырехугольник.

@FloatRange(from = 0.0, to = 60.0)
var angle: Float = 0f
    set(value) {
        if (field != value) {
            field = value.coerceIn(0f, 60f)
            invalidate()
        }
    }

Чтобы еще больше помочь разработчикам, использующим ваш общедоступный API, не стесняйтесь использовать аннотации, такие как @FloatRange.

Вы можете указать все атрибуты, которые считаете нужными, например:

  • количество сегментов
  • цвета сегмента и прогресса
  • интервал
  • продолжительность анимации и интерполяция

Не забудьте вызвать invalidate() при обновлении ваших значений.

Вот окончательный класс с общедоступным API и пользовательскими атрибутами для настройки XML.

Я надеюсь, что эта статья вдохновила вас на создание своих компонентов. Мы стремимся поделиться лучшими знаниями в Betclic, и скоро мы вернемся с более проницательным контентом!