Pinch-to-zoom под микроскопом

Привет! Меня зовут Дёмин Алексей, я Android-разработчик в Prequel - мобильном редакторе для фото и видео. Сегодня я бы хотел детально разобрать реализацию поведения Pinch-to-zoom. Такое поведение широко распространено в приложениях и выглядит привычным и естественным для большинства пользователей. На первый взгляд, его реализация на основе предоставляемого системой api не должна вызывать трудностей. Однако при попытке разработать решение, применимое в большинстве кейсов, возникают интересные нюансы, которые я постараюсь осветить в данной статье.

Постановка задачи

  1. Решение должно подходить для любого View

  2. Можно зумить несколько раз

  3. Можно двигать изображение в рамках границ

  4. Значение зума ограничено снизу и сверху

Реализация

В проекте будем зумить видео, которое проигрывается с помощью ExoPlayer в PlayerView.

Первое, что приходит в голову - это использовать ScaleGestureDetector.

Переопределим в OnScaleGestureListener пару методов:

ScaleGestureDetector(this, object : ScaleGestureDetector.OnScaleGestureListener {
		var totalScale = 1f

		override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
				player_view.pivotX = detector.focusX
				player_view.pivotY = detector.focusY
				return true
		}

		override fun onScale(detector: ScaleGestureDetector): Boolean {
				totalScale *= detector.scaleFactor
				player_view.scale(totalScale)
				return true
		}

		override fun onScaleEnd(detector: ScaleGestureDetector) = Unit
}

Засетим для View, которую хотим зумить, TouchListener

player_view.setOnTouchListener { _, event -> scaleGestureDetector.onTouchEvent(event) }

Первая итерация готова.

Запускаем и сталкиваемся с первой проблемой - View дрожит.

Дрожание происходит из-за того, что View меняется непосредственно во время обработки тача. В логах видно как скачет scaleFactor и следовательно totalScale

totalScale = 1.0 scaleFactor = 1.0
totalScale = 1.0942823 scaleFactor = 1.0942823
totalScale = 1.086125 scaleFactor = 0.9925456
totalScale = 1.1807202 scaleFactor = 1.0870942
totalScale = 1.1435295 scaleFactor = 0.96850175
totalScale = 1.2397153 scaleFactor = 1.0841131
totalScale = 1.1949267 scaleFactor = 0.9638719

Чтобы победить эту проблему, просто кладём поверх нашей PlayerView вспомогательную View и сетим TouchListener в неё.

Так-то лучше.

Но что будет, если теперь мы заходим позумить несколько раз? Например, сначала увеличим View, а потом уменьшим?

Вот и вторая проблема - когда начинается второй scale, видео резко перемещается:

Чтобы решить эту проблему, разберём подробнее, что такое pivot, и как он влияет на итоговое изображение.

Pivot

Pivot - это точка, которая во время zoom'а остаётся неподвижной.

На рисунке пример scale в 2 раза с pivot = (1,1) и pivot = (2,2)

Маленький прямоугольник - это экран нашего смартфона. Для простоты предположим, что View, которую мы растягиваем, изначально занимает весь экран. Всё, что за пределами маленького прямоугольника, пользователю не видно.

Как понятно из рисунка, то что мы увидим на экране смартфона, зависит не только от scale, но и от pivot.

Взглянем ещё раз на наш код.

override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
		player_view.pivotX = detector.focusX
		player_view.pivotY = detector.focusY
		return true
}

В начале скейла мы выставляем pivot в зависимости от положения пальцев. Но, если перед началом первого скейла наша View имела scale = 1, то перед началом второго она имеет scale > 1 и какой-то pivot = (pivotX, pivotY).

Если бы мы заскейлили в 2 раза с pivot = (1,1) и потом выставили pivot = (2,2), картинка бы дёрнулась, резко превратившись из верхнего варианта, изображённого на рисунке, в нижний. Следовательно дёргание в текущей реализации происходит из-за смены pivot'а. При этом мы не можем оставить pivot без изменений, потому что нужно, чтобы именно точка между пальцами оставалась неподвижной во время зума.

