Мы разрабатываем визуальный редактор диалоговых деревьев для мобильных приложений. Это один из самых технически сложных UI-компонентов: бесконечный canvas, gesture-система с несколькими одновременными распознавателями, рендеринг тысяч элементов, undo/redo, сериализация графа. За 5+ лет на рынке мы реализовали десятки подобных проектов для чат-ботов в банкинге, e-commerce и поддержке. Наши инженеры владеют как нативными стеками (Swift, Kotlin), так и кроссплатформенными решениями (Flutter, React Native). Гарантируем корректную работу на всех целевых устройствах и соответствие гайдлайнам App Store и Google Play.
Как реализовать бесконечный холст?
Основа редактора — бесконечное масштабируемое пространство для размещения нод. Два принципиально разных подхода — сравним их в таблице.
| Критерий | UIScrollView как viewport | Кастомный canvas с трансформацией |
|---|---|---|
| Простота реализации | Высокая, нативный scroll | Средняя, требуется ручная обработка жестов |
| Масштабирование текста | Проблема: шрифты масштабируются | Фикс через обратную трансформацию |
| Гибкость zoom-to-fit | Ограниченная | Полная, через матрицу |
| Производительность (100+ нод) | Средняя | Высокая (один CALayer) |
На iOS UIScrollView с contentSize 5000×5000 pt — простой старт. Ноды — UIView внутри contentView. Масштабирование через minimumZoomScale/maximumZoomScale. Минус: при zoom текст становится нечитаемым. Фикс — обратная трансформация label'ов при scrollViewDidZoom. Кастомный canvas через CATransform3D даёт больше контроля: zoom-to-fit, snap-to-grid. Конфликт жестов решается через UIGestureRecognizerDelegate: pan на ноде — drag, pan на пустом месте — панорамирование.
В Jetpack Compose — Modifier.graphicsLayer с pointerInput для detectTransformGestures.
Почему важно корректное управление жестами?
Редактор использует одновременно pan, pinch, long press и tap. Без чёткой приоритетизации пользовательские действия будут интерпретироваться неверно. На iOS мы используем UIGestureRecognizerDelegate с shouldRecognizeSimultaneouslyWith для pan и pinch, а hit-test на начале жеста определяет, перетаскивается нода или холст. На Android — GestureDetector с различными слушателями. Такой подход исключает ложные срабатывания при интенсивной работе.
Рендеринг рёбер (стрелок)
Кривые Безье для соединений между нодами — визуально лучше прямых линий. Кубическая кривая от output handle к input handle:
let path = UIBezierPath() path.move(to: startPoint) let controlPoint1 = CGPoint(x: startPoint.x + (endPoint.x - startPoint.x) * 0.5, y: startPoint.y) let controlPoint2 = CGPoint(x: startPoint.x + (endPoint.x - startPoint.x) * 0.5, y: endPoint.y) path.addCurve(to: endPoint, controlPoint1: controlPoint1, controlPoint2: controlPoint2) edgeLayer.path = path.cgPath Каждое ребро — CAShapeLayer с strokeColor и стрелочным наконечником. При перемещении ноды — пересчитываем все CAShapeLayer.path для входящих и исходящих рёбер. CATransaction.begin(); CATransaction.setDisableActions(true) для мгновенного обновления. При большом количестве рёбер (50+) — рендеринг через один общий CALayer с draw-методом: один CGContext.addPath() для всех рёбер быстрее чем 50 отдельных слоёв.
Интерактивное создание связей
Пользователь тащит от output handle одной ноды к input handle другой — создаётся новое ребро. Реализация:
-
UILongPressGestureRecognizer(duration 0.0) на handle view — начало drag - При
.began— создаёмtemporaryEdgeLayer, начало зафиксировано на handle - При
.changed— обновляем конецtemporaryEdgeLayerна позицию пальца - Hit-test при каждом
.changed:canvas.hitTest(location, with: nil)— если попадаем на input handle — подсвечиваем её - При
.ended— если под пальцем input handle — создаём постоянное ребро, иначе удаляем temporaryEdgeLayer
Предотвращение невалидных связей: нельзя соединить ноду с собой, нельзя создать дублирующую связь, нельзя создать цикл (если граф должен быть DAG). Проверка цикла — DFS из target ноды. O(V + E) — быстро для типичных диалоговых деревьев.
Редактирование нод
Тап на ноду — открывается редактор содержимого. Для диалогового узла типа «Message»: UITextView с rich text поддержкой для текста сообщения, переключатель типа (текст/медиа/кнопки), список кнопок с возможностью добавить/удалить.
Bottom sheet (iOS 15+ UISheetPresentationController с .medium и .large detents) — пользователь видит граф и редактор одновременно в medium режиме. Keyboard avoidance: при появлении клавиатуры нижний padding UIScrollView внутри sheet увеличивается на keyboardFrame.height - sheet.frame.height + sheetMaxHeight. Обрабатывается через UIKeyboardWillShowNotification.
Undo/Redo
Command Pattern — каждое действие (перемещение ноды, создание/удаление ребра, редактирование содержимого) реализуется как объект команды с методами execute() и undo().
protocol GraphCommand { func execute() func undo() } struct MoveNodeCommand: GraphCommand { let node: GraphNode let fromPosition: CGPoint let toPosition: CGPoint func execute() { node.position = toPosition } func undo() { node.position = fromPosition } } UndoManager — встроен в iOS UIKit, работает с registerUndo(withTarget:handler:). Или собственный CommandStack с undoStack: [GraphCommand] и redoStack: [GraphCommand]. Группировка команд: перемещение нескольких выделенных нод — одна операция undo.
Выделение и multiple selection
Лассо-выделение: pan на пустом пространстве при удержании нажатия — рисует прямоугольник выделения через CAShapeLayer с fillColor = UIColor.blue.withAlphaComponent(0.1) и strokeColor = UIColor.blue. При завершении жеста — проверяем все ноды на пересечение. Выделенные ноды — синяя обводка через layer.borderColor. Pan на выделении перемещает все выделенные ноды одновременно. Delete — удаление всех выделенных нод и инцидентных рёбер.
Сериализация и синхронизация
Граф → JSON с полным описанием нод и рёбер. Автосохранение каждые 30 секунд или при каждом изменении с debounce 2 секунды. На iOS — Combine.debounce(for: .seconds(2), scheduler: RunLoop.main) на publisher изменений графа, затем URLSession.shared.dataTask для отправки на сервер.
Conflict resolution при совместном редактировании: Last-Write-Wins на уровне ноды с timestamp сервера. updatedAt: Date на каждой ноде — при merge берём версию с более поздним timestamp.
Что входит в работу?
- Исследование и проектирование структуры узлов под ваш чат-бот
- Реализация кастомного canvas с бесконечным холстом и масштабированием
- Система undo/redo и multiple selection
- Интеграция с вашим бэкендом через REST/GraphQL
- Поддержка всех типов узлов (текст, медиа, кнопки, условия)
- Тестирование на целевых устройствах и публикация в сторах
- Документация и обучение вашей команды
Срок: от 1 недели до 3 месяцев. Стоимость рассчитывается индивидуально после анализа требований. Свяжитесь с нами — оценим ваш проект и предложим оптимальное решение.







