简介
面向 Go 的跨平台 GUI
Gio 是一个在 Go 中编写跨平台即时模式 GUI-s 的库。Gio 支持所有主要平台:Linux、macOS、Windows、Android、iOS、FreeBSD、OpenBSD 和 WebAssembly。
Gio 被设计成具有很少的依赖性。它只依赖于窗口管理、输入和 GPU 绘图的平台库。
对于桌面构件,使用 go
工具直接工作。对于移动和一些额外的桌面功能支持,Gio 使用一个单独的工具 gogio
.
要安装该工具的最新版本,请使用:
go install gioui.org/cmd/gogio@latest
应用程序图标
这 gogio
工具将使用 appicon.png
文件作为应用程序图标,如果存在的话。
交叉编译
Gio 可以针对当前操作系统之外的平台进行交叉编译,但是这需要针对任何本机代码集成的适当的交叉编译器。交叉编译最容易在 Linux 上实现,Linux 指令可以在其他平台的容器或 VM 中执行。
开始
你好,吉奥!
这个例子非常快速地介绍了如何启动和运行一些东西。它没有解释所有的细节,这些将在另一个教程中介绍。
确保您已经遵循安装说明。如果一切设置正确,那么运行:
go run gioui.org/example/hello@latest
应该显示一个漂亮的“你好,吉奥!”消息。
创建新的包
如果你对 Go 不熟悉,那么更多帮助可以在.**go.dev/learn .
我们将使用 gio.test
但是,作为我们的模块名称,当您想要上传它时,建议使用一个存储库名称。模块名称可以在以后更改。
go mod init gio.test
创建程序
让我们创造 main.go
使用以下代码:
package main
import (
"image/color"
"log"
"os"
"gioui.org/app"
"gioui.org/op"
"gioui.org/text"
"gioui.org/widget/material"
)
func main() {
go func() {
window := new(app.Window)
err := run(window)
if err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
func run(window *app.Window) error {
theme := material.NewTheme()
var ops op.Ops
for {
switch e := window.Event().(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
// This graphics context is used for managing the rendering state.
gtx := app.NewContext(&ops, e)
// Define an large label with an appropriate text:
title := material.H1(theme, "Hello, Gio")
// Change the color of the label.
maroon := color.NRGBA{R: 127, G: 0, B: 0, A: 255}
title.Color = maroon
// Change the position of the label.
title.Alignment = text.Middle
// Draw the label to the graphics context.
title.Layout(gtx)
// Pass the drawing operations to the GPU.
e.Frame(gtx.Ops)
}
}
}
然后,让我们用以下内容更新所有依赖项:
go mod tidy
一旦成功,程序应该以如下方式启动:
go run .
现在来解释发生了什么。
创建窗口
每个程序都需要一个窗口 main
启动与操作系统对话的应用程序循环,并在单独的 goroutine 中启动窗口逻辑。
func main() {
go func() {
window := new(app.Window)
err := run(window)
if err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
创建主题
应用程序需要定义它们的字体和不同的颜色设置。主题包含所有必要的信息。
func run(window *app.Window) error {
theme := material.NewTheme()
监听事件
与操作系统(即键盘、鼠标、GPU)的通信通过事件发生。Gio 使用以下方法处理事件:
for {
switch e := window.Event().(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
app.DestroyEvent
意味着用户按下了关闭按钮。app.FrameEvent
意味着程序应该处理输入并呈现一个新的帧。
绘制文本
绘制文本需要经过几个阶段:
// 该图形上下文用于管理呈现状态。
gtx := app.NewContext(&ops, e)
// 用适当的文本定义一个大标签:
title := material.H1(theme, "Hello, Gio")
// 更改标签的颜色。
maroon := color.NRGBA{R: 127, G: 0, B: 0, A: 255}
title.Color = maroon
// 更改标签的位置。
title.Alignment = text.Middle
// 将标签绘制到图形上下文中。
title.Layout(gtx)
// 将绘图操作传递给GPU。
e.Frame(gtx.Ops)
拆分小部件
根据自己的需要裁剪东西
有时需要编写一个定制的小部件或布局。
要实现子元素的渲染,我们可以使用:
type SplitVisual struct{}
func (s SplitVisual) Layout(gtx layout.Context, left, right layout.Widget) layout.Dimensions {
leftsize := gtx.Constraints.Min.X / 2
rightsize := gtx.Constraints.Min.X - leftsize
{
gtx := gtx
gtx.Constraints = layout.Exact(image.Pt(leftsize, gtx.Constraints.Max.Y))
left(gtx)
}
{
gtx := gtx
gtx.Constraints = layout.Exact(image.Pt(rightsize, gtx.Constraints.Max.Y))
trans := op.Offset(image.Pt(leftsize, 0)).Push(gtx.Ops)
right(gtx)
trans.Pop()
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
然后我们可以像这样使用小部件:
func exampleSplitVisual(gtx layout.Context, th *material.Theme) layout.Dimensions {
return SplitVisual{}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return FillWithLabel(gtx, th, "Left", red)
}, func(gtx layout.Context) layout.Dimensions {
return FillWithLabel(gtx, th, "Right", blue)
})
}
func FillWithLabel(gtx layout.Context, th *material.Theme, text string, backgroundColor color.NRGBA) layout.Dimensions {
ColorBox(gtx, gtx.Constraints.Max, backgroundColor)
return layout.Center.Layout(gtx, material.H3(th, text).Layout)
}
比例
让我们把比例调整一下。在这种情况下,我们应该尽量使零值有用 0
可能意味着它是从中间分开的。
type SplitRatio struct {
// Ratio keeps the current layout.
// 0 is center, -1 completely to the left, 1 completely to the right.
Ratio float32
}
func (s SplitRatio) Layout(gtx layout.Context, left, right layout.Widget) layout.Dimensions {
proportion := (s.Ratio + 1) / 2
leftsize := int(proportion * float32(gtx.Constraints.Max.X))
rightoffset := leftsize
rightsize := gtx.Constraints.Max.X - rightoffset
{
gtx := gtx
gtx.Constraints = layout.Exact(image.Pt(leftsize, gtx.Constraints.Max.Y))
left(gtx)
}
{
trans := op.Offset(image.Pt(rightoffset, 0)).Push(gtx.Ops)
gtx := gtx
gtx.Constraints = layout.Exact(image.Pt(rightsize, gtx.Constraints.Max.Y))
right(gtx)
trans.Pop()
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
使用代码如下所示:
func exampleSplitRatio(gtx layout.Context, th *material.Theme) layout.Dimensions {
return SplitRatio{Ratio: -0.3}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return FillWithLabel(gtx, th, "Left", red)
}, func(gtx layout.Context) layout.Dimensions {
return FillWithLabel(gtx, th, "Right", blue)
})
}
相互作用的
为了让它更有用,我们可以把分割变成可拖动的。
因为我们还需要为移动拆分指定一个区域,所以让我们在中心添加一个条形:
bar := gtx.Dp(s.Bar)
if bar <= 1 {
bar = gtx.Dp(defaultBarWidth)
}
proportion := (s.Ratio + 1) / 2
leftsize := int(proportion*float32(gtx.Constraints.Max.X) - float32(bar))
rightoffset := leftsize + bar
rightsize := gtx.Constraints.Max.X - rightoffset
现在我们需要存储我们的交互状态:
type Split struct {
// Ratio keeps the current layout.
// 0 is center, -1 completely to the left, 1 completely to the right.
Ratio float32
// Bar is the width for resizing the layout
Bar unit.Dp
drag bool
dragID pointer.ID
dragX float32
}
然后我们需要处理输入事件:
barRect := image.Rect(leftsize, 0, rightoffset, gtx.Constraints.Max.X)
area := clip.Rect(barRect).Push(gtx.Ops)
// register for input
event.Op(gtx.Ops, s)
pointer.CursorColResize.Add(gtx.Ops)
for {
ev, ok := gtx.Event(pointer.Filter{
Target: s,
Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
if s.drag {
break
}
s.dragID = e.PointerID
s.dragX = e.Position.X
s.drag = true
case pointer.Drag:
if s.dragID != e.PointerID {
break
}
deltaX := e.Position.X - s.dragX
s.dragX = e.Position.X
deltaRatio := deltaX * 2 / float32(gtx.Constraints.Max.X)
s.Ratio += deltaRatio
if e.Priority < pointer.Grabbed {
gtx.Execute(pointer.GrabCmd{
Tag: s,
ID: s.dragID,
})
}
case pointer.Release:
fallthrough
case pointer.Cancel:
s.drag = false
}
}
area.Pop()
结果
将整个部件放在一起:
type Split struct {
// Ratio keeps the current layout.
// 0 is center, -1 completely to the left, 1 completely to the right.
Ratio float32
// Bar is the width for resizing the layout
Bar unit.Dp
drag bool
dragID pointer.ID
dragX float32
}
const defaultBarWidth = unit.Dp(10)
func (s *Split) Layout(gtx layout.Context, left, right layout.Widget) layout.Dimensions {
bar := gtx.Dp(s.Bar)
if bar <= 1 {
bar = gtx.Dp(defaultBarWidth)
}
proportion := (s.Ratio + 1) / 2
leftsize := int(proportion*float32(gtx.Constraints.Max.X) - float32(bar))
rightoffset := leftsize + bar
rightsize := gtx.Constraints.Max.X - rightoffset
{ // handle input
barRect := image.Rect(leftsize, 0, rightoffset, gtx.Constraints.Max.X)
area := clip.Rect(barRect).Push(gtx.Ops)
// register for input
event.Op(gtx.Ops, s)
pointer.CursorColResize.Add(gtx.Ops)
for {
ev, ok := gtx.Event(pointer.Filter{
Target: s,
Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
if s.drag {
break
}
s.dragID = e.PointerID
s.dragX = e.Position.X
s.drag = true
case pointer.Drag:
if s.dragID != e.PointerID {
break
}
deltaX := e.Position.X - s.dragX
s.dragX = e.Position.X
deltaRatio := deltaX * 2 / float32(gtx.Constraints.Max.X)
s.Ratio += deltaRatio
if e.Priority < pointer.Grabbed {
gtx.Execute(pointer.GrabCmd{
Tag: s,
ID: s.dragID,
})
}
case pointer.Release:
fallthrough
case pointer.Cancel:
s.drag = false
}
}
area.Pop()
}
{
gtx := gtx
gtx.Constraints = layout.Exact(image.Pt(leftsize, gtx.Constraints.Max.Y))
left(gtx)
}
{
off := op.Offset(image.Pt(rightoffset, 0)).Push(gtx.Ops)
gtx := gtx
gtx.Constraints = layout.Exact(image.Pt(rightsize, gtx.Constraints.Max.Y))
right(gtx)
off.Pop()
}
return layout.Dimensions{Size: gtx.Constraints.Max}
}
还有一个例子:
var split Split
func exampleSplit(gtx layout.Context, th *material.Theme) layout.Dimensions {
return split.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return FillWithLabel(gtx, th, "Left", red)
}, func(gtx layout.Context) layout.Dimensions {
return FillWithLabel(gtx, th, "Right", blue)
})
}
常见错误
我们都经历过
我的名单。列表不会滚动
问题是:你列出了一个列表,然后它就呆在那里,不滚动。
解释:Gio 中的很多小部件都是上下文无关的——你可以并且应该每次通过你的布局函数来声明它们。列表不是那样的。它们在内部记录它们的滚动位置,这需要在对 Layout 的调用之间保持不变。
解决方案:在事件处理循环之外声明一次列表,然后跨框架重用它。
系统将忽略对小组件的更新
问题:您在小部件结构中定义了一个包含 gioui.org/widget
。您可以隐式或显式地更新子部件状态。子部件顽固地拒绝反映你的更新。
这与列表不能滚动的问题有关。
一种可能的解释是:您可能会在 Go 代码中看到一个常见的“陷阱”,您在值接收器而不是指针接收器上定义了一个方法,因此您对小部件所做的所有更新只在该函数内部可见,并在它返回时被丢弃。
解决方案是:Layout
和 Update
有状态窗口小部件上的方法应该有指针接收器。
自定义小部件忽略大小
问题:您已经创建了一个漂亮的新部件。比如说,你把它放在一个柔性的刚性物体里。下一个刚体在它上面绘制。
解释:Gio 通过返回的 layout.Dimensions
。高级小部件(如标签)返回或传递它们的尺寸,但低级操作,如绘画。PaintOp,不要自动提供它们的尺寸。
解决方案:计算用自定义操作绘制的内容的适当尺寸,并将其返回到 layout.Dimension
.
依赖项不再编译了
问题:您已经用更新了您的 Gio 版本 go get -u gioui.org@latest
而且东西不编译。
解释是:在围棋中 go get -u
(the -u
部分)不幸是一个,其中包括 Gio 和一些依赖项如排版。-u
结束下载所有依赖项的最新次要版本,其中不稳定的依赖项可能会有重大更改。
解决方案:只使用更新 Gio 依赖项 go get gioui.org@latest
。如果你已经在一个非常混乱的情况下结束,你可以首先尝试恢复 go.mod
你以前的承诺。
如果上面的建议没有帮助,那么您可以尝试从 go.mod
,除了 module ...
和 go ...
线条和运行 go mod tidy
。这将导致下载最新的直接依赖项。
体系结构
Gio 的内部
Gio 是一个用于实现即时模式用户界面。这种方法可以通过多种方式实现,但是最大的相似之处是该程序:
- 监听事件,如鼠标或键盘输入。
- 基于事件更新其内部状态。
- 运行布局和重绘用户界面状态的代码。
伪代码形式的最小即时模式命令行 UI:
main() {
checked = false
for every keypress {
clear screen
layoutCheckbox(keypress, &checked)
if checked {
print("info")
}
}
}
layoutCheckbox(keypress, checked) {
if keypress == SPACE {
*checked = !*checked
}
if *checked {
print("[x]")
} else {
print("[ ]")
}
}
在即时模式模型中,程序控制清除和更新显示,并在更新期间直接绘制小部件和处理输入。
相比之下,传统的“保留模式”库通过隐式库管理状态拥有小部件,通常以树状结构(如浏览器的)排列多米尼加。因此,程序必须使用库提供的工具来操作它的小部件。
除了上面的简单示例之外,实际的 GUI 编程还有几个问题:
- 1.如何获取事件?
- 2.什么时候重画州?
- 3.小部件结构看起来像什么?
- 4.如何追踪焦点?
- 5.如何组织事件?
- 6.如何与显卡通信?
- 7.如何处理输入?
- 8.怎么画文字?
- 9.小部件状态属于哪里?
- 10.还有很多。
本文的其余部分试图回答 Gio 是如何做到这一点的。如果您希望了解更多关于即时模式 UI 的信息,这些参考资料是一个很好的开始:
- https://caseymuratori.com/blog_0001
- http://sol.gfxile.net/imgui/
- http://www.johno.se/book/imgui.html
- https://github.com/ocornut/imgui
- https://eliasnaur.com/blog/immediate-mode-gui-programming
窗户
与操作系统对话
var window app.Window
window.Option(app.Title(title))
var ops op.Ops
for {
switch e := window.Event().(type) {
case app.DestroyEvent:
// The window was closed.
return e.Err
case app.FrameEvent:
// A request to draw the window state.
// Reset the operations back to zero.
ops.Reset()
// Draw the state into ops.
draw(&ops)
// Update the display.
e.Frame(&ops)
}
}
app.Window.Run
根据环境和构建上下文选择适当的“驱动程序”。它可能会选择 Wayland、Win32 或 Cocoa。
一;一个 app.Window
允许从显示中访问事件window.Event()
。中还有其他生命周期事件 gioui.org/app
包装如app.DestroyEvent
和app.FrameEvent
.
操作
所有的 UI 库都需要一种方式让程序指定显示什么以及如何处理事件。Gio 程序使用操作,序列化成一个或多个op.Ops
操作列表。操作列表又通过FrameEvent.Frame
功能。
按照惯例,每种操作都由一个带有 Add
方法,该方法将操作记录到 Ops
论点。像任何 Go 结构文字一样,零值字段对于表示可选值很有用。
例如,记录将当前颜色设置为红色的操作:
func addColorOperation(ops *op.Ops) {
red := color.NRGBA{R: 0xFF, A: 0xFF}
paint.ColorOp{Color: red}.Add(ops)
}
您可能会想,如果有一个 ops.Add(ColorOp{Color: red})
方法,而不是使用 op.ColorOp{Color: red}.Add(ops)
。它是这样的,所以 Add
方法不必采用接口类型的参数,这通常需要分配来调用。这是 Gio“零分配”设计的一个关键方面。
图画
在屏幕上显示东西
这paint
包提供了绘制图形的操作。
坐标基于左上角,尽管也可以。这意味着 f32.Point{X:0, Y:0}
是窗口的左上角。所有绘图操作都使用像素单位,请参见部分了解更多信息。
例如,下面的代码将在窗口的左上角绘制一个 100x100 像素的彩色矩形:
func drawRedRect(ops *op.Ops) {
defer clip.Rect{Max: image.Pt(100, 100)}.Push(ops).Pop()
paint.ColorOp{Color: color.NRGBA{R: 0x80, A: 0xFF}}.Add(ops)
paint.PaintOp{}.Add(ops)
}
这 defered line
只是推迟了 .Pop()
操作结束时,我们推一个矩形的裁剪区域,设置颜色为红色 paint.ColorOp
,然后指示 Gio 用当前颜色绘制当前区域 paint.PaintOp
.
抵消
操作op.TransformOp
转换它后面的操作的位置。
例如,以下函数将红色矩形向右偏移 100 个像素:
func drawRedRect10PixelsRight(ops *op.Ops) {
defer op.Offset(image.Pt(100, 0)).Push(ops).Pop()
drawRedRect(ops)
}
请再次注意,我们是 defering the.Pop()
偏移量的。这意味着偏移量在函数持续期间应用,然后被移除。
剪报
在某些情况下,我们希望绘图限制在非矩形形状,例如为了避免绘图重叠。包裹gioui.org/op/clip
恰恰提供了这一点。
clip.RRect
将所有后续绘制操作裁剪为圆角矩形。作为按钮背景的基础,这很有用:
func redButtonBackground(ops *op.Ops) {
const r = 10 // roundness
bounds := image.Rect(0, 0, 100, 100)
clip.RRect{Rect: bounds, SE: r, SW: r, NW: r, NE: r}.Push(ops)
drawRedRect(ops)
}
对于更复杂的剪裁clip.Path
可以表示由直线和贝塞尔曲线构建的形状。此示例绘制了一个带有弯曲边的三角形:
func redTriangle(ops *op.Ops) {
var path clip.Path
path.Begin(ops)
path.Move(f32.Pt(50, 0))
path.Quad(f32.Pt(0, 90), f32.Pt(50, 100))
path.Line(f32.Pt(-100, 0))
path.Line(f32.Pt(50, -100))
defer clip.Outline{Path: path.End()}.Op().Push(ops).Pop()
drawRedRect(ops)
}
线
画出我们可以使用的线clip.Stroke
代替clip.Outline
。Stroke 沿着路径绘制一条固定宽度的线,而 Outline 不允许在描述的路径区域之外绘制。我们也可以使用paint.FillShape
帮助器来避免管理剪辑状态或使用 ColorOp
或者 PaintOp
. paint.FillShape
让我们指定一个 *op.Ops
,一个 color.NRGBA
,和一个 clip.AreaOp
,它负责用颜色填充剪切的区域。
可以使用预定义的形状,例如clip.RRect
:
func strokeRect(ops *op.Ops) {
const r = 10
bounds := image.Rect(20, 20, 80, 80)
rrect := clip.RRect{Rect: bounds, SE: r, SW: r, NW: r, NE: r}
paint.FillShape(ops, red,
clip.Stroke{
Path: rrect.Path(ops),
Width: 4,
}.Op(),
)
}
或者使用用绘制的自定义形状clip.Path
:
func strokeTriangle(ops *op.Ops) {
var path clip.Path
path.Begin(ops)
path.MoveTo(f32.Pt(30, 30))
path.LineTo(f32.Pt(70, 30))
path.LineTo(f32.Pt(50, 70))
path.Close()
paint.FillShape(ops, green,
clip.Stroke{
Path: path.End(),
Width: 4,
}.Op())
}
对于破折号、笔画端帽和连接,有一个单独的包。然而,它们的性能不如 clip.Stroke
,因为构建路径描述的工作必须在 CPU 上执行。
操作堆栈
某些操作会影响其后的所有操作。举个例子,paint.ColorOp
设置用于后续的“画笔”颜色paint.PaintOp
运营。该绘图上下文还包括坐标转换(由op.TransformOp
)和剪辑(由clip.Op
).
例如,在 redButtonBackground
函数有一个不幸的副作用,就是将所有后续操作剪切到按钮背景的轮廓上!让我们制作一个不影响任何呼叫者的版本:
func redButtonBackgroundStack(ops *op.Ops) {
const r = 1 // roundness
bounds := image.Rect(0, 0, 100, 100)
defer clip.RRect{Rect: bounds, SE: r, SW: r, NW: r, NE: r}.Push(ops).Pop()
drawRedRect(ops)
}
绘图顺序
绘画是从后向前进行的。插入的东西 op.Ops
首先绘制第一个元素,随后的元素将绘制在顶部。在此函数中,绿色矩形绘制在红色矩形之上:
func drawOverlappingRectangles(ops *op.Ops) {
// Draw a red rectangle.
cl := clip.Rect{Max: image.Pt(100, 50)}.Push(ops)
paint.ColorOp{Color: color.NRGBA{R: 0x80, A: 0xFF}}.Add(ops)
paint.PaintOp{}.Add(ops)
cl.Pop()
// Draw a green rectangle.
cl = clip.Rect{Max: image.Pt(50, 100)}.Push(ops)
paint.ColorOp{Color: color.NRGBA{G: 0x80, A: 0xFF}}.Add(ops)
paint.PaintOp{}.Add(ops)
cl.Pop()
}
有时你可能想改变这个顺序。例如,您可能想要延迟绘制以应用在绘制期间计算的变换,或者您可能想要多次执行一系列操作。为此目的,有 op .宏操作.
func drawFiveRectangles(ops *op.Ops) {
// Record drawRedRect operations into the macro.
macro := op.Record(ops)
drawRedRect(ops)
c := macro.Stop()
// “Play back” the macro 5 times, each time
// translated vertically 20px and horizontally 110 pixels.
for i := 0; i < 5; i++ {
c.Add(ops)
op.Offset(image.Pt(110, 20)).Add(ops)
}
}
动画
Gio 仅在调整窗口大小时或用户与窗口交互时发出 FrameEvents。但是,动画需要不断重绘,直到动画完成。因为有op.InvalidateCmd
.
下面的代码将动画显示一个红色的“进度条”,该进度条在程序启动后的 10 秒内从左到右填满:
var startTime = time.Now()
var duration = 10 * time.Second
func drawProgressBar(ops *op.Ops, source input.Source, now time.Time) {
// Calculate how much of the progress bar to draw,
// based on the current time.
elapsed := now.Sub(startTime)
progress := elapsed.Seconds() / duration.Seconds()
if progress < 1 {
// The progress bar hasn’t yet finished animating.
source.Execute(op.InvalidateCmd{})
} else {
progress = 1
}
width := 200 * float32(progress)
defer clip.Rect{Max: image.Pt(int(width), 20)}.Push(ops).Pop()
paint.ColorOp{Color: color.NRGBA{R: 0x80, A: 0xFF}}.Add(ops)
paint.PaintOp{}.Add(ops)
}
记录和重放
在…期间 op.MacroOp
允许您记录和重放单个操作列表上的操作,op.CallOp
允许重复使用单独的操作列表。这对于重新创建开销很大的缓存操作或动画显示已移除的小部件的消失非常有用:
func drawWithCache(ops *op.Ops) {
// Save the operations in an independent ops value (the cache).
cache := new(op.Ops)
macro := op.Record(cache)
cl := clip.Rect{Max: image.Pt(100, 100)}.Push(cache)
paint.ColorOp{Color: color.NRGBA{G: 0x80, A: 0xFF}}.Add(cache)
paint.PaintOp{}.Add(cache)
cl.Pop()
call := macro.Stop()
// Draw the operations from the cache.
call.Add(ops)
}
注意:为了让这个缓存真正保存跨帧的工作,你需要分配缓存的 op.Ops
某处持续跨越框架。在这样的局部变量中这样做意味着每一帧都要重新创建缓存。
图像
paint.ImageOp
用于绘制图像。喜欢paint.ColorOp
,它设置绘图上下文的一部分(“画笔”),用于随后的PaintOp
. ImageOp
类似于使用ColorOp
.
注意到image.NRGBA
和image.Uniform
图像是高效的,并经过特殊处理。其他的Image
实现将经历更昂贵的复制和到底层图像模型的转换。
func drawImage(ops *op.Ops, img image.Image) {
imageOp := paint.NewImageOp(img)
imageOp.Filter = paint.FilterNearest
imageOp.Add(ops)
op.Affine(f32.Affine2D{}.Scale(f32.Pt(0, 0), f32.Pt(4, 4))).Add(ops)
paint.PaintOp{}.Add(ops)
}
该图像不得变异,直到另一个FrameEvent
因为在绘制框架时可能会异步读取图像。此外,对图像的改变提供给 paint.ImageOp
不保证会反映在绘制的内容中。要更新屏幕上的图像,请创建新图像。想象并构建一个新的 paint.ImageOp
.
输入
对鼠标和键盘做出反应
输入通过一个app.FrameEvent
穿过Queue
字段。
中一些最常见的事件 input.Source
是:
key.Event
,key.FocusEvent
-用于键盘输入。key.EditEvent
-用于文本编辑。pointer.Event
-用于鼠标和触摸输入。
程序可以按照自己喜欢的方式响应这些事件——例如,通过更新其本地数据结构或运行用户触发的操作。这FrameEvent
是特殊的-当程序收到一个FrameEvent
,它负责通过调用e.Frame
带有表示新状态的操作列表的函数。这些操作是在响应FrameEvent
这就是 Gio 被称为“即时模式”GUI 的主要原因。
事件处理器,例如Click
和Scroll
从包装gioui.org/gesture
从单个点击事件中检测更高级别的动作。
为了在多个不同的小部件之间分发输入,Gio 需要了解事件处理程序及其配置。然而,由于 Gio 框架是无状态的,程序没有直接的方法来指定它。
相反,一些操作将输入事件类型(例如,键盘按压)与任意(界面{}值)由程序选择。程序在处理FrameEvent
–输入操作和其他操作一样。作为回报,一个提供自上一帧以来到达的事件,由标记分隔。
您可以将标签视为给定输入区域的唯一键。Gio 事件路由器会将该区域中的输入事件与为该区域提供的标签相关联。然后,通过向提供相同的标记,您可以在下一帧获取这些事件 input.Source
。小部件通常会通过提供指向其持久状态的指针作为其输入区域的标签来封装该事件逻辑。
下面的示例演示了指针输入处理:
var tag = new(bool) // We could use &pressed for this instead.
var pressed = false
func doButton(ops *op.Ops, q input.Source) {
// Confine the area of interest to a 100x100 rectangle.
defer clip.Rect{Max: image.Pt(100, 100)}.Push(ops).Pop()
// Declare `tag` as being one of the targets.
event.Op(ops, tag)
// Process events that arrived between the last frame and this one.
for {
ev, ok := q.Event(pointer.Filter{
Target: tag,
Kinds: pointer.Press | pointer.Release,
})
if !ok {
break
}
if x, ok := ev.(pointer.Event); ok {
switch x.Kind {
case pointer.Press:
pressed = true
case pointer.Release:
pressed = false
}
}
}
// Draw the button.
var c color.NRGBA
if pressed {
c = color.NRGBA{R: 0xFF, A: 0xFF}
} else {
c = color.NRGBA{G: 0xFF, A: 0xFF}
}
paint.ColorOp{Color: c}.Add(ops)
paint.PaintOp{}.Add(ops)
}
为输入标记使用 Go 指针值很方便,因为将指针转换为接口{}的成本很低,并且很容易使值特定于本地数据结构,这避免了标记冲突的风险。
如需更多详细信息,请查看gioui.org/io/pointer
(指针/鼠标事件)和gioui.org/io/key
(键盘事件)。
外部输入
单个框架由获取输入、注册输入和绘制新状态组成:
var window app.Window
window.Option(app.Title(title))
var ops op.Ops
for {
switch e := window.Event().(type) {
case app.DestroyEvent:
// The window was closed.
return e.Err
case app.FrameEvent:
// A request to draw the window state.
// Reset the operations back to zero.
ops.Reset()
// Draw the state into ops based on events in e.Queue.
draw(&ops, e.Source)
// Update the display.
e.Frame(&ops)
}
}
让我们让按钮每秒改变它的位置。我们将使用Ticker
作为外部变化的一个例子。我们使用锁来保护状态,一旦我们修改了状态,我们需要通知窗口重新触发渲染window.Invalidate()
.
var window app.Window
window.Option(app.Title(title))
var button struct {
lock sync.Mutex
offset int
}
updateOffset := func(v int) {
button.lock.Lock()
defer button.lock.Unlock()
button.offset = v
}
readOffset := func() int {
button.lock.Lock()
defer button.lock.Unlock()
return button.offset
}
go func() {
changes := time.NewTicker(time.Second)
defer changes.Stop()
for t := range changes.C {
updateOffset(int((t.Second() % 3) * 100))
window.Invalidate()
}
}()
ops := new(op.Ops)
for {
switch e := window.Event().(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
ops.Reset()
// Offset the button based on state.
op.Offset(image.Pt(readOffset(), 0)).Add(ops)
// Handle button input and draw.
doButton(ops, e.Source)
// Update display.
e.Frame(ops)
}
}
高级输入主题
该标题下的内容探索了 Gio 输入操作的更高级用法。这些内容对于编写定制小部件的人来说非常有用,对于使用 Gio 的高级小部件和布局 API 来说并不是绝对必要的。
输入树
您可能已经注意到,前面的示例使用了一个 clip.AreaOp
(构造有 clip.Rect
)来描述它想要指针输入的位置。这是因为 Gio 使用 clip.AreaOp
描述绘图和输入区域。正如您在上面看到的,通常您既想在一个区域内进行绘制,又想在该区域内接受输入,因此这种重用很方便。
clip.AreaOp
形成了输入区域的隐式树,每个输入区域可能对指针输入、键盘输入或两者都感兴趣。
这里有一个例子来探究指针事件是如何与这个树形结构交互的。
var (
// Declare a number of variables to use both as state
// and input tags.
root, child1, child2 bool
)
// displayForTag adds a pointer.InputOp interested
// in press and release events to the given op.Ops using
// the given tag. It also paints a color based on the current
// value of the tag to the current clip.
func displayForTag(ops *op.Ops, tag *bool, rect clip.Rect) {
event.Op(ops, tag)
// Choose a color based on whether the tag is being pressed.
c := color.NRGBA{B: 0xFF, A: 0xFF}
if *tag {
c = color.NRGBA{R: 0xFF, A: 0xFF}
}
// Paint the current clipping area with a translucent color.
translucent := c
translucent.A = 0x44
paint.ColorOp{Color: translucent}.Add(ops)
paint.PaintOp{}.Add(ops)
// Reduce our clipping area to the outline of the rectangle, then
// paint that outline. This should make it easier to see overlapping
// rectangles.
defer clip.Stroke{
Path: rect.Path(),
Width: 5,
}.Op().Push(ops).Pop()
paint.ColorOp{Color: c}.Add(ops)
paint.PaintOp{}.Add(ops)
}
func doPointerTree(ops *op.Ops, q input.Source) {
// Process events that arrived between the last frame and this one for every tag.
for _, tag := range []*bool{&root, &child1, &child2} {
for {
ev, ok := q.Event(pointer.Filter{
Target: tag,
Kinds: pointer.Press | pointer.Release,
})
if !ok {
break
}
x, ok := ev.(pointer.Event)
if !ok {
continue
}
switch x.Kind {
case pointer.Press:
*tag = true
case pointer.Release:
*tag = false
}
}
}
// Confine the rootArea of interest to a 200x200 rectangle.
rootRect := clip.Rect(image.Rect(0, 0, 200, 200))
rootArea := rootRect.Push(ops)
displayForTag(ops, &root, rootRect)
// Any clip areas we add before Pop-ing the root area
// are considered its children.
child1Rect := clip.Rect(image.Rect(25, 25, 175, 100))
child1Area := child1Rect.Push(ops)
displayForTag(ops, &child1, child1Rect)
child1Area.Pop()
child2Rect := clip.Rect(image.Rect(100, 25, 175, 175))
child2Area := child2Rect.Push(ops)
displayForTag(ops, &child2, child2Rect)
child2Area.Pop()
rootArea.Pop()
// Now anything we add is _not_ a child of the rootArea.
}
尝试单击三个蓝色矩形中的每一个。您应该会看到,单击最大的矩形只会使它本身变成红色,而单击它内部的两个矩形中的任何一个都会使您单击的两个矩形都变成红色和最外面的长方形红色。
发生这种情况是因为指针输入事件沿 clip.AreaOp
我们在找什么 pointer.Filter
代表那种活动。他们不会停留在第一个感兴趣的地方 pointer.Filter
,而是一直向上到树根。这意味着我们点击的矩形和包含它的矩形接收 pointer.Press
和 pointer.Release
点击其中一个嵌套矩形。
还要注意,如果您单击两个子矩形重叠的区域,只有最上面的(最后绘制的)矩形会收到单击。默认情况下,Gio 在路由指针事件时只考虑最前面的区域及其祖先。如果您想改变这一点,您可以使用 pointer.PassOp
允许指针事件通过输入区域传递到它下面的区域。这对于布局叠加和类似元素非常有用。参见包装文件pointer
以了解有关此操作的详细信息。
小部件
可重复使用和可组合的零件
我们提到小部件已经有一段时间了。原则上,小部件是可组合和可绘制的 UI 元素,可以对输入做出反应。更具体地说:
- 他们从一个
Source.
- 他们可能持有某种状态。
- 他们根据给定的约束条件计算它们的大小。
- 他们把自己吸引到一个
op.Ops
列表。
按照惯例,小部件有一个 Layout
方法,该方法执行上述所有操作。一些小部件有单独的方法来查询它们的状态或将事件传递回程序.
一些小部件有几个可视化表示。例如,有状态的可点击的用作的基础按钮和图标按钮。事实上材料包装仅实现材料设计并且旨在由实现不同设计的其他包来补充。
语境
为了从这些原语构建更复杂的 UI,我们需要一些以可组合方式描述布局的结构。
静态地指定布局是可能的,但是显示大小变化很大,所以我们需要能够动态地计算布局——即约束可用的显示大小,然后计算布局的其余部分。我们还需要一种舒适的方式来通过组合结构传递事件,同样,我们也需要一种方式来传递op.Ops
通过系统。
layout.Context
方便地将这些方面捆绑在一起。它携带了几乎所有布局和小部件都需要的状态。
总结一下术语:
Constraints
是小部件的“传入”参数。这些约束包含小部件的最大(和最小)尺寸。Ops
保存生成的绘制操作。Events
保存自上次绘图操作以来生成的事件。
按照约定,接受 layout.Context
返回layout.Dimensions
它提供了布局小部件的尺寸和该小部件中任何文本内容的基线。
var window app.Window
window.Option(app.Title(title))
var ops op.Ops
for {
switch e := window.Event().(type) {
case app.DestroyEvent:
// The window was closed.
return e.Err
case app.FrameEvent:
// Reset the layout.Context for a new frame.
gtx := app.NewContext(&ops, e)
// Draw the state into ops based on events in e.Queue.
draw(gtx)
// Update the display.
e.Frame(gtx.Ops)
}
}
习俗
作为一个例子,下面是如何实现一个非常简单的按钮。
我们先来画一下:
type ButtonVisual struct {
pressed bool
}
func (b *ButtonVisual) Layout(gtx layout.Context) layout.Dimensions {
col := color.NRGBA{R: 0x80, A: 0xFF}
if b.pressed {
col = color.NRGBA{G: 0x80, A: 0xFF}
}
return drawSquare(gtx.Ops, col)
}
func drawSquare(ops *op.Ops, color color.NRGBA) layout.Dimensions {
defer clip.Rect{Max: image.Pt(100, 100)}.Push(ops).Pop()
paint.ColorOp{Color: color}.Add(ops)
paint.PaintOp{}.Add(ops)
return layout.Dimensions{Size: image.Pt(100, 100)}
}
然后处理指针点击:
type Button struct {
pressed bool
}
func (b *Button) Layout(gtx layout.Context) layout.Dimensions {
// Confine the area for pointer events.
area := clip.Rect(image.Rect(0, 0, 100, 100)).Push(gtx.Ops)
event.Op(gtx.Ops, b)
// here we loop through all the events associated with this button.
for {
ev, ok := gtx.Event(pointer.Filter{
Target: b,
Kinds: pointer.Press | pointer.Release,
})
if !ok {
break
}
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Kind {
case pointer.Press:
b.pressed = true
case pointer.Release:
b.pressed = false
}
}
area.Pop()
// Draw the button.
col := color.NRGBA{R: 0x80, A: 0xFF}
if b.pressed {
col = color.NRGBA{G: 0x80, A: 0xFF}
}
return drawSquare(gtx.Ops, col)
}
布局
布局把东西放在它们该放的地方
包裹gioui.org/layout
提供对常见布局操作的支持,例如间距、重叠小部件的列表和堆叠。
在布局示例中,我们将使用这个 ColorBox
可视化布局的小部件:
// Test colors.
var (
background = color.NRGBA{R: 0xC0, G: 0xC0, B: 0xC0, A: 0xFF}
red = color.NRGBA{R: 0xC0, G: 0x40, B: 0x40, A: 0xFF}
green = color.NRGBA{R: 0x40, G: 0xC0, B: 0x40, A: 0xFF}
blue = color.NRGBA{R: 0x40, G: 0x40, B: 0xC0, A: 0xFF}
)
// ColorBox creates a widget with the specified dimensions and color.
func ColorBox(gtx layout.Context, size image.Point, color color.NRGBA) layout.Dimensions {
defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
paint.ColorOp{Color: color}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
return layout.Dimensions{Size: size}
}
Inset
layout.Inset
在小工具周围添加空间。
func inset(gtx layout.Context) layout.Dimensions {
// Draw rectangles inside of each other, with 30dp padding.
return layout.UniformInset(unit.Dp(30)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, gtx.Constraints.Max, red)
})
}
Stack
layout.Stack
根据对齐方向布局重叠的子元素。堆栈布局的子布局可以是:
例如,在红色背景上绘制绿色和蓝色矩形:
func stacked(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
// Force widget to the same size as the second.
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
// This will have a minimum constraint of 100x100.
return ColorBox(gtx, gtx.Constraints.Min, red)
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, image.Pt(100, 30), green)
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, image.Pt(30, 100), blue)
}),
)
}
Background
因为布局小部件的背景非常频繁,所以该场景有一个更高性能的实现,大致对应于:
layout.Stack{Alignment: layout.C}.Layout(gtx,
layout.Expanded(background),
layout.Stacked(widget)
)
func layoutBackground(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, background)
return layout.Dimensions{Size: gtx.Constraints.Min}
}, func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, image.Pt(30, 100), blue)
})
}
List
layout.List
可以显示可能很大的项目列表。因为 目录
还处理滚动,它必须跨布局保持,否则滚动位置将丢失。List 通过仅布置可见元素来处理大量项目。对于每一帧,所提供的闭包仅针对在当前滚动位置可见的标记(以及可能滚动位置上方和下方的少量项目)被调用。
var list = layout.List{}
func listing(gtx layout.Context) layout.Dimensions {
return list.Layout(gtx, 100, func(gtx layout.Context, i int) layout.Dimensions {
col := color.NRGBA{R: byte(i * 20), G: 0x20, B: 0x20, A: 0xFF}
return ColorBox(gtx, image.Pt(20, 100), col)
})
}
Flex
layout.Flex
根据权重或刚性约束布局子对象。首先使用刚性元素来确定剩余空间,然后根据重量在弯曲的孩子之间划分剩余空间。
这些孩子可以是:
func flexed(gtx layout.Context) layout.Dimensions {
return layout.Flex{}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, image.Pt(100, 100), red)
}),
layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, gtx.Constraints.Min, blue)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, image.Pt(100, 100), red)
}),
layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, gtx.Constraints.Min, green)
}),
)
}
Spacer
layout.Spacer
可与一起使用 layout.List
或者 layout.Flex
在项目之间添加空白空间。
func spacer(gtx layout.Context) layout.Dimensions {
return layout.Flex{}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, image.Pt(100, 100), red)
}),
layout.Rigid(layout.Spacer{Width: 20}.Layout),
layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, gtx.Constraints.Min, blue)
}),
layout.Rigid(layout.Spacer{Width: 20}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, image.Pt(100, 100), red)
}),
layout.Rigid(layout.Spacer{Width: 20}.Layout),
layout.Flexed(0.5, func(gtx layout.Context) layout.Dimensions {
return ColorBox(gtx, gtx.Constraints.Min, green)
}),
)
}
习俗
有时内置布局是不够的。要为小部件创建自定义布局,有一些特殊的函数和结构来操作 layout.Context。
- 使用
op.Save
. - 一组
layout.Context.Constraints
. - 一组
op.TransformOp
. - 打电话
widget.Layout(gtx, ...)
. - 使用小部件返回的维度。
- 使用
StateOp.Load
.
对于复杂的布局,你也需要使用宏。作为一个例子,看一看 layout.Flex。它大致实现了:
- 在宏中记录部件。
- 计算非刚性部件的大小。
- 通过重放宏,根据计算出的尺寸绘制部件。
主题
让事情看起来一样
同一个抽象小部件可以有许多可视化表示,从简单的颜色变化到完全自定义的图形。为了给应用程序一个一致的外观,有一个表示特定“主题”的抽象是有用的。
包裹gioui.org/widget/material
基于实现主题材料设计,以及主题
struct 封装了各种颜色、大小和字体的参数。
要使用主题,必须首先在应用程序循环中初始化它:
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
var window app.Window
window.Option(app.Title(title))
var ops op.Ops
for {
switch e := window.Event().(type) {
case app.DestroyEvent:
// The window was closed.
return e.Err
case app.FrameEvent:
// Reset the layout.Context for a new frame.
gtx := app.NewContext(&ops, e)
// Draw the state into ops based on events in e.Queue.
draw(gtx, th)
// Update the display.
e.Frame(gtx.Ops)
}
}
然后在您的应用程序中使用提供的小部件:
var isChecked widget.Bool
func themedApplication(gtx layout.Context, th *material.Theme) layout.Dimensions {
var checkboxLabel string
isChecked.Update(gtx)
if isChecked.Value {
checkboxLabel = "checked"
} else {
checkboxLabel = "not-checked"
}
return layout.Flex{
Axis: layout.Vertical,
}.Layout(gtx,
layout.Rigid(material.H3(th, "Hello, World!").Layout),
layout.Rigid(material.CheckBox(th, &isChecked, checkboxLabel).Layout),
)
}
Kitchen example 显示所有可用的不同小部件。
单位
测量物体的尺寸
绘制操作使用像素坐标,忽略应用的任何变换。然而,在大多数情况下,你不希望把用户界面的大小和位置与屏幕像素联系起来。人们可能会启用屏幕缩放,并且不同设备之间的像素密度差异很大。
除了物理像素、封装gioui.org/unit
实现独立于设备的单元:
layout.Context
有方法Px
从…转换unit.Value
至像素
有关像素密度的更多信息,请参见:
坐标系统
您可能已经注意到,小部件约束和尺寸大小是以整数为单位的,而绘制命令如PaintOp
使用浮点单位。这是因为它们指的是两个不同的坐标系,即布局坐标系和绘图坐标系。区别很微妙,但很重要。
布局坐标系以整数像素为单位,因为重要的是小部件绝不能无意中重叠在物理像素的中间。事实上,决定使用整数坐标的动机是合并问题在其他 UI 库中,由于允许部分布局而导致。
另外,整数坐标在所有平台上都是完全确定的,这使得布局的调试和测试更加容易。
另一方面,绘图命令需要浮点坐标的通用性,以实现平滑动画和表达固有的分数形状,如贝塞尔曲线。
可以绘制在分数像素坐标处重叠的形状,但这只是有意为之:直接从布局约束派生的绘制命令通过构造具有整数坐标。
文本
字体
Gio 的文本整形器使用这种类型 []text.FontFace
来表示可用字体的集合。
包中捆绑了一种字体gioui.org/font/gofont
,您可以使用gofont.Collection()
得到一个 []text.FontFace
包含 Go 字体的所有变体。
加载其他字体有gioui.org/font/opentype
。使用解析字体后opentype.Parse
,您可以将它们附加到 []text.FontFace
.
形状
要将字符串转换为剪辑形状,有gioui.org/text
包裹。
它包含text.Cache
它实现了缓存的字符串到形状的转换,并带有适当的回退。只需提供您的字体([]text.FontFace
)到 text.NewCache
.
在大多数情况下,您可以使用widget.Label
它处理包装和布局约束。或者当你使用材料设计时material.LabelStyle
.
颜色
了解颜色和混合
色彩处理是我们通常不会考虑的事情。然而,一个框架在处理颜色时可以做出很多权衡。
简短的解释是,Gio 使用 sRGB 颜色进行输入,但使用线性颜色空间进行混合。这导致颜色混合是正确的,而无需手动将通常的颜色值转换到线性颜色空间。
如果这个简短的解释不够充分,下面还有一个更长的解释。
注意:下面将把事情过分简化,使它们更容易理解。要了解所有的细节,请阅读链接文章。
底色
大多数程序用红色、绿色和蓝色明度值来表示颜色。最简单的方法是用您在 RGB 颜色中使用的值来表示准确的亮度值。然而,眼睛对深色比浅色更敏感。由于每个颜色通道只有 8 位可用,亮度的线性映射会浪费位来表示人们无法区分的较亮的值。
一种方法是非线性校正它使用以压缩较亮颜色范围为代价来扩展较暗颜色范围的函数来编码亮度值。
通常伽玛变换看起来像:
// transforming linear color to gamma compressed color
gamma_color := math.Pow(linear_color, gamma)
// transforming gamma compressed color to linear color
linear_color := math.Pow(gamma_color, 1/gamma)
// where
linear_color = [0..1]
gamma_color = [0..1]
gamma = usually 2.2 or 2.4
这个函数的一个问题是颜色变化的速度几乎是无限的。为了避免这种边界条件,有一个亮度值变换,称为 sRGB 颜色空间。sRGB 转换看起来像:
// transforming linear color to sRGB color
if linear_color <= 0.0031308 {
srgb_color = 12.92 * linear_color
} else { // linear_color > 0.0031308
srgb_color = 1.055 * math.Pow(linear_color, 1/2.4) - 0.055
}
// transforming sRGB color to linear color
if srgb_color <= 0.04045 {
linear_color = srgb_color / 12.92
} else { // srgb_color > 0.04045
linear_color = math.Pow((srgb_color + 0.055) / 1.055, 2.4)
}
sRGB 与伽玛校正颜色的细节对于讨论来说并不重要,所以我们将继续使用伽玛变换,因为它比 sRGB 变换更短。
与 sRGB 的问题
sRGB 和伽玛校正颜色的一个问题是,当你直接计算它们的和时,你不能得到正确的颜色混合。
让我们举一个混合的例子 linear_color_alpha
和 linear_color_beta
:
// mix colors using linear color space
linear_color = 0.5*linear_color_alpha + 0.5*linear_color_beta
// mixing colors using sRGB color space
linear_color = math.Pow(
0.5 * math.Pow(linear_color_alpha, gamma) +
0.5 * math.Pow(linear_color_beta, gamma),
1/gamma)
当你用这个例子做实验时,你应该注意到在 sRGB 中混合经常会产生更暗或更灰的颜色,最终导致混合中的颜色变得模糊。
混合问题已在以下章节中详细讨论:
- 软件是如何弄错颜色的 https://bottosson.github.io/posts/colorwrong/
- 线性伽玛与更高伽玛 https://ninedegreesbelow.com/photography/linear-gamma-blur-normal-blend.html
- 图像缩放中的伽玛误差 http://www.ericbrasseur.org/gamma.html
框架选择
总的来说,框架需要选择一个颜色空间来工作。从历史上看,最常见的选择是 sRGB,因为深色的好处。同样,出于意外或性能原因,人们最终使用了 sRGB 混合。这也导致了与调整图像大小.
因此,由于 sRGB 的历史重要性,UI 框架有几个选择:
- 使用 sRGB 进行输入和混合:这会导致不正确的混合和模糊的颜色。但是,这种行为与所有其他程序类似。
- 使用线性颜色输入和混合:这有正确的混合。然而,人们不能使用他们常用的“颜色选择器”(因为他们在 sRGB 工作),必须手动将图像从 sRGB 转换为线性。
- 提供输入时使用 sRGB 颜色;然而,混合使用线性颜色:这是兼容的颜色选择程序。混合颜色将不同于 sRGB 混合。
吉奥选择了方法 3,因为这是一个实用的选择,有正确的混合,没有颜色转换的烦恼。
旁注:当然,有更多的选择,比如使用更高的位深度或宽色域色彩空间,但是对于通常的 UI 应用程序来说,并没有明显的好处。
案例:沸蛋计时器
https://jonegil.github.io/gui-with-gio/egg_timer/
第 1 章 空窗口
目标
本节的目的是创建一个空白画布,我们稍后可以绘制。
大纲
该代码主要执行三项操作:
-
import gio
-
创建并调用一个 goroutine,该例程:
- 创建一个新窗口,称为
w
- 启动一个无限循环,等待窗口事件(在此示例中不会发生任何事件)
- 创建一个新窗口,称为
就是这样!让我们看一下代码:
package main
import (
"gioui.org/app"
)
func main() {
go func() {
// create new window
w := app.NewWindow()
// listen for events in the window.
for range w.Events() {
}
}()
app.Main()
}
说明
代码看起来很简单,对吧?不过,让我们花时间看看发生了什么。
-
我们 import 了什么?
gioui.org/app
查看文档,我们发现:包应用为运行图形用户界面的操作系统功能提供独立于平台的界面。
Gio 为我们处理所有依赖于平台的东西。我经常在 Windows 和 MacOS 上编码。吉奥只是工作。GioUI.org 列出了更多,包括 iOS 和 Android。
这比你意识到的要深刻。即使你的应用现在是单平台的,你的技能组合现在是多平台的。 “我们应该移植到 Mac。 算完成了! “寻求应用程序和桌面专家的热门创业公司。 没关系。 “这里谁知道 tvOS?” 是吗! “飞行员死了,有人能降落这架飞机吗?!” 好吧,也许不是最后一个,但重点仍然存在。吉奥的多样性令人惊叹。 -
goroutine 中的事件循环
-
事件循环是侦听窗口中事件的循环。现在,我们只是让它侦听,而不对它收到的事件做任何事情。稍后我们将开始对它们做出反应。
for range w.Events()
从 app.main 中,我们了解到:
由于 Main 在某些平台上也会阻塞,因此 Window 的事件循环必须在 goroutine 中运行。
-
创建一个没有名称的 goroutine(即匿名函数 )并运行事件循环。由于它位于 goroutine 中,它将与程序的其余部分同时旋转。
go func { // ... }()
Jeremy Bytes 写得很好关于匿名函数的文章。它们在很多情况下都很有用,而不仅仅是 Gio。
-
通过调用“main”应用启动它。主要文档:
app.Main()
必须从程序的主函数调用 Main 函数,以便将主线程的控制权移交给需要它的操作系统。
-
第 2 章 - 标题和大小
目标
本节的目的是设置自定义标题和窗口大小。
大纲
此代码与第 1 章的代码非常相似。我们添加:
- 另外两个 import
- 调用时的两个参数
app.NewWindow()
package main
import (
"os"
"gioui.org/app"
"gioui.org/unit"
)
func main() {
go func() {
// create new window
w := app.NewWindow(
app.Title("Egg timer"),
app.Size(unit.Dp(400), unit.Dp(600)),
)
// listen for events in the window.
for range w.Events() {
}
os.Exit(0)
}()
app.Main()
}
评论
第 1 章是打开窗口的绝对最低限度,我们想在这里做一些改进。一个帮助我们确保干净的退出,所以我们导入并添加一行带有 os 的行。事件循环后的 Exit()。约定是零表示成功,稍后可以添加逻辑来发送其他值。os
gioui.org/unit 实现与设备无关的单位和值。这些文档描述了一些替代方案:
类型 | 描述 |
---|---|
DP | Device independent pixel - 独立于底层设备 |
sp | Scaled pixel - 用于文本大小 |
px | Pixels - 用于实际设备的精度 |
一般来说,是使用最广泛的;我们希望尽可能保持设备的独立性。因此,这就是我们在内部定义窗口大小时使用的。dpapp.NewWindow()
的选项是不言自明的,但请注意几点:app.NewWindow()
-
大小是使用 .
app.Size(x, y)
-
窗口可以自由调整大小。试试吧!如果要限制大小,可以添加:
- 最大尺寸
- 最小尺寸
- 或者两者兼而有之,有效锁定窗口大小
-
如果需要,可以使用全屏选项。
-
如果您正在为 Android 构建,则可以在此处设置状态和导航颜色。
第 3 章 - 按钮
目标
本节的目的是添加一个按钮。我们不仅可以单击它,而且它将有一个很好的悬停和单击动画。
满屏蓝色其实是按钮的背景-看下一章节
大纲
本节将介绍许多新组件。我们不会深入研究,而是专注于程序的整体结构。不要迷失在细节中,专注于大局,你会没事的。
我们首先查看导入的新包。有很多,所以让我们在这里花一些时间。接下来,我们看看如何组合制作一个按钮。operations
widgets
最后,我们谈到 Material Design,这是 Gio 中也可用的用户界面框架。
为了使事情整洁,让我们先讨论导入,然后再讨论主要功能。
import,即 import
import (
"os"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
评论
os
,我们以前知道,但其余的都是新的:app
unit
-
font/gofont - 你知道 Go 有自己专用的高质量 True Type 字体吗?阅读引人入胜的博客,并绝对访问其创建者 Bigelow & Holmes。真正的老派。
-
io/system - 提供从窗口发送的高级事件。最重要的是请求新帧。新帧通过操作列表定义。这些操作详细说明了要显示的内容以及如何处理输入。什么和如何。就是这样。
system.FrameEvent
-
布局 - 定义布局的有用部分,例如尺寸、约束和方向。此外,它还包括称为 Flexbox 的布局概念。它广泛用于 Web 和用户界面开发。在众多介绍中,我推荐 Mozilla 的介绍。
-
op - 操作或操作是 Gio 的核心。它们用于更新用户界面。有一些操作用于绘制、处理输入、更改窗口属性、缩放、旋转等。有趣的是,还有宏,可以记录稍后要执行的操作。综上所述,这意味着操作列表是一个可变堆栈,您可以在其中控制流。
-
小组件 - 小组件提供 UI 组件的基础功能,例如状态跟踪和事件处理。鼠标是否悬停在按钮上?它是否被点击了,如果是,点击了多少次?
-
小部件/材料 - 在提供功能的同时,定义一个主题。请注意,界面实际上分为两部分:
widget
widget/material
- 实际的小部件,具有状态
- 绘制完全无状态的小部件
-
这是为了提高小部件的可重用性和灵活性。我们稍后会使用它。
-
默认值看起来不错,并且是我们将使用的,但是通过设置颜色,文本大小字体属性等属性来调整它同样容易。
主要-main
随着导入的顺利进行,让我们看一下代码。它更长,但仍然很容易。
func main() {
go func() {
// create new window
w := app.NewWindow(
app.Title("Egg timer"),
app.Size(unit.Dp(400), unit.Dp(600)),
)
// ops are the operations from the UI
var ops op.Ops
// startButton is a clickable widget
var startButton widget.Clickable
// th defines the material design style
th := material.NewTheme(gofont.Collection())
// listen for events in the window.
for e := range w.Events() {
// detect what type of event
switch e := e.(type) {
// this is sent when the application should re-render.
case system.FrameEvent:
gtx := layout.NewContext(&ops, e)
//按钮材质定义-按钮主题-按钮类型-按扭文字
btn := material.Button(th, &startButton, "Start")
//按钮布局
btn.Layout(gtx)
e.Frame(gtx.Ops)
}
}
os.Exit(0)
}()
app.Main()
}
评论
-
从顶部,我们识别 main 函数开始定义和调用匿名函数。
-
我们继续定义窗口
w
-
设置了三个新变量
ops
从用户界面定义操作startButton
是我们的按钮,一个可点击的小部件。th
是材质主题,并将字体设置为 gofonts
-
循环更有趣:
for e:= range w.Events()
w.Events()
为我们提供传递事件的渠道。我们只是永远听这个频道。
-
然后。。。这是什么东西。它实际上是一个整洁的东西,称为类型断言,它允许我们根据正在处理的事件采取不同的操作。
e:= e.(type)type
-
在我们的例子中,对事件
system.FrameEvent
-
我们定义一个新的图形上下文,它接收指向以及事件的指针
gtx ops
-
btn
声明为实际按钮,带有主题 ,以及指向小部件的指针。我们还定义了显示的文本(请注意,文本纯粹是按钮上显示的内容,而不是按钮实际有状态小部件的一部分。th start Button
-
现在看这里。要求按钮将自己布置在上下文中。这是关键。布局不布局按钮,按钮自行布局。这非常方便。例如,尝试调整窗口大小。没有压力,按钮只是再次摆出,无论画布的大小或形状如何。
btngtx
- 请注意我们如何免费获得所有鼠标悬停和点击动画。它们都是主题的一部分。那真是太好了!
-
我们通过实际将操作从上下文发送到 FrameEvent 来完成。
ops gtxe
-
-
最后我们打电话给.别忘了。
app.Main()
呼,好长。谢谢,如果你还在。我们可以用三行来概括整章:
gtx := layout.NewContext(&ops, e)
b := material.Button(th, &startButton, "Start")
b.Layout(gtx)
如果你对这些感到满意,你就很好。
类型断言解释:
判断开始定义的 a 的类型,他的类型定义给 v ,然后在 case 判断和 v 一样的类型执行后面的代码
v 的类型和 A 类型一样,执行 v.num
v 的类型和 B 类型一样,执行 v.str
由于开始定义的是 a 的类型是 A,值为 10,所以执行第一行
第 4 章 - 底部按钮
目标
显然,该按钮无法填满屏幕。因此,让我们将按钮移动到底部。为此,我们开始使用称为 Flexbox 的布局概念。
大纲
最后一章是关于程序的整体结构。现在我们放大并开始使用弹性框。如果它对你来说是新的,请先阅读它,例如来自 Mozilla 的这个。system.FrameEvent
总体结构
我们不会在这里重复整个程序,而是放大:system.FrameEvent
我们首先删除了很多细节,以便更好地查看结构:
case system.FrameEvent:
layout.Flex{
// ...
}.Layout( // ...
// We insert two rigid elements:
// First one to hold a button ...
layout.Rigid(),
// .. then one to hold an empty spacer
layout.Rigid(),
}
评论
让我们检查一下此代码的结构。
- 首先,我们定义一个通过结构
Flexboxlayout.Flex{ }
- 然后我们向它发送一份要通过 .图形上下文 gtx 包含孩子们必须遵守的约束,任何数量的孩子都可以遵循。
Layout(gtx, ...)
我们列出的子项都是由以下人员创建的: a.第一个是按钮 b 的占位符。然后另一个是占位符,用于包含按钮下方的空白区域。layout.Rigid( )
你问什么是刚性 Rigid?很简单 - 它的工作是填写它给出的空间。Rigid 的孩子首先被布置,而弯曲 flex 则分享剩下的东西。除此之外,子项按定义的顺序定位。
约束和尺寸
在这一点上,它很好地帮助我们退后一步,看看将所有这些联系在一起的概念,即约束和维度。
父项设置约束,子项使用维度进行响应。父级创建一个 Widget 并调用 ,并且 widget 用它自己的维度进行响应,有效地布局自己。就像在现实世界中一样,并非所有孩子都乖巧,正如任何孩子都可以证明的那样,来自妈妈或爸爸的一些限制可能会感到不公平——所以需要一些细微差别和谈判。但在大多数情况下,就是这样。约束和维度将它们绑定在一起。Layout()
正如我们在上面看到的,布局操作是递归的。孩子本身可以有孩子。布局本身可以包含布局。这种情况还在继续,您可以从简单的组件构建复杂的结构。一路向下。
详细代码
好的,这是高级别。现在是时候深入研究了。让我们详细看一下整个:system.FrameEvent
case system.FrameEvent:
gtx := layout.NewContext(&ops, e)
// Let's try out the flexbox layout concept:
layout.Flex{
// Vertical alignment, from top to bottom
Axis: layout.Vertical,
// Empty space is left at the start, i.e. at the top
Spacing: layout.SpaceStart,
}.Layout(gtx,
// We insert two rigid elements:
// First a button ...
layout.Rigid(
func(gtx layout.Context) layout.Dimensions {
btn := material.Button(th, &startButton, "Start")
return btn.Layout(gtx)
},
),
// ... then an empty spacer
layout.Rigid(
// The height of the spacer is 25 Device independent pixels
layout.Spacer{Height: unit.Dp(25)}.Layout,
),
)
e.Frame(gtx.Ops)
或者
layoutflex := layout.Flex{
// Vertical alignment, from top to bottom
Axis: layout.Vertical,
// Empty space is left at the start, i.e. at the top
Spacing: layout.SpaceStart,
}
layoutflex.Layout(gtx,
// We insert two rigid elements:
// First a button ...
layout.Rigid(
func(gtx layout.Context) layout.Dimensions {
btn := material.Button(th, &startButton, "Start")
return btn.Layout(gtx)
},
),
// ... then an empty spacer
layout.Rigid(
// The height of the spacer is 25 Device independent pixels
layout.Spacer{Height: unit.Dp(25)}.Layout,
),
)
e.Frame(gtx.Ops)
评论
在内部,我们定义了两个特征:layout.Flex{ }
- Axis:轴:垂直对齐意味着东西将被放置在彼此下方或下方。
- Spacing:间距:剩余空间将位于开头。
由于排序起着重要作用,因此您可能会认为小部件从屏幕底部弹出。按钮首先到达,然后从下方进入并将按钮向上推一个档次。所有的比喻都是错误的,有些是有用的
现在让我们来看看对 layout.Rigid( )
:
-
刚性接受 a 小部件
-
小部件只是返回它自己的东西尺寸
-
怎么这是真的没关系。这里有两种非常不同的方法:
a .在第一个刚性中,我们传入一个
func( )
从btn.Layout()
那回来了尺寸a.在第二个刚体中,我们创建了一个
Spacer{ }
结构,调用它的Layout
方法,这反过来给我们尺寸 -
从父的角度来看,这并不重要。只要孩子回来尺寸我们很好。
这将负责小部件的布局。但是真正的小部件是什么呢?
- 顾名思义,
material.Button
是一个按钮基于材料设计,正如我们在上一章中详述的。 - Spacer 添加空白空间,此处定义为高度。因为我们已经定义了整体布局是垂直的,多余的空间应该在顶部,所以它会落在底部,按钮会落在顶部。因此创造了一些空间,将按钮从屏幕底部抬起一点。方便的东西。
干得好我们已经走了很长一段路,走过了很多地方。干得好,谢谢你坚持下去。让我们继续前进,认真查看代码库。
第 5 章-重构底部按钮
目标
本节的目的是更好地组织代码。
概述
到目前为止,我们已经通过一点一点地增加功能来构建程序。这很好地服务了我们,允许我们从一个空的画布开始,通过改变最少的行数进行迭代,同时仍然取得有意义的进展。
然而,展望未来,它开始看起来有点笨拙。将所有代码放在一个大的 main()
这会使理解变得更加困难,继续构建也更加困难。因此,我们将对程序进行一点重构,简单地将它分成更小的部分。
重构是以一种安全而快速的方式转换代码,这对于保持代码廉价且易于修改以满足未来需求至关重要。 马丁·福勒
换句话说,不会增加新的功能,但是我们会为将来更好的东西扫清道路。
第一 main()
太长了
Main 太长,做的太多。最好是 main()
启动和控制程序,但除此之外,还委托其他人。这是新的:
func main() {
go func() {
// create new window
w := app.NewWindow(
app.Title("Egg timer"),
app.Size(unit.Dp(400), unit.Dp(600)),
)
if err := draw(w); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
现在,进去 main()
我们创造了一个窗口 w
像以前一样,并立即将其移交给专门的职能部门 draw()
.
通过存储的结果 draw()
在 err
,我们可以检查执行是否顺利,并且可以有序地处理任何错误。
为此我们使用 os。退出()而且是近亲日志。致命(错误)。两者都来自标准库,并作为导入包含在内。
如前所述,惯例是零退出代码表示成功,这是我们从 os.Exit(0)
如果犯罪为零。如果没有,我们打电话 log.Fatal(err)
它打印错误消息 en exits,并带有 os.Exit(1)
.
第二个-约束和尺寸:一个方便的快捷方式
我们详细讨论了限制和规模之前。因为我们经常使用它们,所以定义两个快捷方式很方便,C
和 D
。约束是上下文的一部分。
type C = layout.Context
type D = layout.Dimensions
第三名 draw( )
功能
的简化版本 draw( )
显示了结构。
func draw(w *app.Window) error {
// ...
// listen for events in the window.
for e := range w.Events() {
// detect what type of event
switch e := e.(type) {
// this is sent when the application should re-render.
case system.FrameEvent:
// ...
// this is sent when the application is closed.
case system.DestroyEvent:
return e.Err
}
}
return nil
}
像以前一样,我们穿过 w.Events()
,检测它们的类型。
system.FrameEvent
像以前一样被处理,- 我们增加了一个新的案例
system.DestroyEvent
,它返回无对于正常的窗户关闭,但是犯错如果是其他原因。
评论
重构是一个品味问题,这是我对它的看法。如果你有不同的需求,做适合你的应用的事情。要点是让您的应用程序足够灵活,以支持持续的改进和未来的需求。祝你好运。
第 6 章-带边距的底部按钮
目标
本章的目的是在按钮的四周增加开放空间。
概述
在上一节看了重构时的整个代码后,这次我们只放大发生变化的行。同样,行动是在内心发生的 layout.Flex
代码-整体结构
为了突出结构,去掉一些细节可能是有用的,这里实际上只有三条关键线:
- 定义边距使用
layout.Inset
- 布置好页边距
- 在这些页边距内创建按钮
layout.Flex{
// ...
}.Layout(gtx,
layout.Rigid(
func(gtx C) D {
// ONE: First define margins around the button using layout.Inset ...
margins := layout.Inset{
// ...
}
// TWO: ... then we lay out those margins ...
margins.Layout(
// THREE: ... and finally within the margins, we define and lay out the button
func(gtx C) D {
btn := material.Button(th, &startButton, "Start")
return btn.Layout(gtx)
},
)
}
}
)
)
评论
上面就像一个甜甜圈,中间有一个按钮。有些隐喻是有用的,记得吗?
页边距是使用 layout.Inset{}。它是一个结构,定义了小部件周围的空间:
margins := layout.Inset{
Top: unit.Dp(25),
Bottom: unit.Dp(25),
Right: unit.Dp(35),
Left: unit.Dp(35),
}
在这里,边距被给定为独立于设备像素,unit.Dp。如果你想要所有的边都一样,也有一个方便的 UniformInset( )
,为您节省了几个按键。
代码-详细信息
总结一下,下面是整体的代码 system.FrameEvent
case system.FrameEvent:
gtx := layout.NewContext(&ops, e)
// Let's try out the flexbox layout concept
layout.Flex{
// Vertical alignment, from top to bottom
Axis: layout.Vertical,
// Empty space is left at the start, i.e. at the top
Spacing: layout.SpaceStart,
}.Layout(gtx,
layout.Rigid(
func(gtx C) D {
// ONE: First define margins around the button using layout.Inset ...
margins := layout.Inset{
Top: unit.Dp(25),
Bottom: unit.Dp(25),
Right: unit.Dp(35),
Left: unit.Dp(35),
}
// TWO: ... then we lay out those margins ...
return margins.Layout(gtx,
// THREE: ... and finally within the margins, we define and lay out the button
func(gtx C) D {
btn := material.Button(th, &startButton, "Start")
return btn.Layout(gtx)
},
)
},
),
)
e.Frame(gtx.Ops)
第 7 章-进度条
目标
本节的目的是添加一个 progressbar
概述
自从我开始写这个系列以来,我一直期待着这一章。我们将涉及相当多的领域,并引入多种新想法:
-
尝试一个新的小部件
material.Progressbar
-
开始使用状态变量来控制行为
-
使用两种并发技术;
一个用于创建和共享使进度条前进的跳动脉冲,
一个用于在独立的通信操作中进行选择
让我们依次看看这些作品。
功能 1 -进度条
progressbar 显然是一个显示进度的栏。但是哪个进步呢?又该如何控制?它应该以多快的速度增长,可以暂停,甚至逆转吗?从 docs 我们发现 ProgressBar(th *Theme, progress float32)
以 0 到 1 之间的小数形式接收进度。
我们在根级别声明进度,在 main 之外,这样它只需设置一次,我们就可以在整个程序中访问它:
// root level, outside main ()
var progress float32
为了展示 progressbar,我们转向 rigid 的 Flexbox,将其插入一个 rigid:
// Inside System.FrameEvent
layout.Flex{
// ...
}.Layout(gtx,
layout.Rigid(
func(gtx C) D {
bar := material.ProgressBar(th, progress) // Here progress is used
return bar.Layout(gtx)
},
),
注意小部件本身没有状态。状态是在程序的其余部分维护的,小部件只知道如何显示我们发送给它的进度。任何逻辑增加,暂停,逆转或重置我们控制的小工具之外。
特征 2 -状态变量
我们提到过 progress
,一个包含状态的变量。另一个有用的跟踪状态是开始按钮是否被点击。在我们的应用程序中,这意味着跟踪鸡蛋是否已经开始沸腾。
// is the egg boiling?
var boiling bool
我们希望在单击开始按钮时翻转布尔值。因此,我们倾听 system.FrameEvent
并检查是否 startButton.Clicked()
是真的。
case system.FrameEvent:
gtx := layout.NewContext(&ops, e)
// Let's try out the flexbox layout concept
if startButton.Clicked() {
boiling = !boiling
}
同样,按钮的唯一工作是在它被点击时喊出声。除此之外,程序的其余部分负责任何需要采取的行动。
一个例子是按钮上的文本应该是什么。我们决定在调用 material.Button( )
函数,首先检查 boiling
是。
// ...the same function we earlier used to create a button
func(gtx C) D {
var text string
if !boiling {
text = "Start"
} else {
text = "Stop"
}
btn := material.Button(th, &startButton, text)
return btn.Layout(gtx)
},
特征 3 -跳动的脉搏
一个好的进度条必须平滑精确地生长。为了实现这一点,我们首先创建一个单独的 go-routine,以稳定的脉冲跳动。后来,我们听事件,我们拿起这些节拍和增长进度条。
下面是代码,首先是 tick 生成器:
// Define the progress variables, a channel and a variable
var progressIncrementer chan float32
var progress float32
func main() {
// Setup a separate channel to provide ticks to increment progress
progressIncrementer = make(chan float32)
go func() {
for {
time.Sleep(time.Second / 25)
progressIncrementer <- 0.004
}
}()
// ...
progressIncrementer
是 channel 在这种类型的情况下,我们向其中发送值 float32
.
同样,这是在一个匿名函数中完成的,这个函数在创建时被调用,这意味着这个 for 循环在整个程序中旋转。每隔 1/25 秒,数字 0.004 被注入通道。
后来我们从频道上看到,里面有这段代码 draw(w *app.window)
:
// .. inside draw()
for {
select {
// listen for events in the window.
case e := <-w.Events():
// ...
// listen for events in the incrementor channel
case p := <-progressIncrementer:
if boiling && progress < 1 {
progress += p
w.Invalidate()
}
}
}
在前面的章节中,我们使用 for e := range w.Events()
。在这里,我们使用一个带有 select 在里面。这是 go 的并发特性,其中 select
耐心地等待一个事件 case
语句可以运行。
- 事件可以来自窗口,如果是这样,我们使用
e := <- w.Events()
. - 或者,事件来自于进展脉冲,我们从
p := <- progressIncrementer
我们添加了 p
到 progress
如果控制变量 boiling
为真,进度小于 1。因为 p
是 0.004,每秒钟进步 25 次,达到 1 需要 10 秒钟。请随意调整这两个中的任何一个,找到适合您的速度和流畅度的组合。
最后,我们通过调用 w.Invalidate()
。它的作用是通知 Gio,旧的渲染现在是无效的,因此必须重新绘制。如果没有这样的通知,Gio 就不会更新,除非通过鼠标点击或按钮按下或其他事件来强制更新。在无效每个尽管框架成本很高,但替代方案是存在的。这是一个有点高深的话题,所以现在让我们保持原样,但是在关于改进动画的奖励章节.
通过使用这样的通道,我们可以得到
- 精确计时,我们完全按照自己的意愿控制执行
- 一致的时序,在快速和慢速硬件中相似
- 并发计时,应用程序的其余部分像以前一样继续
虽然所有这些都有道理,但第二点值得特别注意。如果您在没有 time.Sleep(time.Second / 25)
,你的机器会努力尽可能快的渲染。这会消耗大量的 cpu 资源,反过来也会耗尽电池。它还能确保所有设备的一致性,所有设备都以相同的脉冲运行。例如,来自 3 台不同机器的 pprof 包含在代码文件夹中。这些包括 1/25 的睡眠,确保相同的最终结果。请看一看。
更新
7 月 28 日,埃利亚斯·诺尔宣布加速动画的更新:
GPU:[计算]缓存并重用前一帧的绘制操作。这种改变实现了自动分层方案,使得只有帧的改变部分需要通过计算机程序。没有这种优化,CPU 回退将是不切实际的。
上也有更详细的解释 2020 年 7 月社区电话.
评论
通过组合所有这些构件,我们现在有了一个可以轻松控制的有状态程序。用户界面告诉我们什么时候发生了什么,程序的其余部分用它来处理事务。我们不得不从袋子里拿出一些小把戏,包括 channel
和一个 select
。现在我们已经有了这些工具,我们将在下一章中添加一些自定义图形。
完整代码
package main
import (
"log"
"os"
"time"
"gioui.org/app"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
// Define the progress variables, a channel and a variable
var progressIncrementer chan float32
var progress float32
func main() {
// Setup a separate channel to provide ticks to increment progress
//起一个协程,每隔1/25秒向chan写入数据0.004
progressIncrementer = make(chan float32)
go func() {
for {
time.Sleep(time.Second / 25)
progressIncrementer <- 0.004
}
}()
//窗口协程
go func() {
// create new window
w := app.NewWindow(
app.Title("Egg timer"),
app.Size(unit.Dp(400), unit.Dp(600)),
)
if err := draw(w); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
type C = layout.Context
type D = layout.Dimensions
func draw(w *app.Window) error {
// ops are the operations from the UI
var ops op.Ops
// startButton is a clickable widget
var startButton widget.Clickable
// is the egg boiling?
var boiling bool
// th defines the material design style
th := material.NewTheme()
for {
select {
// listen for events in the window.
//窗口协程
case e := <-w.Events():
// detect what type of event
switch e := e.(type) {
// this is sent when the application should re-render.
case system.FrameEvent:
gtx := layout.NewContext(&ops, e)
// Let's try out the flexbox layout concept
if startButton.Clicked() {
boiling = !boiling
}
layout.Flex{
// Vertical alignment, from top to bottom
Axis: layout.Vertical,
// Empty space is left at the start, i.e. at the top
Spacing: layout.SpaceStart,
}.Layout(gtx,
layout.Rigid(
func(gtx C) D {
bar := material.ProgressBar(th, progress)
return bar.Layout(gtx)
},
),
layout.Rigid(
func(gtx C) D {
// We start by defining a set of margins
margins := layout.Inset{
Top: unit.Dp(25),
Bottom: unit.Dp(25),
Right: unit.Dp(35),
Left: unit.Dp(35),
}
// Then we lay out within those margins ...
return margins.Layout(gtx,
// ...the same function we earlier used to create a button
func(gtx C) D {
var text string
if !boiling {
text = "Start"
} else {
text = "Stop"
}
btn := material.Button(th, &startButton, text)
return btn.Layout(gtx)
},
)
},
),
)
e.Frame(gtx.Ops)
// this is sent when the application is closed.
case system.DestroyEvent:
return e.Err
}
// listen for events in the incrementor channel
//进度条协程
case p := <-progressIncrementer:
if boiling && progress < 1 {
//进度条加0.004,直到1
progress += p
w.Invalidate()
}
}
}
}
for循环套两个case,通过golang特有的select监听
一个是窗口的case协程
一个是进度条的case协程
两个协程同时执行,一边绘制界面,一边接受进度条
select 语句的语法:
select 是 Go 中的一个控制结构,类似于用于通信的 switch 语句。每个 case 必须是一个通信操作,要么是发送要么是接收。 select 随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。一个默认的子句应该总是可运行的。
select {
case communication clause :
statement(s);
case communication clause :
statement(s);
/* 你可以定义任意数量的 case */
default : /* 可选 */
statement(s);
}
每个case都必须是一个通信
所有channel表达式都会被求值
所有被发送的表达式都会被求值
如果任意某个通信可以进行,它就执行;其他被忽略。
如果有多个case都可以运行,Select会随机公平地选出一个执行。其他不会执行。
否则:
如果有default子句,则执行该语句。
如果没有default字句,select将阻塞,直到某个通信可以运行;Go不会重新对channel或值进行求值。
第 8 章-自定义图形-圆形
目标
这一部分的目的是绘制类似鸡蛋的自定义图形
概述
代码引入了自定义图形。应用程序中的圆圈是由 Gio 绘制的,而不是显示静态图片。尽管我们联合起来
- A 夹子来定义我们可以在其中画画的区域
- A 颜料填充该区域的操作
- 设置颜色的一些参数
引用包
有一些新的包,即
-
image 和 image/color,Go 的标准 2D 图像库。
- 陶奈杰尔写得好关于这些包裹的信息。
-
f32。Go 的图像库是基于
int
,而 Gio 的某些功能与float32
。因此 f32 重新实现了两种主要类型的浮点版本,Points
和Rectangles
. -
op/clip 用于定义要在其中绘画的区域。该区域之外的绘图将被忽略。
-
op/paint 包含用颜色填充形状的绘图操作。
点和矩形
点和矩形被广泛使用,所以值得引用上面提到的 Nigel 的博客。点是坐标,矩形由点定义:
type Point struct {
X, Y float32
}
type Rectangle struct {
Min, Max Point
}
一个点是一个 X,Y 坐标对。轴向右下增加(原点=左上角)。它既不是像素,也不是网格正方形。一个点没有固有的宽度、高度或颜色,但是下面的可视化使用了一个小的彩色正方形。
p := image.Point{2, 1}
矩形包含具有最小值的点。X <= X < Max。x,最小值。Y <= Y < Max.Y。它没有固有的颜色,但下面的可视化用一条彩色细线勾勒出它的轮廓,并标出它们的最小值和最大值点。
r := image.Rect(2, 1, 5, 5)
为了方便,形象。Rect(x0,y0,x1,y1)相当于 Rectangle{Point{x0, y0}, Point{x1, y1}}
,但是更容易输入。它还交换最小值和最大值,以确保格式良好。
就是这样。让我们看看代码:
layout.Rigid(
func(gtx C) D {
circle := clip.Ellipse{
// Hard coding the x coordinate. Try resizing the window
Min: image.Pt(80, 0),
Max: image.Pt(320, 240),
// Soft coding the x coordinate. Try resizing the window
//Min: image.Pt(gtx.Constraints.Max.X/2-120, 0),
//Max: image.Pt(gtx.Constraints.Max.X/2+120, 240),
}.Op(gtx.Ops)
color := color.NRGBA{R: 200, A: 255}
paint.FillShape(gtx.Ops, color, circle)
d := image.Point{Y: 400}
return layout.Dimensions{Size: d}
},
),
评论
我们首先使用定义一个圆 clip.Ellipse{ }
。它将圆定义为 Ellipse
在一个框内,其中框的尺寸由左上角和右下角指定。Min
和 Max
分别是。
在代码中,圆圈是硬编码的,但是尝试调整窗口的大小,您会发现这不一定是您想要的。要进行调整,只需注释硬编码的坐标,并取消注释引入动态定位的下两行。您可以尝试这些尺寸,熟悉圆何时上下移动,这取决于您是调整窗口大小还是围绕椭圆移动限制框。
抓住你了:如果您足够幸运地使用高 DPI 显示器,并且碰巧以 125% 的缩放因子运行它,那么硬编码坐标的另一个问题就会浮出水面。Gio 与 Dp
, 独立显示像素,这确保了 1 Dp 在不同的显示器和分辨率下具有相同的外观尺寸。当像这里这样硬编码时,这种动态被否决了。125% 的分辨率比例将转换 400 Dp 宽窗口(定义见 app.NewWindow
)变成了 500 像素。你可以通过观察看到这一点 gtx.Constraints
并将像素转换为 Dp,通过 gtx.Dp()
.
color.NRGBA
定义圆的颜色。请注意,Alpha 通道默认为 0,即不可见,所以我们将其提升到 255,这样我们就可以实际看到它。
paint.FillShape
用填充形状 color
.
最后,我们返回他的 Dimensions
,高度为 400。
第 9 章-画鸡蛋
目标
这部分的目的是画一个真正的蛋。
概述
在这里,我们利用基本的 Gio 功能来绘制完全自定义的鸡蛋形状的图形。
代码
所有新代码都在先前显示的 Rigid 圆圈内。
layout.Rigid(
func(gtx C) D {
// Draw a custom path, shaped like an egg
var eggPath clip.Path
op.Offset(image.Pt(gtx.Dp(200), gtx.Dp(150))).Add(gtx.Ops)
eggPath.Begin(gtx.Ops)
// Rotate from 0 to 360 degrees
for deg := 0.0; deg <= 360; deg++ {
// Egg math (really) at this brilliant site. Thanks!
// https://observablehq.com/@toja/egg-curve
// Convert degrees to radians
rad := deg / 360 * 2 * math.Pi
// Trig gives the distance in X and Y direction
cosT := math.Cos(rad)
sinT := math.Sin(rad)
// Constants to define the eggshape
a := 110.0
b := 150.0
d := 20.0
// The x/y coordinates
x := a * cosT
y := -(math.Sqrt(b*b-d*d*cosT*cosT) + d*sinT) * sinT
// Finally the point on the outline
p := f32.Pt(float32(x), float32(y))
// Draw the line to this point
eggPath.LineTo(p)
}
// Close the path
eggPath.Close()
// Get hold of the actual clip
eggArea := clip.Outline{Path: eggPath.End()}.Op()
// Fill the shape
// color := color.NRGBA{R: 255, G: 239, B: 174, A: 255}
color := color.NRGBA{R: 255, G: uint8(239 * (1 - progress)), B: uint8(174 * (1 - progress)), A: 255}
paint.FillShape(gtx.Ops, color, eggArea)
d := image.Point{Y: 375}
return layout.Dimensions{Size: d}
},
),
主要思想是定义一个自定义的蛋形 clip.Path
。我们画一条线来定义它,填充里面,任何在外面的画都被忽略。
评论
首先定义新路径,var eggPath clip.Path
然后创建一个操作,向右移动 200 点,向下移动 150 点,op.Offset( )
。和以前一样,这是来自这个小部件的左上角。请注意,我们不发送硬像素,而是转换为 Dp,设备无关像素,以确保用户体验在不同设备和分辨率之间具有可比性。
我们现在在蛋的中心。这是道路的起点,eggPath.Begin( )
从这里我们旋转 360 度,继续绘制鸡蛋的轮廓。我们用数学来表示许格尔施费尔鸡蛋,如托本·詹森的优秀的互动博客。该公式接收一个从 0° 到 360° 的角度,计算距中心的适当距离,并将轮廓作为一个点返回。数学很有趣!
关于 Gio,重要的一行是 for 循环的最后一行,eggPath.LineTo(p)
。至此,数学找到了下一个点 p
绕着鸡蛋 360 度旋转,我们用 eggPath.LineTo
将笔移动到这个特定的坐标点。
完成 for 循环后,蛋形就差不多完成了。我们通过调用 eggPath.Close()
关闭了路径。
路径完成后,我们希望得到路径内的区域。clip.Outline{ }.Op( )
给了我们代表这个区域的剪辑操作。
现在我们用颜色填充鸡蛋。上色可以是静态的,但是如果鸡蛋从冷变暖颜色岂不是很酷?我也这么认为。记住进步是一个从 0 到 1 的变量。这个状态变量现在也可以用来慢慢改变颜色。* (1 - progress)
只是另一种说法请逐渐关闭绿色和蓝色。当进度完成时,两者都是 0,我们只剩下红色。漂亮。
我们以返回结束 layout.Dimensions
,此小部件的高度。
第 10 章-设定煮沸时间
目标
本节的目的是添加一个输入字段来设置沸腾时间
概述
代码在几个方面发生了变化
- 导入 gioui.org/textGio 的,以及标准库中的字符串和数字操作。
- 添加第四个刚体以保持 widget.Editor()
- 为按钮添加一些逻辑,使其行为更好一些。
就是这样。让我们看看代码:
1.新引用包
import (
"fmt"
"strconv"
"strings"
"gioui.org/text"
)
标准库是相当划分的,用于字符串和数字操作的有用功能在这些包中集合在一起:
fmt
将用于将浮点转换为字符串strconv
将用于将字符串转换为浮点strings
将用于从输入字符串中删除空格
来自 Gio:
- gioui.org/text 提供处理文本的支持类型。大部分是字体支持和缓存,但我们将使用它来对齐。
2.编辑器小部件
编辑器小部件是输入字段,厨师可以在其中输入鸡蛋应该煮多长时间。
编辑器的一些变量
就像按钮一样,我们需要一个用于输入字段本身的变量。所以我们首先声明一个 widget.Editor 可变。
我们还创建了一个变量来保存输入字段中的实际数值,并调用它 boilDuration
。请注意,这些变量之间没有神奇的联系,但是我们稍后将编写代码从输入字段中读取数据,并将值存储在 boilDuration
。不过,总有一天会的。
有了这些,我们现在在我们的顶部 draw()
函数查找以下行:
// boilDurationInput is a textfield to input boil duration
var boilDurationInput widget.Editor
// is the egg boiling?
var boiling bool
var boilDuration float32
3.从输入箱中读取
我们真正需要检查 inputbox 中写了什么的唯一时间是当用户单击 start 按钮的时候。因此我们把逻辑放在里面 if{ }
阻止。
if startButton.Clicked() {
//...
// Read from the input box
inputString := boilDurationInput.Text()
inputString = strings.TrimSpace(inputString)
inputFloat, _ := strconv.ParseFloat(inputString, 32)
boilDuration = float32(inputFloat)
boilDuration = boilDuration / (1 - progress)
}
第一行是不言自明的:
-
boilDurationInput.Text()
返回输入框中的文本字符串 -
strings.TrimSpace()
删除前导和滞后空格字符(如果有) -
strconv.ParseFloat()
将文本转换为浮点型。请注意第二个参数 bitsize,它是 32。从标准库文档:- ParseFloat 将字符串 s 转换为精度由 bitSize 指定的浮点数:float32 为 32,float64 为 64。当 bitSize=32 时,结果的类型仍然是 float64,但是它可以转换为 float32,而不改变其值。
-
啊哈。我们需要显式转换为
float32()
最后,一个链接 progress
和 boilDuration
。例如,如果煮到 20%,用户输入一个 10 秒的新时间,可以假设用户想要更多的 10 秒,而不是 8 秒。所以我们把它放大到 12.5 倍,除以 (1-progress)
.
还有其他解决方案,比如重新调整进度条,但是调整 progress
状态变量。为了简单起见,我们在这里跳过这一步,但是要注意在你的应用程序中状态变量可能是如何逻辑相关的。
4.把一切都摆出来
现在让我们向世界展示我们的新功能。在 flexbox 内部,我们为输入框创建一个单独的刚体。因为它在蛋的下面和进度条的上面,所以它是四个中的第二个:
layout.Flex{
// Vertical alignment, from top to bottom
Axis: layout.Vertical,
// Empty space is left at the start, i.e. at the top
Spacing: layout.SpaceStart,
}.Layout(gtx,
// 1. The egg
layout.Rigid(
//...
)
// 2. The inputbox
layout.Rigid(
// Add new code for displaying the inputbox here
)
// 3. The progressbar
layout.Rigid(
//...
)
// 4. The button
layout.Rigid(
//...
)
)
5.输入箱的详细信息
现在我们已经有了概述,让我们详细检查第二个刚性:
带主题的编辑器
我们从包装 boilDurationInput
材料设计主题中的变量。我们借此机会补充一点暗示
// The inputbox
layout.Rigid(
func(gtx C) D {
// Wrap the editor in material design
ed := material.Editor(th, &boilDurationInput, "sec")
定义特征
此时,该 boilDurationInput
仍然只是一个空字段,因此我们将进行一些配置:
// Define characteristics of the input box
boilDurationInput.SingleLine = true
boilDurationInput.Alignment = text.Middle
倒数计秒
接下来,因为查看剩余时间很有用,所以我们在 inputbox 中倒计时:
if boiling && progress < 1 {
boilRemain := (1 - progress) * boilDuration
// Format to 1 decimal.
inputStr := fmt.Sprintf("%.1f", math.Round(float64(boilRemain)*10)/10)
// Update the text in the inputbox
boilDurationInput.SetText(inputStr)
}
当我们处于沸腾状态时,我们在这里定义一个新的 boilRemain
它保存煮沸完成前的剩余时间,使用 (1-progress
)
因为 math.Round()不允许舍入到给定的小数位数,我们必须使用一个技巧。
- 先乘以 10。
- 然后四舍五入到零小数。
- 然后除以 10。
- 最后转换成带 1 个小数的文本。它有点紧凑,但希望是直截了当的
最后,又是一些 Gio。我们打电话设置文本它用我们的替换框中的文本 inputStr
布局
至此,输入框完成。然后我们开始布局:
// Define insets ...
margins := layout.Inset{
Top: unit.Dp(0),
Right: unit.Dp(170),
Bottom: unit.Dp(40),
Left: unit.Dp(170),
}
// ... and borders ...
border := widget.Border{
Color: color.NRGBA{R: 204, G: 204, B: 204, A: 255},
CornerRadius: unit.Dp(3),
Width: unit.Dp(2),
}
// ... before laying it out, one inside the other
return margins.Layout(gtx,
func(gtx C) D {
return border.Layout(gtx, ed.Layout)
},
)
},
),
- 定义边距。注意左边和右边都很大,这就是我们如何保持盒子很小的原因。
- 用自定义颜色和圆角定义边框。
- 组合 1+2 并返回维度。
你看现在最后的布局怎么像个俄罗斯娃娃?边距包含边框,边框包含编辑器,都返回自己的 layout.Dimensions
:
6.进度条
最后,如果前一次煮沸已经完成,则进度指示器被重置。这使得我们可以连续煮很多鸡蛋。整洁!
为了向用户展示这一点,我们扩展代码来绘制按钮:
func(gtx C) D {
var text string
if !boiling {
text = "Start"
}
if boiling && progress < 1 {
text = "Stop"
}
if boiling && progress >= 1 {
text = "Finished"
}
btn := material.Button(th, &startButton, text)
return btn.Layout(gtx)
},
- “开始”如果不沸腾
- “停止”如果沸腾但没有完成
- 如果煮沸已经完成,则为“完成”
更多的铃铛和口哨可以添加在这里。比如说,我可以向你挑战设定一个习惯吗背景颜色煮好了吗?
最终意见
仅此而已。感谢您的到来,我希望您已经尝试过 GUI 开发。
我们只是触及了表面,框架中还有比我们在这里展示的更多的功能。但是,现在我们一起煮了鸡蛋,我们也走了很远,对吗?现在,我能问你点事吗?
如果你喜欢你读过的内容,请在 Github 上发表。我只是一个普通人,说实话,收到这些感谢的信物让我激动不已。
和如果当你开始你自己的项目时,无论大小,请给我写信。我很想收到你的来信。
此外,请确保在网站、时事通讯和社区电话中关注 Gio。
现在,终于到了吃早餐的时间了。猜猜我在吃什么。
但是等等,还有更多。在完成这十章之后,我得到了一些你可能会感兴趣的额外特性。准备好了吗?
额外说明-动画
目标
本节的目的是讨论一个与动画相关的稍微高级一点的主题,即我们如何以及何时使一个帧无效,这实际上意味着什么,以及如何用它很好地编码。
概述
这一章的概要如下:
- 首先我们讨论使一个帧无效意味着什么
- 然后我们看两个不同的方法调用来实现
- 最后我们讨论另一种生成和控制动画的模式
1.什么是无效?
Gio 只更新你看到的框架事件已生成。例如,当按下一个键、单击鼠标、小部件接收或失去焦点时。这很有道理,现代设备的刷新率高达每秒 120 帧,应该经常显示的内容很可能与上一帧相同。
经常如此。但并不总是如此。
这个规则的一个例外是动画。制作动画时,您希望它尽可能平滑地运行。为了实现这一点,我们需要要求 Gio 不断重画。在不触发事件的情况下,我们需要明确地告诉 Gio 这样做。这是通过调用 invalidate
.
2.到使无效的方法
有两种选择,让我们一起来看看:
- op.InvalidateOp{}.Add(ops)是最有效的,可用于请求立即重绘或将来重绘
At time.Time
. - window.Invalidate 效率较低,用于外部触发的事件。但是如果您想在布局代码之外使无效,这也是正确的选择。一个例子是在第 7 章-动画我们有一个独立的滴答发生器。
示例 1 - OP.INVALIDATEOP{}
展示 op.InvaliateOp{}.Add(ops)
我们将引用中写得很好的动画例子建筑文件:
// Source: https://gioui.org/doc/architecture#animation
var startTime = time.Now()
var duration = 10 * time.Second
func drawProgressBar(ops *op.Ops, now time.Time) {
// Calculate how much of the progress bar to draw,
// based on the current time.
elapsed := now.Sub(startTime)
progress := elapsed.Seconds() / duration.Seconds()
if progress < 1 {
// The progress bar hasn’t yet finished animating.
op.InvalidateOp{}.Add(ops)
} else {
progress = 1
}
defer op.Save(ops).Load()
width := 200 * float32(progress)
clip.Rect{Max: image.Pt(int(width), 20)}.Add(ops)
paint.ColorOp{Color: color.NRGBA{R: 0x80, A: 0xFF}}.Add(ops)
paint.ColorOp{Color: color.NRGBA{G: 0x80, A: 0xFF}}.Add(ops)
paint.PaintOp{}.Add(ops)
}
编译为可执行程序
去除大黑框
go build -ldflags "-s -w -H=windowsgui"
-s 省略符号表和调试信息
-w Omit the DWARF symbol table 省略DWARF符号表
-H windowsgui 不打印信息到console (On Windows, -H windowsgui writes a "GUI binary" instead of a "console binary."),就不会有cmd窗口了
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于