用Python和Flet制作Trello克隆
让我们用Python和Flet框架制作一个Trello克隆,并将其部署到fly.io上!
本教程的代码可以在这里找到,其中包含了详细的提交说明。在克隆后,请务必运行pip install -r requirements.txt
来安装依赖。您还可以在这里找到一个实时演示。
为什么选择Flet?
大多数开发人员无疑都经历过这样的情况:要么是开发了一个最初意在面向特定用户群体的控制台应用程序,但后来发现受众超过了预期;要么是需要为非开发人员开发一个内部工具,这个工具的用户基数可能较小,使用寿命相对较短。在这些情况下,使用过度复杂的工具,如Electron,或者功能丰富的框架,如Flutter(为讽刺而讥笑!),或者尝试快速掌握其他跨平台框架(如.NET MAUI),往往感觉不太合适。我们真正想要的是在我们的逻辑上添加一个外观通用的用户界面,在性能可接受的情况下,并且最好所需的编写时间要少于业务逻辑的编写时间,最好还能使用我们已经熟悉的编程语言来编写这个界面 - 即使用我们已经熟练掌握的语言(目前只有Python库可用,但C#,TypeScript和Golang库也在路上)。这正是Flet平台的目标。
Flet采用了与许多新的UI框架不同的方法,这种方法对于大多数有经验的程序员来说可能更直观。与当前普遍采用的声明式方法不同,Flet选择了命令式模型。
尽管Flet旨在设计简单的GUI,但我们还是尝试制作一个稍微复杂一些的应用,比如一个最小化版本的Trello,我们给它起了一个完全独立产生的名字——Trolli。对于本教程,我假设读者对Flet项目的基本概念和设置已经很熟悉(如果不熟悉,请阅读教程和文档),因此我将更多地关注不包含在现有教程中的方面。
定义实体和布局
为了创建我们的克隆版本的MVP,让我们首先定义主要实体(boards
,board_lists
,items
),确定一个可以接受的设计和布局,并实现一种伪存储库模式,以便在将来的开发中,我们可以从内存数据存储转移到某种持久性存储。
在main.py
模块中,我们将添加以下代码,并继续定义TrelloApp
类。
import flet
from flet import (
Page,
colors
)
if __name__ == "__main__":
def main(page: Page):
page.title = "Flet Trello clone"
page.padding = 0
page.bgcolor = colors.BLUE_GREY_200
app = TrelloApp(page)
page.add(app)
page.update()
flet.app(target=main, view=flet.WEB_BROWSER)
在布局方面,我们可以将应用程序视为一个包含标题栏(appbar
)的组件,在标题栏下方是可折叠的导航面板,旁边是活动视图,该视图可以是一个板块、设置、成员或其他我们选择的内容。大致如下:
因此,应用程序本身的类可能如下所示:
class TrelloApp(flet.Container):
def __init__(self, page: Page):
super().__init__()
self.page = page
self.bgcolor = colors.BLUE_GREY_50
self.direction = flet.Column()
self.appbar = AppBar()
self.sidebar = Sidebar()
self.board = Board()
self.direction.append(self.appbar)
self.direction.append(
flet.Row(
self.sidebar,
self.board
)
)
self.append(self.direction)
from flet import (
Control,
Container,
Icon,
List,
ListItem,
Text,
colors,
icons,
)
class Sidebar(Container):
def __init__(self, parent, page, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parent = parent
self.page = page
self.nav_list = List(color=colors.GREY_100)
self.nav_items = [
ListItem(
controls=[
Icon(icons.HOME),
Text("Home")
],
on_click=self.go_home
),
ListItem(
controls=[
Icon(icons.PERSON),
Text("Profile")
],
on_click=self.go_profile
),
ListItem(
controls=[
Icon(icons.NOTIFICATIONS),
Text("Notifications")
],
on_click=self.go_notifications
),
ListItem(
controls=[
Icon(icons.SETTINGS),
Text("Settings")
],
on_click=self.go_settings
)
]
self.nav_list.controls = self.nav_items
self.controls = [self.nav_list]
def go_home(self, e):
self.parent.active_view = self.create_home_view()
def go_profile(self, e):
self.parent.active_view = self.create_profile_view()
def go_notifications(self, e):
self.parent.active_view = self.create_notifications_view()
def go_settings(self, e):
self.parent.active_view = self.create_settings_view()
def create_home_view(self):
# Create the control for home view
return Container(
controls=[
Text("Welcome to the Home View")
],
alignment="center",
horizontal_alignment="center"
)
def create_profile_view(self):
# Create the control for profile view
return Container(
controls=[
Text("Welcome to the Profile View")
],
alignment="center",
horizontal_alignment="center"
)
def create_notifications_view(self):
# Create the control for notifications view
return Container(
controls=[
Text("Welcome to the Notifications View")
],
alignment="center",
horizontal_alignment="center"
)
def create_settings_view(self):
# Create the control for settings view
return Container(
controls=[
Text("Welcome to the Settings View")
],
alignment="center",
horizontal_alignment="center"
)
from flet import (
UserControl,
Column,
Container,
Row,
Text,
NavigationRail,
NavigationRailDestination,
alignment,
border_radius,
colors,
icons,
padding,
margin,
)
import itertools
class Sidebar(UserControl):
id_counter = itertools.count()
def __init__(self, app_layout, page):
super().__init__()
self.app_layout = app_layout
self.page = page
self.top_nav_items = [
NavigationRailDestination(
label_content=Text("看板"),
label="看板",
icon=icons.BOOK_OUTLINED,
selected_icon=icons.BOOK_OUTLINED
),
NavigationRailDestination(
label_content=Text("成员"),
label="成员",
icon=icons.PERSON,
selected_icon=icons.PERSON
),
]
self.top_nav_rail = NavigationRail(
selected_index=None,
label_type="all",
on_change=self.top_nav_change,
destinations=self.top_nav_items,
bgcolor=colors.BLUE_GREY,
extended=True,
expand=True
)
self.id = next(Sidebar.id_counter)
def build(self):
self.view = Container(
content=Column([
Row([
Text("工作区"),
]),
# 分隔线
Container(
bgcolor=colors.BLACK26,
border_radius=border_radius.all(30),
height=1,
alignment=alignment.center_right,
width=220
),
self.top_nav_rail,
# 分隔线
Container(
bgcolor=colors.BLACK26,
border_radius=border_radius.all(30),
height=1,
alignment=alignment.center_right,
width=220
),
], tight=True),
padding=padding.all(15),
margin=margin.all(0),
width=250,
bgcolor=colors.BLUE_GREY,
)
return self.view
def top_nav_change(self, e):
self.top_nav_rail.selected_index = e.control.selected_index
self.update()
运行主应用程序
flet main.py -d
能够看到结果,并且在进行任何样式更改时进行热重载。例如,尝试将 alignment="center"
添加到容器中的第一行,如下所示...
content=Column([
Row([Text("工作区")], alignment="center")
保存文件后,您应该能够在应用程序窗口中看到更改。
在继续之前,让我们定义基本实体。我们将需要一个 Board
类,它将保持一个列表的列表,其中每个列表都将是 BoardList
对象(抱歉,这里存在不幸的词法冲突 - 对 "list" 的俗称使用源自于应用程序的性质,而对 "list" 的技术使用则源自于 Python 中特定的术语,表示类似数组的数据结构),每个列表又包含一个 Item
对象的列表。如果这让您感到困惑,请花些时间阅读源代码以澄清疑虑。
对于每个实体,我们将使用 id_counter = itertools.count()
在每个类的顶部添加一个应用程序范围内的唯一 ID,并在初始化中调用 next(Board.id_counter)
。这样,两个列表或看板可以具有相同的名称,但仍表示不同的实体。
数据访问层和自定义
现在我们已经定义了基本的布局和实体,让我们为应用程序本身添加一些自定义参数。我们还需要创建一个基本的数据访问接口。你可以在data_store.py
和memory_store.py
文件中看到接口和内存实现的样板代码。这样,我们将来可以更容易地将持久存储解决方案切换到应用中。
下面是更新后的main
函数。我们需要在main
方法中实例化InMemoryStore
类,这样每个用户会话(即每个使用应用程序的新标签)都有自己的存储实例。然后,我们需要将该存储传递给需要访问它的每个组件。
我们还将在assets目录中添加一个新的字体,该目录在app
函数的命名参数中指定。
if __name__ == "__main__":
def main(page: Page):
page.title = "Flet Trello克隆"
page.padding = 0
page.theme = theme.Theme(
font_family="Verdana")
page.theme.page_transitions.windows = "cupertino"
page.fonts = {
"Pacifico": "/Pacifico-Regular.ttf"
}
page.bgcolor = colors.BLUE_GREY_200
page.update()
app = TrelloApp(page)
flet.app(target=main, assets_dir="../assets", view=flet.WEB_BROWSER)
应用程序逻辑
现在你可以运行应用程序了,除了名称的字体更漂亮之外,它仍然没有任何功能。现在是填写应用程序逻辑的时候了。尽管这个应用程序可能称为复杂的,但我们不需要将代码分成不同的应用和业务层。仅将数据访问与其他逻辑分离即可完成本教程。但是,进一步的分离可能是值得考虑的事情。
创建视图
首先,我们将添加视图以对应侧边栏导航目的地。我们需要一个视图来显示所有看板,并且还需要一个显示成员窗格的视图,这只是一个占位符,暂时留待以后的教程。我们将这些视图作为控件添加到app_layout.py
模块中。
self.members_view = Text("成员视图")
self.all_boards_view = Column([
Row([
Container(
Text(value="你的看板", style="headlineMedium"),
expand=True,
padding=padding.only(top=15)),
Container(
TextButton(
"添加新看板",
icon=icons.ADD,
on_click=self.app.add_board,
style=ButtonStyle(
bgcolor={
"": colors.BLUE_200,
"hovered": colors.BLUE_400
},
shape={
"": RoundedRectangleBorder(radius=3)
}
)
),
padding=padding.only(right=50, top=15))
]),
Row([
TextField(hint_text="搜索所有看板", autofocus=False, content_padding=padding.only(left=10),
width=200, height=40, text_size=12,
border_color=colors.BLACK26, focused_border_color=colors.BLUE_ACCENT, suffix_icon=icons.SEARCH)
]),
Row([Text("没有要显示的看板")])
], expand=True)
由于我们以命令式的范式工作,没有显式的状态管理工具(例如redux或类似工具),我们需要一个“重新加载”视图的方法,以便它的当前状态反映出在其他实体(即侧边栏)中所做的更改。
def hydrate_all_boards_view(self):
self.all_boards_view.controls[-1] = Row([
Container(
content=Row([
Container(
content=Text(value=b.name), data=b, expand=True, on_click=self.board_click),
Container(
content=PopupMenuButton(
items=[
PopupMenuItem(
content=Text(value="删除", style="labelMedium",
text_align="center"),
on_click=self.app.delete_board, data=b),
PopupMenuItem(),
PopupMenuItem(
content=Text(value="归档", style="labelMedium",
text_align="center"),
)
]
),
padding=padding.only(right=-10),
border_radius=border_radius.all(3)
)], alignment="spaceBetween"),
border=border.all(1, colors.BLACK38),
border_radius=border_radius.all(5),
bgcolor=colors.WHITE60,
padding=padding.all(10),
width=250,
data=b
) for b in self.store.get_boards()
], wrap=True)
self.sidebar.sync_board_destinations()