Крутим объекты Compose тапами и свайпами. Часть 1

Aleksandr Masliaev
Plata card
Published in
7 min readJul 12, 2023

Вторую часть доклада можно найти тут

Привет! Меня зовут Саша, и я работаю Android-разработчиком в компании Plata. Я занимаюсь разработкой экрана деталей карты в нашем мобильном банке. В этой статье я расскажу, как Compose позволяет решить задачу, если вы хотите дать пользователю возможность что-то крутить с помощью нажатий и свайпов. У себя в приложении мы применяем это для поворотов банковской карты и просмотра ее реквизитов. Как раз эту карту можно будет увидеть на поясняющих картинках и в конце второй части.

Статья будет состоять из двух частей: в первой мы рассмотрим поворот по нажатию, а во второй — поворот по свайпу и протаскиванию.

Гайд может пригодиться не только для работы с банковской картой, но и для любых других представлений, разработанных на Compose.

Например, можно сделать так:

Подготовка

Для упрощения в этой статье мы будем крутить прямоугольник со скругленными краями. При повороте объект должен ощущаться как реальный:

  • Следовать за пальцем при протаскивании и доворачиваться при отпускании;
  • Поворачиваться в ту сторону, на которую тапнули.

Базовая верстка объекта

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

private const val CardAspectRatio = 1.5819f

@Composable
internal fun Card(modifier: Modifier = Modifier) {
val sideModifier =
modifier
.widthIn(min = 240.dp)
.aspectRatio(CardAspectRatio)
.clip(shape = RoundedCornerShape(20.dp))

Box {
Box(
modifier = sideModifier
.graphicsLayer {
alpha = 1f
}
.background(Color.Red),
)
Box(
modifier = sideModifier
.graphicsLayer {
alpha = 0f
rotationY = 180f
}
.background(Color.Blue),
)
}
}

Из интересного, у задней стороны объекта есть постоянный rotationY = 180. Сделано это для того, чтобы сзади объект не выглядел как будто “наизнанку” – без отражения по горизонтали. Еще можно заметить, что показ и скрытие сторон карты происходит при помощи alpha, а не просто при размещении боксов по условиям if else. Так сделано потому, что при подходе с if будет происходить рекомпозиция, а нам это не нужно.

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

@Composable
internal fun FlippableCardContainer() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp),
)
}
}

Работа с углами поворота

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

@Composable
internal fun Card(
rotationAngle: Float,
modifier: Modifier = Modifier,
) {

Для того, чтобы повернуть Composable представление, необходимо задать нужный угол поворота в градусах в поле rotationY в Modifier.graphicsLayer. Также зададим cameraDistance, чтобы отдалить камеру и при повороте углы карты не обрезались контейнером. Этот параметр задает расстояние по оси Z, которая идет перпендикулярно плоскости, которая складывается из осей X и Y. На плоскости X и Y отрисовываются Composable представления, а на расстоянии по оси Z находится “камера”, которая на эти представления “смотрит”. Подробнее можно почитать тут.

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

val sideModifier =
modifier
.widthIn(min = 240.dp)
.aspectRatio(CardAspectRatio)
.graphicsLayer {
rotationY = rotationAngle
cameraDistance = 12.dp.toPx()
}
.clip(shape = RoundedCornerShape(20.dp))

Теперь отрисуем сторону карты, которая видна пользователю в соответствии с углом поворота.

Угол поворота карты ограничен лишь максимальными и минимальными значениями Float, значит, нам нужно какое-то универсальное решение, которое позволит приводить угол любой величины и любого знака к диапазону от 0 до 360 градусов. Для этого воспользуемся оператором % и будем получать остаток от деления на 360. При таком расчете знак угла для нас не имеет значения, так как здесь нам важно, какую сторону нам необходимо отображать. Не важно, повернет пользователь карту на 45 или на -45 градусов, отобразить в обоих случая необходимо переднюю сторону. Поэтому возьмем абсолютное значение получившегося остатка от деления.

abs(rotationAngle % 360f)

После того, как мы получили нормализованный угол, можно смело брать и смотреть, попадает ли он в диапазон для отрисовки задней стороны карты (от 90 до 270 градусов) или нет.

val normalizedAngle = abs(rotationAngle % 360f)
val needRenderBackSide = normalizedAngle in 90f..270f

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

Box {
Box(
modifier = sideModifier
.graphicsLayer {
alpha = if (needRenderBackSide) 0f else 1f
}
.background(Color.Red),
)
Box(
modifier = sideModifier
.graphicsLayer {
alpha = if (needRenderBackSide) 1f else 0f
rotationY = 180f
}
.background(Color.Blue),
)
}

Поворот по нажатию

Объявим угол поворота в FlippableCardContainer.kt – его-то мы и будем изменять:

var targetAngle by remember { mutableStateOf(0f) }

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

Для того, чтобы отслеживать место, куда нажал пользователь, воспользуемся MutableInteractionSource. MutableInteractionSource – это cущность, которая позволяет отслеживать взаимодействия с компонентом. Под взаимодействиями подразумеваются клики (нажатие, отпускание), перетаскивания, смена фокуса и тд.

Объявляем MutableInteractionSource – в нашем случае делаем это в родителе, тк разруливать угол мы будем в нем.

val cardInteractionSource = remember { MutableInteractionSource() }

Протаскиваем его в нашу карточку и при помощи Modifier-а объявляем, что карта наша теперь кликабельная:

.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = {},
)

