UX Survey: Creating Simple Tree Listing of Items or Categories

A workable prototype for conducting user surveys on Python

Sumit Tripathi
5 min readMay 30, 2024

When working on a user survey analysis, I got the learn about the concept of tree listing an card sorting to better understand how users identify each group of items that we gave. The point is that these kinds of test does not have many free options out there that we can readily use and customise. As such, I wanted to create working prototype for people to use when conducting user surveys like this. Yes, it may not have an in-built function to fully share this to any participants across different places but this prototype can be used for general purpose and can be further tweaked upon.

Let’s start!

The first prototype is a simple card sorting with nesting options.

import tkinter as tk
from tkinter import ttk
import random

class DragAndDropTree(tk.Tk):
def __init__(self):
super().__init__()
self.title("Interactive Tree Testing")
self.geometry("600x400")

# Initialize treeview
self.tree = ttk.Treeview(self, selectmode='browse')
self.tree.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)

# Initialize listbox for elements
self.element_listbox = tk.Listbox(self)
self.element_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

# Sample elements
self.elements = ["Element 1", "Element 2", "Element 3", "Element 4", "Element 5"]
# Shuffle the elements randomly - else comment the following line
random.shuffle(self.elements)
for element in self.elements:
self.element_listbox.insert(tk.END, element)

# Bind events for dragging
self.tree.bind("<ButtonPress-1>", self.on_tree_button_press)
self.tree.bind("<B1-Motion>", self.on_tree_mouse_drag)
self.tree.bind("<ButtonRelease-1>", self.on_tree_button_release)

self.element_listbox.bind("<ButtonPress-1>", self.on_listbox_button_press)
self.element_listbox.bind("<B1-Motion>", self.on_listbox_mouse_drag)
self.element_listbox.bind("<ButtonRelease-1>", self.on_listbox_button_release)

self.drag_data = {"widget": None, "item": None, "x": 0, "y": 0, "moved": False}

def on_tree_button_press(self, event):
item = self.tree.identify_row(event.y)
if item:
self.drag_data["widget"] = self.tree
self.drag_data["item"] = item
self.drag_data["x"] = event.x
self.drag_data["y"] = event.y
self.drag_data["moved"] = False

def on_tree_mouse_drag(self, event):
if self.drag_data["widget"] == self.tree and self.drag_data["item"]:
self.drag_data["moved"] = True
self.drag_data["x"] = event.x
self.drag_data["y"] = event.y

def on_tree_button_release(self, event):
if self.drag_data["widget"] == self.tree and self.drag_data["item"] and self.drag_data["moved"]:
target_item = self.tree.identify_row(event.y)
if target_item and target_item != self.drag_data["item"]:
try:
self.tree.move(self.drag_data["item"], target_item, 'end')
except tk.TclError:
pass
else:
# Check if the drop occurred outside the treeview
tree_bbox = self.tree.bbox(self.tree.get_children()[0])
if not tree_bbox or event.x < tree_bbox[0] or event.x > tree_bbox[2] or event.y < tree_bbox[1] or event.y > tree_bbox[3]:
# Move the item back to the listbox
self.move_tree_item_to_listbox(self.drag_data["item"])
self.drag_data["widget"] = None
self.drag_data["item"] = None
self.drag_data["moved"] = False

def on_listbox_button_press(self, event):
selection = self.element_listbox.curselection()
if selection:
self.drag_data["widget"] = self.element_listbox
self.drag_data["item"] = selection[0]
self.drag_data["x"] = event.x
self.drag_data["y"] = event.y
self.drag_data["moved"] = False

def on_listbox_mouse_drag(self, event):
if self.drag_data["widget"] == self.element_listbox:
self.drag_data["moved"] = True
x = event.x
y = event.y

# Move the selected item visually (not functional in Listbox, shown for consistency)
self.element_listbox.selection_clear(0, tk.END)
self.element_listbox.selection_set(self.drag_data["item"])
self.element_listbox.activate(self.drag_data["item"])

self.drag_data["x"] = x
self.drag_data["y"] = y

def on_listbox_button_release(self, event):
if self.drag_data["widget"] == self.element_listbox and self.drag_data["moved"]:
item_text = self.element_listbox.get(self.drag_data["item"])
target_item = self.tree.identify_row(event.y)

# Add item to tree under the target item or at the root
if target_item:
self.tree.insert(target_item, "end", text=item_text)
else:
self.tree.insert("", "end", text=item_text)

# Remove the item from the listbox
self.element_listbox.delete(self.drag_data["item"])

self.drag_data["widget"] = None
self.drag_data["item"] = None
self.drag_data["moved"] = False

def move_tree_item_to_listbox(self, item):
"""Move tree item back to the listbox."""
item_text = self.tree.item(item, "text")
if item_text not in self.elements:
self.elements.append(item_text)
self.element_listbox.insert(tk.END, item_text)
self.tree.delete(item)

