Реализация зума и панорамирования изображений в мобильном приложении
Зум и пан выглядят как два gesture recognizer'а. На деле это связанная система трансформаций, которая должна работать плавно на 60 fps, не конфликтовать с другими жестами в приложении и корректно обрабатывать edge cases — двойное нажатие, границы изображения при панорамировании, возврат в исходное состояние.
Технические детали реализации
React Native — react-native-gesture-handler + react-native-reanimated. Стандартный <Image> не поддерживает трансформации через жесты — нужны Animated.Image или Reanimated. Подход с useSharedValue для scale и translate X/Y, useGestureHandler для PinchGesture + PanGesture:
const scale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const pinchGesture = Gesture.Pinch()
.onUpdate((e) => {
scale.value = clamp(savedScale.value * e.scale, 1, 5);
})
.onEnd(() => {
savedScale.value = scale.value;
if (scale.value < 1) {
scale.value = withSpring(1);
}
});
Gesture.Simultaneous(pinchGesture, panGesture) — позволяет пинч и пан работать одновременно. Gesture.Race() — если нужно разделить их по приоритету.
Ограничение пана по границам изображения. При zoom x3 изображение 375px становится 1125px. Максимальный допустимый translateX = (scaledWidth - containerWidth) / 2. Без этой проверки пользователь утащит изображение за пределы экрана. Логика bounds checking реализуется в onUpdate через clamp():
const maxTranslateX = (containerWidth * (scale.value - 1)) / 2;
translateX.value = clamp(translateX.value + delta, -maxTranslateX, maxTranslateX);
Flutter — InteractiveViewer. Встроенный виджет Flutter с minScale, maxScale, boundaryMargin. Для базового кейса достаточно. Для галереи с несколькими изображениями — InteractiveViewer внутри PageView, но тут возникает конфликт: горизонтальный свайп для смены фото vs горизонтальный пан при зуме. Решение: InteractiveViewer перехватывает пан только когда scale > 1, при scale == 1 жест передаётся PageView.
iOS нативный — UIPinchGestureRecognizer + UIPanGestureRecognizer. gestureRecognizer.require(toFail:) для корректного разрешения конфликтов. CGAffineTransform для применения трансформаций к UIImageView. UIScrollView + UIScrollViewDelegate.viewForZooming — альтернатива, которая бесплатно даёт bounce при выходе за границы и zoomRect анимации.
Двойной тап
Двойное нажатие: если scale == 1 — зумим до 2–3x в точку касания. Если уже зумлено — возвращаем к scale == 1. Анимация через withSpring (для ощущения «резиновости») или withTiming с Easing.out(Easing.cubic).
Точка зума определяется из координат тапа относительно изображения:
const focalX = tapEvent.x - containerWidth / 2;
const focalY = tapEvent.y - containerHeight / 2;
translateX.value = withSpring(-focalX * (targetScale - 1));
Из практики: приложение просмотра медицинских снимков, React Native. Зум на рентгеновских снимках в высоком разрешении (4096×4096px). На Android загрузка полноразмерного изображения в Image компонент вызывала OutOfMemoryError. Решение: react-native-fast-image с resizeMode="contain" для превью + tile-based загрузка полноразмерного через react-native-zoom-toolkit с Deep Zoom format поддержкой.
Галерея с зумом
Галерея: FlatList горизонтальный с pagingEnabled={true} (или ViewPager на Android). Каждый элемент — зумируемое изображение. Конфликт жестов при горизонтальном пане — разруливаем через activeOffsetX в Pan gesture handler: пан активируется только при смещении > 10px по горизонтали, пока scale > 1 блокируем свайп страниц.
Что входит в работу
- Pinch-to-zoom с ограничением min/max scale (обычно 1x–5x)
- Pan при увеличенном изображении с ограничением по границам
- Двойной тап — zoom in/out с анимацией в точку касания
- Bounce-возврат при выходе за пределы границ
- Интеграция в галерею/карусель с корректным разрешением конфликтов жестов
- Поддержка высокоразрешённых изображений без OutOfMemoryError
Сроки
1–3 рабочих дня — одиночное изображение с зумом. С галереей и разрешением конфликтов жестов — 2–3 дня. Стоимость рассчитывается индивидуально.