Как видно из рисунка, мы можем преобразовать верхнее состояние в нижнее, просто сдвинув картинку на одну клетку вверх и влево. Вообще scale с любым pivot'ом - это scale с дефолтным pivot (центр картинки) + какой-то сдвиг по осям X и Y.

Получается, чтобы избежать дёрганий и выставить правильный pivot, нам просто нужно рассчитать на сколько сдвинуть изображение, чтобы компенсировать изменение pivot'а.

Преобразование при смене pivot'а

В первую очередь напомним, что тачи принимает вспомогательная View и соответствующий фокус детектора приходит в её координатах. Поэтому для начала нужно перевести их в координаты View, которую мы скейлим.

Пусть сначала мы сделали скейл в 3 раза с pivot = (1,1) а теперь хотим сделать скейл с pivot = (4,4) в координатах экрана

Нам нужно перевести координаты экрана в координаты View.

Если pivot имел координаты (pivotX, pivotY), то после скейла он будет иметь координаты (pivotX * scale, pivotY * scale) Следовательно, начало координат сдвинулось на pivotX * (scale-1), pivotY*(scale-1), то есть, если фокус детектора имеет координаты (focusX, focusY) то в координатах увеличенной View координата по X будет focusX + pivotX * (scale - 1), для Y аналогично.

Осталось не забыть вычесть translation по каждой из координат и, так как нам нужны координаты до скейла, всё нужно разделить на scale.

Итоговое преобразование:

val actualPivot = PointF(
	(detector.focusX - translationX + pivotX * (totalScale - 1)) / totalScale,
	(detector.focusY - translationY + pivotY * (totalScale - 1)) / totalScale,
)

Проверим на нашем примере:

focusX = 4, pivotX = 1, scale = 3, translationX = 0

(4 + (3 - 1)) / 3 = 2

Всё верно, нижний угол синего квадрата - это точка (2,2) - в изначальном положении.

Но, как мы говорили ранее, если просто поменять pivot, то будет скачок, нам надо его компенсировать за счёт translation. На сколько же нам надо сдвинуть нашу View? Давайте разберём как сдвигаются точки при скейле в конкретным pivot.

Рассмотрим scale = 2 с pivot = (1,1)

Любая точка перемещается так, чтобы её расстояние до pivot увеличилось в scale раз. На примере чёрная точка переместилась из координат (1,2) в координаты (1,3) то есть расстояние было = 1, а стало 1 * scale = 2.

Общая формула для изменения координаты Х (для точек правее pivot'а и scale > 1)

x1 перейдёт в x1 + (x1 - pivotX) * (scale - 1)

Вернёмся к нашему преобразованию:

неподвижная точка имеет координаты (pivotX, pivotY).

Если бы мы просто скейлили с нашим actualPivot, то неподвижная точка сдвинулась бы на (actualPivot.x - pivotX)*(scale-1). Следовательно, именно на такое расстояние надо изменить translation, чтобы компенсировать сдвиг. В итоге теперь onScaleBegin будет выглядеть так:

override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
		player_view.run {
				val actualPivot = PointF(
						(detector.focusX - translationX + pivotX * (totalScale - 1)) / totalScale,
						(detector.focusY - translationY + pivotY * (totalScale - 1)) / totalScale,
				)
				translationX -= (pivotX - actualPivot.x) * (totalScale - 1)
				translationY -= (pivotY - actualPivot.y) * (totalScale - 1)
				setPivot(actualPivot)
		}
		return true
}

Перемещение

Добавим отдельный TouchListener для перемещения нашей View. Тут всё довольно просто. В начале движения запомним координаты и далее будем перемещать по ACTION_MOVE

