I’m writing an app resembling PureData or Blender’s node editor : boxes on screen with inlets and outlets that can be connected with cables.
With some objects on screen, the app stays reactive. It however becomes very laggy when I try to put lots of objects, and I’m wondering if SwiftUI has problems to keep up because there are too many views (or maybe the views are refreshed too often).
Would the project maybe be better suited for something else (AppKit, qt) to get better performances ?
Here is the code (this simplified version only displays draggable nodes on screen, but in the whole project there are additional mechanics for dragging cables and attaching them to in/outlets). It gets painfully slow the more you add objects.
Main View:
import SwiftUI
struct ContentView: View {
@State var nodes: [NodeView] = [NodeView]()
var size: (width: CGFloat, height: CGFloat) = (3000, 3000)
var body: some View {
ScrollView([.horizontal, .vertical]) {
GeometryReader { geo in
ForEach(nodes) { node in
node
}
}
.frame(width: size.width, height: size.height)
}
.navigationTitle("Cables")
.toolbar {
ToolbarItem() {
Button(action: addNode, label: {
Image(systemName: "plus")
})
.keyboardShortcut("n", modifiers: [])
}
}
}
func addNode() {
print("Nodes added")
for _ in 1...1000 {
self.nodes.append(NodeView(location: CGPoint(x: CGFloat.random(in: 100...2900), y: CGFloat.random(in: 100...2900))))
}
}
}
Node view:
struct ObjectConfiguration {
// height of a basic object/ui element
let defaultUnit: CGFloat
var width: CGFloat
let height: CGFloat
var inOutSize: CGFloat { return defaultUnit/4 }
var fontSize: CGFloat { return defaultUnit/2 }
init(defaultUnit: CGFloat, connections: (inlets: Int, outlets: Int)) {
var maxOutlets = connections.inlets >= connections.outlets ? connections.inlets : connections.outlets
maxOutlets = maxOutlets < 8 ? 8 : maxOutlets
self.defaultUnit = defaultUnit
// width = inOutSize * (2 * in-or-outlets + 1) * 2
self.width = (defaultUnit/4) * CGFloat((2 * maxOutlets) + 1)
self.height = defaultUnit
}
}
struct NodeView: View, Identifiable {
let id = UUID()
@GestureState private var startLocation: CGPoint? = nil
@State var location: CGPoint
var config: ObjectConfiguration = ObjectConfiguration(defaultUnit: 25, connections: (2, 2))
var body: some View {
/* inlets/outlets:
- spacers are added to separate the circles, the last one takes all of the remaining space
- offset so they bleed over the main object */
VStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(0 ..< 2) { _ in
Spacer().frame(width: config.inOutSize)
Circle()
.fill(.green)
.frame(height: config.inOutSize).offset(y: config.inOutSize/2)
}
Spacer().frame(minWidth: config.inOutSize, maxWidth: .infinity)
}
.frame(maxWidth: config.width)
.zIndex(10)
// node name
HStack(alignment: .center) {
Text("test")
.font(.system(size: config.fontSize)).monospaced()
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, config.inOutSize)
}
.frame(width: config.width, height: config.height)
.background(.blue)
.cornerRadius(config.inOutSize)
.overlay(RoundedRectangle(cornerRadius: config.inOutSize).stroke(.red, lineWidth: 1))
.zIndex(0)
HStack(spacing: 0) {
ForEach(0 ..< 2) { _ in
Spacer().frame(width: config.inOutSize)
Circle()
.fill(.red)
.frame(height: config.inOutSize)
.offset(y: -config.inOutSize/2)
}
Spacer().frame(minWidth: config.inOutSize, maxWidth: .infinity)
}
.frame(maxWidth: config.width)
.zIndex(10)
}
.position(location)
.gesture(moveObject)
}
var moveObject: some Gesture {
DragGesture()
.onChanged { value in
var newLocation = startLocation ?? location
newLocation.x += value.translation.width
newLocation.y += value.translation.height
location = newLocation
}.updating($startLocation) { (value, startLocation, transaction) in
startLocation = startLocation ?? location
}
}
}