Кадр из фильма «Дюна» Дени Вильнёва

16 сентября в России стартовал фильм по мотивам романа Фрэнка Герберта «Дюна». Команда маркетинга Додо Пиццы часто проводит коллаборации по самым разным поводам и с самыми разными компаниями. «Дюна» не стала исключением — в продаже появилось комбо Дюна в специальной коробке и маска AR в Instagram.

Обычно промоушен в приложении сводится к замене картинок в меню и добавлению рекламного баннера, но на этот раз разработчики решили не оставаться в стороне и немножко покреативить. Команда мобильной разработки захотела привлечь больше внимания к новому комбо «Дюна» в приложении. Задача осложнялась тем, что нужно было всё сделать не просто быстро, а супербыстро из-за переноса премьеры на месяц раньше.

Основную идею предложил iOS-разработчик нашей команды Алексей Берёзка, а весь процесс реализации он описал в посте в Twitter. Всё, что оставалось для Android — «сделать, как на iOS» (кажется, эта шутка никогда не надоест… или это уже не шутка).

Делаем первый вариант «как обычно»

Привлекать внимание к комбо «Дюна» мы решили в двух местах — в меню и в карточке комбо. По умолчанию они выглядят так:

Комбо «Дюна» в меню
Карточка комбо «Дюна»

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

Выделили комбо «Дюна» в меню

Хотим сделать карточку продукта красивее. Самое простое — добавить фон. Получилось бы примерно так:

Делаем карточку красивее. По крайней мере, мы так считаем.

Анимируем карточку в iOS

Но Берёзке пришла идея лучше — оживить карточку с помощью движения изображений. Проследить за развитием этой мысли можно в его посте. Обсудили и пришли к тому, что очень круто было бы заставить пиццу летать вместе со стикерами и пуншем в ответ на поворот устройства в пространстве. А ещё круче, если продукты будут двигаться с разной скоростью, создавая у наблюдателя эффект параллакса. Маркетинг был в восторге от первого прототипа этой идеи. Решили делать именно так.

Приступаем к реализации в Android

У iOS есть UIInterpolatingMotionEffect, позволяющий добиться эффекта параллакса в одну строку. Для Android не удалось найти подходящего коробочного решения, поэтому пришлось стряхнуть пыль с медали за достижения в велосипедном спорте и сделать всё практически с нуля.

На решение вдохновила библиотека Parallax Layer Layout, однако она нам не очень подходила — пришлось бы использовать view group из неё, что потребовало бы больше изменений, чем мы хотели, а в будущем снизило бы гибкость разметки.

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

viewMotionHelper.registerView(view, maxTranslation)

Как это реализовать? Идея очень простая: нам нужно получить изменение угла поворота устройства вокруг осей. Попробуем сделать.

О сенсорах

Android предоставляет множество сенсоров, сообщающих об изменении положения устройства в пространстве. Для определения наклона устройства можно использовать сенсоры GYROSCOPE и ROTATION VECTOR. Второй сенсор, rotation vector, является виртуальным и совмещает в себе показания нескольких физических сенсоров устройства (в том числе и гироскопа). Над этими показаниями выполняются некоторые вычисления и мы получаем уже преобразованный результат. Данный сенсор чуть удобнее, чем гироскоп, потому что нужно будет выполнить меньше вычислений вручную. Для начала получим сенсор:

 val sensorManager = context.getSystemService(Context.SENSOR_SERVICE)
as? SensorManager
    val rotationVectorSensor =
sensorManager?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)

Далее нужно зарегистрировать слушателя изменений сенсора:

if (sensorManager == null || rotationVectorSensor == null) return

sensorManager.registerListener(object : SensorEventListener{
      override fun onSensorChanged(event: SensorEvent?) {
      }

      override fun onAccuracyChanged(
        sensor: Sensor?,
        accuracy: Int
      ) {
      }
    }, rotationVectorSensor, DEFAULT_SAMPLING_PERIOD)

Первый параметр — слушатель изменений сенсора. Мы реализуем его дальше. Второй — непосредственно сенсор, изменения которого нужно слушать. Третий параметр довольно загадочный — это желаемый период времени между событиями сенсора в микросекундах. Загадочный он потому, что система вовсе не обязана соблюдать указанное количество времени между событиями, и об этом явно говорится в документации. Для себя мы установили этот период в 100 000 микросекунд, так как при этом события приходили от сенсора с примерно устраивающим нас периодом.

