Алло! Реализуем дисковый телефон с помощью Jetpack Compose

Привет, Хабр! Меня зовут Артем и я автор и ведущий YouTube и Telegram каналов Android Insights.

Данная статья — идейный продолжатель моей предыдущей статьи Создание Custom Layout в Jetpack Compose

Введение

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

Олды на месте?

7e4ca136772fcb094e7439a29b4d17eb.png

Да, по какой-то причине мне вспомнился именно телефон, по которому я мог долго разговаривать с друзьями в детстве.

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

Базовый UI

Если в прошлой статье я рассказывал о том, что как создать свой собственный Layout, который может располагать внутри себя различные объекты и применять к ним определенные правила, то в этот раз я буду использовать Canvas, на котором будет происходить рисование, а также анимации и обработка жестов.

Любое вращение должно производиться вокруг какой-то оси, в нашем случае это ось симметрии объекта. Схематично можно изобразить следующим образом

Схема вращения

7151d0a505d89fff5563fcb253627934.png

Перед тем, как реализовать само вращение, давайте создадим интерфейс, который будет похож на диск набора номера

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

У нас 10 цифр, для полного круга каждая займёт угол (полный круг — 360 градусов)

angleStep =\frac{360}{10}= 36

Просмотрев множество фотографий дисковых телефонов, я обнаружил, что у большинства циферблат занимает примерно 3 / 4 пространства на круге, поэтому шаг расположения цифр будет чуть меньше

angleStep =\frac{270}{10}= 27

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

Цифры размещаются на окружности, используя угол и радиус. Это делается с помощью тригонометрических функций:

x=centerX+radius⋅cos(angle)y=centerY+radius⋅sin(angle)

Здесь:

  • centerX, centerY — координаты центра круга

  • radius — радиус круга

  • angle — угол в радианах

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

angleInRadians=angleInDegrees⋅\frac{180}{π}

Для того, чтобы циферблат выглядел так, как я хотел, необходимо добавить смещение на -45 градусов. Формула расчета положения каждой цифры в общем виде:

angleInDegrees=angleStart+index⋅angleStep

А конкретно для нашей задачи приобретает следующий вид:

angleInDegrees=index⋅angleStep-45

Еще необходимо не забыть отцентровать текст, но это делается совсем легко, можно использовать следующие формулы:

x_{text}=x-\frac{width}{2}y_{text}=y-\frac{width}{2}

width, height — размеры текста, которые рассчитываются через TextMeasurer

Итоговый код с циферблатом выглядит следующим образом

Код

private val digits = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")

@Composable
fun OldPhone(
    modifier: Modifier,
) {
    var rotationAngle by remember { mutableFloatStateOf(0f) }
    val textMeasurer = rememberTextMeasurer()
    val typography = MaterialTheme.typography.headlineLarge

    Canvas(
        modifier = modifier.aspectRatio(1f)
    ) {
        rotate(rotationAngle) {
            drawCircle(
                color = Color.White,
            )

            val angleStep = -270f / digits.size
            val center = Offset(size.width / 2, size.height / 2)
            val radius = size.width / 2f - 20.dp.toPx()

            digits.forEachIndexed { index, digit ->
                val angleInDegrees = index * angleStep - 45
                val angleInRadians = Math.toRadians(angleInDegrees.toDouble())

                val x = center.x + radius * cos(angleInRadians).toFloat()
                val y = center.y + radius * sin(angleInRadians).toFloat()

                val textLayoutResult: TextLayoutResult = textMeasurer.measure(
                    text = digit,
                    style = typography,
                )

                drawText(
                    textLayoutResult = textLayoutResult,
                    topLeft = Offset(
                        x - textLayoutResult.size.width / 2f,
                        y - textLayoutResult.size.height / 2f,
                    ),
                )
            }
        }
    }
}

А вот так все это выглядит

Результат

ead11a431af26dc1ec03fa78578bba09.png

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

Вращение телефонного диска

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

Схематично выглядит так:

Изображение

d0fbbd8bf6e5bab96205bbd91e0c2c31.jpg

Давайте я покажу получившийся код, а потом разберу, что именно в нем происходит

Код

