Skip to content

绘制与动画 ​

Draw 在元素背景之后绘制,DrawOver 在其子元素之后绘制,使用以窗口 DIP 为单位的 *ui.Painter:用 Fill、FillGradient、Stroke 和 StrokeDashed 画矩形,以及 Shadow、Line、Image、Clip;用 FillPath 和 StrokePath 绘制直线与曲线路径,或用 FillPathGradient 和 StrokePathGradient 以渐变绘制;用 Text 绘制文本,或用 RichText 绘制任意样式的文本片段,可先用 MeasureText 测量以便居中或对齐:

go
ui.Box(c).Height(120).Draw(func(p *ui.Painter, r ui.Rect) {
	var wave ui.Path
	for i := 0; i <= 100; i++ {
		x := r.X + r.W*float32(i)/100
		y := r.Y + r.H/2 + 40*float32(math.Sin(float64(i)/8))
		if i == 0 {
			wave.MoveTo(x, y)
		} else {
			wave.LineTo(x, y)
		}
	}
	p.StrokePath(&wave, 2, c.Theme().Accent)
})
go
ui.Box(c).Size(200, 40).Draw(func(p *ui.Painter, r ui.Rect) {
	label := ui.Span{Text: "42%", Weight: 600, Color: c.Theme().TextMuted}
	w, h := p.MeasureText(0, label)
	p.RichText(r.X+(r.W-w)/2, r.Y+(r.H-h)/2, 0, label)
})

由视图自行排版的段落——如阅读器应用的页面——用 ui.ShapeRichTextWith 排版一次,必要时两端对齐并缩进,再用 Painter.TextLayout 绘制;其 Lines 给出每一行的位置,可据此在行之间分页(见保留文本几何信息)。

绘制函数只负责绘画:MyGo 可能在一帧内多次调用它们。Painter.Icon 和 Painter.Image 绘制 SVG 和位图,Painter.ImageRegion 绘制位图的一部分(见位图的局部),Painter.FocusRing 绘制键盘焦点环,供自行绘制的控件使用。

Path.Trim(from, to) 保留路径长度从 from 到 to 的部分(以比例表示);让 to 从 0 动画到 1,就能把路径"画"出来,正如 SwiftUI 的 trim 和 Motion 的 pathLength:

go
drawn := float32(0)
if app.done {
	drawn = 1
}
drawn = box.AnimateTo("check", drawn, ui.Smooth)
box.Draw(func(p *ui.Painter, r ui.Rect) {
	check := (&ui.Path{}).MoveTo(r.X+4, r.Y+12).LineTo(r.X+10, r.Y+18).LineTo(r.X+20, r.Y+6)
	p.StrokePath(check.Trim(0, drawn), 2, c.Theme().Accent)
})

除非路径自行设置,笔触的两端和拐角都是圆的,设置方式类似 SVG 的 stroke-linecap、stroke-linejoin 和 stroke-miterlimit:Path.Cap 让端头平齐(ui.ButtCap)或呈方形(ui.SquareCap);Path.Join 把拐角变成交汇的尖角(ui.MiterJoin,超过 Path.MiterLimit 时改为斜切)或直接切掉(ui.BevelJoin)。Path.Dash 以虚线描边,类似 stroke-dasharray 和 stroke-dashoffset,并且当 Trim 逐渐画出路径时,虚线保持原位:

go
box.Draw(func(p *ui.Painter, r ui.Rect) {
	edge := (&ui.Path{}).MoveTo(r.X, r.Y+r.H/2).LineTo(r.X+r.W, r.Y+r.H/2)
	p.StrokePath(edge.Cap(ui.ButtCap).Dash(0, 6, 4), 1.5, c.Theme().TextMuted)
})

位图的局部 ​

一张位图可以包含许多图像:图标雪碧图、动画的帧、缩略图墙的各格。Painter.ImageRegion 把它的一部分——以位图像素表示的 src——绘制到一个矩形中;该部分周围的位图内容不会显示,无论是平滑还是像素化的。其 ImageOptions 可以镜像该部分(FlipX、FlipY)并为其着色(Tint 会乘以其颜色和 alpha):

go
icons, _ := ui.DecodeBitmap(iconsPNG) // 一排 16×16 的图标
icon := func(i int) ui.Rect { return ui.Rect{X: float32(i * 16), W: 16, H: 16} }

