图表与画布 API
Chart、Canvas、DrawingContext 和 Path。
本页覆盖 Chart、Canvas、DrawingContext 和 Path。Chart 用系统图表展示结构化数据;Canvas 和 DrawingContext 用命令列表画 2D 图形;Path 用矢量路径命令画自定义形状。
#什么时候用
| 目标 | 首选 API | 说明 |
|---|---|---|
| 单一柱状、折线、面积、散点图 | Chart(type=..., x=..., y=...) | 旧写法继续支持,适合一种图形。 |
| 组合图、区间图、饼图、阈值和逐 Mark 样式 | Chart(marks=[...]) | 一份数据可组合多种系统原生 Mark。 |
| 简单 2D 绘图 | Canvas + DrawingContext | 矩形、圆、线、文本、渐变、路径等命令。 |
| 自定义矢量形状 | Path | 三角形、曲线、弧线、可填充或描边的路径。 |
| 实时高频绘制 | Canvas + 稳定命令列表 | 避免每次 body() 重建大量命令。 |
#Chart
Chart(data=None, x='x', y='y', type='bar', color=None, series=None, *, ...)
| API | 签名 | 分类 |
|---|---|---|
Chart | Chart(data: Optional[Sequence[Dict[str, Any]]] = None, x: str = 'x', y: str = 'y', type: str = 'bar', color: Optional[ColorLike] = None, series: Optional[str] = None, *, x_type: str = 'auto', interpolation: str = 'linear', stacking: str = 'standard', symbol: Optional[str] = None, symbol_size: Optional[float] = None, show_legend: bool = True, show_x_axis: bool = True, show_y_axis: bool = True, y_domain: Optional[Sequence[float]] = None, selection: Any = None, on_selection_change: Optional[Callable[[Any], Any]] = None, on_select: Optional[Callable[[Optional[Dict[str, Any]]], Any]] = None, scrollable_axes: Optional[str] = None, visible_domain: Optional[float] = None, annotation_key: Optional[str] = None, area_opacity: float = 0.3, marks: Optional[Sequence[ChartMark]] = None, selection_x: Optional[str] = None) | media |
Chart 直接由系统原生图表框架渲染,坐标计算、滚动、选择命中、插值和动画都留在原生端执行;Python 只在数据或用户选择真正变化时收到事件。
| 参数 | 取值与语义 |
|---|---|
x_type | 'auto'、'category'、'number'、'date';日期接受 ISO-8601 字符串或 Unix 时间戳。 |
interpolation | 'linear'、'monotone'、'cardinal'、'catmull_rom'、'step_start'、'step_center'、'step_end'。 |
stacking | 'standard'、'normalized'、'center'、'unstacked'。 |
symbol / symbol_size | 折线或散点的原生符号及面积;可用 circle、square、triangle、diamond、pentagon、plus、cross、asterisk。 |
show_legend / show_x_axis / show_y_axis | 控制系统图例和坐标轴可见性。 |
y_domain | 两个数值组成的 [minimum, maximum]。 |
selection | 标量或 State.bind.<field>;用户在图表中选择 x 值后双向同步。iOS 17+ 支持触摸选择。 |
on_select | 接收最接近所选 x 值的原始数据字典。 |
scrollable_axes / visible_domain | iOS 17+ 的原生图表滚动方向和可见 x 域长度。 |
annotation_key | 数据字典中用作原生标注文字的字段名。 |
marks | 1–32 个 BarMark / LineMark / AreaMark / PointMark / RuleMark / RectangleMark / SectorMark;传入后启用组合 Mark。 |
selection_x | 组合 Mark 用于触摸选择、最近行回调和横向可见域的字段名;省略时继续使用 x。 |
旧的 Chart(data, x, y, type, ...) 没有废弃,也不会被偷偷改写成新的用户 API。它仍是单一图形的最短写法;需要组合多个 Mark、区间坐标、环形扇区或每个 Mark 独立样式时,再使用 marks=。两条写法共享同一套系统原生图表渲染、选择和数据更新路径。
import appui
def body():
rows = [
{"day": "Mon", "value": 3},
{"day": "Tue", "value": 7},
{"day": "Wed", "value": 5},
{"day": "Thu", "value": 9},
]
return appui.NavigationStack(
appui.Chart(
data=rows,
x="day",
y="value",
type="bar",
color="systemBlue",
)
.frame(height=240)
.padding()
.navigation_title("Chart")
)
appui.run(body)
#组合 Mark 构造器
| API | 签名 | 分类 |
|---|---|---|
ChartField | __init__( self, key: str, label: Optional[str] = ..., value_type: str = ..., ) | 公开类型 |
ChartValue | __init__( self, value: Any, label: str = ..., value_type: str = ..., ) | 公开类型 |
BarMark | __init__( self, x: Optional[ChartCoordinate] = ..., y: Optional[ChartCoordinate] = ..., *, x_start: Optional[ChartCoordinate] = ..., x_end: Optional[ChartCoordinate] = ..., y_start: Optional[ChartCoordinate] = ..., y_end: Optional[ChartCoordinate] = ..., series: Optional[ChartCoordinate] = ..., stacking: str = ..., width: Optional[float] = ..., corner_radius: Optional[float] = ..., foreground_style: Optional[ColorLike] = ..., opacity: float = ..., annotation: Optional[ChartCoordinate] = ..., annotation_position: str = ..., ) | 公开类型 |
LineMark | __init__( self, x: ChartCoordinate, y: ChartCoordinate, *, series: Optional[ChartCoordinate] = ..., interpolation: str = ..., line_width: float = ..., dash: Optional[Sequence[float]] = ..., symbol: Optional[str] = ..., symbol_size: Optional[float] = ..., foreground_style: Optional[ColorLike] = ..., opacity: float = ..., annotation: Optional[ChartCoordinate] = ..., annotation_position: str = ..., ) | 公开类型 |
AreaMark | __init__( self, x: ChartCoordinate, y: Optional[ChartCoordinate] = ..., *, y_start: Optional[ChartCoordinate] = ..., y_end: Optional[ChartCoordinate] = ..., series: Optional[ChartCoordinate] = ..., interpolation: str = ..., stacking: str = ..., foreground_style: Optional[ColorLike] = ..., opacity: float = ..., annotation: Optional[ChartCoordinate] = ..., annotation_position: str = ..., ) | 公开类型 |
PointMark | __init__( self, x: ChartCoordinate, y: ChartCoordinate, *, series: Optional[ChartCoordinate] = ..., symbol: str = ..., symbol_size: float = ..., foreground_style: Optional[ColorLike] = ..., opacity: float = ..., annotation: Optional[ChartCoordinate] = ..., annotation_position: str = ..., ) | 公开类型 |
RuleMark | __init__( self, *, x: Optional[ChartCoordinate] = ..., y: Optional[ChartCoordinate] = ..., series: Optional[ChartCoordinate] = ..., line_width: float = ..., dash: Optional[Sequence[float]] = ..., foreground_style: Optional[ColorLike] = ..., opacity: float = ..., annotation: Optional[ChartCoordinate] = ..., annotation_position: str = ..., ) | 公开类型 |
RectangleMark | __init__( self, x_start: ChartCoordinate, x_end: ChartCoordinate, y_start: ChartCoordinate, y_end: ChartCoordinate, *, series: Optional[ChartCoordinate] = ..., corner_radius: Optional[float] = ..., foreground_style: Optional[ColorLike] = ..., opacity: float = ..., annotation: Optional[ChartCoordinate] = ..., annotation_position: str = ..., ) | 公开类型 |
SectorMark | __init__( self, angle: ChartCoordinate, *, series: Optional[ChartCoordinate] = ..., inner_radius: float = ..., outer_radius: float = ..., angular_inset: float = ..., foreground_style: Optional[ColorLike] = ..., opacity: float = ..., annotation: Optional[ChartCoordinate] = ..., annotation_position: str = ..., ) | 公开类型 |
#坐标值
Mark 的 x、y、series、区间端点和 annotation 使用同一套 ChartCoordinate 规则:
| 写法 | 含义 |
|---|---|
"sales" | 当前数据行中的 sales 字段;字符串默认是字段名。 |
ChartField("sales", label="销售额", value_type="number") | 带轴标题与类型的字段引用。value_type 为 auto、category、number 或 date。 |
ChartValue(100, label="目标") | 常量坐标,适合阈值或固定区间。常量字符串必须显式包成 ChartValue,否则会被当成字段名。 |
ChartValue 接受字符串、有限数字、date 或 datetime,不接受 bool。日期会以 ISO-8601 传给原生端。
#Mark 能力
| Mark | 必要坐标 | 专属能力 |
|---|---|---|
BarMark | x + y,或成对的 x_start/x_end、y_start/y_end | stacking、宽度、圆角、区间柱。 |
LineMark | x + y | 插值、线宽、虚线、系统 symbol。 |
AreaMark | x + y,或 x + y_start/y_end | 插值、stacking、范围面积。 |
PointMark | x + y | symbol 与 symbol_size。 |
RuleMark | x 或 y,必须且只能选一个 | 阈值线、虚线、标注。 |
RectangleMark | x_start/x_end/y_start/y_end | 热力区间、时间窗口、圆角矩形。 |
SectorMark | angle | iOS 17+ 扇区/饼图/环形图,支持内外半径与间隔。 |
所有 Mark 都支持 series、foreground_style、opacity、annotation 和 annotation_position。标注位置可用 top、bottom、leading、trailing、overlay。
#组合面积、折线、点和阈值
import appui
state = appui.State(selected_day="Wed", detail="")
ROWS = [
{"day": "Mon", "actual": 7},
{"day": "Tue", "actual": 11},
{"day": "Wed", "actual": 9},
{"day": "Thu", "actual": 14},
]
def selected_row(row):
state.detail = "未选择" if row is None else f"{row['day']}: {row['actual']}"
def body():
day = appui.ChartField("day", label="日期", value_type="category")
actual = appui.ChartField("actual", label="实际值", value_type="number")
marks = [
appui.AreaMark(
day,
actual,
interpolation="monotone",
foreground_style="systemBlue",
opacity=0.16,
),
appui.LineMark(
day,
actual,
interpolation="monotone",
line_width=3,
foreground_style="systemBlue",
),
appui.PointMark(
day,
actual,
symbol="circle",
symbol_size=34,
foreground_style="systemBlue",
),
appui.RuleMark(
y=appui.ChartValue(10, label="目标"),
dash=[5, 3],
foreground_style="systemOrange",
annotation=appui.ChartValue("目标", label="目标"),
annotation_position="top",
),
]
return appui.VStack([
appui.Chart(
ROWS,
marks=marks,
selection_x="day",
selection=state.bind.selected_day,
on_select=selected_row,
y_domain=[0, 16],
).frame(height=260),
appui.Text(state.detail or "触摸图表查看数据"),
], spacing=12).padding()
appui.run(body, state=state)
逐帧拖动、命中测试、插值与过渡动画留在原生端;Python 只接收离散选择结果。marks 是结构描述,不要在手势过程中反复重建它。
#环形图
import appui
ROWS = [
{"name": "照片", "size": 42},
{"name": "视频", "size": 31},
{"name": "其他", "size": 27},
]
def body():
return appui.Chart(
ROWS,
marks=[
appui.SectorMark(
"size",
series="name",
inner_radius=0.55,
outer_radius=1.0,
angular_inset=1.5,
)
],
selection_x="name",
).frame(height=260)
appui.run(body)
SectorMark 在 iOS 17+ 使用系统原生扇区;应用仍需为更低系统版本准备可用的替代页面或不显示该图表。
#可滚动、可选择的数值轴
import appui
state = appui.State(selected=2.0, detail="")
rows = [
{"id": "m1", "month": 1.0, "value": 8, "label": "Jan"},
{"id": "m2", "month": 2.0, "value": 13, "label": "Feb"},
{"id": "m3", "month": 3.0, "value": 9, "label": "Mar"},
{"id": "m4", "month": 4.0, "value": 16, "label": "Apr"},
]
def selected_row(row):
state.detail = row["label"] if row else ""
def body():
return appui.VStack([
appui.Chart(
rows,
x="month",
y="value",
type="line",
x_type="number",
interpolation="monotone",
symbol="circle",
y_domain=[0, 20],
selection=state.bind.selected,
on_select=selected_row,
scrollable_axes="horizontal",
visible_domain=3,
annotation_key="label",
).frame(height=240),
appui.Text(state.detail or "Touch a point"),
], spacing=12).padding()
appui.run(body, state=state)
#多序列示例
import appui
def body():
data = [
{"month": "Jan", "value": 10, "category": "A"},
{"month": "Jan", "value": 20, "category": "B"},
{"month": "Feb", "value": 14, "category": "A"},
{"month": "Feb", "value": 18, "category": "B"},
{"month": "Mar", "value": 22, "category": "A"},
{"month": "Mar", "value": 16, "category": "B"},
]
return appui.NavigationStack(
appui.Chart(
data=data,
x="month",
y="value",
series="category",
type="line",
)
.frame(height=240)
.padding()
.navigation_title("Series")
)
appui.run(body)
#Canvas
Canvas(width=300, height=300, commands=None, context=None)
| API | 签名 | 分类 |
|---|---|---|
Canvas | Canvas(width: float = 300, height: float = 300, commands: Optional[Sequence[Dict[str, Any]]] = None, context: Optional[DrawingContext] = None) | drawing |
import appui
def make_context():
ctx = appui.DrawingContext()
ctx.gradient_rect(0, 0, 240, 140, colors=["systemBlue", "systemTeal"])
ctx.rounded_rect(20, 20, 200, 100, corner_radius=16, color="systemBackground", fill=True)
ctx.fill_text("Canvas", 72, 76, color="label", font_size=22)
ctx.line(40, 98, 200, 98, color="separator", line_width=2)
return ctx
def body():
return appui.NavigationStack(
appui.Canvas(width=240, height=140, context=make_context())
.frame(width=240, height=140)
.padding()
.navigation_title("Canvas")
)
appui.run(body)
#DrawingContext 方法
DrawingContext 的每个方法都会向 commands 追加一个公开命令字典,并返回自身,方便链式调用。
| API | 签名 | 所属类型 |
|---|---|---|
fill_rect | fill_rect(x: float, y: float, width: float, height: float, color: ColorLike = 'black') -> Self | DrawingContext |
stroke_rect | stroke_rect(x: float, y: float, width: float, height: float, color: ColorLike = 'black', line_width: float = 1, **kwargs: Any) -> Self | DrawingContext |
fill_circle | fill_circle(cx: float, cy: float, radius: float, color: ColorLike = 'black') -> Self | DrawingContext |
stroke_circle | stroke_circle(cx: float, cy: float, radius: float, color: ColorLike = 'black', line_width: float = 1, **kwargs: Any) -> Self | DrawingContext |
fill_ellipse | fill_ellipse(x: float, y: float, width: float, height: float, color: ColorLike = 'black') -> Self | DrawingContext |
stroke_ellipse | stroke_ellipse(x: float, y: float, width: float, height: float, color: ColorLike = 'black', line_width: float = 1, **kwargs: Any) -> Self | DrawingContext |
line | line(x1: float, y1: float, x2: float, y2: float, color: ColorLike = 'black', line_width: float = 1, **kwargs: Any) -> Self | DrawingContext |
fill_text | fill_text(text: str, x: float, y: float, color: ColorLike = 'black', font_size: float = 16, **kwargs: Any) -> Self | DrawingContext |
fill_path | fill_path(points: Sequence[Tuple[float, float]], color: ColorLike = 'black', close: bool = True) -> Self | DrawingContext |
stroke_path | stroke_path(points: Sequence[Tuple[float, float]], color: ColorLike = 'black', line_width: float = 1, close: bool = False, **kwargs: Any) -> Self | DrawingContext |
arc | arc(cx: float, cy: float, radius: float, start_angle: float = 0, end_angle: float = 360, color: ColorLike = 'black', line_width: float = 1, fill: bool = False, **kwargs: Any) -> Self | DrawingContext |
rounded_rect | rounded_rect(x: float, y: float, width: float, height: float, corner_radius: float = 8, color: ColorLike = 'black', line_width: float = 1, fill: bool = True, **kwargs: Any) -> Self | DrawingContext |
gradient_rect | gradient_rect(x: float, y: float, width: float, height: float, colors: Optional[Sequence[ColorLike]] = None, vertical: bool = True) -> Self | DrawingContext |
#命令字段
如果直接传 commands,使用下面的公开字段:
op | 主要字段 |
|---|---|
fill_rect / stroke_rect | x、y、w、h、c、lw |
fill_circle / stroke_circle | cx、cy、r、c、lw |
fill_ellipse / stroke_ellipse | x、y、w、h、c、lw |
line | x1、y1、x2、y2、c、lw |
fill_text | t、x、y、c、fs |
fill_path / stroke_path | pts、c、close、lw |
arc | cx、cy、r、sa、ea、c、lw、fill |
rounded_rect | x、y、w、h、cr、c、lw、fill |
gradient_rect | x、y、w、h、colors、vertical |
import appui
ctx = appui.DrawingContext()
ctx.fill_rect(0, 0, 10, 10, color="systemRed")
ctx.stroke_rect(10, 0, 10, 10, color="systemBlue", line_width=2)
ctx.fill_circle(50, 50, 20, color="systemGreen")
ctx.line(0, 100, 100, 100, color="systemOrange", line_width=3)
ctx.fill_text("Hi", 5, 105, color="label", font_size=14)
assert len(ctx.commands) == 5
#Path
Path(commands=None, fill=None, stroke=None, line_width=None)
| API | 签名 | 分类 |
|---|---|---|
Path | Path(commands: Optional[Sequence[Dict[str, Any]]] = None, fill: Optional[ColorLike] = None, stroke: Optional[ColorLike] = None, line_width: Optional[float] = None) | drawing |
Path 命令结构:
| 命令键 | 结构 | 行为 |
|---|---|---|
move | [x, y] | 移动当前点。 |
line | [x, y] | 添加直线。 |
curve | {"to": [x, y], "control1": [x, y], "control2": [x, y]?} | 二次或三次贝塞尔曲线。 |
arc | {"cx": x, "cy": y, "r": r, "start": deg, "end": deg, "clockwise": bool} | 弧线。 |
close | 任意真值 | 闭合路径。 |
import appui
def body():
commands = [
{"move": [40, 10]},
{"line": [80, 70]},
{"line": [0, 70]},
{"close": True},
]
return appui.NavigationStack(
appui.Path(
commands=commands,
fill="systemOrange",
stroke="label",
line_width=2,
)
.frame(width=100, height=90)
.padding()
.navigation_title("Path")
)
appui.run(body)
#与相邻 API 的区别
| API | 不同点 |
|---|---|
Chart vs Canvas | Chart 负责数据可视化和坐标轴;Canvas 只画你给的图形命令。 |
Canvas vs Path | Canvas 可以混合矩形、圆、文字、渐变;Path 专注一个可填充/描边的矢量形状。 |
Path vs Rectangle / Circle | 常见形状用形状 API;不规则图形才用 Path。 |
Canvas 命令 vs DrawingContext | 直接命令适合从数据生成;DrawingContext 适合手写绘图逻辑。 |
#常见错误
| 错误 | 正确做法 |
|---|---|
每次 body() 都重新生成大量静态命令。 | 静态命令放到模块级常量或函数缓存,状态变化只更新必要数据。 |
Chart 的 x / y 字段名和数据字典不匹配。 | 统一字段名,缺失值先清洗再传入。 |
| 把常量字符串直接传给 Mark。 | 字符串表示字段名;常量字符串用 ChartValue("文字")。 |
同时给 AreaMark 传 y 和 y_start/y_end。 | 标量与范围二选一,范围端点必须成对。 |
RuleMark 同时传 x 和 y。 | 只传一个坐标,表示垂直线或水平线。 |
| 动态创建超过 32 个 Mark。 | Mark 描述图层而不是数据点;把大量点放进 data 行。 |
用 Chart 展示非数值 y 值。 | y 对应字段应是数值。 |
Canvas(width,height) 和外层 .frame(...) 尺寸冲突。 | 两者保持一致,或明确只让外层控制显示尺寸。 |
用 Canvas 手写原生控件。 | 表单、按钮、列表、进度优先用 AppUI 原生控件。 |