Реалізація коментаріїв до елементів (Annotations) в мобільному додатку
Аннотації в мобільному додатку — це не просто чат поверх контенту. Це привязка текстового або голосового коментарю до конкретної точки на зображенні, документі, чертежі або елементі списку. Завдання технічно цікавіше, ніж здається.
Привязка коментарю до координат
Ключова проблема — нормалізація координат. Користувач тапає на зображення на iPhone SE з однією роздільною здатністю екрана, другий дивиться той же документ на iPad Pro у landscape. Пін повинен указувати на одне й те ж місце.
Рішення: зберігаємо не абсолютні піксели, а відносні координаты — x та y як доля від ширини та висоти контейнера (від 0.0 до 1.0). При рендеринге множимо на актуальний розмір контейнера. На iOS це CGPoint(x: pin.relativeX * containerWidth, y: pin.relativeY * containerHeight). На Flutter — аналогічно через Positioned усередині Stack з обчисленими left та top.
Для документів із зумом складніше: потрібно урахувати contentOffset та zoomScale у UIScrollView. Зберігаємо координату в просторі контенту (content space), а при відображенні конвертуємо на екранні координаты через UIScrollView.convert(_:to:).
Типовий баг: піни «уїжджають» після масштабування. Відбувається тому що розрахунок велся у координатах viewport, а не content. Після исправління на content-координаты та додавання scrollViewDidZoom для примусового оновлення позицій — піни встають корректно при будь-якому масштабі.
Зберігання та синхронізація
Кожен пін — об'єкт з полями: id, contentId, relativeX, relativeY, authorId, createdAt, text, resolved. Останнє поле важливо: можливість відмічати коментар як вирішений — стандартна фіча для review-інструментів.
Для синхронізації між користувачами в реальному часі використовуємо WebSocket (Socket.io або нативний URLSessionWebSocketTask). Новий пін одразу появляється у всіх, хто дивиться той же документ. Оптимістичне оновлення: додаємо пін у локальний стейт одразу, відправляємо запит, при помилці откатуємо.
Для офлайн-сценаріїв: Core Data або SQLite з флагом pendingSync. При відновленні з'єднання батч-синхронізація через REST.
UI компонента піна
Пін на екрані — це UIView (або View у SwiftUI / widget у Flutter) з абсолютним позиціюванням. Кілька деталей з практики:
Піни не повинні вилазити за границі контейнера. При relativeX > 0.95 прижимаємо тултип до лівого краю, при < 0.05 — до правого. Аналогічно по вертикалі. Проста логіка, але без неї тултип уходить за екран.
Якщо пінів багато (50+), рендерити їх все одночасно не варто. Використовуємо кластеризацію: при дрібному масштабі групуємо близькі піни в кластер з числом. Розкриваємо при зуме. На iOS — MKClusterAnnotation як паттерн (навіть якщо не працюємо з картою). На Flutter — ручна кластеризація через quadtree або бібліотека flutter_map_marker_cluster.
Тред коментаріїв
До одного піна може бути кілька відповідей — потрібен тред. Реалізуємо через parentId: корневі коментарії мають parentId: null, відповіді ссилаються на батька. Глибше одного рівня вложеності у мобільному UI не робимо — незручно.
Компонент треда відкривається як bottom sheet (iOS: UISheetPresentationController з .medium та .large detents; Flutter: DraggableScrollableSheet). Це не перекриває весь екран та не втрачає контекст піна.
Що входить у роботу
- Компонент піна з нормалізованими координатами та підтримкою зума
- Форма додавання та редагування коментарю
- Тред відповідей у bottom sheet
- Статус «розвязано» з візуальним відрізненням
- REST API інтеграція + опціональна WebSocket синхронізація
- Кластеризація пінів при великій кількості
- Підтримка зображень, PDF, довільних View-контейнерів
Терміни
Базова реалізація (піни на зображенні, без треда та синхронізації): 2 дні. Повна версія з тредами, real-time синхронізацією та кластеризацією: 4–5 днів. Вартість розраховується індивідуально після аналізу вимог та існуючого API.







