Flet 中级指南
最后更新:2026-03-06 继承已有控件,修改默认样式: 继承容器控件,封装复合 UI: animate 参数说明 常用 AnimationCurve: LINEAR EASE EASE_IN EASE_OUT EASE_IN_OUT BOUNCE_IN BOUNCE_OUT ELASTIC_IN ELASTIC_OUT 路由参数用 URL query string 传递,不用全局变量:通过 page.route(如 /user?id=42)传递页面间数据,而非将数据存到模块级变量。URL 方式支持浏览器历史、书签和刷新恢复,全局变量在多用户 Web
官方文档:https://docs.flet.dev/
适用版本:Flet 0.82+(2026-05-08 核实)
最后更新:2026-03-06
本文全程使用 Flet 0.82+ 声明式语法。
一、@ft.observable 深入
嵌套 observable
import flet as ft
from dataclasses import dataclass, field
@ft.observable
@dataclass
class Address:
city: str = ""
country: str = ""
@ft.observable
@dataclass
class User:
name: str = ""
email: str = ""
address: Address = field(default_factory=Address)
def update_name(self, name: str):
self.name = name
def move_to(self, city: str, country: str):
self.address.city = city # 嵌套 observable 的修改也会触发更新
self.address.country = country
@ft.component
def UserProfile(user: User) -> ft.Control:
return ft.Column([
ft.Text(f"姓名:{user.name}"),
ft.Text(f"城市:{user.address.city}"), # 嵌套字段也是响应式的
ft.Text(f"国家:{user.address.country}"),
])
observable 列表操作
@ft.observable
@dataclass
class TaskList:
tasks: list = field(default_factory=list)
filter: str = "all" # "all" | "active" | "done"
def add(self, text: str):
self.tasks.append({"id": len(self.tasks), "text": text, "done": False})
def toggle(self, task_id: int):
for t in self.tasks:
if t["id"] == task_id:
t["done"] = not t["done"]
# 列表内部对象修改后需要手动通知(触发 tasks 字段的更新)
self.tasks = list(self.tasks) # 重新赋值触发更新
def remove(self, task_id: int):
self.tasks = [t for t in self.tasks if t["id"] != task_id]
@property
def visible_tasks(self):
if self.filter == "active":
return [t for t in self.tasks if not t["done"]]
if self.filter == "done":
return [t for t in self.tasks if t["done"]]
return self.tasks
二、自定义控件
@ft.control — 样式化控件
继承已有控件,修改默认样式:
@ft.control
class PrimaryButton(ft.ElevatedButton):
"""统一主色按钮,圆角更大"""
style: ft.ButtonStyle = field(default_factory=lambda: ft.ButtonStyle(
shape=ft.RoundedRectangleBorder(radius=20),
bgcolor=ft.Colors.BLUE_600,
color=ft.Colors.WHITE,
padding=ft.Padding(left=24, right=24, top=12, bottom=12),
))
@ft.control
class DangerButton(ft.ElevatedButton):
style: ft.ButtonStyle = field(default_factory=lambda: ft.ButtonStyle(
bgcolor=ft.Colors.RED_600,
color=ft.Colors.WHITE,
))
# 使用
PrimaryButton("确认", on_click=lambda _: ...)
DangerButton("删除", on_click=lambda _: ...)
@ft.control — 组合控件
继承容器控件,封装复合 UI:
@ft.control
class Card(ft.Container):
"""统一卡片样式"""
padding: ft.Padding = field(default_factory=lambda: ft.Padding(16, 16, 16, 16))
border_radius: ft.BorderRadius = field(default_factory=lambda: ft.border_radius.all(12))
bgcolor: str = ft.Colors.SURFACE
shadow: ft.BoxShadow = field(default_factory=lambda: ft.BoxShadow(
blur_radius=8,
color=ft.Colors.BLACK12,
offset=ft.Offset(0, 2),
))
@ft.control
class Avatar(ft.CircleAvatar):
"""带默认图标的头像控件"""
radius: float = 24
bgcolor: str = ft.Colors.BLUE_100
color: str = ft.Colors.BLUE_600
def build(self):
if not self.foreground_image_url:
self.content = ft.Icon(ft.Icons.PERSON, size=self.radius)
return super().build()
# 使用
Card(
content=ft.Column([
Avatar(foreground_image_url="https://example.com/avatar.jpg"),
ft.Text("Alice"),
])
)
三、路由与导航
基础路由配置
import flet as ft
from dataclasses import dataclass, field
# 页面组件
@ft.component
def HomePage() -> ft.Control:
return ft.Column([
ft.Text("首页", size=24, weight=ft.FontWeight.BOLD),
ft.ElevatedButton(
"去用户列表",
on_click=lambda e: e.page.go("/users"),
),
])
@ft.component
def UserListPage() -> ft.Control:
users = ["Alice", "Bob", "Carol"]
return ft.Column([
ft.Text("用户列表", size=24),
ft.ListView([
ft.ListTile(
title=ft.Text(name),
on_click=lambda e, n=name: e.page.go(f"/users/{n}"),
)
for name in users
]),
ft.TextButton("返回", on_click=lambda e: e.page.go("/")),
])
@ft.component
def UserDetailPage(name: str) -> ft.Control:
return ft.Column([
ft.Text(f"用户:{name}", size=24),
ft.TextButton("返回列表", on_click=lambda e: e.page.go("/users")),
])
# 路由配置
def main(page: ft.Page):
page.title = "路由示例"
def route_change(e: ft.RouteChangeEvent):
route = e.route
if route == "/":
page.render(HomePage)
elif route == "/users":
page.render(UserListPage)
elif route.startswith("/users/"):
name = route.split("/")[-1]
page.render(lambda: UserDetailPage(name=name))
else:
page.render(NotFoundPage)
page.on_route_change = route_change
page.go(page.route or "/")
ft.app(main)
带参数的路由解析
import re
def parse_route(route: str) -> tuple[str, dict]:
"""解析路由,返回 (路由名, 参数字典)"""
patterns = [
(r"^/$", "home", {}),
(r"^/users$", "user_list", {}),
(r"^/users/(\d+)$", "user_detail", lambda m: {"user_id": int(m.group(1))}),
(r"^/posts/(\d+)/comments$", "post_comments", lambda m: {"post_id": int(m.group(1))}),
]
for pattern, name, params_fn in patterns:
m = re.match(pattern, route)
if m:
params = params_fn(m) if callable(params_fn) else params_fn
return name, params
return "not_found", {}
def main(page: ft.Page):
def route_change(e: ft.RouteChangeEvent):
name, params = parse_route(e.route)
match name:
case "home":
page.render(HomePage)
case "user_list":
page.render(UserListPage)
case "user_detail":
page.render(lambda: UserDetailPage(**params))
case _:
page.render(NotFoundPage)
page.on_route_change = route_change
page.go(page.route or "/")
四、主题与样式
配置应用主题
def main(page: ft.Page):
page.title = "主题示例"
page.theme_mode = ft.ThemeMode.LIGHT # LIGHT / DARK / SYSTEM
# 自定义主题
page.theme = ft.Theme(
color_scheme_seed=ft.Colors.INDIGO, # 种子色,自动生成色系
use_material3=True,
font_family="Roboto",
)
# 暗色主题单独配置
page.dark_theme = ft.Theme(
color_scheme_seed=ft.Colors.INDIGO,
use_material3=True,
)
page.render(MyApp)
主题色常量
# Colors 常量(Material Design 色板)
ft.Colors.PRIMARY # 主色
ft.Colors.SECONDARY # 辅色
ft.Colors.SURFACE # 表面色
ft.Colors.BACKGROUND # 背景色
ft.Colors.ON_PRIMARY # 主色上的文字色
ft.Colors.ERROR # 错误色
# 具体颜色(带深度)
ft.Colors.BLUE # 标准蓝
ft.Colors.BLUE_100 # 浅蓝 100
ft.Colors.BLUE_600 # 深蓝 600
ft.Colors.BLUE_ACCENT_400 # 强调蓝
# 透明度
ft.Colors.with_opacity(0.5, ft.Colors.BLACK) # 50% 黑色
主题感知组件
@ft.component
def ThemedCard(title: str, content: str) -> ft.Control:
"""自动跟随主题色的卡片"""
return ft.Container(
content=ft.Column([
ft.Text(title, size=18, weight=ft.FontWeight.BOLD,
color=ft.Colors.PRIMARY), # 使用主题主色
ft.Text(content, color=ft.Colors.ON_SURFACE_VARIANT),
]),
bgcolor=ft.Colors.SURFACE_VARIANT, # 使用主题表面色
border_radius=12,
padding=16,
border=ft.border.all(1, ft.Colors.OUTLINE), # 使用主题轮廓色
)
自定义字体
def main(page: ft.Page):
# 加载字体
page.fonts = {
"NotoSansSC": "fonts/NotoSansSC-Regular.ttf",
"NotoSansSC-Bold": "fonts/NotoSansSC-Bold.ttf",
}
page.theme = ft.Theme(font_family="NotoSansSC")
page.render(MyApp)
五、动画
隐式动画(属性变化自动过渡)
@ft.component
def AnimatedBox() -> ft.Control:
big, set_big = ft.use_state(False)
size = 200 if big else 100
color = ft.Colors.RED if big else ft.Colors.BLUE
return ft.Column([
ft.Container(
width=size,
height=size,
bgcolor=color,
border_radius=size / 2 if big else 8,
animate=ft.Animation(duration=400, curve=ft.AnimationCurve.EASE_IN_OUT),
animate_rotation=ft.Animation(300, ft.AnimationCurve.BOUNCE_OUT),
on_click=lambda _: set_big(not big),
),
ft.Text("点击切换动画", size=12, color=ft.Colors.GREY),
], horizontal_alignment=ft.CrossAxisAlignment.CENTER)
animate 参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
duration |
int | 动画持续时间(毫秒) |
curve |
AnimationCurve | 缓动曲线 |
常用 AnimationCurve:
LINEAR EASE EASE_IN EASE_OUT EASE_IN_OUT BOUNCE_IN BOUNCE_OUT ELASTIC_IN ELASTIC_OUT
各属性动画
ft.Container(
# ...
animate=ft.Animation(500), # 容器尺寸/颜色/边框动画
animate_opacity=ft.Animation(300), # 透明度动画
animate_scale=ft.Animation(300), # 缩放动画
animate_rotation=ft.Animation(500), # 旋转动画
animate_offset=ft.Animation(400), # 偏移动画(相对位置)
)
AnimatedSwitcher — 切换动画
@ft.component
def PageSwitcher() -> ft.Control:
page_index, set_page_index = ft.use_state(0)
pages = [
ft.Text("第一页", size=32),
ft.Text("第二页", size=32, color=ft.Colors.BLUE),
ft.Text("第三页", size=32, color=ft.Colors.RED),
]
return ft.Column([
ft.AnimatedSwitcher(
content=pages[page_index],
transition=ft.AnimatedSwitcherTransition.SCALE,
duration=300,
reverse_duration=200,
),
ft.Row([
ft.Button(str(i+1), on_click=lambda _, i=i: set_page_index(i))
for i in range(len(pages))
]),
])
六、对话框与覆盖层
AlertDialog
@ft.component
def DialogExample() -> ft.Control:
open_dialog, set_open_dialog = ft.use_state(False)
result, set_result = ft.use_state("")
return ft.Column([
ft.ElevatedButton("打开对话框", on_click=lambda _: set_open_dialog(True)),
ft.Text(result),
ft.AlertDialog(
open=open_dialog,
modal=True,
title=ft.Text("确认操作"),
content=ft.Text("你确定要删除这条记录吗?"),
actions=[
ft.TextButton("取消", on_click=lambda _: set_open_dialog(False)),
ft.TextButton(
"确认删除",
style=ft.ButtonStyle(color=ft.Colors.RED),
on_click=lambda _: (set_result("已删除"), set_open_dialog(False)),
),
],
),
])
BottomSheet
@ft.component
def BottomSheetExample() -> ft.Control:
open_sheet, set_open_sheet = ft.use_state(False)
return ft.Column([
ft.ElevatedButton("打开底部弹窗", on_click=lambda _: set_open_sheet(True)),
ft.BottomSheet(
open=open_sheet,
on_dismiss=lambda _: set_open_sheet(False),
content=ft.Container(
content=ft.Column([
ft.Text("底部弹窗", size=18, weight=ft.FontWeight.BOLD),
ft.Divider(),
ft.ListTile(leading=ft.Icon(ft.Icons.SHARE), title=ft.Text("分享")),
ft.ListTile(leading=ft.Icon(ft.Icons.EDIT), title=ft.Text("编辑")),
ft.ListTile(
leading=ft.Icon(ft.Icons.DELETE, color=ft.Colors.RED),
title=ft.Text("删除", style=ft.TextStyle(color=ft.Colors.RED)),
on_click=lambda _: set_open_sheet(False),
),
]),
padding=16,
),
),
])
SnackBar(消息提示)
@ft.component
def SnackBarExample() -> ft.Control:
show_snack, set_show_snack = ft.use_state(False)
return ft.Column([
ft.ElevatedButton("显示消息", on_click=lambda _: set_show_snack(True)),
ft.SnackBar(
open=show_snack,
content=ft.Text("操作成功!"),
action="撤销",
on_action=lambda _: print("撤销"),
on_dismiss=lambda _: set_show_snack(False),
bgcolor=ft.Colors.GREEN_700,
),
])
七、导航栏与 AppBar
AppBar
def main(page: ft.Page):
page.appbar = ft.AppBar(
leading=ft.Icon(ft.Icons.MENU),
leading_width=40,
title=ft.Text("我的应用"),
center_title=False,
bgcolor=ft.Colors.SURFACE_VARIANT,
actions=[
ft.IconButton(ft.Icons.SEARCH, on_click=lambda _: ...),
ft.IconButton(ft.Icons.NOTIFICATIONS, on_click=lambda _: ...),
ft.PopupMenuButton(
items=[
ft.PopupMenuItem("设置", icon=ft.Icons.SETTINGS),
ft.PopupMenuItem("帮助", icon=ft.Icons.HELP),
ft.PopupMenuItem(), # 分割线
ft.PopupMenuItem("退出", icon=ft.Icons.EXIT_TO_APP),
]
),
],
)
page.render(MyApp)
NavigationBar(底部导航)
@ft.component
def AppWithNavBar() -> ft.Control:
selected_index, set_selected_index = ft.use_state(0)
pages = [HomePage, SearchPage, ProfilePage]
CurrentPage = pages[selected_index]
return ft.Column(
controls=[
CurrentPage(expand=True),
ft.NavigationBar(
selected_index=selected_index,
on_change=lambda e: set_selected_index(e.control.selected_index),
destinations=[
ft.NavigationBarDestination(icon=ft.Icons.HOME, label="首页"),
ft.NavigationBarDestination(icon=ft.Icons.SEARCH, label="搜索"),
ft.NavigationBarDestination(icon=ft.Icons.PERSON, label="我的"),
],
),
],
expand=True,
spacing=0,
)
NavigationRail(侧边导航)
@ft.component
def AppWithRail() -> ft.Control:
selected, set_selected = ft.use_state(0)
pages = [HomePage, SettingsPage, AboutPage]
CurrentPage = pages[selected]
return ft.Row(
controls=[
ft.NavigationRail(
selected_index=selected,
on_change=lambda e: set_selected(e.control.selected_index),
label_type=ft.NavigationRailLabelType.ALL,
destinations=[
ft.NavigationRailDestination(icon=ft.Icons.HOME, label="首页"),
ft.NavigationRailDestination(icon=ft.Icons.SETTINGS, label="设置"),
ft.NavigationRailDestination(icon=ft.Icons.INFO, label="关于"),
],
),
ft.VerticalDivider(width=1),
CurrentPage(expand=True),
],
expand=True,
spacing=0,
)
八、表单与验证
from dataclasses import dataclass
@dataclass
class FormState:
username: str = ""
email: str = ""
password: str = ""
def validate(self) -> dict[str, str]:
errors = {}
if len(self.username) < 3:
errors["username"] = "用户名至少 3 个字符"
if "@" not in self.email:
errors["email"] = "请输入有效的邮箱地址"
if len(self.password) < 6:
errors["password"] = "密码至少 6 个字符"
return errors
@ft.component
def RegisterForm() -> ft.Control:
form, set_form = ft.use_state(FormState())
errors, set_errors = ft.use_state({})
submitted, set_submitted = ft.use_state(False)
def update_field(field: str, value: str):
new_form = FormState(**{**form.__dict__, field: value})
set_form(new_form)
# 实时清除已修改字段的错误
if field in errors:
set_errors({k: v for k, v in errors.items() if k != field})
def submit():
validation_errors = form.validate()
if validation_errors:
set_errors(validation_errors)
else:
set_submitted(True)
if submitted:
return ft.Column([
ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN, size=64),
ft.Text(f"注册成功!欢迎,{form.username}", size=18),
], horizontal_alignment=ft.CrossAxisAlignment.CENTER)
return ft.Column(
controls=[
ft.Text("注册账号", size=24, weight=ft.FontWeight.BOLD),
ft.TextField(
label="用户名",
value=form.username,
error_text=errors.get("username"),
on_change=lambda e: update_field("username", e.control.value),
),
ft.TextField(
label="邮箱",
value=form.email,
error_text=errors.get("email"),
keyboard_type=ft.KeyboardType.EMAIL,
on_change=lambda e: update_field("email", e.control.value),
),
ft.TextField(
label="密码",
value=form.password,
error_text=errors.get("password"),
password=True,
can_reveal_password=True,
on_change=lambda e: update_field("password", e.control.value),
),
ft.ElevatedButton(
"注册",
width=300,
on_click=lambda _: submit(),
),
],
width=320,
spacing=16,
)
九、异步编程
async 组件与异步事件
import asyncio
import flet as ft
@ft.component
def AsyncDataView() -> ft.Control:
loading, set_loading = ft.use_state(False)
data, set_data = ft.use_state(None)
error, set_error = ft.use_state(None)
async def load_data():
set_loading(True)
set_error(None)
try:
await asyncio.sleep(1.5) # 模拟网络请求
set_data({"name": "Alice", "score": 98})
except Exception as e:
set_error(str(e))
finally:
set_loading(False)
if loading:
return ft.Column([
ft.ProgressRing(),
ft.Text("加载中...", color=ft.Colors.GREY),
], horizontal_alignment=ft.CrossAxisAlignment.CENTER)
if error:
return ft.Column([
ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED, size=48),
ft.Text(f"错误:{error}", color=ft.Colors.RED),
ft.ElevatedButton("重试", on_click=lambda _: asyncio.create_task(load_data())),
])
if data is None:
return ft.ElevatedButton("加载数据", on_click=lambda _: asyncio.create_task(load_data()))
return ft.Column([
ft.Text(f"姓名:{data['name']}"),
ft.Text(f"分数:{data['score']}"),
ft.TextButton("刷新", on_click=lambda _: asyncio.create_task(load_data())),
])
与 httpx 集成(真实网络请求)
import httpx
import asyncio
import flet as ft
from dataclasses import dataclass, field
@ft.observable
@dataclass
class PostsState:
posts: list = field(default_factory=list)
loading: bool = False
error: str = ""
async def fetch(self):
self.loading = True
self.error = ""
try:
async with httpx.AsyncClient() as client:
resp = await client.get("https://jsonplaceholder.typicode.com/posts?_limit=10")
resp.raise_for_status()
self.posts = resp.json()
except httpx.HTTPError as e:
self.error = f"网络错误:{e}"
finally:
self.loading = False
@ft.component
def PostsList() -> ft.Control:
state, _ = ft.use_state(PostsState())
if state.loading:
return ft.ProgressRing()
if state.error:
return ft.Column([
ft.Text(state.error, color=ft.Colors.RED),
ft.ElevatedButton("重试", on_click=lambda _: asyncio.create_task(state.fetch())),
])
if not state.posts:
return ft.ElevatedButton(
"加载文章",
on_click=lambda _: asyncio.create_task(state.fetch()),
)
return ft.Column([
ft.Text("文章列表", size=20, weight=ft.FontWeight.BOLD),
ft.ListView(
controls=[
ft.ListTile(
title=ft.Text(p["title"][:40]),
subtitle=ft.Text(p["body"][:60]),
)
for p in state.posts
],
spacing=4,
expand=True,
),
], expand=True)
十、客户端存储
import flet as ft
from dataclasses import dataclass
import json
@ft.observable
@dataclass
class Settings:
theme: str = "light"
language: str = "zh"
notifications: bool = True
@ft.component
def SettingsView() -> ft.Control:
settings, set_settings = ft.use_state(Settings())
saved, set_saved = ft.use_state(False)
async def load_settings(page: ft.Page):
"""从本地存储加载设置"""
stored = await page.client_storage.get_async("app_settings")
if stored:
data = json.loads(stored)
set_settings(Settings(**data))
async def save_settings(page: ft.Page):
"""保存设置到本地存储"""
await page.client_storage.set_async(
"app_settings",
json.dumps(settings.__dict__)
)
set_saved(True)
def update_settings(**kwargs):
new = Settings(**{**settings.__dict__, **kwargs})
set_settings(new)
set_saved(False)
return ft.Column([
ft.Text("应用设置", size=20, weight=ft.FontWeight.BOLD),
ft.Dropdown(
label="主题",
value=settings.theme,
options=[
ft.dropdown.Option("light", "浅色"),
ft.dropdown.Option("dark", "深色"),
ft.dropdown.Option("system", "跟随系统"),
],
on_change=lambda e: update_settings(theme=e.control.value),
),
ft.Switch(
label="开启通知",
value=settings.notifications,
on_change=lambda e: update_settings(notifications=e.control.value),
),
ft.ElevatedButton(
"保存设置",
on_click=lambda e: asyncio.create_task(save_settings(e.page)),
),
ft.Text("已保存!", color=ft.Colors.GREEN) if saved else ft.Container(),
], spacing=16)
十一、拖放(Drag & Drop)
import flet as ft
from dataclasses import dataclass, field
@ft.observable
@dataclass
class KanbanState:
todo: list = field(default_factory=lambda: ["任务A", "任务B", "任务C"])
doing: list = field(default_factory=lambda: ["任务D"])
done: list = field(default_factory=list)
def move(self, item: str, from_col: str, to_col: str):
getattr(self, from_col).remove(item)
getattr(self, to_col).append(item)
# 触发更新
self.todo = list(self.todo)
self.doing = list(self.doing)
self.done = list(self.done)
@ft.component
def KanbanColumn(title: str, items: list, col_name: str, state: KanbanState) -> ft.Control:
return ft.Container(
content=ft.Column([
ft.Text(title, weight=ft.FontWeight.BOLD),
ft.DragTarget(
group="kanban",
content=ft.Column(
controls=[
ft.Draggable(
group="kanban",
data=f"{col_name}|{item}",
content=ft.Container(
content=ft.Text(item),
bgcolor=ft.Colors.SURFACE,
padding=8,
border_radius=4,
border=ft.border.all(1, ft.Colors.OUTLINE),
),
)
for item in items
],
spacing=4,
height=200,
),
on_accept=lambda e: state.move(
*e.data.split("|"),
col_name,
),
),
]),
bgcolor=ft.Colors.SURFACE_VARIANT,
padding=12,
border_radius=8,
width=180,
)
@ft.component
def KanbanBoard() -> ft.Control:
state, _ = ft.use_state(KanbanState())
return ft.Row([
KanbanColumn("待处理", state.todo, "todo", state),
KanbanColumn("进行中", state.doing, "doing", state),
KanbanColumn("已完成", state.done, "done", state),
], spacing=12)
最佳实践
路由参数用 URL query string 传递,不用全局变量:通过 page.route(如 /user?id=42)传递页面间数据,而非将数据存到模块级变量。URL 方式支持浏览器历史、书签和刷新恢复,全局变量在多用户 Web 部署时会产生数据混用。
主题统一在 page.theme 配置,不要在控件上硬编码颜色:用 ft.Theme(color_scheme_seed=ft.Colors.BLUE) 和语义颜色(ft.Colors.PRIMARY、ft.Colors.ON_SURFACE),使暗色模式切换自动生效,避免手动维护两套颜色值。
动画使用 AnimatedSwitcher 而非手动控制 visible:切换控件显隐时,ft.AnimatedSwitcher 自动处理进出场动画,且无需管理 page.update() 时机;手动切换 visible 没有过渡效果且更难维护。
表单验证在 on_submit 中统一处理:不要在每个字段的 on_change 里实时校验所有规则(性能差),而是在提交时做完整校验,并在 TextField.error_text 上反馈错误信息,校验通过后清空 error_text。
拖拽列表用 key 参数保证控件身份:ft.Draggable 列表在重排后,Flet 通过 key 匹配控件身份以计算最小 diff;不设置 key 时可能出现控件状态错位(如输入框内容跑到相邻控件中)。
常见陷阱
陷阱:路由切换后旧页面的事件回调仍然触发
现象: 导航到新页面后,旧页面的定时器或后台任务仍在运行并调用 page.update(),导致 UI 异常或报错。
原因: Flet 不会自动取消旧视图的异步任务;路由切换只是替换 page.views,旧回调仍持有对 page 的引用。
解决: 在 on_view_pop 或路由变化回调中显式取消后台任务(task.cancel());使用 page.on_disconnect 钩子做清理。
陷阱:暗色模式下自定义颜色与主题不协调
现象: 切换到暗色模式后,某些区域颜色与背景反差不足或过于突兀。
原因: 控件上直接写了 bgcolor=ft.Colors.WHITE 或 color="#333333" 等固定颜色值,暗色模式不会自动反转。
解决: 统一使用主题语义颜色(ft.Colors.SURFACE、ft.Colors.ON_PRIMARY 等),或通过 page.theme_mode 变化时动态更新颜色。
陷阱:Dropdown 的 value 与 options 中的值不匹配导致显示空白
现象: Dropdown 设置了 value="active",但下拉框显示为空。
原因: options 中的每个 ft.dropdown.Option 的 key 必须与 value 精确匹配(区分大小写和类型),若 key 是 "Active"(首字母大写),则 value="active" 无法匹配。
解决: 确保 value 的值与 Option(key=...) 的 key 完全一致;建议统一用枚举或常量管理选项值,避免字符串拼写错误。