@Composable
fun OldPhone(
    modifier: Modifier,
) {
    var rotationAngle by remember { mutableFloatStateOf(0f) }

    // пропущенный код

    Canvas(
        modifier = modifier
            .aspectRatio(1f)
            .pointerInput(Unit) {
                awaitEachGesture {
                    val down = awaitFirstDown()
                    var currentAngle = calculateAngle(down.position, size.center)

                    do {
                        val event = awaitPointerEvent()
                        val newAngle = calculateAngle(event.changes.first().position, size.center)
                        val deltaAngle = newAngle - currentAngle
                        rotationAngle = (rotationAngle + deltaAngle)
                        currentAngle = newAngle
                        event.changes.first().consume()
                    } while (event.changes.any { it.pressed })
                }
            }
    ) {
        rotate(rotationAngle) {
            // пропущенный код
        }
    }
}

private fun calculateAngle(position: Offset, center: IntOffset): Float {
    val dx = position.x - center.x
    val dy = position.y - center.y
    return Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat()
}

Как вычисляется угол?

При каждом движении пальца мы определяем разницу по осям X и Y:

val dx = position.x - center.x // Смещение по X
val dy = position.y - center.y // Смещение по Y

Эти значения описывают вектор от центра круга к точке касания. С этим вектором мы можем вычислить угол с помощью функции atan2:

val angleInRadians = atan2(dy.toDouble(), dx.toDouble())

Теперь у нас есть угол в диапазоне от −180 до 180 градусов.

Как использовать этот угол?

Когда палец двигается, мы вычисляем разницу между новым углом и предыдущим:

val deltaAngle = newAngle - currentAngle
rotationAngle += deltaAngle
currentAngle = newAngle
  • deltaAngle — это изменение угла, которое показывает, насколько диск должен повернуться

  • Если deltaAngle положительный, диск поворачивается по часовой стрелке

  • Если отрицательный — против часовой

Вот так кратко можно описать данный алгоритм

  1. В момент касания экрана, вычисляется начальный угол

  2. При движении пальца вычисляется новый угол

  3. Разница между ними обновляет угол вращения

  4. Это позволяет диску плавно вращаться в зависимости от движения пальца.

Этот простой алгоритм делает процесс вращения интуитивным и точным.

Давайте посмотрим на результат

Результат

ac4b311635d169483ec3505e2d1246e6.gif

Мне уже нравится!

Определение нажатой цифры и ограничение вращения диска

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

Для начала нам необходимо нормализовать deltaAngle, потому что при вычислении разницы между двумя углами, результат может быть больше 180 или меньше −180 градусов из-за перехода через границы −180 и 180 градусов. Нормализация корректирует такие значения:

  • Если разница больше 180 градусов, вычитается 360 градусов, чтобы вернуть угол в нормальный диапазон

  • Если меньше −180 градусов, добавляется 360 градусов

Код выглядит следующим образом:

val deltaAngle = (newAngle - currentAngle).let { delta ->
    when {
        delta > 180f -> delta - 360f
        delta < -180f -> delta + 360f
        else -> delta
    }
}

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

var accumulatedDelta by remember { mutableFloatStateOf(0f) }

val targetRotation = rotationAngle + deltaAngle

val canRotate = (accumulatedDelta <= 270f && deltaAngle > 0) ||
                (accumulatedDelta >= -270f && deltaAngle < 0)
if (canRotate) {
    rotationAngle = targetRotation
    accumulatedDelta += deltaAngle
}

Проверка знака deltaAngle необходима, чтобы понимать направление вращения диска:

Теперь займемся определением того, какая цифра была нажата. Для этого я добавлю новый класс DigitBounds. Этот класс необходим для хранения границ каждой цифры.

private class DigitBounds(
    var left: Float = 0f,
    var top: Float = 0f,
    var right: Float = 0f,
    var bottom: Float = 0f
) {
    fun contains(point: Offset): Boolean {
        return point.x >= left && point.x <= right &&
                point.y >= top && point.y <= bottom
    }

    fun update(x: Float, y: Float, size: Float) {
        left = x - size / 2
        top = y - size / 2
        right = x + size / 2
        bottom = y + size / 2
    }
}
  • Метод contains:

    • Проверяет, находится ли точка внутри границ цифры

    • Используется для определения нажатой цифры

  • Метод update:

    • Обновляет границы цифры при отрисовке, основываясь на её позиции (x, y) и размерах

Все поля класса DigitBounds — мутабельные, это необходимо для того, чтобы не пересоздавать экземпляры данного класса во время рисования, потому что это приведет к созданию большого количества мусора