when (event.actionMasked) {
  MotionEvent.ACTION_DOWN -> {
      prevX = event.x
      prevY = event.y
  }
       
  MotionEvent.ACTION_MOVE -> {
      moveStarted = true
      contentView?.run {
        translationX += (event.x - prevX)
        translationY += (event.y - prevY)
      }
      prevX = event.x
      prevY = event.y
  }

  MotionEvent.ACTION_UP -> {
      if (!moveStarted) return false
      reset()
  }
}

Единственная хитрость в обработке мультитача. Координаты в event для ACTION_DOWN - это координаты первого пальца, если мы поставим второй и потом уберём первый и начнём двигать, то начальные координаты надо переопределить, ну и отменить движение, когда более одного пальца касаются View. Итого:

when (event.actionMasked) {
  MotionEvent.ACTION_DOWN -> {
      prevX = event.x
      prevY = event.y
  }
        
  MotionEvent.ACTION_POINTER_UP -> {
      if (event.actionIndex == 0) {
        try {
          prevX = event.getX(1)
          prevY = event.getY(1)
        } catch (e: Exception) {
        }
      }
  }

  MotionEvent.ACTION_MOVE -> {
      if (event.pointerCount > 1) {
        prevX = event.x
        prevY = event.y
        return false
      }
      moveStarted = true
      contentView?.run {
        translationX += (event.x - prevX)
        translationY += (event.y - prevY)
      }
      prevX = event.x
      prevY = event.y
  }
  
  MotionEvent.ACTION_UP -> {
      if (!moveStarted) return false
      reset()
  }
}

Добавим коррекцию положения по завершению перемещения.

private fun translateToOriginalRect() {
		getContentViewTranslation().takeIf { it != PointF(0f, 0f) }?.let { translation ->
				player_view?.let { view ->
						view.animateWithDetach()
								.translationXBy(translation.x)
								.translationYBy(translation.y)
								.apply { duration = CORRECT_LOCATION_ANIMATION_DURATION }
								.start()
            }
        }
}

private fun getContentViewTranslation(): PointF {
    return player_view.run {
        originContentRect.let { rect ->
            val array = IntArray(2)
            getLocationOnScreen(array)
            PointF(
                when {
                    array[0] > rect.left -> rect.left - array[0].toFloat()
                    array[0] + width * scaleX < rect.right -> rect.right - (array[0] + width * scaleX)
                    else -> 0f
                },
                when {
                    array[1] > rect.top -> rect.top - array[1].toFloat()
                    array[1] + height * scaleY < rect.bottom -> rect.bottom - (array[1] + height * scaleY)
                    else -> 0f
                }
            )
        }
    }
}

Запускаем, тестим и сталкиваемся со следующей проблемой - View не сохраняет начальное положение:

Проблема объясняется следующим образом. Представим, что мы скейлим в 2 раза с pivot = (0,0). Тогда вся картинка будет увеличиваться и перемещаться вниз и вправо. А теперь будем скейлить в 1/2 c pivot в правом нижнем углу. Тогда вся картинка будет уменьшатся и приближаться к pivot, то есть также перемещаться вниз и вправо. И мы получим тот же размер, но картинка будет смещена из-за обеспечения неподвижности pivot.

Коррекция pivot

Придётся отказаться от полной неподвижности и добавить коррекцию, опять же за счёт translation. Используем метод getContentViewTranslation() и сдвинем View, чтобы она осталась в границах. Теперь onScale будет выглядеть следующим образом:

override fun onScale(detector: ScaleGestureDetector): Boolean {
  totalScale *= detector.scaleFactor
  totalScale = totalScale.coerceIn(MIN_SCALE_FACTOR, MAX_SCALE_FACTOR)               
  player_view.run {
        scale(totalScale)
        getContentViewTranslation().run {
               translationX += x
               translationY += y
        }
  }
  return true
}                          

Итоговый результат:

Выводы

ScaleGestureDetector даёт возможность легко реализовать лишь первый шаг полноценного поведения Pinch-to-zoom. Взаимодействие с уже зазумленной View имеет ряд нюансов, которые, я надеюсь, мне удалось раскрыть и предложить переиспользуемые решения.

Всем добра и плавного Pinch-to-zoom!