Silsilah is a cross-platform family tree application designed to let users build, visualize, and interact with complex genealogy graphs. Delivering a local-first user experience across iOS, Android, and Web clients introduced unique architectural challenges, particularly regarding relational database integrity and graph layout rendering.
In this log, I'll walk through how I structured the relational database schema in SwiftData, resolved cascading deletions on cyclical data configurations, and engineered the layout algorithms for the rendering canvas.
1. Relational Modeling with SwiftData
Genealogy trees are graphs rather than simple hierarchies. A person can have multiple marriages, step-children, and complex ancestral loops. SwiftData represents these as linked objects, but its default compiler macros require careful handling to avoid retain cycles and database deadlocks.
I structured the schema around two main entities: Person and Marriage. By representing marriages as distinct entities rather than direct connections between people, Silsilah can model children belonging to specific partnerships, divorces, and blended families.
@Model
final class Person {
@Attribute(.unique) var id: UUID
var name: String
var birthDate: Date?
// Relationships
@Relationship(deleteRule: .cascade, inverse: \Marriage.husband)
var husbandMarriages: [Marriage] = []
@Relationship(deleteRule: .cascade, inverse: \Marriage.wife)
var wifeMarriages: [Marriage] = []
@Relationship(inverse: \Marriage.children)
var parentMarriage: Marriage?
init(name: String) {
self.id = UUID()
self.name = name
}
}
@Model
final class Marriage {
@Attribute(.unique) var id: UUID
var husband: Person?
var wife: Person?
@Relationship(deleteRule: .nullify)
var children: [Person] = []
init(husband: Person?, wife: Person?) {
self.id = UUID()
self.husband = husband
self.wife = wife
}
}
Cascading Deletion Rules
To prevent orphaned database records while avoiding cyclical deletion loops (which crash SwiftData's persistent store), the deletion rules are configured as follows:
- Deleting a
Personcascades and deletes any associatedMarriageobjects they participated in as husband or wife. - Deleting a
Marriagedoes not delete the spouses; it merely nullifies their relationship references. - Deleting a
Marriagenullifies the child references, leaving the children'sPersonrecords intact in the system.
2. The Graph Traversal & Coordinate Layout Challenge
Drawing a genealogy tree requires mapping individuals to 2D coordinates (X, Y). Standard tree-drawing algorithms (e.g., Reingold-Tilford) assume a strict single-root tree structure, which fails when husbands and wives introduce multiple parent lines.
Silsilah resolves layout calculations using a depth-first traversal variant that establishes generational columns (Y-axis) and lineage lanes (X-axis):
// Pseudocode of the coordinate assignment loop
func calculatePositions(for person: Person, generation: Int, offset: Double) {
// 1. Assign generational Y coordinate
person.y = Double(generation) * verticalSpacing
// 2. Traverse spouses
let marriages = person.marriages.sortedByDate()
for (index, marriage) in marriages.enumerated() {
let spouse = marriage.partner(of: person)
spouse.y = person.y
spouse.x = person.x + horizontalSpouseSpacing
// 3. Coordinate children below spouses
let children = marriage.children.sortedByBirthDate()
var currentChildOffset = spouse.x - (Double(children.count) * childSpacing / 2)
for child in children {
calculatePositions(for: child, generation: generation + 1, offset: currentChildOffset)
currentChildOffset += childSpacing
}
}
}
To prevent overlapping branches on massive family trees, a secondary "overlap resolver" pass traverses the calculated tree, measures collisions between sub-trees, and pushes colliding branches horizontally using dynamic spacers.
3. Custom Drawing with Bezier Paths
In SwiftUI, rendering connection lines dynamically between spouse blocks and child blocks is achieved using a custom Shape structure drawing smooth Bezier curves.
struct ConnectionLine: Shape {
var from: CGPoint
var to: CGPoint
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: from)
// Calculate control points for smooth S-curves
let midY = (from.y + to.y) / 2
let control1 = CGPoint(x: from.x, y: midY)
let control2 = CGPoint(x: to.x, y: midY)
path.addCurve(to: to, control1: control1, control2: control2)
return path
}
}
4. Cross-Platform Adaptations
While iOS runs the drawing operations on a GPU-backed SwiftUI Canvas, the Android client uses Jetpack Compose Canvas. I optimized Compose canvas draw cycles by wrapping the layout computations inside static state snapshots, ensuring that pan-and-zoom actions do not trigger garbage collection spikes.
For the web frontend built with SvelteKit, using standard HTML5 Canvas would restrict web accessibility. Instead, I designed the web tree viewer using an absolute-positioned DOM grid. This design allows screen readers to navigate parent-child hierarchies via native HTML structure, while enabling search engine bots to index historical names and branches.