Крутим объекты Compose тапами и свайпами. Часть 2
Привет! Это 2 часть статьи про поворот Composable объектов по тапу и свайпу. В этой части мы разберемся, как можно научить Composable объект следовать за пальцем пользователя, а также, в качестве бонуса, оптимизируем рекомпозиции.
Напомню, что в итоге мы хотим получить объект, который можно вращать тапами и свайпами. При повороте объект должен ощущаться как реальный:
- Следовать за пальцем при протаскивании и доворачиваться при отпускании,
- Поворачиваться в ту сторону, на которую тапнули.
Если вы еще не читали первую часть, то она тут.
Поворот по свайпу
Для поворота по свайпу есть следующие требования:
- объект должен следовать за пальцем, если свайп не был отпущен (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.
В нашем приложении эта функциональность используется для показа реквизитов банковской карты и выглядит все следующим образом:
Код, рассмотренный в данном докладе, лежит тут.
Надеюсь, что этот гайд оказался полезен и теперь вы знаете, как сделать ваше приложение красивее и приятнее для пользователей.