In this second set of lecture notes on the graph drawing program I am going to complete the drawing app. The final version of the app will be designed to run on an Android tablet, so this will also give us an opportunity to look at some design strategies that are best suited for tablets.
Here is what the finished app will look like:

The app will add these new capabilities:
Our first improvement is to make it easy to store our graphs in a database. We will set up a Room database for this purpose.
I have rewritten the Vertex and Edge classes from the previous version to make it easier to store these objects in a database. I have also introduced a new GraphInfo class that will represent a graph. This will give the ability to create an unlimited number of graphs and store them in the database.
Here is the code for the new versions of the graph classes.
const val radius = 20.0f
@Entity(tableName = "vertices")
class Vertex(@PrimaryKey(autoGenerate=true) val id : Int = 0,var centerX : Float, var centerY : Float, var graph : Int) {
fun drawIn(scope: DrawScope) {
scope.drawCircle(color = Color.Red, radius = radius,
center = Offset(x = centerX , y = centerY))
}
fun containsPoint(pt : Offset) : Boolean {
val deltaX = (centerX - pt.x)
val deltaY = (centerY - pt.y)
val r = 1.5*radius
val distSquared = deltaX*deltaX + deltaY*deltaY
return distSquared < r*r
}
}
@Entity(tableName = "edges")
class EdgeInfo(@PrimaryKey(autoGenerate=true) val id : Int = 0,var origin : Int,var dest : Int,var graph : Int)
class Edge(var origin : Vertex,var dest: Vertex){
fun drawIn(scope: DrawScope) {
scope.drawLine(
color = Color.Green,
start = Offset(x = origin.centerX, y = origin.centerY),
end = Offset(x = dest.centerX, y = dest.centerY),
strokeWidth = 3.0f
)
}
}
@Entity(tableName = "graphs")
class GraphInfo(@PrimaryKey(autoGenerate=true) val id : Int = 0,var name : String)
One important aspect of this code has to do with the Edge class. The problem with the Edge class is that an edge contains object references to the two Vertex objects that an Edge connects. Unfortunately, the Room database system will not allow us to store object references in a database. To work around this problem I introduced a new EdgeInfo class that instead represents an edge as a pair of vertex ids. I will then add some code to make a list of Edge objects from a list of EdgeInfo objects that I pull from the database.
Notice also that both the Vertex class and the EdgeInfo class contain member variables that store the id number of the graph that these objects belong inside. When we load a GraphInfo object from the database and decide we want to work with that graph we can do a query to look up all of the vertices and edges that refer to that particular graph.
The next step in setting up the Room database is to set up the required interface for database operations. Here is the code for that interface.
@Dao
interface GraphDao {
@Insert
suspend fun addGraph(graph : GraphInfo)
@Insert
suspend fun addVertex(vertex : Vertex) : Long
@Insert
suspend fun addEdge(edge : EdgeInfo)
@Update
suspend fun updateVertex(vertex : Vertex)
@Query("select * from graphs")
suspend fun loadGraphs() : List<GraphInfo>
@Query("select * from vertices where graph = :graph")
suspend fun loadVertices(graph:Int) : List<Vertex>
@Query("select * from vertices where id = :id")
suspend fun findVertex(id:Int) : Vertex
@Query("select * from edges where graph = :graph")
suspend fun loadEdges(graph:Int) : List<EdgeInfo>
}
@Database(entities = [Vertex::class,EdgeInfo::class,GraphInfo::class], version = 1)
abstract class GraphDatabase : RoomDatabase() {
abstract fun graphDao() : GraphDao
}
Notice the loadVertices() and loadEdges() methods: these make it easy to find the vertices and edges once we know the id number of the graph we want to work with.
To help with the process of turning EdgeInfo objects into Edge objects I have also provided a findVertex() method here that will help in looking for the Vertex that has a particular id.
In this version of the app we will still have a GraphModel class. The main difference this time around as that the GraphModel will be working with a Room database.
Here is the code for the new GraphModel class.
class GraphModel(private val dao : GraphDao) : ViewModel() {
var graphs = mutableStateOf<List<GraphInfo>>(listOf<GraphInfo>())
var curGraph = mutableIntStateOf(0)
var vertices = mutableStateOf<List<Vertex>>(listOf<Vertex>())
var edges = mutableStateOf<List<Edge>>(listOf<Edge>())
var dragging = mutableStateOf<Vertex?>(null)
var start = mutableStateOf<Vertex?>(null)
init {
viewModelScope.launch {
var graphList: List<GraphInfo>
withContext(Dispatchers.IO) {
graphList = dao.loadGraphs()
}
withContext(Dispatchers.Main) {
graphs.value = graphList
}
}
}
fun setGraph(id : Int) {
curGraph.intValue = id
viewModelScope.launch {
var vertexList : List<Vertex>
withContext(Dispatchers.IO) {
vertexList = dao.loadVertices(id)
}
withContext(Dispatchers.Main) {
vertices.value = vertexList
}
var edgeInfoList : List<EdgeInfo>
val edgeList = mutableListOf<Edge>()
withContext(Dispatchers.IO) {
edgeInfoList = dao.loadEdges(id)
for(ei in edgeInfoList) {
val o = dao.findVertex(ei.origin)
val d = dao.findVertex(ei.dest)
edgeList.add(Edge(origin=o,dest=d))
}
}
withContext(Dispatchers.Main) {
edges.value = edgeList.toList()
}
}
}
fun makeGraph(name : String) {
val gi = GraphInfo(name = name)
viewModelScope.launch {
var graphList: List<GraphInfo>
withContext(Dispatchers.IO) {
dao.addGraph(gi)
graphList = dao.loadGraphs()
}
withContext(Dispatchers.Main) {
graphs.value = graphList
}
}
}
fun makeVertex(center : Offset) {
var v = Vertex(centerX = center.x,centerY = center.y,graph = curGraph.intValue)
var id : Long
viewModelScope.launch {
withContext(Dispatchers.IO) {
id = dao.addVertex(v)
v = dao.findVertex(id.toInt())
}
withContext(Dispatchers.Main) {
vertices.value += v
}
}
}
fun startDrag(pt:Offset) {
for (v in vertices.value) {
if(v.containsPoint(pt)) {
dragging.value = v
}
}
}
fun continueDrag(pt:Offset) {
if(dragging.value != null) {
val v = dragging.value!!
v.centerX = pt.x
v.centerY = pt.y
}
}
fun endDrag(pt:Offset) {
if(dragging.value != null) {
val v = dragging.value!!
viewModelScope.launch {
withContext(Dispatchers.IO) {
dao.updateVertex(v)
}
}
dragging.value = null
}
}
fun startDraw(pt:Offset) {
for (v in vertices.value) {
if(v.containsPoint(pt)) {
start.value = v
}
}
}
fun endDraw(pt:Offset) {
if(start.value != null) {
val s = start.value!!
for (v in vertices.value) {
if(v.containsPoint(pt) && v != s) {
val newEdge = Edge(origin = s,dest = v)
edges.value += newEdge
val ei = EdgeInfo(origin = s.id,dest=v.id,graph=curGraph.intValue)
viewModelScope.launch {
withContext(Dispatchers.IO) {
dao.addEdge(ei)
}
}
break
}
}
}
start.value = null
}
}
Here are some important changes to note.
init() method will load that list of GraphInfo objects when the app starts up.setGraph() method takes care of that for us.setGraph() is code that loads the EdgeInfo objects for the graph in question and then turns each EdgeInfo object into an Edge object.makeGraph() and makeVertex() methods handle making new graphs and new vertices.endDraw() method. To make a new edge we make an Edge object and add it to the list of Edges and then also make an EdgeInfo object that goes in the database.To take full advantage of the tablet platform we are going to be working with a new top level component that is well-suited to the platform. The top level component in our app is going to be a NavigableListDetailPaneScaffold. Here are the key features of that component:
Here is the code for our top level view:
@Composable
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun CanvasApp(gm : GraphModel) {
val navigator = rememberListDetailPaneScaffoldNavigator<Any>()
NavigableListDetailPaneScaffold(
navigator = navigator,
listPane = { GraphList(gm, onClick = { gi -> navigator.navigateTo(
ListDetailPaneScaffoldRole.Detail, content = gi) }) },
detailPane = { GraphDetailPane(gm,navigator) }
)
}
The roles of the list view and the detail view are handled by the GraphList and GraphDetailPane components. Here is the code for those two:
@Composable
fun GraphList(gm : GraphModel,onClick : (GraphInfo)->Unit) {
val showDialog = remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(20.dp)) {
gm.graphs.value.forEach { gi ->
Text(gi.name,
modifier = Modifier.padding(10.dp).clickable(onClick = { onClick(gi) }))
}
Button(onClick = { showDialog.value = true }) {
Text("Make New Graph")
}
}
if(showDialog.value) {
NameDialog(
onConfirm = {
gm.makeGraph(it)
showDialog.value = false
},
onDismiss = { showDialog.value = false })
}
}
@Composable
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun GraphDetailPane(gm : GraphModel,navigator: ThreePaneScaffoldNavigator<Any>) {
val graph : GraphInfo? = navigator.currentDestination?.content as GraphInfo?
if(graph != null) {
gm.setGraph(graph.id)
CanvasDemo(gm)
} else {
Text("Make a selection.")
}
}
Here are some important things to notice here:
Another useful characteristic of the NavigableListDetailPaneScaffold is its adaptability. You can experiment with rotating the tablet in the emulator or even running this new app on a phone instead of a tablet to see how this component reacts to different configurations. In situations where space is limited the list pane will slide off the edge of the screen to make room for the detail pane. The user can swipe back from the edge to pull the list view back out into view like a drawer when they need to access it.