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

Aleksandr Masliaev
Plata card
Published in
6 min readJul 13, 2023

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

Напомню, что в итоге мы хотим получить объект, который можно вращать тапами и свайпами. При повороте объект должен ощущаться как реальный:

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

Если вы еще не читали первую часть, то она тут.

Поворот по свайпу

Для поворота по свайпу есть следующие требования:

  • объект должен следовать за пальцем, если свайп не был отпущен (drag);
  • если объект отпустили с конечной скоростью > N, то он должен довернуться до полного разворота;
  • если отпустили с меньшей скоростью, то должен вернуться в предыдущее состояние.

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

var dragInProgress by remember { mutableStateOf(false) }

Объект должен без задержек двигаться за пальцем, значит нам необходимо изменить анимацию во время протаскивания карты:

val rotation by animateFloatAsState(
targetValue = targetAngle,
animationSpec = tween(if (dragInProgress) 0 else 1000),
)

Отслеживать drag карты можно несколькими способами. В первом варианте решения я воспользовался модифаером pointerInput. Он не подошел нам, потому что при его использовании Composable, на котором висел этот модифаер захватывал все взаимодействия. Это приводило к тому, что объект сам обрабатывал вертикальный скролл, и из-за этого нельзя было проскроллить экран. Исправляя это, я использовал Modifier draggable, у него можно выставить orientation = Orientation.Horizontal и startDragImmediately = false (по дефолту)и тогда эта проблема будет решена:

Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalPadding)
.draggable(
orientation = Orientation.Horizontal,
onDragStarted = {
dragInProgress = true
},
onDragStopped = {
dragInProgress = false
},
),
rotationAngle = rotation,
interactionSource = cardInteractionSource,
)

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

Студия подсказывает нам, что не хватает как минимум одного обязательного поля — DraggableState. При помощи него мы будем определять, как мы будем взаимодействовать с драгом, давайте зададим и его:

state = rememberDraggableState(
onDelta = {},
),

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

Здесь важно обратить внимание на следующее отношение, которое должно выполняться: если пользователь протащил объект с левой стороны в правую полностью, то он должен развернуться на 180 градусов. Оформим это соотношение математически, чтобы узнать, сколько градусов приходится на 1.dp.

val diff = 180f / cardWidth.value

Исходя из этого знания, посчитаем угол поворота в зависимости от смещения (offsetX: Float), которое мы получим в лямбде onDelta:

private fun calculateAngle(offsetX: Float, density: Density, diff: Float): Float {
val offsetInDp = with(density) { offsetX.toDp() }
return offsetInDp.value * diff
}

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

state = rememberDraggableState(
onDelta = { offsetX ->
val calculatedAngle = calculateAngle(offsetX, density, diff)
targetAngle += calculatedAngle
},
),

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

Как можно заметить на видео, карта не доворачивается до конечного угла или “не ложится на определенную сторону”.

Давайте исправим это.

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

private fun Float.findNearAngle(velocity: Float): Float {
val velocityAddition = if (abs(velocity) > 1000) {
90f * velocity.sign
} else {
0f
}
val normalizedAngle = this.normalizeAngle() + velocityAddition
val minimalAngle = (this / 360f).toInt() * 360f
return when {
normalizedAngle in -90f..90f -> minimalAngle
abs(normalizedAngle) >= 270f -> minimalAngle + 360f * this.sign
abs(normalizedAngle) >= 90f -> minimalAngle + 180f * this.sign
else -> 0f
}
}

velocityAddition – это угол, который зависит от скорости. Он может быть либо ±90, либо 0.

normalizedAngle – это сумма нормализованного угла и угла, который зависит от скорости.

minimalAngle – это угол, который будет находиться для любого угла в 0 на тригонометрической окружности. Например, нам пришел угол 1200, для него минимальный угол будет 1080 градусов. И это тоже самое, что для 120 градусов 0, потому что в обоих случаях карта ляжет “лицом” к нам. На картинке ниже minimalAngle отмечен точкой.

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

Если преодолел и прошел отметку в 90 градусов, то его необходимо довернуть до π, учитывая знак пришедшего угла.

Если преодолел и прошел отметку в 270 градусов, то его необходимо довернуть до 2*π, учитывая знак пришедшего угла.

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

onDragStopped = { lastVelocity ->
dragInProgress = false
targetAngle = targetAngle.findNearAngle(velocity = lastVelocity)
},

Как можно заметить, сейчас присутствует остановка после отпускания пальца. Скорости протаскивания и доворота не синхронизированы, и это выглядит некрасиво. Можно вручную синхронизировать скорости, а можно прибегнуть к хаку и просто изменить animationSpec в нашей анимации поворота с tween на spring:

val rotation by animateFloatAsState(
targetValue = targetAngle,
animationSpec = spring(
stiffness = if (dragInProgress) Spring.StiffnessHigh else Spring.StiffnessLow,
),
)

И тогда все будет выглядеть гораздо плавнее:

Бонус

Если воспользоваться тулзой студии Layout Inspector и посмотреть на рекомпозиции, то увидим, что Card рекомпозируется каждый раз, когда происходит рекомпозиция у FlippableCardContainer – это можно оптимизировать.

Во-первых, обернем rotation в derivedStateOf. При перетаскивании карты targetAngle меняется очень часто и это плохо сказывается на производительности, потому что при каждом изменении targetAngle триггерится рекомпозиция всего экрана и Card в том числе.

val rotationAngleState = remember {
derivedStateOf { rotation.value }
}

Во-вторых, обернем frontSideIsShowing в derivedStateOf, в противном случае рекомпозиции все равно останутся, ведь если слушать rotationAngleState.value напрямую, каждое изменение будет триггерить рекомпозицию

val frontSideIsShowing by remember {
derivedStateOf {
abs(rotationAngleState.value.normalizeAngle()) !in 90f..270f
}
}

В-третьих, обернем Modifier.draggable в remember, потому что этот Modifier нестабильный , а в onDragStopped читается переменный стейт targetAngle и без remember этот Modifier будет каждый раз создаваться заново. Вызов Card из FlippableCardContainer будет выглядеть следующим образом:

Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalPadding)
.then(
remember {
Modifier.draggable(
orientation = Orientation.Horizontal,
onDragStarted = {
dragInProgress = true
},
onDragStopped = { lastVelocity ->
dragInProgress = false
targetAngle = targetAngle.findNearAngle(velocity = lastVelocity)
},
state = draggableState,
)
},
),
rotationAngle = rotationAngleState,
interactionSource = cardInteractionSource,
)

Card теперь будет читать угол как State<Float> это позволит избежать рекомпозиции Card при каждом изменении угла (в Card угол читается в лямбде, поэтому Card не будет рекомпозироваться лишние разы), а также оптимальнее расчитывать needRenderBackSide:

val needRenderBackSide = remember {
derivedStateOf {
val normalizedAngle = abs(rotationAngle.value % 360f)
normalizedAngle in 90f..270f
}
}

Опять же, rotationAngle.value меняется очень часто, чтобы каждое изменение не триггерило рекомпозицию, мы обернули подсчет needRenderBackSide в derivedStateOf.

В итоге получаем следующую картинку по количеству рекомпозиций:

Как видно из скриншота, Compose пропускает все рекомпозиции для Card — как результат, пользователь видит более плавную анимацию поворота.

Заключение

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

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

Код, рассмотренный в данном докладе, лежит тут.

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

--

--