聊天界面
消息列表、底部输入、安全区和滚动定位。
本页演示:ScrollViewReader + LazyVStack 构建消息列表、TextField 的 on_submit 与 .submit_label("send") 发送消息、.focused 管理键盘焦点、.animation 在列表变化时做过渡,以及滚动到底部锚点。
#预期效果
运行后会出现聊天页面,消息流、底部输入、安全区和发送反馈保持稳定。
#可访问性与键盘
- 发送按钮提供与键盘「发送」键并行的入口,避免只依赖
on_submit造成的操作单一路径。 - 若需朗读友好命名,可为气泡外包一层
Group并添加.accessibility_label,将「谁说了什么」拼成完整句子(本示例为缩短篇幅未展示)。 keyboard_dismiss("interactive")与ScrollViewReader同屏出现时,注意手势冲突:若发现滚动被键盘手势抢占,可尝试改为keyboard_dismiss("immediately")做 A/B。
#要点
ScrollViewReader通过scroll_to传入目标子视图的.id()字符串;anchor常用top/bottom。TextField(..., on_submit=...)与视图级.on_submit(...)均在appui中存在;本例使用构造函数内的on_submit。.focused(field_id, equals=...):field_id为字符串时与equals比较以决定焦点。- 在
LazyVStack末尾放置一个极矮的占位视图并标记.id("bottom"),便于「滚到最新消息」。
#完整示例
已复制
import appui
state = appui.State(
next_id=1,
messages=[{"id": 0, "who": "bot", "text": "你好,这是可运行的聊天示例。"}],
input_text="",
scroll_to="",
focus_field="input",
)
def send_message():
text = (state.input_text or "").strip()
if not text:
return
mid = state.next_id
state.batch_update(
messages=list(state.messages) + [{"id": mid, "who": "me", "text": text}],
next_id=mid + 1,
input_text="",
scroll_to="bottom",
focus_field="input",
)
def bubble(msg):
mine = msg["who"] == "me"
bubble_bg = "systemBlue" if mine else "secondarySystemBackground"
fg = "white" if mine else "label"
align = "trailing" if mine else "leading"
return appui.HStack(
[
appui.Text(msg["text"])
.padding(10)
.background(color=bubble_bg, corner_radius=14)
.foreground_color(fg),
],
alignment=align,
).frame(max_width=appui.infinity)
def message_key(msg):
return msg["id"]
def set_input_text(value):
state.input_text = value
def body():
reader = appui.ScrollViewReader(
scroll_to=state.scroll_to or None,
anchor="bottom",
content=[
appui.LazyVStack(
[
appui.ForEach(
state.messages,
row_builder=bubble,
key=message_key,
).animation("spring", value=len(state.messages)),
appui.Color("clear").frame(height=1).id("bottom"),
],
spacing=10,
)
.padding(12)
.keyboard_dismiss("interactive"),
],
)
composer = appui.HStack(
[
appui.TextField(
"输入消息…",
text=state.input_text,
on_change=set_input_text,
on_submit=send_message,
)
.submit_label("send")
.focused("input", equals=state.focus_field)
.text_field_style("rounded_border")
.frame(max_width=appui.infinity),
appui.Button("发送", action=send_message).button_style("bordered_prominent"),
],
spacing=8,
).padding(12)
return appui.NavigationStack(
appui.VStack(
[
reader,
composer,
],
spacing=0,
).navigation_title("聊天")
)
appui.run(body, state=state, presentation="sheet")
#说明
- 滚动到底部:发送后把
state.scroll_to设为"bottom",与列表末尾占位视图的.id("bottom")对齐;如需连续发送仍触发滚动,可在下一次构建前把scroll_to清空再由业务写入。 - 焦点:
focus_field与TextField上的.focused("input", equals=state.focus_field)搭配;若需多段输入框,可为不同字段使用不同字符串标识。 - 动画:
.animation("spring", value=len(state.messages))将隐式动画绑定到消息条数变化;亦可在send_message外层使用appui.animate(参见主文档)。
#ScrollViewReader 参数摘要
| 参数 | 作用 |
|---|---|
content | 滚动区域的子视图列表,通常包含 LazyVStack。 |
axes | 'vertical'(默认)或 'horizontal' 控制滚动方向。 |
shows_indicators | 是否显示滚动指示条。 |
scroll_to | 设置为与某个子视图 .id("…") 相同的字符串时,驱动滚动定位。 |
anchor | 与目标视图对齐的锚点,例如 'bottom' 便于对齐最新消息。 |
#行为调优
- 空消息禁止发送:
send_message已strip()并忽略空串,避免插入空白气泡。 - 键盘与滚动:
keyboard_dismiss("interactive")绑在消息区域的LazyVStack上,便于用户向下轻扫收起键盘;若希望轻扫即发送,可结合.on_submit自定义。 - 大量历史消息:
LazyVStack只构建可视区域;若单条消息文本极长,可配合.line_limit(20)等修饰符防止单格过高。 - 显式动画包一帧更新:若希望发送动作必定以动画呈现,定义一个
send_with_animation()包住send_message(),再传给appui.animate(send_with_animation, type="spring")。
#手动测试清单
- 启动后检查机器人首条消息是否出现,气泡左对齐且背景为次级系统灰。
- 在输入框键入文本,点击「发送」与键盘发送键各一次,确认行为一致。
- 连续发送多条消息,观察列表是否保持在底部附近(依赖
scroll_to与anchor='bottom')。 - 在输入框聚焦状态下滚动消息列表,确认键盘可随交互收起。
- 将设备切换深色模式,检查对比度是否仍可接受(必要时为气泡增加描边
border)。
#消息模型扩展
示例中的 messages 使用 dict 承载 id / who / text 三字段,便于 ForEach 的 key 去重与左右气泡分支。接入真实业务时常见演进方式如下(仍使用已存在的 State / List / Text 等 API,不引入虚构类型):
- 时间戳:为每条消息增加
ts整数或 ISO 字符串,在bubble内用较小字号Text显示在气泡下方。 - 送达状态:增加
status字段(如sending/sent/failed),在HStack尾部追加ProgressView或Image(system_name="checkmark")。 - 分页加载:将
state.messages拆为「已加载窗口」与「游标」,在ScrollView的on_appear或列表顶部Button("加载更多")中向列表前端插入历史项;LazyVStack保持懒构建特性。 - 服务端同步:在独立线程拉取新消息后,对
state.messages做extend或整表替换;若更新频率高,可评估将输入框绑定迁移到ReactiveState以减少重建范围。
#界面刷新排错
| 现象 | 排查方向 |
|---|---|
| 发送后界面不刷新 | 确认 appui.run(body, state=state, ...) 已传入同一 state 实例;避免在 body 外重新构造新的 State。 |
scroll_to 无效 | 确认目标 .id 字符串与 scroll_to 完全一致;占位条高度不宜为 0(示例使用 frame(height=1))。 |
| 键盘「发送」无响应 | 检查是否链式 .submit_label("send");若使用自定义 return 键文案,需与 iOS 本地化键名一致。 |
| 焦点无法切换 | 多字段场景下为每个 TextField 使用不同的 field_id 字符串,并统一由 state.focus_field 驱动 equals。 |
#发送时的一帧更新
示例用一次 state.batch_update(...) 同时写入新消息、递增 next_id、清空 input_text 并设置 scroll_to。这样比先 append 再改输入框更稳定,能避免用户看到「消息已发送但输入框仍保留旧文本」的中间状态。
如果用户可能连续快速点击发送,可增加 sending 布尔字段并在发送期间禁用按钮;写法上仍只需要 State 字段与 Button(..., action=...)。