Тк обрабатывать клики мы будем сразу в родителе, оставляем лямбду onClick пустой.

Теперь взаимодействия пользователя с картой можно отслеживать и нам ничего не мешает ловить их в родителе (FlippableCardContainer.kt):

LaunchedEffect(Unit) {    
cardInteractionSource.interactions
.filterIsInstance<PressInteraction.Release>()
.map { println("release!") }
.launchIn(this)
}

Как писалось ранее, нам необходимо понимать, на какую именно точку/координату произошло нажатие.

Для этого у сущностей PressInteraction.Release существует поле press, внутри которого есть pressPosition. pressPosition – это оффсет, который показывает насколько далеко пользователь нажал от левой верхней точки (точки начала координат). Из этого смещения нам интересно только смещение по оси x.

Возьмем смещение по оси X для нашего клика:

val offsetInDp = with(density) {
it.press.pressPosition.x.toDp()
}

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

Посчитать ширину карточки несложно — возьмем боковой паддинг для карты и отнимем его дважды (слева и справа) от ширины экрана:

val screenWidth = LocalConfiguration.current.screenWidthDpval 
cardWidth = screenWidth.dp - CardHorizontalPadding * 2

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

Давайте заведем переменную, которая будет сигнализировать нам о том, какая сторона карты сейчас отображается:

val frontSideIsShowing = abs(targetAngle.normalizeAngle()) !in 90f..270f

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

LaunchedEffect(frontSideIsShowing) {
cardInteractionSource.interactions
.filterIsInstance<PressInteraction.Release>()
.map {
val offsetInDp = with(density) {
it.press.pressPosition.x.toDp()
}
val offsetXForContainer =
if (frontSideIsShowing) {
offsetInDp
} else {
cardWidth - offsetInDp
}
cardWidth / offsetXForContainer > 2
}
}

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

Тут тоже несложно: если по часовой, то прибавляем 180 градусов, если против — то отнимаем:

internal fun Float.findNextAngle(spinClockwise: Boolean): Float {
return if (spinClockwise) this - 180f else this + 180f
}

И все, теперь осталось только засетить новый угол и передать этот угол карточке:

LaunchedEffect(frontSideIsShowing) {
cardInteractionSource.interactions
.filterIsInstance<PressInteraction.Release>()
.map {
val offsetInDp = with(density) {
it.press.pressPosition.x.toDp()
}
val offsetXForContainer =
if (frontSideIsShowing) {
offsetInDp
} else {
cardWidth - offsetInDp
}
cardWidth / offsetXForContainer > 2
}
.collect { spinClockwise ->
targetAngle = targetAngle.findNextAngle(spinClockwise)
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalPadding),
rotationAngle = targetAngle,
interactionSource = cardInteractionSource,
)

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

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

Все, что нам нужно сделать — это лишь обернуть наш targetAngle анимированной оберткой и заменить все чтения targetAngle на эту обертку. Назовем обертку rotation, и получим следующий финальный код в FlippableCardContainer:

private val CardHorizontalPadding = 40.dp
@Composable
internal fun FlippableCardContainer() {
val density = LocalDensity.current
val cardInteractionSource = remember { MutableInteractionSource() }
var targetAngle by remember { mutableStateOf(0f) }
val rotation by animateFloatAsState(
targetValue = targetAngle,
animationSpec = tween(1000),
)

val frontSideIsShowing = abs(rotation.normalizeAngle()) !in 90f..270f
val screenWidth = LocalConfiguration.current.screenWidthDp
val cardWidth = screenWidth.dp - CardHorizontalPadding * 2
LaunchedEffect(frontSideIsShowing) {
cardInteractionSource.interactions
.filterIsInstance<PressInteraction.Release>()
.map {
val offsetInDp = with(density) {
it.press.pressPosition.x.toDp()
}
val offsetXForContainer = if (frontSideIsShowing) {
offsetInDp
} else {
cardWidth - offsetInDp
}
cardWidth / offsetXForContainer > 2
}
.collect { spinClockwise ->
targetAngle = targetAngle.findNextAngle(spinClockwise)
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalPadding),
rotationAngle = rotation,
interactionSource = cardInteractionSource,
)
}
}

internal fun Float.findNextAngle(spinClockwise: Boolean): Float {
return if (spinClockwise) this - 180f else this + 180f
}

Итоги:

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

Код, рассмотренный в обеих частях, лежит тут.

--

--