ALPHA RELEASE - This library is in early development. The API is subject to change and may contain bugs. Feedback and bug reports are welcome at GitHub Issues.
- 2 built-in layout algorithms (hierarchical and force-directed) plus support for custom layouts
- Handles both acyclic and cyclic graphs
- Customizable nodes and edges
- Zooming and panning
- Resizable canvas
- Layout animations
Kuiver is available on Maven Central.
For multiplatform projects, add to your common source set:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.justdeko:kuiver:0.2.1")
}
}
}Or for a specific platform only:
kotlin {
sourceSets {
androidMain.dependencies {
implementation("io.github.justdeko:kuiver-android:0.2.1")
}
iosMain.dependencies {
implementation("io.github.justdeko:kuiver-iosarm64:0.2.1")
}
// etc.
}
}- Android (minSdk 24)
- iOS
- JVM (Desktop)
- Web (wasmJs/js) - experimental, see limitations
@Composable
fun MyGraphViewer() {
// Create graph structure
val kuiver = remember {
buildKuiver {
// Add nodes
nodes("A", "B", "C")
// Add edges
edges(
"A" to "B",
"B" to "C",
"A" to "C"
)
}
}
// Configure layout
val layoutConfig = LayoutConfig.Hierarchical(
direction = LayoutDirection.HORIZONTAL
)
// Create viewer state
val viewerState = rememberKuiverViewerState(
initialKuiver = kuiver,
layoutConfig = layoutConfig
)
// Render the graph
KuiverViewer(
state = viewerState,
nodeContent = { node ->
// Customize node appearance
Box(
modifier = Modifier
.size(80.dp)
.background(Color.Blue, CircleShape),
contentAlignment = Alignment.Center
) {
Text(node.id, color = Color.White)
}
},
edgeContent = { edge, from, to ->
// Customize edge appearance
EdgeContent(from, to, color = Color.Gray)
}
)
}Kuiver only handles visual graph structure using node IDs. Store your application data separately
and look it up by node ID in your nodeContent composable.
The edgeContent lambda receives the edge data and start/end positions (from: Offset,
to: Offset). You can use built-in components or create custom rendering with Canvas:
// Using built-in styled edges (automatically styles FORWARD, BACK, CROSS, SELF_LOOP)
edgeContent = { edge, from, to ->
StyledEdgeContent(
edge = edge,
from = from,
to = to,
baseColor = Color.Black,
backEdgeColor = Color(0xFFFF6B6B),
strokeWidth = 3f
)
}
// Custom edge rendering
edgeContent = { edge, from, to ->
Canvas(modifier = Modifier.fillMaxSize()) {
drawLine(
color = Color.Blue,
start = from,
end = to,
strokeWidth = 2.dp.toPx()
)
// Draw custom arrows, labels, etc.
}
}Kuiver automatically measures node dimensions from your nodeContent. You can also specify
dimensions explicitly:
buildKuiver {
// Auto-measured (recommended)
nodes("A")
// Explicit dimensions
addNode(
KuiverNode(
id = "B",
dimensions = NodeDimensions(width = 120.dp, height = 80.dp)
)
)
}By default, edges point and connect to the node center (with consideration of the node boundaries). For precise control, you can define custom anchor points:
nodeContent = { node ->
Box(modifier = Modifier.size(120.dp, 80.dp).background(Color.Blue)) {
// Define anchors with optional visual indicators
KuiverAnchor(
anchorId = "left",
nodeId = node.id,
modifier = Modifier.align(Alignment.CenterStart)
) {
Box(
Modifier
.size(8.dp)
.background(Color.White, CircleShape)
)
}
KuiverAnchor(
anchorId = "right",
nodeId = node.id,
modifier = Modifier.align(Alignment.CenterEnd)
)
Text("Node ${node.id}", modifier = Modifier.align(Alignment.Center))
}
}
// Reference anchors in edges
buildKuiver {
nodes("A", "B")
edge(
from = "A",
to = "B",
fromAnchor = "right",
toAnchor = "left"
)
}Things to keep in mind:
- Anchor IDs are scoped per-node (each node has its own namespace).
- Missing anchors or anchors that aren't found fallback to automatic edge positioning
See ProcessDiagramDemo.kt for a complete example with multiple anchors per side.
Note: The layout algorithms are simple implementations based on established graph layouting techniques. While inspired by academic research, they are not direct ports of published implementations. Expect flaws and suboptimal layouts on complex graphs.
Best for directed acyclic graphs (DAGs) and tree structures. Automatically handles cycles by classifying back edges.
val layoutConfig = LayoutConfig.Hierarchical(
direction = LayoutDirection.HORIZONTAL, // or VERTICAL
levelSpacing = 150f, // Distance between hierarchy levels
nodeSpacing = 100f // Distance between nodes in same level
)Edge Types in Hierarchical Layout:
FORWARD- Edges to descendants (typical parent-child edges)BACK- Edges to ancestors (creates cycles, rendered as dashed byStyledEdgeContent)CROSS- Edges between nodes at similar hierarchy levelsSELF_LOOP- Edges from a node to itself
Best for understanding relationships in general graphs. Creates organic, balanced layouts using physics simulation.
val layoutConfig = LayoutConfig.ForceDirected(
iterations = 200, // Simulation steps (more = better layout, slower)
repulsionStrength = 500f, // How strongly nodes push apart
attractionStrength = 0.02f, // How strongly connected nodes pull together
damping = 0.85f // Velocity damping (stability vs convergence speed)
)You can provide your own layout algorithm using LayoutConfig.Custom. This gives you full
control over node positioning.
// Define a custom circular layout
val circularLayout: LayoutProvider = { kuiver, config ->
val nodesList = kuiver.nodes.values.toList()
val radius = minOf(config.width, config.height) * 0.4f
val centerX = config.width / 2f
val centerY = config.height / 2f
val updatedNodes = nodesList.mapIndexed { index, node ->
val angle = (index.toFloat() / nodesList.size) * 2f * PI.toFloat()
node.copy(
position = Offset(
x = centerX + radius * cos(angle),
y = centerY + radius * sin(angle)
)
)
}
buildKuiverWithClassifiedEdges(updatedNodes, kuiver.edges)
}
// Use the custom layout
val layoutConfig = LayoutConfig.Custom(
provider = circularLayout
)Custom Layout Tips:
- Your layout function receives the
Kuivergraph andLayoutConfig(useLayoutConfig.Custom) - Access canvas dimensions via
config.widthandconfig.height - Always use
buildKuiverWithClassifiedEdges(updatedNodes, kuiver.edges)to construct the result - Handle zero dimensions gracefully (canvas might not be measured yet on first layout)
- Use
rememberto stabilize your layout function in Compose to avoid unnecessary recompositions
Customize viewer behavior with KuiverViewerConfig:
KuiverViewer(
state = viewerState,
config = KuiverViewerConfig(
// Visual
showDebugBounds = false, // Show node bounding boxes for debugging
// Viewport
fitToContent = true, // Auto-fit graph to viewport on load
contentPadding = 0.8f, // Padding around content (0-1 scale)
// Zoom
minScale = 0.1f, // Minimum zoom level (10%)
maxScale = 5f, // Maximum zoom level (500%)
// Pan
panVelocity = 1.0f, // Scroll sensitivity (platform-specific default)
// Animations
scaleAnimationSpec = spring( // Zoom animation
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
offsetAnimationSpec = spring(), // Pan animation
nodeAnimationSpec = spring(), // Node position animation (during layout)
// Desktop-specific
zoomConditionDesktop = { event -> // When to zoom vs pan on desktop
event.keyboardModifiers.isCtrlPressed
}
),
nodeContent = { node -> /* ... */ },
edgeContent = { edge, from, to -> /* ... */ }
)// Zoom and navigation
viewerState.zoomIn() // Zoom in (1.2x)
viewerState.zoomOut() // Zoom out (1/1.2x)
viewerState.centerGraph() // Center graph in viewport
// Direct control
viewerState.updateTransform(scale = 1.5f, offset = Offset(100f, 100f))
// Access current state
val currentScale = viewerState.scale
val currentOffset = viewerState.offset- Touch/Mobile: Drag to pan, pinch to zoom
- Mouse/Desktop: Drag to pan, scroll to pan, Ctrl+Scroll to zoom
Use rememberSaveableKuiverViewerState to preserve zoom/pan across process death.
Update the graph structure by passing an updated or new Kuiver instance with
viewerState.updateKuiver(newKuiver).
val kuiver = buildKuiver {
nodes("A", "B", "C")
edges(
"A" to "B",
"B" to "C"
)
// Check before adding edge that would create a cycle
if (!wouldCreateCycle(from = "C", to = "A")) {
edge("C", "A")
} else {
println("Skipping edge C -> A: would create a cycle")
}
}
// Check existing graph
if (kuiver.hasCycles()) {
val components = kuiver.findStronglyConnectedComponents()
println("Strongly connected components: $components")
}val kuiver = buildKuiver {
nodes("A", "B", "C")
edges(
"A" to "B",
"B" to "C",
"C" to "A" // Back edge (creates cycle)
)
}
// Classify all edges
val edgeTypes = kuiver.classifyAllEdges()
edgeTypes.forEach { (edge, type) ->
println("${edge.fromId} -> ${edge.toId}: $type")
}
// Output:
// A -> B: FORWARD
// B -> C: FORWARD
// C -> A: BACK// For DAGs or graphs with back edges removed
val order = kuiver.getTopologicalOrder()
println("Topological order: $order")
// Useful for dependency resolution, task scheduling, etc.A complete demo app is included in /sample. Open the project in IntelliJ IDEA or Android Studio, sync, and select a run configuration (Desktop/Android/iOS/Web) from the dropdown.
You can also run from the command line:
./gradlew :sample:composeApp:run # DesktopThe Web target is experimental and has known issues.
The library implements several web-specific adjustments to handle browser limitations:
- Reduced Pan Velocity: Default pan velocity is
4f(vs30fon native platforms) to compensate for higher scroll sensitivity in browsers- See:
core/src/wasmJsMain/kotlin/com/dk/kuiver/renderer/PlatformDefaults.wasmJs.kt:4
- See:
- Font Loading Delay: 100ms delay on initial node measurement to prevent text wrapping issues
when browser fonts haven't finished loading
- See:
core/src/wasmJsMain/kotlin/com/dk/kuiver/renderer/PlatformDefaults.wasmJs.kt:5 - This delay only occurs once on initial render
- See:
- Multiple Edges: The library does not currently support multiple edges between the same pair of nodes
- Edge Labels: Built-in edge label support is not yet implemented (can be added via custom
edgeContent) - Large Graphs: Performance with graphs >100 nodes has not been extensively tested
As an alpha release, the public API may change between versions. Breaking changes will be noted in the changelog.
Contributions are welcome! Please see CONTRIBUTING.md for guidelines on:
- Reporting bugs and requesting features
- Contributing code and submitting pull requests
- Development setup and testing
In mathematics, a quiver is a directed graph in its most general sense.
"K" instead of "Q" for Kotlin. Just pronounce it like quiver: /ˈkwɪvər/
From Wikipedia:
"a quiver is another name for a multidigraph; that is, a directed graph where loops and multiple arrows between two vertices are allowed."
Technically this library is not quite a "true" quiver, as it doesn't support multiple edges between the same two nodes.



