Resizable Nodes Example
Demonstrates resizable nodes with NodeResizeControl, NodeResizer, and NodeToolbar for per-node contextual actions.
swift
import SwiftUI
import SwiftFlow
// MARK: - Data Models
struct ResizeNodeData: Equatable, Sendable, Codable {
var title: String
var content: String
}
// MARK: - Content View
struct ResizableNodesExample: View {
@State var nodes: [Node<ResizeNodeData>] = [
Node(
id: "note1",
position: XYPosition(x: 100, y: 100),
data: ResizeNodeData(title: "Meeting Notes", content: "Discuss Q4 roadmap and resource allocation for the engineering team."),
type: "note",
width: 240, height: 160
),
Node(
id: "note2",
position: XYPosition(x: 400, y: 100),
data: ResizeNodeData(title: "Action Items", content: "1. Update API schema\n2. Fix edge rendering\n3. Write unit tests\n4. Deploy to staging"),
type: "note",
width: 200, height: 200
),
Node(
id: "card1",
position: XYPosition(x: 100, y: 350),
data: ResizeNodeData(title: "Product Card", content: "Price: $29.99\nIn stock: Yes\nSKU: PROD-001"),
type: "card",
width: 220, height: 130
),
]
@State var edges: [FlowEdge<EmptyEdgeData>] = [
FlowEdge(id: "e1", source: "note1", target: "note2",
type: .smoothstep, markerEnd: .arrowClosed),
FlowEdge(id: "e2", source: "note2", target: "card1",
type: .smoothstep, markerEnd: .arrowClosed),
]
var body: some View {
SwiftFlow(
nodes: nodes,
edges: edges,
onNodesChange: { nodes = applyNodeChanges($0, nodes: nodes) },
onEdgesChange: { edges = applyEdgeChanges($0, edges: edges) },
onConnect: { edges = addEdge($0, edges: edges) },
snapToGrid: true,
snapGrid: (x: 10, y: 10)
) { node in
ResizableNodeView(node: node,
onResize: { w, h in
nodes = applyNodeChanges([
.dimensions(id: node.id, width: w, height: h)
], nodes: nodes)
},
onDuplicate: {
duplicateNode(node)
},
onDelete: {
deleteNode(node)
}
)
} overlay: {
Background(variant: .lines, color: .gray.opacity(0.1), gap: 40)
Controls()
MiniMap()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
func duplicateNode(_ node: Node<ResizeNodeData>) {
var copy = node
copy.id = "\(node.id)-copy-\(nodes.count)"
copy.position = XYPosition(x: node.position.x + 40, y: node.position.y + 40)
copy.selected = false
nodes = applyNodeChanges([.add(item: copy)], nodes: nodes)
}
func deleteNode(_ node: Node<ResizeNodeData>) {
let connectedEdgeIds = edges
.filter { $0.source == node.id || $0.target == node.id }
.map(\.id)
nodes = applyNodeChanges([.remove(id: node.id)], nodes: nodes)
edges = applyEdgeChanges(connectedEdgeIds.map { .remove(id: $0) }, edges: edges)
}
}
// MARK: - Resizable Node View
struct ResizableNodeView: View {
let node: Node<ResizeNodeData>
let onResize: (CGFloat, CGFloat) -> Void
let onDuplicate: () -> Void
let onDelete: () -> Void
@State private var isHovered = false
private var accentColor: Color {
switch node.type {
case "note": return .blue
case "card": return .purple
default: return .gray
}
}
var body: some View {
ZStack(alignment: .topLeading) {
// Node content
VStack(spacing: 0) {
// Header
HStack {
Circle()
.fill(accentColor)
.frame(width: 8, height: 8)
Text(node.data.title)
.font(.system(size: 12, weight: .semibold))
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(accentColor.opacity(0.1))
Divider()
// Content
Text(node.data.content)
.font(.system(size: 11))
.foregroundColor(.primary)
.lineLimit(nil)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
// Connection handles at bottom
HStack {
Handle(nodeId: node.id, id: "in", type: .target,
position: .left, color: accentColor)
Spacer()
Handle(nodeId: node.id, id: "out", type: .source,
position: .right, color: accentColor)
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
.background(
RoundedRectangle(cornerRadius: 10)
.fill(.white)
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(node.selected ? accentColor : Color.gray.opacity(0.3),
lineWidth: node.selected ? 2 : 1)
)
// Resize handles (visible when selected)
if node.selected {
// Corner handles
NodeResizeControl(
nodeId: node.id,
position: .bottomRight,
minWidth: 120, maxWidth: 600,
minHeight: 80, maxHeight: 500,
color: accentColor,
handleSize: 11,
onResize: onResize
)
NodeResizeControl(
nodeId: node.id,
position: .bottomLeft,
minWidth: 120, maxWidth: 600,
minHeight: 80, maxHeight: 500,
color: accentColor,
handleSize: 11,
onResize: onResize
)
NodeResizeControl(
nodeId: node.id,
position: .topRight,
minWidth: 120, maxWidth: 600,
minHeight: 80, maxHeight: 500,
color: accentColor,
handleSize: 11,
onResize: onResize
)
// Edge handles
NodeResizeControl(
nodeId: node.id,
position: .right,
minWidth: 120, maxWidth: 600,
minHeight: 80, maxHeight: 500,
color: accentColor,
handleSize: 9,
onResize: onResize
)
NodeResizeControl(
nodeId: node.id,
position: .bottom,
minWidth: 120, maxWidth: 600,
minHeight: 80, maxHeight: 500,
color: accentColor,
handleSize: 9,
onResize: onResize
)
// NodeToolbar for quick actions
NodeToolbar(position: .top, align: .center, offset: 10) {
HStack(spacing: 4) {
Button(action: {
onResize(300, 200)
}) {
Image(systemName: "arrow.up.backward.and.arrow.down.forward")
.font(.system(size: 10))
}
.buttonStyle(.borderless)
.help("Reset size")
Divider().frame(height: 14)
Button(action: onDuplicate) {
Image(systemName: "doc.on.doc")
.font(.system(size: 10))
}
.buttonStyle(.borderless)
.help("Duplicate")
Button(action: onDelete) {
Image(systemName: "trash")
.font(.system(size: 10))
}
.buttonStyle(.borderless)
.help("Delete")
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(.ultraThinMaterial)
.cornerRadius(6)
}
}
}
.frame(width: max(node.width ?? 240, node.width ?? 240),
height: max(node.height ?? 160, node.height ?? 160))
}
}
// MARK: - Preview
#Preview {
ResizableNodesExample()
}What This Example Demonstrates
- NodeResizeControl: Individual resize handles at specific positions (corners and edges) with per-handle constraints
- Multi-handle resize: Five resize handles (three corners, two edges) for comprehensive resizing
- Per-node dimensions: Nodes use
widthandheightproperties for explicit sizing - Dimension change application: Using
NodeChange.dimensionsto update node size through the change pipeline - Snap-to-grid with resize: Grid snapping works alongside manual resize for aligned layouts
- NodeToolbar: Contextual floating toolbar with reset size, duplicate, and delete actions
- Selection-aware UI: Resize handles and toolbar only appear when the node is selected
- Accent color theming: Resize handles and node decorations match the node type color
- Handle layering: ZStack ensures resize handles render on top of node content
- Custom sized nodes: Explicit width/height override the default measured-from-content sizing