前言
前期项目需求写过一个日期多选的控件(Python Tkinter实现日历多选_楠奇湖畔的博客-CSDN博客_tkinter 日历控件),后面使用的时候发现有很多可以改进的部分。因此重新写了一个日历控件,可以单选或多选,此次只放了单选的代码。
一、参考文章
Python tkinter 下拉日历控件_我的眼_001的博客-CSDN博客
此次还是参考了上述的文章,作为一名新手,基于学习吸收的原则,自己写代码的过程中,因为不能完全吸收看懂原代码,所以很多功能实现,使用了自己能看懂且不出现bug的代码来实现。所以有不妥之处,或可优化之处,或者有不明白之处,欢迎评论或私信指出,相互学习,感激不尽!
二、期望实现功能
日历选择
时间选择
控件位置随唤醒按键动态调整体
由唤醒控件在屏幕上的相对位置实现
三、实现步骤
1.引入库
tkinter,calendar
import calendar
import tkinter as tk
from tkinter import ttk
2.代码实现
控件位置动态调整实现
日历控件的宽高固定(w_w, w_h),知道唤醒控件按键的绝对坐标与宽高信息(geomtry),屏幕的宽高信息,就可以判断应该将控件置放哪一个点上(LU, RU, LD, RD):
如果高度差(△h) > 控件高度(w_h),则置于LU/RU,反之为LD/RD
如果宽度差(△w) > 控件宽度(w_w),则置于RU/RD,反之为LU/LD
唤醒按键信息获取
# 获取按键的宽高,相对起始坐标,只用到宽高信息,相对起始坐标无用处
widget_geometry = event.widget.winfo_geometry().replace('x', '+')
widget_width = int(widget_geometry.split('+')[0])
widget_height = int(widget_geometry.split('+')[1])
# 获取按键的绝对起始坐标
point_x, point_y = event.widget.winfo_rootx(), event.widget.winfo_rooty()
屏幕宽高获取
screen_width = root.winfo_screenwidth() # 显示器大小
screen_height = root.winfo_screenheight()
实现效果
更优解建议
现有代码中,将获取唤醒按键geometry信息和唤醒日历组件并赋值分成了两个函数实现,并且需要对按键绑定两次函数,查了下资料应该是可以实现用一个函数实现的,自己也在过程中这样操作了,将传参与事件放在了一个函数中,但是运行时,会出现按键点击后一直处于按下的状态,使用时,除了这个状态不对外,其它都正常,又找不到具体是那个地方错误,所以最后还是改成了分两次实现以避免这个bug,如果有更好的建议,欢迎大家分享,谢谢。(一个代码实现如下以及bug图:)
# 二合一
def set_date(event, time_type):
global point_x, point_y, widget_width, widget_height
# 根据点击事件获取组件的geometry,也可以用正则获得值
widget_geometry = event.widget.winfo_geometry().replace('x', '+')
widget_width = int(widget_geometry.split('+')[0])
widget_height = int(widget_geometry.split('+')[1])
point_x, point_y = event.widget.winfo_rootx(), event.widget.winfo_rooty()
available_x = screen_width - point_x - widget_width # 宽度差
position = None # 传给日历组件的起始坐标信息
point = None # 传给日历组件的置放位置信息
if available_x >= 260 and point_y >= 280: # 日历组件默认宽高:260,280
position = (point_x + widget_width, point_y)
point = 'RU'
print('RU')
elif available_x >= 260 and point_y < 280:
position = (point_x + widget_width, point_y + widget_height)
point = 'RD'
print('RD')
elif available_x < 260 and point_y >= 280:
position = (point_x, point_y)
point = 'LU'
print('LU')
elif available_x < 260 and point_y < 280:
position = (point_x, point_y + widget_height)
point = 'LD'
print('LD')
if time_type == 'start':
date = Calendar(position=position, point=point).date_selection()
start_time.set(date)
elif time_type == 'end':
date = Calendar(position=position, point=point).date_selection()
end_time.set(date)
elif time_type == 'diff':
if len(start_time.get()) < 7 or len(end_time.get()) < 7:
messagebox.showwarning(message='请先选择日期!')
else:
start = datetime.strptime(start_time.get(), '%Y-%m-%d %H:%M:%S')
end = datetime.strptime(end_time.get(), '%Y-%m-%d %H:%M:%S')
diff = (end - start)
if end < start:
diff_time.set('开始时间大于结束时间!')
else:
diff_time.set(diff)
# 按键绑定部分
btn_start_time = ttk.Button(text='开始时间:',)
btn_start_time.bind("<Button-1>", lambda event: set_date(event, time_type='start'))
btn_start_time.grid(row=0, column=0, padx=5, pady=5)
完整代码
# -*- coding = utf-8 -*-
# Author : Abner
# @Software : PyCharm
import calendar
import tkinter as tk
from tkinter import ttk
from tkinter import messagebox
datetime = calendar.datetime.datetime
timedelta = calendar.datetime.timedelta
class Calendar:
def __init__(self, position=None, point=None):
self.selection_state = None # 用存放选中的日期(为日期序号,非具体日期)
self.date_sele = None # 选中的具体日期
self.toplevel = tk.Toplevel()
self.toplevel.bind("<FocusOut>", self._exit) # 组件绑定事件函数,焦点在组件外触发函数
self.toplevel.overrideredirect(1) # 无工具栏模式
self.toplevel.attributes('-alpha', 0.9) # 设置透明度
self.toplevel.withdraw()
self.root_frame = ttk.Frame(self.toplevel)
# 提前执行函数————————————————————————————
self.update_time()
self.button_style()
self.creat_frame()
self.first_floor()
self.second_floor()
self.third_floor()
self.fourth_floor()
self.root_frame.pack(expand=1, fill='both')
self.toplevel.update() # 问题终结者 update
# 根据传入唤醒按键信息,设置窗口位置及大小 ——————————————————————————
width, height = 260, 280
if point == 'LU':
self.toplevel.geometry('%dx%d+%d+%d' % (width, height, position[0] - width, position[1] - height))
elif point == 'RU':
self.toplevel.geometry('%dx%d+%d+%d' % (width, height, position[0], position[1] - height))
elif point == 'LD':
self.toplevel.geometry('%dx%d+%d+%d' % (width, height, position[0] - width, position[1]))
elif point == 'RD':
self.toplevel.geometry('%dx%d+%d+%d' % (width, height, position[0], position[1]))
self.toplevel.deiconify()
self.toplevel.focus()
self.toplevel.wait_window()
def update_time(self):
date = datetime.now()
self.date_year = date.year
self.date_month = date.month
self.date_day = date.day
self.date_hour = date.hour
self.date_minute = date.minute
self.date_second = date.second
def button_style(self): # 这个设置按键样式的方法,一直没搞清楚,如果大家有专栏介绍的,可以推荐下
style = ttk.Style(self.root_frame)
arrow_layout = lambda dir: (
[('Button.focus', {'children': [('Button.%sarrow' % dir, None)]})]
)
style.layout('L.TButton', arrow_layout('left'))
style.layout('R.TButton', arrow_layout('right'))
def creat_frame(self):
self.first_frame = ttk.Frame(self.root_frame)
self.first_frame.pack(pady=5)
self.second_frame = ttk.Frame(self.root_frame)
self.second_frame.pack(pady=5)
self.third_frame = ttk.Frame(self.root_frame)
self.third_frame.pack(pady=5)
self.fourth_frame = ttk.Frame(self.root_frame)
self.fourth_frame.pack(pady=5)
def first_floor(self): # 年月选择栏
btn_left = ttk.Button(self.first_frame, style='L.TButton', command=self._prev_month)
btn_left.grid(in_=self.first_frame, row=0, column=0, padx=5)
btn_right = ttk.Button(self.first_frame, style='R.TButton', command=self._next_month)
btn_right.grid(in_=self.first_frame, row=0, column=5, padx=5)
self.combox_year = ttk.Combobox(self.first_frame, values=[str(year) for year in
range(self.date_year, self.date_year - 11, -1)],
width=5, state='readonly')
self.combox_year.current(0)
self.combox_year.grid(row=0, column=1, padx=5)
lab_year = tk.Label(self.first_frame, text='年')
lab_year.grid(row=0, column=2)
self.combox_month = ttk.Combobox(self.first_frame, values=[str('%02d' % x) for x in range(1, 13)], width=5,
state='readonly')
self.combox_month.current(self.date_month - 1) # 0~11
self.combox_month.grid(row=0, column=3, padx=5)
lab_month = tk.Label(self.first_frame, text='月')
lab_month.grid(row=0, column=4)
# 函数绑定下拉框
self.combox_year.bind('<<ComboboxSelected>>', self._update)
self.combox_month.bind('<<ComboboxSelected>>', self._update)
def second_floor(self): # 日历栏
year = int(self.combox_year.get())
month = int(self.combox_month.get())
self.tk_cv = tk.Canvas(self.second_frame, bg='white', width=225, height=160)
self.tk_cv.pack()
# 标题
cols = ['日', '一', '二', '三', '四', '五', '六']
for i in range(len(cols)):
coord = 10 + i * 30, 10, 40 + i * 30, 30
rec_x = self.tk_cv.create_rectangle(coord, fill='red', outline='white')
self.tk_cv.create_text(25 + i * 30, 20, text=cols[i])
def on_click(event):
x, y = (event.x - 10) // 30, (event.y - 30) // 20
try:
if self.selection_state is None:
self.tk_cv.itemconfig(rect[y][x], fill='lightgreen')
self.selection_state = rect[y][x]
elif self.selection_state != rect[y][x]:
self.tk_cv.itemconfig(self.selection_state, fill='white')
self.tk_cv.itemconfig(rect[y][x], fill='lightgreen')
self.selection_state = rect[y][x]
elif self.selection_state and self.selection_state == rect[y][x]:
self.tk_cv.itemconfig(rect[y][x], fill='white')
self.selection_state = None
except:
pass
def date_get():
c = calendar.TextCalendar(calendar.SUNDAY) # 括号内内容表示以哪天为起始
cal = c.monthdayscalendar(year, month) # 只由日组成的一组二维数组,每七天一组列表内嵌成一组列表
cal_dates = c.monthdatescalendar(year, month) # 由日期组成的二维数组
return cal, cal_dates
xy = [10 + i * 30 for i in range(7)]
dates_num, dates = date_get()
rect = [[0] * 7 for _ in range(len(dates_num))]
dates = sum(dates, [])
dates_states = list(map(lambda x: [0, x], dates))
for i in range(len(dates_num)):
for j, x in enumerate(xy):
if dates_num[i][j] != 0:
rect[i][j] = self.tk_cv.create_rectangle(x, 30 + i * 20, x + 30, 50 + i * 20, tags=('imgButton1'))
self.tk_cv.itemconfig(rect[i][j], outline='white') # , fill='white', )
self.tk_cv.create_text(x + 15, 40 + i * 20, text='%02d' % dates_num[i][j], tags=('imgButton1'))
if dates_num[i][j] == self.date_day:
self.tk_cv.itemconfig(rect[i][j], fill='lightgreen') # 高亮显示当天日期
self.selection_state = rect[i][j]
self.date_state = dict(zip(sum(rect, []), dates_states)) # {15: [0, datetime.date(2022, 8, 1)],...}
self.tk_cv.tag_bind('imgButton1', '<Button-1>', on_click)
def third_floor(self): # 时间栏
self.combox_hour = ttk.Combobox(self.third_frame, values=[str('%02d' % x) for x in range(24)], width=3,
state='readonly')
self.combox_hour.current(self.date_hour)
self.combox_hour.grid(row=0, column=0, padx=5)
lab_hour = tk.Label(self.third_frame, text='时')
lab_hour.grid(row=0, column=1)
self.combox_minu = ttk.Combobox(self.third_frame, values=[str('%02d' % x) for x in range(60)], width=3,
state='readonly')
self.combox_minu.current(self.date_minute)
self.combox_minu.grid(row=0, column=2, padx=5)
lab_minute = tk.Label(self.third_frame, text='分')
lab_minute.grid(row=0, column=3)
self.combox_sec = ttk.Combobox(self.third_frame, values=[str('%02d' % x) for x in range(60)], width=3,
state='readonly')
self.combox_sec.current(self.date_second)
self.combox_sec.grid(row=0, column=4, padx=5)
lab_sec = tk.Label(self.third_frame, text='秒')
lab_sec.grid(row=0, column=5)
def fourth_floor(self): # 确认与取消
btn_submit = ttk.Button(self.fourth_frame, text='提 交', command=lambda: self._exit(confirm=1))
btn_submit.grid(row=0, column=0, padx=10)
btn_cancel = ttk.Button(self.fourth_frame, text='取 消', command=lambda: self._exit(confirm=2))
btn_cancel.grid(row=0, column=1, padx=10)
def _submit(self):
# 正常模式下的提交代码
if self.selection_state is None:
self.date_sele = '未选择!'
else:
clock = '{}:{}:{}'.format(self.combox_hour.get(), self.combox_minu.get(), self.combox_sec.get())
self.date_sele = self.date_state[self.selection_state][1].strftime('%Y-%m-%d') + ' ' + clock
def _update(self, *args):
self.update_time()
self.tk_cv.pack_forget()
self.second_floor()
self.combox_hour.current(self.date_hour)
self.combox_minu.current(self.date_minute)
self.combox_sec.current(self.date_second)
def _prev_month(self):
date = datetime(int(self.combox_year.get()), int(self.combox_month.get()), 1) # 数字1代表第几天
date = date - timedelta(days=1)
date = datetime(date.year, date.month, 1)
self.combox_year.set(date.year)
self.combox_month.set(date.month)
self._update()
def _next_month(self):
date = datetime(int(self.combox_year.get()), int(self.combox_month.get()), 1) # 数字1代表第几天
date = date + timedelta(days=calendar.monthrange(date.year, date.month)[1] + 1)
date = datetime(date.year, date.month, 1)
self.combox_year.set(date.year)
self.combox_month.set(date.month)
self._update()
def date_selection(self, *args):
if self.date_sele is None:
return '未选择!'
else:
return self.date_sele
def _exit(self, confirm=1):
try:
# 点击combox等内容时,会出现focusout导致窗口关闭,加一个if判断避免
if 'toplevel' not in str(self.toplevel.focus_displayof()) or confirm == 2:
self.toplevel.destroy()
elif confirm == 1:
self._submit()
self.toplevel.destroy()
except:
pass
if __name__ == '__main__':
# 主窗口
root = tk.Tk()
root.title('时间计算器')
root_width, root_height = 400, 110
screen_width = root.winfo_screenwidth() # 显示器大小
screen_height = root.winfo_screenheight()
x, y = (screen_width - root_width) / 2, (screen_height - root_height) / 2 # 窗口默认起始坐标
root.geometry('%dx%d+%d+%d' % (root_width, root_height, x, y))
root.resizable(0, 0)
point_x, point_y = 0, 0 # 唤醒按键的起始坐标
widget_width, widget_height = 0, 0 # 唤醒按键的宽高
# -----------------------------------
def set_position(event):
global point_x, point_y, widget_width, widget_height
# 根据点击事件获取组件的geometry,也可以用正则获得值
widget_geometry = event.widget.winfo_geometry().replace('x', '+')
widget_width = int(widget_geometry.split('+')[0])
widget_height = int(widget_geometry.split('+')[1])
point_x, point_y = event.widget.winfo_rootx(), event.widget.winfo_rooty()
def set_date(time_type):
available_x = screen_width - point_x - widget_width # 宽度差
position = None # 传给日历组件的起始坐标信息
point = None # 传给日历组件的置放位置信息
if available_x >= 260 and point_y >= 280: # 日历组件默认宽高:260,280
position = (point_x + widget_width, point_y)
point = 'RU'
print('RU')
elif available_x >= 260 and point_y < 280:
position = (point_x + widget_width, point_y + widget_height)
point = 'RD'
print('RD')
elif available_x < 260 and point_y >= 280:
position = (point_x, point_y)
point = 'LU'
print('LU')
elif available_x < 260 and point_y < 280:
position = (point_x, point_y + widget_height)
point = 'LD'
print('LD')
if time_type == 'start':
date = Calendar(position=position, point=point).date_selection()
start_time.set(date)
elif time_type == 'end':
date = Calendar(position=position, point=point).date_selection()
end_time.set(date)
elif time_type == 'diff':
if len(start_time.get()) < 7 or len(end_time.get()) < 7:
messagebox.showwarning(message='请先选择日期!')
else:
start = datetime.strptime(start_time.get(), '%Y-%m-%d %H:%M:%S')
end = datetime.strptime(end_time.get(), '%Y-%m-%d %H:%M:%S')
diff = (end - start)
if end < start:
diff_time.set('开始时间大于结束时间!')
else:
diff_time.set(diff)
# 显示部分——————————————————————————————
start_time = tk.StringVar()
start_time.set('请选择日期!')
end_time = tk.StringVar()
end_time.set('请选择日期!')
diff_time = tk.StringVar()
diff_time.set('请选择日期!')
lab_start_time = ttk.Label(textvariable=start_time, font=25)
lab_start_time.grid(row=0, column=1, padx=5, pady=5)
lab_end_time = ttk.Label(textvariable=end_time, font=25)
lab_end_time.grid(row=1, column=1, padx=5, pady=5)
lab_diff_time = ttk.Label(textvariable=diff_time, font=25)
lab_diff_time.grid(row=2, column=1, padx=5, pady=5)
# 按键部分——————————————————————————————
# 如果有更好的方法将两个函数合并一个,绑定给按键的话,欢迎评论或私信,感激不尽
btn_start_time = ttk.Button(text='开始时间:', command=lambda: set_date(time_type='start'))
btn_start_time.bind("<Button-1>", lambda event: set_position(event))
btn_start_time.grid(row=0, column=0, padx=5, pady=5)
btn_end_time = ttk.Button(text='结束时间:', command=lambda: set_date(time_type='end'))
btn_end_time.bind("<Button-1>", lambda event: set_position(event))
btn_end_time.grid(row=1, column=0, padx=5, pady=5)
btn_diff_time = ttk.Button(text='开始计算:', command=lambda: set_date(time_type='diff'))
btn_diff_time.grid(row=2, column=0, padx=5, pady=5)
root.mainloop()
归纳
组件的关闭
参考文章中,用root.after(self, ms, func=None, *args)方法来定时调用函数,监视实时焦点focus,如果焦点在组件外,就关闭组件。自己写的过程中,将此改成了事件触发的方式,给组件绑定了事件<FocusOut>,当组件失去焦点时,触发关闭函数。
# 绑定
self.toplevel.bind("<FocusOut>", self._exit)
# 设置焦点
self.toplevel.focus()
自定义按键风格
这个样式设置,没搞清楚,等找到资料再补充