Імплементація зуму та панорамування зображень у мобільному застосунку
Зум та пан виглядають як два gesture recognizer'а. Насправді це пов'язана система трансформацій, яка має працювати плавно на 60 fps, не конфліктувати з іншими жестами у застосунку та правильно обробляти edge cases — подвійне нажиття, межі зображення при панорамуванні, повернення в вихідний стан.
Технічні деталі реалізації
React Native — react-native-gesture-handler + react-native-reanimated. Стандартний <Image> не підтримує трансформації через жести — потрібні Animated.Image або Reanimated. Підхід з useSharedValue для scale та translateX/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 дні. Вартість розраховується індивідуально.