Изначально я создаю список DigitBounds равный количеству цифр

val digitBoundsList = remember { List(digits.size) { DigitBounds() } }

А заполняю этот список реальными данными уже в момент отрисоки

digits.forEachIndexed { index, _ ->
    val angleInDegrees = index * angleStep - 45
    val angleInRadians = Math.toRadians(angleInDegrees.toDouble())

    val x = center.x + radius * cos(angleInRadians).toFloat()
    val y = center.y + radius * sin(angleInRadians).toFloat()

    digitBoundsList[index].update(x, y, digitSize)
}

Само же определение нажатой цифры происходит внутри обработчика касаний

val rotatedPoint = down.position.rotate(
  center = size.center,
  degrees = -rotationAngle,
)

val digitHit = digitBoundsList.indexOfFirst { it.contains(rotatedPoint) }

if (digitHit != -1) {
    // остальной код
}

То есть я сначала я привожу точку касания к координатам диска, затем прохожусь по списку и пытаюсь найти нажатую цифру, если таковой нет, то вернется -1 и жест не будет обработан. Сама функция rotate выглядит так:

fun Offset.rotate(
    center: IntOffset,
    degrees: Float,
): Offset {
    val angle = Math.toRadians(degrees.toDouble())
    val cos = cos(angle).toFloat()
    val sin = sin(angle).toFloat()

    val x = this.x - center.x
    val y = this.y - center.y

    return Offset(
        x = center.x + (x * cos - y * sin),
        y = center.y + (x * sin + y * cos)
    )
}

Снова немного тригонометрии и получаем необходимый результат.

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

var activeDigitIndex by remember { mutableIntStateOf(-1) }

awaitEachGesture {
  // остальной код

  val digitHit = digitBoundsList.indexOfFirst { it.contains(rotatedPoint) }
  activeDigitIndex = digitHit

  if (digitHit != -1) {
      // остальной код

      activeDigitIndex = -1
  }
}

Индекс нажатой цифры был найден, осталось только нарисовать индикатор. В моем случае индикатором будет прозрачный синий круг. Давайте нарисуем его.

digits.forEachIndexed { index, digit ->
    // пропущенный код

    val isActive = index == activeDigitIndex

    if (isActive) {
        drawCircle(
            color = Color.Blue.copy(alpha = 0.3f),
            radius = digitSize / 1.8f,
            center = Offset(x, y)
        )
    }
}

Давайте соберем приложение и посмотрим на результат.

Результат

cfddc217b203f476d2d1220e62dc720c.gif

Посмотрев на текущее состояние, я решил оставить движения диска только в одну сторону, а именно от 0 до 270 градусов

val canRotate = accumulatedDelta in 0f..270f

if (canRotate) {
    rotationAngle = targetRotation.coerceIn(0f, 270f)
    accumulatedDelta = (accumulatedDelta + deltaAngle).coerceIn(0f, 270f)
}

Результат

6cca1576df5f4783827e90843eacd27b.gif

И вот так выглядит весь код компонента на данный момент

Код

private class DigitBounds(
    var left: Float = 0f,
    var top: Float = 0f,
    var right: Float = 0f,
    var bottom: Float = 0f
) {
    fun contains(point: Offset): Boolean {
        return point.x >= left && point.x <= right &&
                point.y >= top && point.y <= bottom
    }

    fun update(x: Float, y: Float, size: Float) {
        left = x - size / 2
        top = y - size / 2
        right = x + size / 2
        bottom = y + size / 2
    }
}

private val digits = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")

