Hello Jordi,
Fun enough, i just managed to come up with something nice. Please try out my class:
import customtkinter as ctk
class CTkDropdown(ctk.CTkFrame):
"""
Reusable dropdown widget for CustomTkinter.
Usage:
from ctk_dropdown import CTkDropdown
combo = CTkDropdown(root, values=["A", "B", "C"])
combo.pack()
"""
def __init__(
self,
master,
values=None,
width=130,
height=35,
corner_radius=10,
fg_color=None,
dropdown_color=None,
hover_color=None,
button_color=None,
button_hover_color=None,
selected_color=None,
text_color=None,
font=None,
command=None,
**kwargs,
):
super().__init__(
master,
width=width,
height=height,
corner_radius=corner_radius,
fg_color=fg_color,
**kwargs,
)
self.grid_propagate(False)
self.pack_propagate(False)
self.values = values or []
self.command = command
self._current_value = self.values[0] if self.values else ""
self.width = width
self.height = height
self.corner_radius = corner_radius
self.dropdown_color = dropdown_color or "#565656"
self.hover_color = hover_color or "#444444"
self.selected_color = selected_color or "#383838"
self.button_color = button_color or "transparent"
self.button_hover_color = button_hover_color or "#4A4A4A"
self.text_color = text_color or "white"
self.font = font or ctk.CTkFont(size=10)
self.popup = None
self.popup_frame = None
self.is_open = False
self._highlight_index = 0
self._outside_click_binding = None
self._configure_binding = None
self.columnconfigure(0, weight=1)
self.columnconfigure(1, weight=0)
self.label = ctk.CTkLabel(
self,
text=self._current_value,
anchor="w",
padx=12,
text_color=self.text_color,
font=self.font,
)
self.label.grid(row=0, column=0, sticky="nsew", padx=(10, 0), pady=4)
self.arrow_button = ctk.CTkButton(
self,
text="▼",
width=34,
fg_color=self.button_color,
hover_color=self.button_hover_color,
corner_radius=8,
command=self.toggle,
)
self.arrow_button.grid(row=0, column=1, sticky="ns", padx=(0, 5), pady=4)
self.bind("<Button-1>", lambda e: self.toggle())
self.label.bind("<Button-1>", lambda e: self.toggle())
def get(self):
return self._current_value
def set(self, value):
if value not in self.values:
return
self._current_value = value
self.label.configure(text=value)
def configure_values(self, values):
self.values = list(values)
if self.values:
self.set(self.values[0])
def toggle(self):
if self.is_open:
self.close()
else:
self.open()
def open(self):
if self.is_open:
return
self.is_open = True
self.arrow_button.configure(text="▲")
self._build_popup()
root = self.winfo_toplevel()
self._outside_click_binding = root.bind("<Button-1>", self._on_global_click, add="+")
self._configure_binding = root.bind("<Configure>", lambda e: self._reposition_popup(), add="+")
def close(self):
if not self.is_open:
return
self.is_open = False
self.arrow_button.configure(text="▼")
root = self.winfo_toplevel()
if self._outside_click_binding is not None:
try:
root.unbind("<Button-1>", self._outside_click_binding)
except Exception:
pass
if self._configure_binding is not None:
try:
root.unbind("<Configure>", self._configure_binding)
except Exception:
pass
self._outside_click_binding = None
self._configure_binding = None
if self.popup is not None:
try:
self.popup.destroy()
except Exception:
pass
self.popup = None
self.popup_frame = None
def _select(self, value):
self.set(value)
self.close()
if self.command is not None:
self.command(value)
def _build_popup(self):
if self.popup is not None:
self.popup.destroy()
self.popup = ctk.CTkToplevel(self)
self.popup.overrideredirect(True)
self.popup.attributes("-topmost", True)
self.update_idletasks()
x = self.winfo_rootx()
y = self.winfo_rooty() + self.winfo_height()
popup_height = min(220, len(self.values) * 36 + 10)
self.popup.geometry(f"{self.width}x{popup_height}+{x}+{y}")
self.popup_frame = ctk.CTkScrollableFrame(
self.popup,
fg_color=self.dropdown_color,
corner_radius=self.corner_radius,
)
self.popup_frame.pack(fill="both", expand=True)
self.option_buttons = []
for i, value in enumerate(self.values):
colour = self.selected_color if value == self._current_value else "transparent"
btn = ctk.CTkButton(
self.popup_frame,
text=value,
anchor="w",
fg_color=colour,
hover_color=self.hover_color,
text_color=self.text_color,
font=self.font,
corner_radius=6,
command=lambda v=value: self._select(v),
)
btn.pack(fill="x", padx=6, pady=2)
self.option_buttons.append(btn)
if value == self._current_value:
self._highlight_index = i
def _reposition_popup(self):
if not self.is_open or self.popup is None:
return
self.update_idletasks()
x = self.winfo_rootx()
y = self.winfo_rooty() + self.winfo_height()
self.popup.geometry(f"+{x}+{y}")
def _on_global_click(self, event):
if not self.is_open or self.popup is None:
return
px = self.popup.winfo_rootx()
py = self.popup.winfo_rooty()
pw = self.popup.winfo_width()
ph = self.popup.winfo_height()
x = event.x_root
y = event.y_root
inside_popup = px <= x <= px + pw and py <= y <= py + ph
wx = self.winfo_rootx()
wy = self.winfo_rooty()
ww = self.winfo_width()
wh = self.winfo_height()
inside_widget = wx <= x <= wx + ww and wy <= y <= wy + wh
if not inside_popup and not inside_widget:
self.close()
CTkComboBox = CTkDropdown
__all__ = ["CTkDropdown", "CTkComboBox"]