box.Draw(func(p *ui.Painter, r ui.Rect) {
	p.ImageRegion(icons, icon(3), ui.Rect{X: r.X, Y: r.Y, W: 24, H: 24},
		ui.ImageOptions{Tint: c.Theme().TextMuted})
})

完全在绘制器可见区域之外的部分不会绘制任何内容,因此一个包含大量图像的视图可以全部画出而无需自行剔除,就像游戏绘制精灵和图块那样。

画布 ​

ui.Canvas 是由绘制器绘制的位图;若窗口在 GPU 上绘制,画布也在 GPU 上:即"渲染到纹理",就像 Skia 的 SkSurface 和 LÖVE 的 Canvas。Draw 在帧内重新绘制它,使用的绘制器以其像素为单位,而它会保留已有内容,并通过 Bitmap 出现在任何位图能出现的地方:

go
grid := ui.NewCanvas(800, 400)
drawn := false

box.Draw(func(p *ui.Painter, r ui.Rect) {
	if !drawn { // 只画一次,之后每帧都只花一张图的代价
		drawn = true
		grid.Draw(p, func(cp *ui.Painter) { drawGrid(cp, 800, 400) })
	}
	p.Image(grid.Bitmap(), r, ui.FillBox)
	drawSeries(p, r)
})

以着色或不透明度绘制的画布可以一次性让整个图层淡入淡出或上色。画布可以显示其他画布,也可以显示文本。画布以自身像素为单位:按窗口的缩放比例绘制它(Painter.Scale),就能得到与窗口一样清晰的画面;或者画得小一些,再把位图设为 Pixelated,即可把像素艺术清晰地放大(游戏插件 以整数倍缩放显示)。着色器可以把画布当作图像读取,也可以绘制到画布中。

变换 ​

Painter.Transform(t, ox, oy, fn) 用 t(ui.Transform,如 Translation、Scaling 或 Rotation)变换 fn 绘制的内容,以点 (ox, oy) 为中心,该点保持不动。变换可以嵌套,叠加在绘制器自身的变换之上,因此一个可平移缩放的视图(如地图或示意图)通过一个变换来绘制其内容,并可在其中再旋转一部分:

go
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
	view := ui.Translation(-doc.panX, -doc.panY).Then(ui.Scaling(doc.zoom, doc.zoom))
	p.Transform(view, r.X, r.Y, func() {
		for _, n := range doc.nodes {
			p.Fill(n.box, n.color, 6) // 视图看不到的地方会被剔除
		}
		cx, cy := doc.dial.X, doc.dial.Y
		p.Transform(ui.Rotation(doc.dial.angle), cx, cy, func() {
			p.Line(cx, cy, cx, cy-40, 2, c.Theme().Accent)
		})
	})
})

在其中创建的裁剪区域会随被裁剪的内容一起变换。输入不会被变换:指针坐标以元素的坐标系给出,应用用自己的视图的逆变换把它映射到内容上。在变换之下,图像及其局部会被放置在它们实际所在的位置,而不是对齐到整数像素,因此在放大的视图中移动图像时能平滑滑动。

文字排版 ​

由控件自行排版的文本(如网格单元格中的文本),用 ui.Shape 排版一次:它返回由系统文本引擎沿一行放置的字形,包含连字、字距调整和回退字体,以及每个字形对应的 rune。修改它们的 X 后用 Painter.Glyphs 绘制;Font.Metrics 返回字体的上伸部、下伸部和行距,Painter.Scale 返回一个 DIP 对应的设备像素,以便与显示屏像素对齐。与元素的文本不同,Shape 不做任何缓存:对于在多帧中绘制的文本,请自行保留其字形。Font.Features 可以开启和关闭 OpenType 特性;Font.Thicken 在 macOS 上以更粗的笔画绘制文本,类似 Ghostty 的 font-thicken;Font.Antialiased 则以更细的笔画绘制、不启用字体平滑,正如浏览器绘制设置了 -webkit-font-smoothing: antialiased 的文本。

