В четвертой части этой серии мы начнем собирать воедино все, чему мы научились до сих пор, и даже немного больше, и создадим наш слайдер Labeled Range Slider.
Нарисуйте пользовательский интерфейс
Для начала нам нужно разделить различные элементы, задействованные в нашем Composable, и то, как их рисовать.
Как видно на следующем изображении, мы можем разбить ползунок Labeled Range Slider на 5 элементов.
У нас есть
- Ярлыки над ползунком, указывающие на доступные и выбранные значения. Цвет и стиль шрифта должны отражать выбранный нами диапазон (красный).
- Закругленная полоса на заднем плане, направляющая наши ползунки (фиолетовый)
- Маркеры шагов, указывающие на все доступные значения на нашей панели (зеленый)
- Индикация выбранного диапазона на самой полосе (синий).
- И, наконец, ручки ползунков, которые мы хотим перетащить по полосе, чтобы выбрать диапазон (оранжевый).
Полоса с закругленным фоном (Фиолетовый)
Самым простым элементом для начала работы и настройки Composable является серая полоса на заднем плане, направляющая наши ползунки. Прежде чем приступить к рисованию полосы, нам сначала нужно выполнить некоторые подготовительные действия, например, рассчитать ширину и высоту.
По ширине мы хотим, чтобы наша полоса заполняла всю ширину доступного пространства, с некоторым отступом для ползунков, но мы вернемся к этому позже.
Чтобы получить размер нашей композиции, мы можем использовать Modifier.onSizeChanged и сохранить это значение в состоянии.
В зависимости от этого значения мы можем определить высоту прямоугольника. Для высоты мы оставляем ее простой и позволяем вызывающей стороне настраивать ее, но предоставляем разумное значение по умолчанию — 12 Dp.
Также мы добавляем параметр для цвета полосы и размера закругленных углов.
@Composable
fun LabeledRangeSlider(
modifier: Modifier = Modifier,
barHeight: Dp = 12.dp,
barColor: Color = Color.LightGray,
barCornerRadius: Dp = 6.dp
) {
var composableSize by remember { mutableStateOf(IntSize(0, 0)) }
val height = barHeight
val barWidth = remember(key1 = composableSize) { composableSize.width.toFloat() }
val barXStart = 0f
val barYStart = 0f
Canvas(
modifier = modifier
.height(height)
.onSizeChanged {
composableSize = it
}
) {
drawRoundRect(
color = barColor,
topLeft = Offset(barXStart, barYStart),
size = Size(barWidth, barHeight.toPx()),
cornerRadius = CornerRadius(barCornerRadius.toPx(), barCornerRadius.toPx())
)
}
}
Мы уже подготовили некоторые переменные, такие как height, barWidth, barXStart и barYStart. Они понадобятся нам позже для расчета лучшего позиционирования. Высоту мы поместили в Modifier.height нашего холста, и мы используем лучшую практику, позволяющую передавать модификатор, так что ширина может быть определена вызывающей стороной.
Интересно отметить: мы сделали пересчет barWidth зависимым от размера Composable. Поскольку до тех пор, пока размер Composable не изменится, мы можем просто помнить barWidth.
Результат на данный момент выглядит следующим образом
Что мы также можем видеть из этого небольшого фрагмента, так это то, что у нас уже есть необходимость в некоторой конфигурации и необходимость преобразования Dp в пиксели с помощью toPx для рисования.
Что мы можем сделать, чтобы немного очистить это, так это, как мы видели в части 3, ввести класс данных конфигурации.
data class SliderConfig(
val barHeight: Dp = 12.dp,
val barColor: Color = Color.LightGray,
val barCornerRadius: Dp = 6.dp
) {
context(Density)
val barHeightPx: Float
get() = barHeight.toPx()
context(Density)
val barCornerRadiusPx
get() = barCornerRadius.toPx()
}
Мы перенесли наш конфиг в SliderConfig, и с его помощью мы можем использовать Context Receivers для инкапсуляции преобразования в пиксели непосредственно в этом классе. На этот раз мы используем Density в качестве контекста, потому что он реализуется DrawScope, но может быть использован более широко. Почему? Мы увидим чуть позже :-).
Ручка ползунка (оранжевая)
Далее добавим ручки ползунка. Как мы видим в GIF выше, вокруг ручки есть тень, и она также реагирует на прикосновение, увеличивая размер тени.
Без тени мы могли бы просто вызвать drawCircle и все. К сожалению, мы не можем легко применить эффект тени с помощью обычной функции drawCircle холста. Но, к счастью, мы можем использовать drawIntoCanvas и его функцию drawCircle. Она позволяет нам предоставить параметр Paint, с помощью которого мы можем реализовать нашу тень.
private fun DrawScope.drawCircleWithShadow(
position: Offset,
touched: Boolean,
sliderConfig: SliderConfig
) {
val touchAddition = if (touched) {
sliderConfig.touchCircleShadowTouchedSizeAdditionPx
} else {
0f
}
drawIntoCanvas {
val paint = androidx.compose.ui.graphics.Paint()
val frameworkPaint = paint.asFrameworkPaint()
frameworkPaint.color = sliderConfig.touchCircleColor.toArgb()
frameworkPaint.setShadowLayer(
sliderConfig.touchCircleShadowSizePx + touchAddition,
0f,
0f,
Color.DarkGray.toArgb()
)
it.drawCircle(
position,
sliderConfig.touchCircleRadiusPx,
paint
)
}
}
Как мы видим, мы создаем объект Paint и преобразуем его в NativePaint. Это дает нам доступ к setShadowLayer. Мы задаем тени размер, в зависимости от того, касается ли она круга или нет, и рисуем ею наш круг.
Мы также добавили немного больше конфигурации в наш класс SliderConfig.
С готовой функцией окружности нам нужно обновить расчет нашей полосы. Когда ручка добавляет конец полосы, мы не хотим, чтобы она перекрывала наш Composable или, что еще хуже, выходила за пределы экрана. Поэтому нам нужно добавить немного прокладки к нашей полосе.
Для этого нам нужно получить доступ к пиксельным значениям нашей конфигурации, а для этого, как мы видели выше, нам нужно находиться в области действия объекта Density. Одним из способов решения этой проблемы может быть перемещение вычислений в лямбду onDraw нашего холста. Это будет означать пересчет этих значений каждый раз, когда мы рисуем, но обновлять их нужно будет только при изменении плотности или размера нашей композиции.
Что мы можем сделать, так это создать небольшую функцию расширения для размера и плотности.
@Composable
private fun <T> Pair<IntSize, Density>.derive(additionalKey: Any? = null, block: Density.() -> T): T =
remember(key1 = first, key2 = additionalKey) {
second.block()
}
И с ее помощью мы можем написать наши вычисления размера следующим образом:
val currentDensity = LocalDensity.current
val sizeAndDensity = composableSize to currentDensity
val barYCenter = sizeAndDensity.derive { (height / 2).toPx() }
val barXStart = sizeAndDensity.derive { sliderConfig.touchCircleRadiusPx }
val barYStart = sizeAndDensity.derive { barYCenter - sliderConfig.barHeightPx / 2f }
val barWidth = sizeAndDensity.derive { composableSize.width - 2 * barXStart }
Важным моментом здесь является то, что мы всегда можем получить текущую плотность с помощью LocalDensity.current внутри Composable.
Давайте пока расположим наши ручки в начале и конце полосы и нарисуем их.
val leftCirclePosition = remember(key1 = composableSize) {
Offset(barXStart, barYCenter)
}
val rightCirclePosition = remember(key1 = composableSize) {
Offset(barXStart + barWidth, barYCenter)
}
...
// in our Canvas
drawCircleWithShadow(
leftCirclePosition,
false,
sliderConfig
)
drawCircleWithShadow(
rightCirclePosition,
false,
sliderConfig
)
Результат до сих пор выглядел следующим образом:
Метки и маркеры шагов (красный и зеленый)
Далее мы хотим нарисовать метки над полосой, а также маркеры шагов. Имеет смысл рассматривать их вместе, потому что метка и ее маркер шага должны быть правильно выровнены. Мы уже знаем, как расположить по оси y наши метки и маркеры шагов. Метки должны находиться в верхней части нашей композиции, а маркеры шагов — в середине полосы. Нам все еще нужно позиционирование на оси x для отдельных шагов. Для этого нам нужно передать шаги в наш Composable и создать небольшую функцию для вычисления координат x.
private fun calculateStepCoordinatesAndSpacing(
numberOfSteps: Int,
barXStart: Float,
barWidth: Float,
stepMarkerRadius: Float,
): Pair<FloatArray, Float> {
val stepOffset = barXStart + stepMarkerRadius
val stepSpacing = (barWidth - 2 * stepMarkerRadius) / (numberOfSteps - 1)
val stepXCoordinates = generateSequence(stepOffset) { it + stepSpacing }
.take(numberOfSteps)
.toList()
return stepXCoordinates.toFloatArray() to stepSpacing
}
Мы вычисляем начало, которое должно быть выровнено с началом нашей полосы, и, в зависимости от количества шагов, мы вычисляем расстояние между ними.
Поскольку этот расчет зависит не только от размера и плотности композита, но и от количества шагов, мы используем функцию derive для его выполнения.
val (stepXCoordinates, stepSpacing) = sizeAndDensity.derive(steps) {
calculateStepCoordinatesAndSpacing(
numberOfSteps = steps.size,
barXStart = barXStart,
barWidth = barWidth,
stepMarkerRadius = sliderConfig.stepMarkerRadiusPx
)
}
Кроме того, мы предоставляем шаги в качестве второго ключа для функции remember. Таким образом, мы можем быть уверены, что если шаги будут меняться, мы сможем обновить наш Composable.
После того как мы рассчитали позиции, мы можем нарисовать наши метки и маркеры шагов.
private fun <T> DrawScope.drawStepMarkersAndLabels(
steps: List<T>,
stepXCoordinates: FloatArray,
leftCirclePosition: Offset,
rightCirclePosition: Offset,
barYCenter: Float,
sliderConfig: SliderConfig
) {
assert(steps.size == stepXCoordinates.size) { "Step value size and step coordinate size do not match. Value size: ${steps.size}, Coordinate size: ${stepXCoordinates.size}" }
steps.forEachIndexed { index, step ->
val stepMarkerCenter = Offset(stepXCoordinates[index], barYCenter)
val isCurrentlySelectedByLeftCircle =
(leftCirclePosition.x > (stepMarkerCenter.x - sliderConfig.stepMarkerRadiusPx / 2)) &&
(leftCirclePosition.x < (stepMarkerCenter.x + sliderConfig.stepMarkerRadiusPx / 2))
val isCurrentlySelectedByRightCircle =
(rightCirclePosition.x > (stepMarkerCenter.x - sliderConfig.stepMarkerRadiusPx / 2)) &&
(rightCirclePosition.x < (stepMarkerCenter.x + sliderConfig.stepMarkerRadiusPx / 2))
val paint = when {
isCurrentlySelectedByLeftCircle || isCurrentlySelectedByRightCircle -> sliderConfig.textSelectedPaint
stepMarkerCenter.x < leftCirclePosition.x || stepMarkerCenter.x > rightCirclePosition.x -> sliderConfig.textOutOfRangePaint
else -> sliderConfig.textInRangePaint
}
drawCircle(
color = sliderConfig.stepMarkerColor,
radius = sliderConfig.stepMarkerRadiusPx,
alpha = .1f,
center = stepMarkerCenter
)
drawIntoCanvas {
val stepText = step.toString().let { text ->
if (text.length > 3) {
text.substring(0, 2)
} else {
text
}
}
it.nativeCanvas.drawText(
stepText,
stepMarkerCenter.x - (stepText.length * sliderConfig.textSizePx) / 3,
sliderConfig.textSizePx,
paint
)
}
}
}
Мы передаем вычисленные позиции оси x и шаги, чтобы выполнить итерацию по ним и расположить маркер шага и метку соответствующим образом. Как вы можете видеть, в функции drawIntoCanvas мы обращаемся к родному холсту для рисования нашей метки, поскольку обычно холст не имеет функции для рисования текста.
В зависимости от положения двух ручек мы выбираем разные краски, чтобы метки отражали выбранный диапазон и в нашем слайдере.
Мы добавили дополнительные свойства в наш SliderConfig для управления цветами, размером текста, смещением текста и цветом маркеров шага. С помощью дополнительных размеров мы можем обновить расчет высоты нашего Composable.
val height = remember(key1 = sliderConfig) { sliderConfig.touchCircleRadius * 2 + sliderConfig.textSize.value.dp + sliderConfig.textOffset }
А также расчет переменных позиционирования.
val barYCenter = sizeAndDensity.derive { composableSize.height - sliderConfig.touchCircleRadiusPx }
val barXStart = sizeAndDensity.derive { sliderConfig.touchCircleRadiusPx - sliderConfig.stepMarkerRadiusPx }
val barYStart = sizeAndDensity.derive { barYCenter - sliderConfig.barHeightPx / 2 }
val barWidth = sizeAndDensity.derive { composableSize.width - 2 * barXStart }
val barCornerRadius = sizeAndDensity.derive { CornerRadius(sliderConfig.barCornerRadiusPx, sliderConfig.barCornerRadiusPx) }
Мы помещаем drawStepMarkersAndLabels в наш canvas ниже drawRoundRect, но выше функций для рисования наших маркеров. Результат выглядит следующим образом:
Завершение работы над пользовательским интерфейсом (синий)
Как мы видим, мы почти закончили рисовать пользовательский интерфейс. Не хватает только индикации выбранного диапазона на панели и правильного позиционирования наших ручек относительно выбранного значения.
Сначала мы расположим наши ручки. Для этого мы хотим, чтобы наш Composable мог получать эти значения от вызывающей стороны, поскольку мы не хотим управлять таким состоянием.
@Composable
fun <T : Number> LabeledRangeSlider(
selectedLowerBound: T,
selectedUpperBound: T,
steps: List<T>,
modifier: Modifier = Modifier,
sliderConfig: SliderConfig = SliderConfig()
) {
...
}
С помощью этих двух значений мы можем обновить позиционирование наших манипуляторов
var leftCirclePosition by remember(key1 = composableSize) {
val lowerBoundIdx = steps.indexOf(selectedLowerBound)
mutableStateOf(Offset(stepXCoordinates[lowerBoundIdx], barYCenter))
}
var rightCirclePosition by remember(key1 = composableSize) {
val upperBoundIdx = steps.indexOf(selectedUpperBound)
mutableStateOf(Offset(stepXCoordinates[upperBoundIdx], barYCenter))
}
Теперь метка выбранного шага нарисована правильно с жирным стилем шрифта.
Последним шагом для завершения рисования пользовательского интерфейса является добавление функции drawRect ниже рисования фона панели с помощью drawRoundRect.
drawRect(
color = sliderConfig.barColorInRange,
topLeft = Offset(leftCirclePosition.x, barYStart),
size = Size(rightCirclePosition.x - leftCirclePosition.x, sliderConfig.barHeightPx)
)
Чтобы лучше видеть результат, мы установили значения selectedLowerBound и selectedUpperBound равными 10 и 90 соответственно.
Похоже, мы закончили рисовать часть нашего слайдера Labeled Range Slider :-).
Сделаем его интерактивным
Мы нарисовали все, что нужно для нашего слайдера Labeled Range Slider. Теперь нам нужно сделать его интерактивным. Пока я писал этот пост, я понял, что он уже довольно длинный, поэтому я решил разделить эту часть на другой пост.
Давайте перейдем прямо к делу или посетите GitHub, чтобы изучить полный исходный код.