@Composable
fun OldPhone(
    modifier: Modifier,
) {
    var rotationAngle by remember { mutableFloatStateOf(0f) }
    val textMeasurer = rememberTextMeasurer()
    val typography = MaterialTheme.typography.headlineLarge
    var accumulatedDelta by remember { mutableFloatStateOf(0f) }
    var activeDigitIndex by remember { mutableIntStateOf(-1) }

    val digitBoundsList = remember { List(digits.size) { DigitBounds() } }

    Canvas(
        modifier = modifier
            .aspectRatio(1f)
            .pointerInput(Unit) {
                awaitEachGesture {
                    val down = awaitFirstDown()

                    val rotatedPoint = down.position.rotate(
                        center = size.center,
                        degrees = -rotationAngle,
                    )

                    val digitHit = digitBoundsList.indexOfFirst { it.contains(rotatedPoint) }
                    activeDigitIndex = digitHit

                    if (digitHit != -1) {
                        var currentAngle = calculateAngle(down.position, size.center)

                        do {
                            val event = awaitPointerEvent()
                            val newAngle = calculateAngle(event.changes.first().position, size.center)
                            val deltaAngle = (newAngle - currentAngle).let { delta ->
                                when {
                                    delta > 180f -> delta - 360f
                                    delta < -180f -> delta + 360f
                                    else -> delta
                                }
                            }

                            val targetRotation = rotationAngle + deltaAngle

                            val canRotate = accumulatedDelta in 0f..270f
                            if (canRotate) {
                                rotationAngle = targetRotation.coerceIn(0f, 270f)
                                accumulatedDelta = (accumulatedDelta + deltaAngle).coerceIn(0f, 270f)
                            }

                            currentAngle = newAngle
                            event.changes.first().consume()
                        } while (event.changes.any { it.pressed })

                        activeDigitIndex = -1
                    }
                }
            }
    ) {
        val center = Offset(size.width / 2, size.height / 2)
        val radius = size.width / 2f - 40.dp.toPx()
        val digitSize = 64.dp.toPx()
        val angleStep = -270f / digits.size

        digits.forEachIndexed { index, _ ->
            val angleInDegrees = index * angleStep - 45
            val angleInRadians = Math.toRadians(angleInDegrees.toDouble())

            val x = center.x + radius * cos(angleInRadians).toFloat()
            val y = center.y + radius * sin(angleInRadians).toFloat()

            digitBoundsList[index].update(x, y, digitSize)
        }

        rotate(rotationAngle) {
            drawCircle(
                color = Color.White,
            )

            digits.forEachIndexed { index, digit ->
                val bounds = digitBoundsList[index]
                val x = (bounds.left + bounds.right) / 2
                val y = (bounds.top + bounds.bottom) / 2

                val textLayoutResult: TextLayoutResult = textMeasurer.measure(
                    text = digit,
                    style = typography,
                )

                drawText(
                    textLayoutResult = textLayoutResult,
                    topLeft = Offset(
                        x - textLayoutResult.size.width / 2f,
                        y - textLayoutResult.size.height / 2f,
                    ),
                )

                val isActive = index == activeDigitIndex

                if (isActive) {
                    drawCircle(
                        color = Color.Blue.copy(alpha = 0.3f),
                        radius = digitSize / 1.8f,
                        center = Offset(x, y)
                    )
                }
            }
        }
    }
}

private fun calculateAngle(position: Offset, center: IntOffset): Float {
    val dx = position.x - center.x
    val dy = position.y - center.y
    return Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat()
}

fun Offset.rotate(
    center: IntOffset,
    degrees: Float,
): Offset {
    val angle = Math.toRadians(degrees.toDouble())
    val cos = cos(angle).toFloat()
    val sin = sin(angle).toFloat()

    val x = this.x - center.x
    val y = this.y - center.y

    return Offset(
        x = center.x + (x * cos - y * sin),
        y = center.y + (x * sin + y * cos)
    )
}

Возвращение диска в исходное положение

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

Заведем переменную, которая будет анимировать значение угла поворота диска, для этого я использовал функцию animateFloatAsState

    val animatedRotationAngle by animateFloatAsState(
        targetValue = rotationAngle
    )

Теперь используется animatedRotationAngle вместо rotationAngle в функции rotate

Canvas {
  // остальной код
  
  rotate(animatedRotationAngle) {
    // остальной код
  }
}

Осталось только сбрасывать угол вращения диска в 0, когда жест вращения завершается

Canvas(
        modifier = modifier
            .aspectRatio(1f)
            .pointerInput(Unit) {
                awaitEachGesture {
                        // остальной код

                        rotationAngle = 0f
                        activeDigitIndex = -1
                    }
                }
            }
    ) {
        // остальной код
    }

Кстати, пока писал статью, заметил, что переменная accumulatedDelta не нужна, поэтому я ее удалил

Вот так выглядит весь код компонента на данном этапе:

Код

private class DigitBounds(
    var left: Float = 0f,
    var top: Float = 0f,
    var right: Float = 0f,
    var bottom: Float = 0f
) {
    fun contains(point: Offset): Boolean {
        return point.x >= left && point.x <= right &&
                point.y >= top && point.y <= bottom
    }

    fun update(x: Float, y: Float, size: Float) {
        left = x - size / 2
        top = y - size / 2
        right = x + size / 2
        bottom = y + size / 2
    }
}

