Реализация визуального редактора диалоговых деревьев бота в мобильном приложении
Визуальный редактор диалоговых деревьев — это полноценный graph editor прямо в мобильном приложении. Пользователь перемещает ноды по холсту, соединяет их стрелками, редактирует содержимое каждого узла. Это один из самых технически сложных UI-компонентов в мобильной разработке: бесконечный canvas, gesture-система с несколькими одновременными распознавателями, рендеринг тысяч элементов, undo/redo, сериализация графа.
Фундамент: бесконечный canvas
Основа редактора — бесконечное масштабируемое пространство для размещения нод. Два принципиально разных подхода:
UIScrollView как viewport
На iOS UIScrollView с contentSize намного большим видимой области (например, 5000×5000 pt). Ноды — UIView внутри contentView. Масштабирование через UIScrollView.minimumZoomScale / maximumZoomScale и viewForZooming(in:). Плюс: нативный scroll, momentum, rubber band — всё бесплатно. Минус: при zoom ноды масштабируются вместе с текстом, label'ы становятся слишком маленькими или слишком большими. Фикс — обратная трансформация label'ов: при scrollViewDidZoom применяем CGAffineTransform(scaleX: 1/zoomScale, y: 1/zoomScale) к тексту внутри нод, чтобы шрифт оставался читаемым.
Кастомный canvas с трансформацией координат
Более гибкий подход: canvas — один UIView с CATransform3D трансформацией. UIPinchGestureRecognizer обновляет scale, UIPanGestureRecognizer — translation. Все ноды позиционируются в «мировых» координатах, перевод в экранные — через матрицу трансформации. Это позволяет реализовать zoom-to-fit, zoom-to-selection, snap-to-grid точнее чем через UIScrollView.
Конфликт жестов решается через UIGestureRecognizerDelegate: pan и pinch работают одновременно, но pan на ноде — это drag ноды, pan на пустом месте — панорамирование холста. Hit-test на начало жеста определяет режим.
В Jetpack Compose — Modifier.graphicsLayer { scaleX = canvasScale; scaleY = canvasScale; translationX = offsetX; translationY = offsetY } с pointerInput для обработки pan и pinch через detectTransformGestures.
Рендеринг рёбер (стрелок)
Кривые Безье для соединений между нодами — визуально лучше прямых линий. Кубическая кривая от 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 с треугольником или lineCap = .round с кастомным endcap через CAShapeLayer + UIBezierPath.
Обновление рёбер при перемещении ноды: при pan.changed на ноде — пересчитываем все CAShapeLayer.path для входящих и исходящих рёбер. CATransaction.begin(); CATransaction.setDisableActions(true) для мгновенного обновления без implicit animation.
При большом количестве рёбер (50+) — рендеринг через один общий CALayer с draw-методом и setNeedsDisplay() при изменениях. Один большой CGContext.addPath() для всех рёбер быстрее чем 50 отдельных CAShapeLayer.
Интерактивное создание связей
Пользователь тащит от 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
Предотвращение невалидных связей: нельзя соединить output ноды с собой, нельзя создать дублирующую связь, нельзя создать цикл (если граф должен быть DAG). Проверка цикла — DFS из target ноды, проверяем достижимость source ноды. O(V + E) — быстро для типичных диалоговых деревьев.
Редактирование нод
Тап на ноду — открывается редактор содержимого. Для диалогового узла типа «Message»: UITextView с rich text поддержкой для текста сообщения, переключатель типа (текст/медиа/кнопки), список кнопок с возможностью добавить/удалить.
Bottom sheet (iOS 15+ UISheetPresentationController с .medium и .large detents) для редактора — пользователь видит граф и редактор одновременно в medium режиме. При развёртывании до large — граф уходит за sheet. sheetPresentationController.animateChanges { self.detentIdentifier = .large } для программного переключения.
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]. Shake-to-undo на iOS работает автоматически с UndoManager при motionBegan(_:with:).
Группировка команд: перемещение нескольких выделенных нод — одна операция undo. UndoManager.beginUndoGrouping() / endUndoGrouping() или wrapper CompositeCommand.
Выделение и multiple selection
Лассо-выделение: pan на пустом пространстве при удержании нажатия — рисует прямоугольник выделения через CAShapeLayer с fillColor = UIColor.blue.withAlphaComponent(0.1) и strokeColor = UIColor.blue. При завершении жеста — проверяем все ноды на пересечение с прямоугольником через node.frame.intersects(selectionRect) в мировых координатах.
Выделенные ноды — визуальное выделение (синяя обводка через layer.borderColor, layer.borderWidth). Pan на выделении перемещает все выделенные ноды одновременно, сохраняя относительные позиции. Delete — удаление всех выделенных нод и инцидентных рёбер.
Сериализация и синхронизация
Граф → JSON с полным описанием нод и рёбер. Автосохранение каждые 30 секунд или при каждом изменении с debounce 2 секунды. На iOS — Combine.debounce(for: .seconds(2), scheduler: RunLoop.main) на publisher изменений графа, затем URLSession.shared.dataTask для отправки на сервер.
Conflict resolution при совместном редактировании: Operational Transform (сложно) или CRDT (Conflict-free Replicated Data Types) — для диалоговых деревьев достаточно Last-Write-Wins на уровне ноды с timestamp сервера. updatedAt: Date на каждой ноде, при merge берём версию с более поздним timestamp.
Срок: от 1 недели до 3 месяцев. WebView-based редактор на React Flow с нативной оберткой — 1–2 недели. Полноценный нативный редактор с кастомным canvas, undo/redo, multiple selection, real-time синхронизацией и всеми типами нод — 1,5–3 месяца.