Следим за положением устройства в пространстве

Напишем реализацию метода onSensorChanged. Класс SensorManager содержит вспомогательные методы для выполнения рутинной математики в трёхмерном пространстве. Среди прочих есть метод getAngleChange. С его помощью мы как раз можем получить изменение угла наклона устройства в пространстве между двумя положениями. Начальное значение нам придётся запомнить, когда придёт первое событие от сенсора. Далее будем считать изменение угла относительно этого положения.

Метод getAngleChange принимает на вход начальную и конечную матрицу поворота, а ROTATION_VECTOR сообщает вектор поворота. Для преобразования одного в другое в SensorManager есть метод getRotationMatrixFromVector. Свяжем всё вместе и получим первую реализацию:

var isInitialized = false

// матрица поворота начального положения устройства в пространстве
// массив размера 16 нужен для хранения значений матрицы 4х4
val initialValues = FloatArray(16)

// буфер для матрицы поворота
val rotationMatrix = FloatArray(16)

// буфер вычисления для изменения угла
val angleChange = FloatArray(16)

override fun onSensorChanged(event: SensorEvent) {
    // получаем вектор поворота устройства в пространстве
    val rotationVector = event.value
    
    if (!initialized) {
      initialized = true

      // запоминаем матрицу поворота для начального положения устройства
      SensorManager.getRotationMatrixFromVector(initialValues, rotationVector)
      return
    }
    
    // получаем матрицу поворота для текущего положения устройства
    SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)

    // получаем пространственный угол между начальным и текущим положением
    SensorManager.getAngleChange(angleChange, rotationMatrix, initialValues)

    // ...
}

Прототип: двигаем квадратики

Теперь в массиве angleChange хранится изменение угла поворота в радианах в диапазоне от -π до +π. Элемент с индексом 0 — поворот относительно оси Z, 1 — относительно оси X и 2 — относительно Y. Чтобы было удобнее, преобразуем радианы в число от -1 до 1:

angleChange.forEachIndexed { index, value -> 
    val fraction = (value / Math.PI)
   .coerceIn(-1.0, 1.0)
   .toFloat()

    angleChange[index] = fraction 
}

Теперь ничто не мешает нам изменить положение view на экране:

val xTranslation = -angleChange[2] * maxTranslation
val yTranslation = angleChange[1] * maxTranslation

 view.translationX = xTranslation
 view.translationY = yTranslation

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

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

Анимируем!

Чтобы сделать движение более плавным, добавим анимацию:

private fun View.animate(builder: ViewPropertyAnimator.() -> Unit) {
 animate()
     .apply {
       duration = 300
       interpolator = DecelerateInterpolator()
       builder()
     }
     .start()
}

val xTranslation = -angleChange[2] * maxTranslation
val yTranslation = angleChange[1] * maxTranslation

view.animate { translationX(xTranslation) }
view.animate { translationY(yTranslation) }

Вот что получим:

Уже выглядит значительно лучше!

Полная реализация класса ViewMotionHelper
package com.maleev.viewmotiondemo

import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.view.View
import android.view.ViewPropertyAnimator
import android.view.animation.DecelerateInterpolator
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.OnLifecycleEvent

