Site icon AppTractor

Создано в Compose: диск выбора месяца в Airbnb

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

Циферблат

Для первоначальной настройки давайте создадим циферблат с диапазоном, соответствующим версии Airbnb. У них 12 сегментов, всего 12 месяцев, поэтому у нас будет интервал 30º (360÷12).

Но у него есть нижняя граница 1, поэтому пользователь не может выбрать 0 месяцев. Из-за этого мы установим startDegrees равным 30º, а общий диапазон — 330º (360º — 30º).

Таким образом, начальная точка будет 1 месяц, в положении «1 час», а 12 месяцев будут в положении «12 часов». Просто имейте в виду, что нам нужно будет добавить эти 30º для активной части.

Наконец, valueRange автоматически сопоставит наш 12-месячный диапазон.

var degree by remember { mutableStateOf(90f) }
val animatedDegree by animateFloatAsState(targetValue = degree)

Dial(
    degree = animatedDegree,
    onDegreeChange = { degree = it },
    sweepDegrees = 330f,
    startDegrees = 30f,
    interval = 30f,
    modifier = Modifier.size(280.dp),
    valueRange = 1f..12f,
)

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

Ползунок и неактивная часть

Начнем настройку с ползунка, который мы хотим сделать похожим на физическую ручку. Мы создадим ползунок размером 56 dp и добавим тени, градиенты и рамку для создания 3D-эффекта.

thumb = {
    Box(
        Modifier
            .size(56.dp)
            .padding(6.dp)
            .graphicsLayer {
                rotationZ = -it.absoluteDegree
            }
            .dropShadow(shape = CircleShape) {
                radius = 10f
                alpha = .4f
            }
            .border(
                width = 3.dp,
                shape = CircleShape,
                brush = Brush.verticalGradient(
                    colors = listOf(Zinc50, Zinc400)
                )
            )
            .background(Zinc200, CircleShape)
    )
}

Параметр rotationZ = -it.absoluteDegree отменяет вращение ползунка при его перемещении по циферблату. Без этого градиентная рамка вращалась бы вместе с ползунком.

Что касается неактивного трека, давайте отобразим её, используя базовые функции холста внутри модификатора drawBehind.

val ringStroke = 56.dp.toPx()
val ringRadius = center.x - (ringStroke / 2)

drawCircle(
	color = Zinc300,
	radius = ringRadius,
	style = Stroke(width = ringStroke),
)

drawEveryInterval(
	startDegrees = 0f,
	sweepDegrees = 330f,
	radius = ringRadius,
	spacing = 30f,
) { data ->
	drawCircle(
		color = Neutral500,
		radius = 3.dp.toPx(),
		center = data.position,
	)
}

Сначала мы вычисляем необходимый радиус, который равен радиусу всего циферблата за вычетом радиуса ползунка. Для обводки мы устанавливаем её размер равным размеру ползунка.

Диаграмма отображается как просто круг со стилем обводки. Но точки позиционируются с помощью функции drawEveryInterval из ChromaDial, которая принимает диапазон, радиус и интервал и позволяет нам рисовать что угодно вдоль этой окружности.

Активная часть должна располагаться поверх кольца, поэтому нам также нужно обернуть всё в Box для наложения. Заодно добавим тень со смещением вверх, чтобы кольцо слегка приподнялось над поверхностью.

track = { dialState ->
    Box {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .dropShadow(shape = CircleShape) {
                    radius = 7f
                    alpha = .15f
                    offset = Offset(0f, -8f)
                    spread = 4f
                }
                .background(shape = CircleShape, color = Zinc100)
                .drawBehind {
                    val ringStroke = 56.dp.toPx()
                    val ringRadius = center.x - (ringStroke / 2)

                    drawCircle(
                        color = Zinc300,
                        radius = ringRadius,
                        style = Stroke(width = ringStroke),
                    )

                    drawEveryInterval(
                        startDegrees = 0f,
                        sweepDegrees = 330f,
                        radius = ringRadius,
                        spacing = 30f,
                    ) { data ->
                        drawCircle(
                            color = Neutral500,
                            radius = 3.dp.toPx(),
                            center = data.position,
                        )
                    }
                }
        )
        // Active track will go here
    }
}

Форма дуги и активный трек

В Jetpack Compose нет встроенной формы для дуги с толщиной и скруглёнными концами. Обычную дугу можно нарисовать как линию, задать ей толщину (stroke) и скруглённые окончания. Однако это работает только для отрисовки, и такую форму нельзя удобно использовать в других сценариях — например, для клиппинга или применения обводки к самой форме.

В частности, для этого Dial-компонента от Airbnb требуется возможность отдельно задавать радиус скругления для каждого конца дуги. Это позволяет сделать начальный конец с небольшим радиусом, а конечный — полностью скруглённым, чтобы корректно отображать ползунок.

В библиотеке ChromaDial это реализовано через TubeShape. Она принимает радиус «трубки» (толщину), радиусы скругления для каждого конца (cap), начальный угол (start angle) и угол размаха (sweep angle).

TubeShape наследуется от Shape, поэтому его можно напрямую передавать в .clip(), .background() или .border().