if __name__ == "__main__":
app = DragAndDropTree()
app.mainloop()

And here is how the UI looks like:

User can drag and drop each card back and forth including putting each card or moving each card as a nested element under other card — the starting list can also be set to sort the cards randomly as well

Extending the function to different groups

Now let’s say we want to allow users to create the same type of tree listings across multiple groups. The users will also be able to rename each group so that we can better understand their grouping process.

Here is how I will trying to build the prototype:

***Note: it still does not work as intended yet since I am not able to get the users to rename the group and the nesting function is not fully functioning within and across the groups.***

import tkinter as tk
from tkinter import ttk
import random

class DragAndDropTree(tk.Tk):
def __init__(self):
super().__init__()
self.title("Interactive Tree Testing")
self.geometry("800x400")

# Initialize listbox for elements
self.element_listbox = tk.Listbox(self)
self.element_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

# Sample elements
self.elements = ["Element 1", "Element 2", "Element 3", "Element 4", "Element 5"]
# Shuffle the elements randomly - else comment the following line
random.shuffle(self.elements)
for element in self.elements:
self.element_listbox.insert(tk.END, element)

# Initialize dictionary to store group trees
self.group_trees = {}

# Initialize counter for group names
self.group_counter = 1

# Create a frame to hold group boxes
self.group_frame = tk.Frame(self, bd=2, relief="groove")
self.group_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True)

# Add the first group box
self.add_group_box()

# Create the "+" button for adding new groups
self.add_group_button = tk.Button(self, text="+", bg="lightgray", padx=10, pady=5, command=self.add_group_box)
self.add_group_button.pack(side=tk.TOP)

# Bind events for dragging
self.element_listbox.bind("<ButtonPress-1>", self.on_listbox_button_press)
self.element_listbox.bind("<B1-Motion>", self.on_listbox_mouse_drag)
self.element_listbox.bind("<ButtonRelease-1>", self.on_listbox_button_release)

self.drag_data = {"widget": None, "item": None, "x": 0, "y": 0, "moved": False}

def on_listbox_button_press(self, event):
selection = self.element_listbox.curselection()
if selection:
self.drag_data["widget"] = self.element_listbox
self.drag_data["item"] = selection[0]
self.drag_data["x"] = event.x
self.drag_data["y"] = event.y
self.drag_data["moved"] = False

def on_listbox_mouse_drag(self, event):
if self.drag_data["widget"] == self.element_listbox:
self.drag_data["moved"] = True
x = event.x
y = event.y

# Move the selected item visually (not functional in Listbox, shown for consistency)
self.element_listbox.selection_clear(0, tk.END)
self.element_listbox.selection_set(self.drag_data["item"])
self.element_listbox.activate(self.drag_data["item"])

self.drag_data["x"] = x
self.drag_data["y"] = y

def on_listbox_button_release(self, event):
if self.drag_data["widget"] == self.element_listbox and self.drag_data["moved"]:
item_text = self.element_listbox.get(self.drag_data["item"])
target_group_tree = self.group_trees[list(self.group_trees.keys())[0]] # Use the first group tree

# Add item to tree under the target item or at the root
target_group_tree.insert("", "end", text=item_text)

# Remove the item from the listbox
self.element_listbox.delete(self.drag_data["item"])

self.drag_data["widget"] = None
self.drag_data["item"] = None
self.drag_data["moved"] = False

def add_group_box(self):
"""Add a new group box."""
group_name = f"Group {self.group_counter}"
self.group_counter += 1

# Create a frame for the group box
group_box = tk.Frame(self.group_frame, bd=2, relief="groove")
group_box.pack(side=tk.LEFT, padx=10, pady=10, fill=tk.BOTH, expand=True)

# Create a Label for displaying the group name
group_label = tk.Label(group_box, text=group_name, bg="lightgray", padx=5, pady=5)
group_label.pack(side=tk.TOP, fill=tk.X)

# Create a Treeview for the group
group_tree = ttk.Treeview(group_box, selectmode='browse')
group_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

# Store the group tree
self.group_trees[group_name] = group_tree

if __name__ == "__main__":
app = DragAndDropTree()
app.mainloop()

The UI would look like something similar to this:

Now let’s add the timer and analytics capabilities

This is the part where I would like to record multiple user sessions’ data and allow the system to calculate the summary of the results in terms of time spent, number of users sessions, average number of groups, average ranking for each element, average number of element in each group, average distance from the parent element, etc.

WIP

This is how I envision the UI to look like for the simple tree listing test

Takeaway

UX survey like tree listing and grouping is a powerful tool to understand how user associate different things. They could look at things very different than how we look and hence could give us insights that could better help use tailor better experience for them.

I hope that having this kind of test could at least be a handy tool for all to try and see whether they can derive any insights from such tests.

--

--