PythonIDE Docs
中文
简体中文

聊天界面

消息列表、底部输入、安全区和滚动定位。

本页演示:ScrollViewReader + LazyVStack 构建消息列表、TextFieldon_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"),便于「滚到最新消息」。

#完整示例

python
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")

#说明

  1. 滚动到底部:发送后把 state.scroll_to 设为 "bottom",与列表末尾占位视图的 .id("bottom") 对齐;如需连续发送仍触发滚动,可在下一次构建前把 scroll_to 清空再由业务写入。
  2. 焦点focus_fieldTextField 上的 .focused("input", equals=state.focus_field) 搭配;若需多段输入框,可为不同字段使用不同字符串标识。
  3. 动画.animation("spring", value=len(state.messages)) 将隐式动画绑定到消息条数变化;亦可在 send_message 外层使用 appui.animate(参见主文档)。

#ScrollViewReader 参数摘要

参数作用
content滚动区域的子视图列表,通常包含 LazyVStack
axes'vertical'(默认)或 'horizontal' 控制滚动方向。
shows_indicators是否显示滚动指示条。
scroll_to设置为与某个子视图 .id("…") 相同的字符串时,驱动滚动定位。
anchor与目标视图对齐的锚点,例如 'bottom' 便于对齐最新消息。

#行为调优

  • 空消息禁止发送send_messagestrip() 并忽略空串,避免插入空白气泡。
  • 键盘与滚动keyboard_dismiss("interactive") 绑在消息区域的 LazyVStack 上,便于用户向下轻扫收起键盘;若希望轻扫即发送,可结合 .on_submit 自定义。
  • 大量历史消息LazyVStack 只构建可视区域;若单条消息文本极长,可配合 .line_limit(20) 等修饰符防止单格过高。
  • 显式动画包一帧更新:若希望发送动作必定以动画呈现,定义一个 send_with_animation() 包住 send_message(),再传给 appui.animate(send_with_animation, type="spring")

#手动测试清单

  1. 启动后检查机器人首条消息是否出现,气泡左对齐且背景为次级系统灰。
  2. 在输入框键入文本,点击「发送」与键盘发送键各一次,确认行为一致。
  3. 连续发送多条消息,观察列表是否保持在底部附近(依赖 scroll_toanchor='bottom')。
  4. 在输入框聚焦状态下滚动消息列表,确认键盘可随交互收起。
  5. 将设备切换深色模式,检查对比度是否仍可接受(必要时为气泡增加描边 border)。

#消息模型扩展

示例中的 messages 使用 dict 承载 id / who / text 三字段,便于 ForEachkey 去重与左右气泡分支。接入真实业务时常见演进方式如下(仍使用已存在的 State / List / Text 等 API,不引入虚构类型):

  1. 时间戳:为每条消息增加 ts 整数或 ISO 字符串,在 bubble 内用较小字号 Text 显示在气泡下方。
  2. 送达状态:增加 status 字段(如 sending / sent / failed),在 HStack 尾部追加 ProgressViewImage(system_name="checkmark")
  3. 分页加载:将 state.messages 拆为「已加载窗口」与「游标」,在 ScrollViewon_appear 或列表顶部 Button("加载更多") 中向列表前端插入历史项;LazyVStack 保持懒构建特性。
  4. 服务端同步:在独立线程拉取新消息后,对 state.messagesextend 或整表替换;若更新频率高,可评估将输入框绑定迁移到 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=...)

#延伸阅读