Implementing Visual Dialog Tree Editor for Bot in Mobile Apps
A visual dialog tree editor is a full-featured graph editor right in the mobile app. User moves nodes around canvas, connects them with arrows, edits each node's content. One of the most technically complex UI components in mobile development: infinite canvas, multi-gesture system, rendering thousands of elements, undo/redo, graph serialization.
Foundation: Infinite Canvas
Base of the editor — infinite scalable space for node placement. Two fundamentally different approaches:
UIScrollView as Viewport
On iOS, UIScrollView with contentSize much larger than visible area (e.g., 5000×5000 pt). Nodes — UIView inside contentView. Zoom via UIScrollView.minimumZoomScale / maximumZoomScale and viewForZooming(in:). Pros: native scroll, momentum, rubber band — free. Cons: at zoom, nodes scale with text, labels become too small or large. Fix — inverse transform text: on scrollViewDidZoom apply CGAffineTransform(scaleX: 1/zoomScale, y: 1/zoomScale) to text inside nodes so font stays readable.
Custom Canvas with Transform
More flexible: canvas — single UIView with CATransform3D transform. UIPinchGestureRecognizer updates scale, UIPanGestureRecognizer — translation. All nodes positioned in "world" coordinates, screen space conversion via transform matrix. This enables zoom-to-fit, zoom-to-selection, snap-to-grid more precisely.
Gesture conflict solved via UIGestureRecognizerDelegate: pan and pinch work simultaneously, but pan on node — drag node, pan on empty space — pan canvas. Hit-test on gesture start determines mode.
In Jetpack Compose — Modifier.graphicsLayer { scaleX = canvasScale; scaleY = canvasScale; translationX = offsetX; translationY = offsetY } with pointerInput for pan and pinch via detectTransformGestures.
Edge (Arrow) Rendering
Bezier curves for connections between nodes — visually better than straight lines. Cubic curve from source node's output handle to target's 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
Each edge — CAShapeLayer with strokeColor and arrow head via additional CAShapeLayer with triangle or lineCap = .round with custom endcap via CAShapeLayer + UIBezierPath.
Update edges on node movement: on pan.changed on node — recalculate all CAShapeLayer.path for incoming and outgoing edges. CATransaction.begin(); CATransaction.setDisableActions(true) for instant update without implicit animation.
With many edges (50+) — render via single CALayer with draw-method and setNeedsDisplay() on changes. One large CGContext.addPath() for all edges faster than 50 separate CAShapeLayer.
Interactive Connection Creation
User drags from output handle of one node to input handle of another — new edge created. Implementation:
-
UILongPressGestureRecognizer(duration 0.0) on handle view — start drag - On
.began— createtemporaryEdgeLayer, start fixed on handle - On
.changed— updatetemporaryEdgeLayerend to finger position - Hit-test on each
.changed:canvas.hitTest(location, with: nil)— if hit input handle, highlight it - On
.ended— if input handle under finger, create permanent edge, else remove temporaryEdgeLayer
Prevent invalid connections: can't connect output to itself, no duplicate connections, no cycles (if graph should be DAG). Cycle check — DFS from target node, check source reachability. O(V + E) — fast for typical dialog trees.
Node Editing
Tap node — opens content editor. For "Message" dialog node: UITextView with rich text for message text, type toggle (text/media/buttons), button list with add/delete.
Bottom sheet (iOS 15+ UISheetPresentationController with .medium and .large detents) for editor — user sees graph and editor simultaneously in medium. At full expansion, graph hidden. sheetPresentationController.animateChanges { self.detentIdentifier = .large } for programmatic toggle.
Keyboard avoidance: on keyboard show, bottom UIScrollView padding inside sheet increases by keyboardFrame.height - sheet.frame.height + sheetMaxHeight. Handle via UIKeyboardWillShowNotification.
Undo/Redo
Command Pattern — each action (move node, create/delete edge, edit content) as command object with execute() and undo() methods.
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 — built-in iOS, works with registerUndo(withTarget:handler:). Or custom CommandStack with undoStack: [GraphCommand] and redoStack: [GraphCommand]. Shake-to-undo on iOS works automatically with UndoManager on motionBegan(_:with:).
Group commands: moving multiple selected nodes — one undo operation. UndoManager.beginUndoGrouping() / endUndoGrouping() or wrapper CompositeCommand.
Selection and Multiple Selection
Lasso selection: pan on empty space while held — draws selection rectangle via CAShapeLayer with fillColor = UIColor.blue.withAlphaComponent(0.1) and strokeColor = UIColor.blue. On gesture end — check all nodes for intersection with rectangle via node.frame.intersects(selectionRect) in world coordinates.
Selected nodes — visual highlight (blue border via layer.borderColor, layer.borderWidth). Pan on selection moves all selected nodes together, preserving relative positions. Delete — remove all selected nodes and incident edges.
Serialization and Sync
Graph → JSON with complete node and edge description. Auto-save every 30 seconds or on each change with 2-second debounce. On iOS — Combine.debounce(for: .seconds(2), scheduler: RunLoop.main) on graph change publisher, then URLSession.shared.dataTask to send server.
Conflict resolution in collaborative editing: Operational Transform (complex) or CRDT (Conflict-free Replicated Data Types) — for dialog trees, Last-Write-Wins per node with server timestamp suffices. updatedAt: Date on each node, on merge take version with later timestamp.
Timeline: 1 week to 3 months. WebView-based React Flow editor with native wrapper — 1–2 weeks. Full native editor with custom canvas, undo/redo, multi-select, real-time sync and all node types — 1.5–3 months.