private val digits = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")

@Composable
fun OldPhone(
    modifier: Modifier,
) {
    var rotationAngle by remember { mutableFloatStateOf(0f) }
    val textMeasurer = rememberTextMeasurer()
    val typography = MaterialTheme.typography.headlineLarge
    var activeDigitIndex by remember { mutableIntStateOf(-1) }

    val digitBoundsList = remember { List(digits.size) { DigitBounds() } }

    val animatedRotationAngle by animateFloatAsState(
        targetValue = rotationAngle
    )

    Canvas(
        modifier = modifier
            .aspectRatio(1f)
            .pointerInput(Unit) {
                awaitEachGesture {
                    val down = awaitFirstDown()

                    val rotatedPoint = down.position.rotate(
                        center = size.center,
                        degrees = -rotationAngle,
                    )

                    val digitHit = digitBoundsList.indexOfFirst { it.contains(rotatedPoint) }
                    activeDigitIndex = digitHit

                    if (digitHit != -1) {
                        var currentAngle = calculateAngle(down.position, size.center)

                        do {
                            val event = awaitPointerEvent()
                            val newAngle = calculateAngle(event.changes.first().position, size.center)
                            val deltaAngle = (newAngle - currentAngle).let { delta ->
                                when {
                                    delta > 180f -> delta - 360f
                                    delta < -180f -> delta + 360f
                                    else -> delta
                                }
                            }

                            val targetRotation = rotationAngle + deltaAngle

                            if (rotationAngle in 0f..270f) {
                                rotationAngle = targetRotation.coerceIn(0f, 270f)
                            }

                            currentAngle = newAngle
                            event.changes.first().consume()
                        } while (event.changes.any { it.pressed })

                        rotationAngle = 0f
                        activeDigitIndex = -1
                    }
                }
            }
    ) {
        val center = Offset(size.width / 2, size.height / 2)
        val radius = size.width / 2f - 40.dp.toPx()
        val digitSize = 64.dp.toPx()
        val angleStep = -270f / digits.size

        digits.forEachIndexed { index, _ ->
            val angleInDegrees = index * angleStep - 45
            val angleInRadians = Math.toRadians(angleInDegrees.toDouble())

            val x = center.x + radius * cos(angleInRadians).toFloat()
            val y = center.y + radius * sin(angleInRadians).toFloat()

            digitBoundsList[index].update(x, y, digitSize)
        }

        rotate(animatedRotationAngle) {
            drawCircle(
                color = Color.White,
            )

            digits.forEachIndexed { index, digit ->
                val bounds = digitBoundsList[index]
                val x = (bounds.left + bounds.right) / 2
                val y = (bounds.top + bounds.bottom) / 2

                val textLayoutResult: TextLayoutResult = textMeasurer.measure(
                    text = digit,
                    style = typography,
                )

                drawText(
                    textLayoutResult = textLayoutResult,
                    topLeft = Offset(
                        x - textLayoutResult.size.width / 2f,
                        y - textLayoutResult.size.height / 2f,
                    ),
                )

                val isActive = index == activeDigitIndex

                if (isActive) {
                    drawCircle(
                        color = Color.Blue.copy(alpha = 0.3f),
                        radius = digitSize / 1.8f,
                        center = Offset(x, y)
                    )
                }
            }
        }
    }
}

private fun calculateAngle(position: Offset, center: IntOffset): Float {
    val dx = position.x - center.x
    val dy = position.y - center.y
    return Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat()
}

fun Offset.rotate(
    center: IntOffset,
    degrees: Float,
): Offset {
    val angle = Math.toRadians(degrees.toDouble())
    val cos = cos(angle).toFloat()
    val sin = sin(angle).toFloat()

    val x = this.x - center.x
    val y = this.y - center.y

    return Offset(
        x = center.x + (x * cos - y * sin),
        y = center.y + (x * sin + y * cos)
    )
}

Посмотрим на результат

Результат

e13d20df544887a896bb5d98ce3f3799.gif

Наш виртуальный телефонный диск становится все более похожим на настоящий!

Финальные штрихи

