Скачайте код уроков с GitLab: https://gitlab.com/PythonRu/tkinter-uroki
Виджеты определяют, какие действия смогут выполнять пользователи с помощью графического интерфейса. Однако важно обращать внимание на их взаимное и индивидуальное расположение. Эффективные макеты позволяют интуитивно определять значение и приоритетность каждого графического элемента, так что пользователь способен быстро разобраться, как нужно взаимодействовать с программой.
Макет также определяет внешний вид, который должен прослеживаться во всем приложении. Например, если кнопки расположены в правом верхнем углу, то они должны находиться там всегда. Хотя это может казаться очевидным для разработчиков, конечные пользователи будут путаться, если не провести их по приложению.
В этом материале погрузимся в разные механизмы, которые Tkinter предлагает для формирования макета, группировки виджетов и управления другими атрибутами, например, размером и отступами.
Группировка виджетов с фреймами
Фрейм представляет собой прямоугольную область окна, обычно используемую в сложных макетах. В них содержатся другие виджеты. Поскольку у фреймов есть собственные внутренние отступы, рамки и фон, то нужно отметить, что группа виджетов связана логически.
Еще одна распространенная особенность фреймов — инкапсуляция части функциональности приложения таким образом, что с помощью абстракции можно спрятать детали реализации дочерних виджетов.
Дальше будут рассмотрены оба сценария на примере создания компонента, который наследует класс Frame
и раскрывает определенную информацию о включенных виджетах.
Создадим приложение, которое будет включать два списка, где первый — это список элементов, а второй изначально пустой. Оба можно пролистывать. Также есть возможность перемещать элементы между ними с помощью двух кнопок по центру:
Определим подкласс Frame
, который представляет собой список с возможностью скроллинга и два его экземпляра. Также в основное окно будут добавлены две кнопки:
import tkinter as tk
class ListFrame(tk.Frame):
def __init__(self, master, items=[]):
super().__init__(master)
self.list = tk.Listbox(self)
self.scroll = tk.Scrollbar(self, orient=tk.VERTICAL,
command=self.list.yview)
self.list.config(yscrollcommand=self.scroll.set)
self.list.insert(0, *items)
self.list.pack(side=tk.LEFT)
self.scroll.pack(side=tk.LEFT, fill=tk.Y)
def pop_selection(self):
index = self.list.curselection()
if index:
value = self.list.get(index)
self.list.delete(index)
return value
def insert_item(self, item):
self.list.insert(tk.END, item)
class App(tk.Tk):
def __init__(self):
super().__init__()
months = ["Январь", "Февраль", "Март", "Апрель",
"Май", "Июнь", "Июль", "Август", "Сентябрь",
"Октябрь", "Ноябрь", "Декабрь"]
self.frame_a = ListFrame(self, months)
self.frame_b = ListFrame(self)
self.btn_right = tk.Button(self, text=">",
command=self.move_right)
self.btn_left = tk.Button(self, text="<",
command=self.move_left)
self.frame_a.pack(side=tk.LEFT, padx=10, pady=10)
self.frame_b.pack(side=tk.RIGHT, padx=10, pady=10)
self.btn_right.pack(expand=True, ipadx=5)
self.btn_left.pack(expand=True, ipadx=5)
def move_right(self):
self.move(self.frame_a, self.frame_b)
def move_left(self):
self.move(self.frame_b, self.frame_a)
def move(self, frame_from, frame_to):
value = frame_from.pop_selection()
if value:
frame_to.insert_item(value)
if __name__ == "__main__":
app = App()
app.mainloop()
Как работает группировка виджетов
У класса ListFrame
есть только два метода для взаимодействия с внутренним списком: pop_selection()
и insert_item()
. Первый возвращает и удаляет текущий выделенный элемент, или не делает ничего, если элемент не был выбран. Второй — вставляет элемент в конец списка.
Эти методы используются в родительском классе для перемещения элемента из одного списка в другой:
def move(self, frame_from, frame_to):
value = frame_from.pop_selection()
if value:
frame_to.insert_item(value)
Также можно воспользоваться особенностями контейнеров родительского фрейма, чтобы правильно размещать их с нужными внутренними отступами:
# ...
self.frame_a.pack(side=tk.LEFT, padx=10, pady=10)
self.frame_b.pack(side=tk.RIGHT, padx=10, pady=1
Благодаря фреймам вызовы управлять геометрией макетов проще.
Еще одно преимущество такого подхода — возможность использовать geometry manager в контейнерах каждого виджета. Это могут быть grid()
для виджетов во фрейме или pack()
для укладывания фрейма в основном окне.
Однако смешивать эти менеджеры в одном контейнере в Tkinter запрещено. Из-за этого приложение просто не будет работать.
Geometry manager Pack
В прошлых материалах можно было обратить внимание на то, что после создания виджета он не отображается на экране автоматически. Для каждого нужно было вызывать метод pack()
. Это подразумевает использование соответствующего geometry manager.
Это один из трех доступных в Tkinter менеджеров и он отлично подходит для простых макетов, как в случае, когда, например, нужно разместить все друг над другом или рядом.
Предположим, что нужно получить следующий макет для приложения:
Он состоит из трех строк, где в последней есть три виджета, расположенных рядом друг с другом. В таком случае Pack сможет добавить виджеты как требуется без использования дополнительных фреймов.
Для этого будут использоваться пять виджетов Label
с разными текстом и фоном, что поможет различать каждую прямоугольную область:
import tkinter as tk
class App(tk.Tk):
def __init__(self):
super().__init__()
label_a = tk.Label(self, text="Label A", bg="yellow")
label_b = tk.Label(self, text="Label B", bg="orange")
label_c = tk.Label(self, text="Label C", bg="red")
label_d = tk.Label(self, text="Label D", bg="green")
label_e = tk.Label(self, text="Label E", bg="blue")
opts = { 'ipadx': 10, 'ipady': 10, 'fill': tk.BOTH }
label_a.pack(side=tk.TOP, **opts)
label_b.pack(side=tk.TOP, **opts)
label_c.pack(side=tk.LEFT, **opts)
label_d.pack(side=tk.LEFT, **opts)
label_e.pack(side=tk.LEFT, **opts)
if __name__ == "__main__":
app = App()
app.mainloop()
Также были добавлены параметры в словаре opts
. Они делают яснее размеры каждой области:
Как работает Pack
Чтобы лучше понимать принципы работы Pack, разберем пошагово, как виджеты добавляются в родительский контейнер. Стоит обратить особое внимание на значение параметра side
, который определяет относительное положение виджета по отношению к следующему в этом же контейнере.
Сначала добавляются две метки в верхней части экрана. Пусть значение параметра side
по умолчанию является tk.TOP
,все равно зададим его явно, чтобы отличать от тех случаев, где используется tk.LEFT
:
Дальше добавляем еще три метки со значением tk.LEFT
у параметра side
, в результате чего они размещаются рядом друг рядом с другом:
Определение стороны label_e
особой роли не играет, поскольку это последний виджет, который добавляется в контейнер.
Важно запомнить, что это основная причина, почему порядок так важен при работе с Pack. Чтобы не столкнутся с непредвиденными результатами в сложных макетах распространенной практикой считается их расположение в пределах фрейма так, что бы они не пересекались.
В таких случаях рекомендуется использовать geometry manager Grid, поскольку он позволяет прямо задавать положение каждого виджета с помощью вызова geometry manager и избегать использования дополнительных фреймов.
В side
можно передать не только tk.TOP
и tk.LEFT
, но также tk.BOTTOM
и tk.RIGHT
. Они разместят виджеты в другом порядке, но это может быть не интуитивно, ведь мы естественным путем следим сверху вниз и слева направо.
Например, если заменить значение tk.LEFT
на tk.RIGHT
в последних трех виджетах, их порядок будет следующим: label_e
, label_d
и label_c
.
Geometry manager Grid
Grid — самый гибкий из всех доступных geometry manager. Он полностью переосмысливает концепцию сетки (grid), которая традиционно используется при дизайне пользовательских интерфейсов. Сетка — это двумерная таблица, разделенная на строки и колонки, где каждая ячейка представляет собой пространство, которое доступно для виджета.
Продемонстрируем работу Grid с помощью следующего макета:
Его можно представить в виде таблицы 3×3, где виджеты во второй и третьей колонках растягиваются на две строки, а виджет в третьей строке занимает все три колонки.
Как и в предыдущем варианте используем 5 меток с разным фоном, чтобы проиллюстрировать распределение ячеек:
import tkinter as tk
class App(tk.Tk):
def __init__(self):
super().__init__()
label_a = tk.Label(self, text="Label A", bg="yellow")
label_b = tk.Label(self, text="Label B", bg="orange")
label_c = tk.Label(self, text="Label C", bg="red")
label_d = tk.Label(self, text="Label D", bg="green")
label_e = tk.Label(self, text="Label E", bg="blue")
opts = { 'ipadx': 10, 'ipady': 10 , 'sticky': 'nswe' }
label_a.grid(row=0, column=0, **opts)
label_b.grid(row=1, column=0, **opts)
label_c.grid(row=0, column=1, rowspan=2, **opts)
label_d.grid(row=0, column=2, rowspan=2, **opts)
label_e.grid(row=2, column=0, columnspan=3, **opts)
if __name__ == "__main__":
app = App()
app.mainloop()
Также будем передавать словарь параметров, включающий внутренний отступ, который растянет виджеты на все доступное пространство внутри ячеек.
Как работает Grid
Расположение label_a
и label_b
говорит само за себя: они занимают первую и вторую строки первой колонки соответственно (важно не забывать, что индексация начинается с нуля):
Чтобы растянуть label_c
и label_d
на несколько ячеек, зададим значение 2 для параметра rowspan
. Таким образом они будут занимать две ячейки, начиная с положения, отмеченного опциями row
и column
. Наконец, значение columnspan
для label_e
будет 3.
Важно запомнить, что в отличие от Pack есть возможность менять порядок вызовов к grid()
для каждого виджета без изменения финального макета.
Параметр sticky
определяет границы, к которым виджет должен крепиться. Он выражается в координатах сторон света: север, юг, запад и восток. В Tkinter эти значения выражены константами tk.N
, tk.S
, tk.W
и tk.E
, а также их комбинациями: tk.NW
, tk.NE
, tk.SW
и tk.SE
.
Например, sticky=tk.N
выравнивает виджет у верхней границы ячейки (north – север), а sticky=tk.SE
— в правом нижнем углу (south-ease – юго-восток).
Поскольку эти константы представляют соответствующие символы в нижнем регистре, выражение tk.N + tk.S + tk.W + tk.E
можно записать в виде строки nwse
. Это значит, что виджет должен расширяться одновременно горизонтально и вертикально — по аналогии с работой fill=tk.BOTH
из Pack.
Если параметру sticky
значение не передается, виджет располагается по центру ячейки.
Geometry manager Place
Менеджер Place позволяет задать положение и размер виджета в абсолютном или относительном значении.
Из трех менеджеров этот является наименее используемым. С другой стороны, он может работать со сложными сценариями, где есть необходимость свободно разместить виджет или перекрыть другой.
Для демонстрации работы Place повторим следующий макет, смешав абсолютные и относительные положения и размеры:
Метки, которые будут отображаться, имеют разный фон и определены в том порядке, в котором они будут расположены слева направо и сверху вниз:
import tkinter as tk
class App(tk.Tk):
def __init__(self):
super().__init__()
label_a = tk.Label(self, text="Label A", bg="yellow")
label_b = tk.Label(self, text="Label B", bg="orange")
label_c = tk.Label(self, text="Label C", bg="red")
label_d = tk.Label(self, text="Label D", bg="green")
label_e = tk.Label(self, text="Label E", bg="blue")
label_a.place(relwidth=0.25, relheight=0.25)
label_b.place(x=100, anchor=tk.N,
width=100, height=50)
label_c.place(relx=0.5, rely=0.5, anchor=tk.CENTER,
relwidth=0.5, relheight=0.5)
label_d.place(in_=label_c, anchor=tk.N + tk.W,
x=2, y=2, relx=0.5, rely=0.5,
relwidth=0.5, relheight=0.5)
label_e.place(x=200, y=200, anchor=tk.S + tk.E,
relwidth=0.25, relheight=0.25)
if __name__ == "__main__":
app = App()
app.mainloop()
Если запустить эту программу, то можно будет увидеть наложение label_c
и label_d
в центре экрана. Этого не удастся добиться с помощью других менеджеров.
Как работает Place
Первая метка располагается со значением 0.25 у параметров relwidth
и relheight
. Это значит, что виджет будет занимать 25% ширины и высоты родительского. По умолчанию виджеты расположены в положениях x=0
и y=0
, а также выравнены к северо-западу, то есть, верхнему левому углу экрана.
Вторая метка имеет абсолютное положение — x=100
. Она выравнена по верхней границе с помощью параметра anchor
, который имеет значение tk.N
. Тут также определен абсолютный размер с помощью width
и height
.
Третья метка расположена по центру окна с помощью относительного позиционирования и параметра anchor
для tk.CENTER
. Важно запомнить, что значение 0.5 для relx
и relwidth
обозначает половину родительской ширины, а 0.5 для rely
и relheight
— половину родительской высоты.
Четвертая метка расположена в верхней части label_c
. Это делается с помощью переданного аргумента in_
(суффикс используется из-за того, что in
— зарезервированное ключевое слово в Python). При использовании in_
можно обратить внимание на то, что выравнивание не является геометрически точным. В этом примере нужно добавить смещение на 2 пикселя в каждом направлении, чтобы идеально перекрыть правый нижний угол label_c
.
Наконец, пятая метка использует абсолютное позиционирование и относительный размер. Как можно было заметить, эти размеры легко переключаются, поскольку значение размера родительского контейнера предполагается (200 х 200 пикселей). Однако при изменении размера основного окна будут работать только относительные величины. Это поведение легко проверить.
Еще одно важное преимущество Place — возможность совмещать его с Pack и Grid.
Например, представьте, что есть необходимость динамически отобразить текст на виджете при нажатии правой кнопкой мыши на нем. Его можно представить в виде виджета Label, который располагается в относительном положении при нажатии:
Лучше всего использовать другие менеджеры в своих приложениях Tkinter, а специализированные оставить для случаев, когда нужно кастомное позиционирование.
Группировка полей ввода с помощью виджета LabelFrame
Класс LabelFrame
может быть использован для группировки нескольких виджетов ввода. Он представляет собой логическую сущность с соответствующей меткой. Обычно он используется в формах и сильно напоминает виджет Frame
.
Создадим форму с парой экземпляров LabelFrame
, каждый из которых будет включать соответствующие виджеты ввода:
Поскольку цель этого примера — показать финальный макет, добавим некоторые виджеты без сохранения их ссылок в виде атрибутов:
import tkinter as tk
class App(tk.Tk):
def __init__(self):
super().__init__()
group_1 = tk.LabelFrame(self, padx=15, pady=10,
text="Персональная информация")
group_1.pack(padx=10, pady=5)
tk.Label(group_1, text="Имя").grid(row=0)
tk.Label(group_1, text="Фамилия").grid(row=1)
tk.Entry(group_1).grid(row=0, column=1, sticky=tk.W)
tk.Entry(group_1).grid(row=1, column=1, sticky=tk.W)
group_2 = tk.LabelFrame(self, padx=15, pady=10,
text="Адрес")
group_2.pack(padx=10, pady=5)
tk.Label(group_2, text="Улица").grid(row=0)
tk.Label(group_2, text="Город").grid(row=1)
tk.Label(group_2, text="Индекс").grid(row=2)
tk.Entry(group_2).grid(row=0, column=1, sticky=tk.W)
tk.Entry(group_2).grid(row=1, column=1, sticky=tk.W)
tk.Entry(group_2, width=8).grid(row=2, column=1,
sticky=tk.W)
self.btn_submit = tk.Button(self, text="Отправить")
self.btn_submit.pack(padx=10, pady=10, side=tk.RIGHT)
if __name__ == "__main__":
app = App()
app.mainloop()
Как работает группировка полей ввода
Виджет LabelFrame
принимает параметр labelWidget
для задания виджета, который будет использоваться как метка. Если его нет, отображается строка, переданная в параметре text
. Например, вместо создания экземпляра с tk.LabelFrame(master, text="Инфо")
можно заменить это на следующие инструкции:
label = tk.Label(master, text="Инфо", ...)
frame = tk.LabelFrame(master, labelwidget=label)
# ...
frame.pack()
Это позволит делать любые изменения, например, добавить изображение. Стоит обратить внимание, что здесь не используются geometry manager, поскольку метка располагается сама при размещении фрейма.
Динамическое расположение виджетов
Grid легко использовать как для простых, так и для более продвинутых макетов. Он также является мощным инструментом для объединения со списком виджетов.
Рассмотрим, как можно уменьшить количество строк и вызовем geometry manager с помощью всего нескольких строк благодаря «list comprehension», а также встроенным функциям zip
и enumerate
.
Приложение, которое будет создавать, включает четыре виджета Entry, каждый из которых имеет соответствующую метку, указывающую на значение поля. Также добавим кнопку для вывода всех значений.
Вместо того чтобы создавать и присваивать каждый виджет отдельному атрибуту, будем работать со списками виджетов. Поскольку при итерации по списку идет отслеживание индекса, можно легко вызвать метод grid()
с помощью соответствующего параметра column
.
Выполним агрегацию списка меток и виджетов с помощью функции zip
. Кнопка будет создана и расположена отдельно, поскольку у нее нет общих с другими виджетами параметров:
import tkinter as tk
class App(tk.Tk):
def __init__(self):
super().__init__()
fields = ["Имя", "Фамилия", "Телефон", "Email"]
labels = [tk.Label(self, text=f) for f in fields]
entries = [tk.Entry(self) for _ in fields]
self.widgets = list(zip(labels, entries))
self.submit = tk.Button(self, text="Распечатать",
command=self.print_info)
for i, (label, entry) in enumerate(self.widgets):
label.grid(row=i, column=0, padx=10, sticky=tk.W)
entry.grid(row=i, column=1, padx=10, pady=5)
self.submit.grid(row=len(fields), column=1, sticky=tk.E,
padx=10, pady=10)
def print_info(self):
for label, entry in self.widgets:
print("{} = {}".format(label.cget("text"), entry.get()))
if __name__ == "__main__":
app = App()
app.mainloop()
Можно ввести разный текст в каждое из полей и нажать кнопку «Распечатать», чтобы убедиться, что каждый кортеж содержит соответствующие метку и текст.
Как работает динамическое расположение
Каждый генератор списка выполняет итерацию по строкам списка полей. Поскольку метки используют каждый элемент как отображаемый текст, ссылки нужны только на родительский контейнер — нижнее подчеркивание подразумевает, что значение переменной игнорируется.
Начиная с Python 3, функция zip
возвращает итератор вместо списка, поэтому результат — агрегация с функцией списка. В результате атрибут widgets
содержит список кортежей, по которому можно пройти несколько раз:
fields = ["Имя", "Фамилия", "Телефон", "Email"]
labels = [tk.Label(self, text=f) for f in fields]
entries = [tk.Entry(self) for _ in fields]
self.widgets = list(zip(labels, entries))
Теперь нужно вызвать geometry manager для каждого кортежа виджетов. С помощью функции enumerate
можно отслеживать индекс каждой итерации и передавать его в виде числа row
:
for i, (label, entry) in enumerate(self.widgets):
label.grid(row=i, column=0, padx=10, sticky=tk.W)
entry.grid(row=i, column=1, padx=10, pady=5)
Стоит обратить внимание, что был использован синтаксис for i, (label, entry) in …
, потому что нужно распаковать кортеж, сгенерированный с помощью enumerate
и затем распаковать каждый кортеж атрибута widgets
.
Внутри функции обратного вызова print_info()
пройдем по виджетам для вывода текста каждой метки с соответствующими значениями поля. Для получения text
из меток используем метод cget()
, который позволяет получить значение параметра виджета по его имени.