PythonIDE Docs
中文
简体中文

布局系统

用原生容器搭列表、表单、网格和可滚动页面。

appui 的布局优先用组合:Stack 负责方向,ScrollView 负责滚动,Grid 负责网格,frame/padding/background 负责局部尺寸和外观。列表和设置页优先使用系统容器,复杂视觉再用自定义布局。

#预期效果

示例会展示 Stack、ScrollView、Grid、Form 和 GeometryReader 在不同页面结构中的可见布局结果。

#容器速查

容器适合场景常用参数
VStack垂直排列spacing, alignment
HStack水平排列spacing, alignment
ZStack层叠覆盖alignment
ScrollView长内容和自定义滚动页axes, shows_indicators
LazyVStack / LazyHStack长列表自定义布局spacing, alignment
LazyVGrid / LazyHGrid卡片网格columns / rows, spacing
Grid / GridRow表格式二维布局行列组合
List原生动态列表Section, ForEach
Form设置页和编辑页Section
GeometryReader需要父容器尺寸on_change 或 geometry 回调

#Stack

Stack 解决 80% 的局部布局。用 VStack 组织纵向内容,用 HStack + Spacer 组织行。

python
import appui

state = appui.State(selected="Revenue")


def select_revenue():
    state.selected = "Revenue"


def select_orders():
    state.selected = "Orders"


def card(title, value, action):
    content = appui.VStack([
        appui.Text(title).font("caption").foreground_color("secondaryLabel"),
        appui.Text(value).font("title2").bold(),
    ], alignment="leading", spacing=6)

    return (
        appui.Button(action=action, content=content)
        .button_style("plain")
        .padding(14)
        .frame(max_width=appui.infinity, alignment="leading")
        .background("secondarySystemBackground", corner_radius=12)
    )


def body():
    return appui.NavigationStack(
        appui.VStack([
            appui.Text("Dashboard").font("largeTitle").bold(),
            appui.HStack([
                card("Revenue", "$12.4k", select_revenue),
                card("Orders", "284", select_orders),
            ], spacing=12),
            appui.ZStack([
                appui.Rectangle().fill("systemBlue").frame(height=120).corner_radius(16),
                appui.Text(f"Selected: {state.selected}").foreground_color("white").bold(),
            ]),
        ], alignment="leading", spacing=16)
        .padding()
        .navigation_title("Stack")
    )


appui.run(body, state=state)

只有 Stack 难以表达时,再使用 Grid 或 GeometryReader。

#ScrollView 与长内容

长列表优先用 List;需要完全自定义外观时再用 ScrollView + LazyVStack

python
import appui

state = appui.State(selected="None")


def select_row(index):
    state.selected = f"Item {index}"


def row(index):
    def select_current():
        select_row(index)

    return appui.Button(
        action=select_current,
        content=appui.HStack([
            appui.Text(str(index))
                .frame(width=36, height=36)
                .background("systemBlue", corner_radius=18)
                .foreground_color("white"),
            appui.VStack([
                appui.Text(f"Item {index}").bold(),
                appui.Text("Subtitle").font("caption").foreground_color("secondaryLabel"),
            ], alignment="leading"),
            appui.Spacer(),
        ], spacing=12).padding(vertical=6),
    ).button_style("plain")


def body():
    return appui.NavigationStack(
        appui.ScrollView(
            appui.LazyVStack([row(i) for i in range(1, 31)], spacing=8).padding(),
            axes="vertical",
        )
        .safe_area_inset(
            edge="bottom",
            content=appui.Text(f"Selected: {state.selected}")
                .padding()
                .frame(max_width=appui.infinity, alignment="leading")
                .background(material="regularMaterial"),
        )
        .navigation_title("ScrollView")
    )


appui.run(body, state=state)

底部操作条优先用 safe_area_inset,不要靠固定 offset 硬顶。

#Grid

两三列卡片用 LazyVGrid 最直接;需要严格行列对齐时用 Grid/GridRow

python
import appui

state = appui.State(selected="None")


def select_tile(index):
    state.selected = f"Card {index}"


def tile(index):
    def select_current():
        select_tile(index)

    content = appui.VStack([
        appui.Image(system_name="square.grid.2x2").font("title2"),
        appui.Text(f"Card {index}").font("caption"),
    ], spacing=8)

    return (
        appui.Button(action=select_current, content=content)
        .button_style("plain")
        .frame(max_width=appui.infinity, min_height=92)
        .background("secondarySystemBackground", corner_radius=12)
    )


def body():
    columns = [appui.flexible(), appui.flexible()]
    return appui.NavigationStack(
        appui.ScrollView(
            appui.VStack([
                appui.LabeledContent("Selected", value=state.selected),
                appui.LazyVGrid(
                    columns=columns,
                    spacing=12,
                    content=[tile(i) for i in range(1, 9)],
                ),
            ], spacing=16).padding()
        ).navigation_title("Grid")
    )


appui.run(body, state=state)

#List 和 Form

普通数据列表使用 List + Section + ForEach,设置和编辑页使用 Form + Section。这两个容器负责原生滚动、分组、键盘避让和辅助功能。

python
import appui

state = appui.State(query="", notifications=True)


def set_query(value):
    state.query = value


def set_notifications(value):
    state.notifications = value


def body():
    return appui.NavigationStack(
        appui.Form([
            appui.Section("Search", [
                appui.TextField("Query", text=state.query, on_change=set_query),
            ]),
            appui.Section("Preferences", [
                appui.Toggle(
                    "Notifications",
                    is_on=state.notifications,
                    on_change=set_notifications,
                ),
            ], footer=f"Query: {state.query or '-'}"),
        ]).navigation_title("Form")
    )


appui.run(body, state=state)

不要把每个设置项包成自绘卡片,也不要把 List 放进 ScrollViewSection 里。

#frame、padding、background

常见顺序:

python
(
    view
    .padding()
    .frame(max_width=appui.infinity, alignment="leading")
    .background("secondarySystemBackground")
    .corner_radius(12)
)
需求写法
占满宽度.frame(max_width=appui.infinity)
固定高度.frame(height=120)
左对齐.frame(max_width=appui.infinity, alignment="leading")
卡片内边距.padding() 放在 .background()
外边距.padding() 放在 .background()

#GeometryReader

只有在需要父容器尺寸时使用 GeometryReader.on_geometry(...)。不要把整页都包进 GeometryReader,它会改变布局提案,容易让子视图占满空间。

python
def remember_size(info):
    print(info["width"], info["height"])


appui.GeometryReader(
    content=appui.Text("Measure me"),
    on_change=remember_size,
)

#常见问题

问题检查
背景只包住文字.padding() 是否放在 .background() 前。
宽度没有撑开是否设置了 .frame(max_width=appui.infinity)
长内容滑不动是否外层使用 ScrollViewList
底部按钮挡住内容使用 safe_area_inset(edge="bottom")
网格宽度不均检查 appui.flexible() 列配置和 spacing。
列表不像原生是否用 ScrollView + VStack 模拟了动态列表。

#下一步