class ViewMotionHelper(
  context: Context,
  lifecycleOwner: LifecycleOwner
) : SensorEventListener {
  companion object {
    private const val MATRIX_SIZE = 16
    private const val ROTATION_VECTOR_SIZE = 4
    private const val DEFAULT_SAMPLING_PERIOD = 100000
    private const val DEFAULT_DURATION = 300L
    private val DEFAULT_INTERPOLATOR = DecelerateInterpolator()
  }

  private val sensorManager: SensorManager? =
    context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager

  init {
    lifecycleOwner.lifecycle.addObserver(SensorLifecycleObserver())
  }

  private var initialized = false
  private var initialValues = FloatArray(MATRIX_SIZE)
  private val angleChange = FloatArray(MATRIX_SIZE)
  private val rotationMatrix = FloatArray(MATRIX_SIZE)
  private val truncatedRotationVector = FloatArray(ROTATION_VECTOR_SIZE)

  private val viewMotionSpecs = mutableListOf<ViewMotionSpec>()

  fun registerView(
    view: View,
    maxTranslation: Int
  ) {
    viewMotionSpecs.add(ViewMotionSpec(view, maxTranslation))
  }

  override fun onSensorChanged(event: SensorEvent?) {
    if (event == null) return

    val rotationVector = getRotationVectorFromSensorEvent(event)

    if (!initialized) {
      initialized = true
      SensorManager.getRotationMatrixFromVector(initialValues, rotationVector)
      return
    }
    SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)

    SensorManager.getAngleChange(angleChange, rotationMatrix, initialValues)

    angleChange.forEachIndexed { index, value ->
      angleChange[index] = radianToFraction(value)
    }

    animate()
  }

  /**
   * Map domain of tilt vector from radian (-PI, PI) to fraction (-1, 1)
   */
  private fun radianToFraction(value: Float): Float {
    return (value / Math.PI)
      .coerceIn(-1.0, 1.0)
      .toFloat()
  }

  private fun animate() {
    viewMotionSpecs.forEach { viewMotionSpec ->
      val view = viewMotionSpec.view

      val xTranslation = -angleChange[2] * viewMotionSpec.maxTranslation
      val yTranslation = angleChange[1] * viewMotionSpec.maxTranslation

      view.animate { translationX(xTranslation) }
      view.animate { translationY(yTranslation) }
    }
  }

  private fun View.animate(builder: ViewPropertyAnimator.() -> Unit) {
    animate()
      .apply {
        duration = DEFAULT_DURATION
        interpolator = DEFAULT_INTERPOLATOR
        builder()
      }
      .start()
  }

  override fun onAccuracyChanged(
    sensor: Sensor,
    p1: Int
  ) {
  }

  private fun getRotationVectorFromSensorEvent(event: SensorEvent): FloatArray {
    return if (event.values.size > ROTATION_VECTOR_SIZE) {
      // On some Samsung devices SensorManager.getRotationMatrixFromVector
      // appears to throw an exception if rotation vector has length > 4.
      // For the purposes of this class the first 4 values of the
      // rotation vector are sufficient (see crbug.com/335298 for details).
      System.arraycopy(event.values, 0, truncatedRotationVector, 0, ROTATION_VECTOR_SIZE)
      truncatedRotationVector
    } else {
      event.values
    }
  }

  private fun registerSensorListener() {
    if (sensorManager == null) return

    sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
      ?.also { sensor ->
        sensorManager.registerListener(this, sensor, DEFAULT_SAMPLING_PERIOD)
      }
  }

  private fun unregisterSensorListener() {
    sensorManager?.unregisterListener(this)
    initialized = false
  }

  private fun removeAllViews() {
    viewMotionSpecs.clear()
  }

  private class ViewMotionSpec(
    val view: View,
    val maxTranslation: Int
  )

  private inner class SensorLifecycleObserver : LifecycleObserver {
    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    fun onResume() {
      registerSensorListener()
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
    fun onPause() {
      unregisterSensorListener()
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    fun onDestroy() {
      removeAllViews()
    }
  }
}

Обратите внимание на пару нюансов:

  • если хранить view напрямую, будет происходить утечка памяти. Также нужно не забывать отписываться от событий сенсора. Проще всего подчистить всё, подписавшись на lifecycle с помощью androidx.lifecycle;

  • на некоторых устройствах Samsung метод getRotationMatrixFromVector бросает исключение при длине вектора поворота больше 4. Решение: обрезать вектор поворота по длине (это делается в методе getRotationVectorFromSensorEvent).

Заставим пиццу полетать

Вся интеграция класса в приложение теперь занимает 3 строки (по одной на пиццу, пунш и стикеры):

viewMotionHelper.registerView(duneImageStickers, dpToPx(26))
viewMotionHelper.registerView(duneImagePizza, dpToPx(50))
viewMotionHelper.registerView(duneImageDrink, dpToPx(108))

Значения максимального смещения 26, 50 и 108 были подобраны дизайнером. В итоге получаем более живую карточку комбо:

Демо проект можно найти вот тут на GitHub. А комбо «Дюна» можно заказать в приложении до 31 октября. ;-) Приятного аппетита!