Реалізація коментарів до елементів (Annotations) у мобільному додатку
Уявіть: ви переглядаєте креслення на iPad, і вам потрібно вказати на конкретну деталь. Стандартний чат не підходить — доводиться описувати словами. Ми розробляємо системи анотацій, які прив'язують коментар до точної точки на зображенні, документі або елементі списку. Маємо понад 10 років досвіду в ІТ, з них 5+ років у сфері анотацій — реалізували 20+ проєктів для iOS, Android та Flutter. Розповімо про ключові технічні рішення, які забезпечують стабільність і продуктивність.
Як нормалізувати координати для різних екранів?
Ключова проблема — користувач тапає на зображення на 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. Виправляємо додаванням scrollViewDidZoom для примусового оновлення позицій.
Як реалізувати синхронізацію анотацій у реальному часі?
Кожен пін — об'єкт з полями: id, contentId, relativeX, relativeY, authorId, createdAt, text, resolved. Останнє важливо: можливість відмічати коментар як вирішений — стандартна фіча для review-інструментів.
Для синхронізації між користувачами використовуємо WebSocket (Socket.io або нативний URLSessionWebSocketTask). Новий пін одразу з'являється у всіх, хто дивиться той самий документ. Оптимістичне оновлення: додаємо пін у локальний стейт негайно, відправляємо запит, при помилці відкочуємо. Для офлайн-сценаріїв: Core Data або SQLite з прапорцем pendingSync. При відновленні з'єднання — батч-синхронізація через REST.
Приклад із практики: кластеризація для зниження навантаження
На одному з проєктів для review архітектурних креслень ми зіткнулися з проблемою: при 100+ пінах на екрані FPS падав до 15. Рішення — кластеризація. При дрібному масштабі групуємо близькі піни в кластер з числом. Розкриваємо при зумі. На iOS використовуємо MKClusterAnnotation як паттерн (навіть якщо не працюємо з картою). На Flutter — ручна кластеризація через quadtree або бібліотека flutter_map_marker_cluster. Результат: навантаження на UI знизилося в 10 разів, FPS стабільно 60.
Порівняння підходів до зберігання координат
| Підхід | Опис | Переваги | Недоліки |
|---|---|---|---|
| Абсолютні пікселі | Фіксовані x,y в пікселях | Простота реалізації | Не працюють на різних екранах |
| Відносні координати | x,y як частка від ширини/висоти | Масштабованість під екрани | Вимагають нормалізації |
| Координати content space | Внутрішня система координат документа | Коректні при зумі та скролі | Складніша конвертація |
Відносні координати з content space — оптимальний вибір. Кластеризація пінів знижує навантаження на UI в 10 разів порівняно з рендерингом усіх маркерів при 50+ точках.
UI компонента піна
Пін на екрані — це UIView (або View в SwiftUI / widget в Flutter) з абсолютним позиціюванням. Декілька деталей з практики:
- Піни не повинні вилазити за межі контейнера. При
relativeX > 0.95притискаємо тултип до лівого краю, при< 0.05— до правого. Аналогічно по вертикалі. Проста логіка, але без неї тултип йде за екран. - Якщо пінів багато (50+), рендерити їх всі одночасно не варто. Використовуємо кластеризацію: при дрібному масштабі групуємо близькі піни в кластер з числом. Розкриваємо при зумі.
Типові помилки при реалізації
- Зберігання координат у пікселях viewport — піни «їдуть» при зумі.
- Ігнорування offset і zoomScale ScrollView — позиція піна не збігається після скролу.
- Відсутність оптимістичного UI — користувач чекає відповіді сервера.
- Рендеринг усіх пінів одразу при 100+ — падіння FPS.
- Глибокі треди (більше 2 рівнів) — незручно на мобільному пристрої.
Тред коментарів
До одного піна може бути декілька відповідей — потрібен тред. Реалізуємо через 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.
Зв'яжіться з нами для оцінки вашого проєкту. Ми гарантуємо якість та дотримання термінів. Замовте розробку під ключ — отримайте готове рішення, адаптоване під ваш стек.