go
font := ui.Font{Family: "monospace", Size: 13}
glyphs := ui.Shape("grid", font)
for i := range glyphs {
	glyphs[i].X = float32(glyphs[i].Cluster) * cellWidth // 每格一个
}
ui.Box(c).Height(20).Draw(func(p *ui.Painter, r ui.Rect) {
	p.Glyphs(glyphs, r.X, r.Y+font.Metrics().Ascent, c.Theme().Text)
})

动画 ​

要实现动态效果,Element.Animate 返回一个缓动到目标值的数值,并持续绘制帧直到到达:

go
panel := ui.Column(c).Clip()
width := float32(0)
if app.sidebar {
	width = 280
}
panel.Width(panel.Animate("width", width, 200*time.Millisecond))

AnimateWith 接受缓动函数:ui.Linear、ui.EaseIn、ui.EaseOut(Animate 的默认值)、ui.EaseInOut、ui.CubicBezier(x1, y1, x2, y2)、任意 func(t float32) float32,或 ui.Bounce(e)——沿 e 前进再弹回。AnimateTo 还支持弹簧(见动态效果)。Loop 返回每个周期重新开始播放的动画的进度,适用于加载指示器和脉冲效果;Rotate 旋转一个元素,如图标:

go
spin := ui.Icon(c, loader)
spin.Rotate(spin.Loop("spin", time.Second, ui.Linear) * 360)

skeleton := ui.Box(c).Height(14).Radius(7).Background(t.Border)
skeleton.Opacity(0.4 + 0.6*skeleton.Loop("pulse", 1600*time.Millisecond, ui.Bounce(ui.EaseInOut)))

要用其他方式做动画,请基于 c.Now() 计算,并在每个有移动的帧调用 c.AnimationFrame():MyGo 会在显示屏能够呈现时绘制下一帧,没有变化时则什么都不画。

布局不变而绘制内容在动的动画(如加载指示器或滚动的图表),应在其 Draw 中驱动:基于 p.Now() 计算,并在运动期间调用 p.AnimationFrame(),或者分步变化时调用 p.After(d)。除非有其他变化,这些帧不会运行视图,只是重新绘制上一帧的元素,因此只花费绘制的代价;而不在可见范围内的元素不会被绘制,也就不会请求帧。当窗口不可见(隐藏、最小化或被其他窗口遮挡)时,MyGo 完全不绘制帧——无论来自 Draw 还是视图——直到窗口有部分重新可见:像下面这样基于时间计算运动,重新显示时就会呈现时间所对应的位置(见动态效果):

go
ui.Box(c).Height(4).Draw(func(p *ui.Painter, r ui.Rect) {
	p.AnimationFrame()
	x := r.X + (r.W-20)*float32(p.Now().UnixMilli()%1000)/1000
	p.Fill(ui.Rect{X: x, Y: r.Y, W: 20, H: r.H}, c.Theme().Accent, 2)
})

用更少的帧也能看起来同样平滑的绘制(如旋转的黑胶唱片、缓慢的波浪或电平表),可以用 p.AnimationFrameRate(fps) 代替 p.AnimationFrame() 来请求:窗口按该频率重绘它,而不是跟随显示屏的每次刷新(ProMotion 显示屏为每秒 120 次),在 macOS 上还会让显示屏也以同样的频率刷新,从而节省电量。该帧率会向上取整到显示屏能均匀呈现的数值,即每帧对应整数次刷新:120 Hz 显示屏上为每秒 60、40 或 30 帧,60 Hz 显示屏上为 60 或 30 帧。窗口按其内部所有请求中最快的速率绘制,因此只要有一处请求 AnimationFrame 或视图在做动画,就仍会按显示屏的刷新率绘制。改用 p.After(time.Second / 60) 来控制节奏则会画得不均匀:晚于一次刷新请求的帧要等到下一次刷新。

这样的 Draw 看到的是视图上次运行时的状态:其中读取的、由其他方式变化的内容,要等视图在 Window.Update 之后重新构建。

当桌面要求减少动态效果时(c.Preferences().ReduceMotion),Animate 和 AnimateWith 会立即到达目标值。Loop 会继续运行,就像系统的加载指示器那样:它表示有事情正在进行。自行计算的动态效果也应读取该偏好设置。

动态效果介绍了保持速度的弹簧、延迟、跟随指针的值、关键帧,以及如何判断动画完成;变换可以移动、缩放和旋转任意元素。