Files
ds-galo/openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/design.md
T

5.5 KiB
Raw Blame History

Design

Context

См. proposal.md — Why. Текущее устройство просмотра:

  • ViewerScreen.kt: ViewerContent держит HorizontalPager; pagerScrollEnabled = current?.isPanorama != true. MediaPage разветвляет видео / панораму / изображение.
  • Изображение рисуется GaloAsyncImage(... ContentScale.Fit) — статично, без жестов.
  • Панорама уже имеет pinch-зум через PanoramaView + PanoramaCamera.
  • Веб-просмотрщик (frontend/.../Viewer.svelte) задаёт семантику зума: scale — множитель к натуральному размеру, предел 200% (MAX_ZOOM = 2), минимум — fit (fitScale), при зуме догружается original_url, зум сбрасывается при смене фото. Тач-жестов в вебе нет — жесты проектируем заново.
  • Прецедент ручных жестов: AvatarCropDialog.kt (pointerInput { detectDragGestures }).

Goals / Non-Goals

Goals:

  • Дать зум обычных изображений в Android-просмотрщике с веб-паритетом по семантике (предел от натурального размера, догрузка оригинала).
  • Сохранить корректную навигацию по альбому (свайп) вне зума.

Non-Goals:

  • Зум видео (исключён требованием просмотра медиа).
  • Изменения панорамы — её зум остаётся как есть.
  • Изменения веб-клиента и сервера.

Decisions

Ручные жесты вместо библиотеки

Зум реализуется composable-обёрткой над изображением с Modifier.pointerInput + graphicsLayer (scale/translation), без сторонних зависимостей. Альтернатива — библиотека зума; отклонено: в проекте уже принят ручной подход (AvatarCropDialog), а новых зависимостей избегаем.

Масштаб как множитель к натуральному размеру (вариант A)

Храним scale относительно натурального размера, как в вебе: fitScale = min(viewW / natW, viewH / natH, 1), допустимый диапазон [fitScale, 2.0] (200% натурального). Натуральные размеры берём из Photo.width/height (уже есть в модели), при отсутствии — из измеренного intrinsicSize изображения. Альтернатива (множитель от fit) отклонена в пользу веб-паритета.

Двойной тап -> 100% натурального с якорем в точку тапа

Двойной тап переключает fit <-> 100% натурального размера, центрируя на точке нажатия. Это тач-замена веб-Space, но с якорем в палец вместо центра.

Состояние зума живёт в ViewerContent

zoomed поднимается в ViewerContent, а zoomable-обёртка сообщает его вверх через колбэк. pagerScrollEnabled = current?.isPanorama != true && !zoomed — по той же логике, что уже применяется к панораме. Альтернатива (держать состояние внутри MediaPage) не даёт управлять pager'ом.

Догрузка оригинала через Coil по стабильному ключу

При входе в зум источник изображения переключается с previewUrl на originalUrl; Coil кэширует по устойчивому ключу объекта (ObjectCacheKey), поэтому подмена не ломает кэш. Пока оригинал грузится, на экране остаётся превью: запрос оригинала получает placeholderMemoryCacheKey превью, чтобы вход в зум не мигал пустым (чёрным) фоном. Сбрасывается при смене фото.

Сброс при смене фото

Состояние зума живёт на страницу pager (ключ — id фото); при переходе на другой элемент оно пересоздаётся во вписанном виде, что соответствует требованию сброса.

Risks / Trade-offs

  • [Свайп pager перехватывает горизонтальный жест увеличенного фото] -> pagerScrollEnabled выключается, пока zoomed.
  • [Photo.width/height может быть 0 до извлечения EXIF] -> fallback на intrinsicSize загруженного изображения; при 0/0 зум временно недоступен.
  • [Догрузка оригинала расходует трафик при каждом входе в зум] -> загрузка только по факту зума и с дисковым кэшем Coil.
  • [Точность якоря при двойном тапе и pinch] -> покрываем юнит-тестом на пересчёт scale/offset.