По сравнению с обычной дугой, TubeShape заполняет всю толщину «трубки». Параметр tubeRadius равен половине ширины thumb, поэтому форма точно вписывается в кольцо. Радиусы скругления управляют окончаниями (caps) на каждом конце дуги. Если задать topEnd и bottomEnd равными tubeRadius, конец дуги станет идеальным полукругом. Для начального конца (topStart и bottomStart) используется значение 20f, что даёт более мягкий визуальный эффект.

val tubeRadius = with(LocalDensity.current) { 56.dp.toPx() / 2 }
val tubeCornerRadius = RoundedCornerShape(
    topStart = 20f,
    bottomStart = 20f,
    topEnd = tubeRadius,
    bottomEnd = tubeRadius,
)

val activeShape = remember(dialState.degree, dialState.overshootDegrees) {
    TubeShape(
        startAngleDegrees = 0f,
        sweepAngleDegrees = 30f + dialState.degree + dialState.overshootDegrees,
        tubeRadius = tubeRadius,
        cornerRadius = tubeCornerRadius,
    )
}

val donutShape = remember {
    TubeShape(
        startAngleDegrees = 0f,
        sweepAngleDegrees = 360f,
        tubeRadius = tubeRadius,
        cornerRadius = tubeCornerRadius,
    )
}

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

Параметр startAngleDegrees = 0f фиксирует начало заполнения на позиции «12 часов». К значению sweep дополнительно прибавляется 30f, чтобы компенсировать смещение startDegrees, заданное в компоненте.

Box(
    modifier = Modifier
        .fillMaxSize()
        .background(
            color = Red500,
            shape = activeShape,
        )
)

Как вы видите выше, нарисовать формы трубок так же просто, как передать их в модификатор background с желаемым цветом.

Центральный дисплей находится внутри компонуемого элемента дорожки. ChromaDial предоставляет нам dialState.mappedValue, число с плавающей запятой в нашем диапазоне значений, которое отслеживает движение ползунка в реальном времени. Давайте преобразуем его в целое число и используем для нашего дисплея.

val monthIndex = dialState.mappedValue.roundToInt()

Column(
    modifier = Modifier
        .fillMaxSize()
        .padding(56.dp)
        .background(color = Zinc200, shape = CircleShape)
        .innerShadow(shape = CircleShape) {
            radius = 6f
            offset = Offset(0f, -6f)
            alpha = .2f
            spread = 5f
        },
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.spacedBy(0.dp, Alignment.CenterVertically),
) {
	Text(
		text = "$monthIndex",
		modifier = Modifier.fillMaxWidth(),
		textAlign = TextAlign.Center,
		color = Neutral950,
		fontSize = 72.sp,
		fontFamily = FontFamily.Monospace,
		fontWeight = FontWeight.Bold,
	)
    Text(
        text = if (monthIndex == 1) "month" else "months",
        color = Zinc600,
        fontSize = 12.sp,
        fontFamily = FontFamily.Monospace,
    )
}

Отступ padding(56.dp) соответствует размеру ползунка, вырезая внутренний круг из области дорожки. Внутренняя тень, направленная вверх, имитирует углубление отверстия под поверхностью трубки.

Добавляем детали

Плоская заливка — хорошее начало, но Airbnb пошел еще дальше и создал эффект 3D. Давайте сделаем то же самое, добавив несколько слоев, чтобы имитировать физическую трубку. Это достигается с помощью ряда стратегических градиентов и размытий.

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

Box(
    modifier = Modifier
        .fillMaxSize()
        .clip(donutShape)
        .blur(
	        radius = 12.dp, 
	        edgeTreatment = BlurredEdgeTreatment.Unbounded
		)
        .border(
            width = 5.dp,
			color = Neutral400.copy(alpha = .6f),
            shape = donutShape,
        )
)

Далее добавляем цвет заливки с размытием 30 dp. Это рассеянное свечение, благодаря которому цвет как бы исходит изнутри трубки, а не располагается на её поверхности.

Box(
    modifier = Modifier
        .fillMaxSize()
        .clip(donutShape)
        .blur(radius = 30.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
        .background(
            color = Red500,
            shape = activeShape,
        )
)

Далее следует сплошная заливка. Поверх неё наносится радиальный градиент, который осветляет центральную часть пути и плавно переходит к краям. Это имитирует то, как цилиндрическая поверхность наиболее яркая там, где она обращена прямо к зрителю.

Box(
    modifier = Modifier
        .fillMaxSize()
        .background(
            color = Red500,
            shape = activeShape,
        )
        .background(
            brush = Brush.radialGradient(
                .7f to Orange600,
                1f to Transparent,
            ),
            shape = activeShape,
        )
)

Более мягкое внутреннее свечение создает впечатление прохождения света сквозь материал трубки. Используется более светлый оттенок с размытием 20 dp, обрезанный по заполненной области.

Box(
    modifier = Modifier
        .fillMaxSize()
        .clip(activeShape)
        .blur(radius = 20.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
        .background(
            color = Red300.copy(alpha = .2f),
            shape = activeShape,
        )
)

Наконец, тонкая штриховка, смещенная на 3 dp вверх и влево, размытая на 4 dp, с вертикальным градиентом, переходящим от прозрачного к Zinc100. Это верхний свет, падающий на верхний край трубки.

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

Спасибо за чтение и удачи!

Источник

Exit mobile version