5.5 KiB
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.