Сейчас доступный угол вращения диска не зависит от цифры и всегда равен 270 градусам, но обычно на дисковых телефонах добавляли упор, дальше которого провернуть диск не получится. Другими словами, каждая цифра имела разную длину хода: 1 — cамую короткую, а 0 — самую длинную

Давайте реализуем и это

// Вычисляем максимальный угол поворота для выбранной цифры
val maxRotation = (digitHit + 1) * 27f // 270 / 10 = 27 градусов на цифру

if (rotationAngle <= maxRotation) {
    rotationAngle = targetRotation.coerceIn(0f, maxRotation)
}

Да, вот так просто

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

// рисуем внешнюю рамку телефонного диска
drawCircle(
    color = Color.LightGray,
    radius = size.width / 2f,
    center = center,
    style = Stroke(
        width = 8.dp.toPx(),
    ),
)

// радиус внутреннего диска, который находится в центре
val centerCircleRadius = size.width / 3.21f

// рисуем внутренний диск серого цвета
drawCircle(
    color = Color.LightGray,
    radius = centerCircleRadius,
    center = center
)

// количество точек, которые будут расположены по кругу
val dotsCount = 12

// радиус каждой точки
val dotRadius = 2.dp.toPx()

// расстояние от центра диска до точек (немного меньше радиуса центрального диска)
val dotDistance = centerCircleRadius - 10.dp.toPx()

// отрисовываем точки по кругу
repeat(dotsCount) { index ->
    
    // вычисляем угол для текущей точки (360 градусов делим на количество точек)
    val angle = (360f / dotsCount) * index
    
    // переводим угол в радианы для использования в тригонометрических функциях
    val angleRad = Math.toRadians(angle.toDouble())
    
    // вычисляем x координату точки используя косинус угла
    val dotX = center.x + dotDistance * cos(angleRad).toFloat()
    
    // вычисляем y координату точки используя синус угла
    val dotY = center.y + dotDistance * sin(angleRad).toFloat()

    // рисуем точку
    drawCircle(
        color = Color.Gray,
        radius = dotRadius,
        center = Offset(dotX, dotY)
    )
}

И последняя деталь, можно сказать, что это наша вишенка на торте — ограничитель движения пальца или, если хотите, стоппер.

// Добавляем стоппер в виде трапеции
val stopperAngle = 0.0
val stopperOuterDistance = size.width / 2f // внешний радиус, на границе диска
val stopperInnerDistance = size.width / 2f - 48.dp.toPx() // увеличили глубину стоппера

// Точки для внешней (широкой) части трапеции
val outerLeftX = center.x + stopperOuterDistance * cos(Math.toRadians(stopperAngle - 5).toDouble()).toFloat()
val outerLeftY = center.y + stopperOuterDistance * sin(Math.toRadians(stopperAngle - 5).toDouble()).toFloat()
val outerRightX = center.x + stopperOuterDistance * cos(Math.toRadians(stopperAngle + 5).toDouble()).toFloat()
val outerRightY = center.y + stopperOuterDistance * sin(Math.toRadians(stopperAngle + 5).toDouble()).toFloat()

// Точки для внутренней (узкой) части трапеции
val innerLeftX = center.x + stopperInnerDistance * cos(Math.toRadians(stopperAngle - 2.5).toDouble()).toFloat()
val innerLeftY = center.y + stopperInnerDistance * sin(Math.toRadians(stopperAngle - 2.5).toDouble()).toFloat()
val innerRightX = center.x + stopperInnerDistance * cos(Math.toRadians(stopperAngle + 2.5).toDouble()).toFloat()
val innerRightY = center.y + stopperInnerDistance * sin(Math.toRadians(stopperAngle + 2.5).toDouble()).toFloat()

drawPath(
    path = Path().apply {
        moveTo(outerLeftX, outerLeftY)
        lineTo(outerRightX, outerRightY)
        lineTo(innerRightX, innerRightY)
        lineTo(innerLeftX, innerLeftY)
        close()
    },
    color = Color.LightGray
)

Мы реализовали все, что хотели. Теперь самое время насладиться финальным результатом!

Результат

abce85c1649aa58c0b3a623d54c9427f.gif

Заключение

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

Надеюсь, что данная статья вам понравилась.

Если вы дочитали до конца, то хочу пригласить вас на свои YouTube и Telegram каналы.

Спасибо!

Habrahabr.ru прочитано 1707 раз