Реалізація візуального редактора діалогових дерев бота в мобільному додатку
Візуальний редактор діалогових дерев — це повнофункціональний graph editor прямо в мобільному додатку. Користувач перемішує вузли по холсту, з'єднує їх стрілками, редагує вміст кожного вузла. Один з найтехнічно складних UI-компонентів у мобільній розробці: нескінченний canvas, система з кількома одночасними розпізнавачами, рендеринг тисяч елементів, undo/redo, сериалізація графа.
Фундамент: нескінченний canvas
Основа редактора — нескінченне масштабоване простір для розміщення вузлів. Два принципово різних підходи:
UIScrollView як viewport
На iOS UIScrollView з contentSize набагато більшим видимої площі (наприклад, 5000×5000 pt). Вузли — UIView усередині contentView. Масштабування через UIScrollView.minimumZoomScale / maximumZoomScale та viewForZooming(in:). Плюси: нативний scroll, momentum, rubber band — все безкоштовно. Мінуси: при zoom вузли масштабуються разом з текстом, labels стають надто маленькими або великими. Фікс — обернена трансформація labels: при 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 точніше.
Конфлікт жестів вирішується через 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 — граф ховається. 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, працює з registerUndo(withTarget:handler:). Або власний CommandStack з undoStack: [GraphCommand] та redoStack: [GraphCommand]. Shake-to-undo на iOS працює автоматично з UndoManager при motionBegan(_:with:).
Групування команд: переміщення кількох виділених вузлів — одна операція undo. UndoManager.beginUndoGrouping() / endUndoGrouping() або wrapper CompositeCommand.
Виділення та множинне виділення
Лассо-виділення: 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 на рівні вузла з server timestamp. updatedAt: Date на кожній вузлі, при merge беремо версію з більш пізнім timestamp.
Терміни: від 1 тижня до 3 місяців. WebView-based редактор на React Flow з нативною обертткою — 1–2 тижні. Повнофункціональний нативний редактор з кастомним canvas, undo/redo, множинним виділенням, real-time синхронізацією та всіма типами вузлів — 1,5–3 місяці.







