简介

Go 语言生态中,GUI 一直是短板,更别说跨平台的 GUI 了。fyne向前迈了一大步。fyne 是 Go 语言编写的跨平台的 UI 库,它可以很方便地移植到手机设备上。fyne使用上非常简单,同时它还提供fyne命令打包静态资源和应用程序。我们先简单介绍基本控件和布局,然后介绍如何发布一个fyne应用程序。

快速使用

本文代码使用 Go Modules。

先初始化:

  1. $ mkdir fyne && cd fyne
  2. $ go mod init github.com/go-quiz/go-daily-lib/fyne

由于fyne包含一些 C/C++ 的代码,所以需要gcc编译工具。在 Linux/Mac OSX 上,gcc基本是标配,在 windows 上我们有 3 种方式安装gcc工具链:

本文选择TDM-GCC的方式安装。到https://jmeubank.github.io/tdm-gcc/download/下载安装程序并安装。正常情况下安装程序会自动设置PATH路径。打开命令行,键入gcc -v。如果正常输出版本信息,说明安装成功且环境变量设置正确。

安装fyne

  1. $ go get -u fyne.io/fyne

到此准备工作已经完成,我们开始编码。按照惯例,先以Hello, World程序开始:

  1. package main
  2. import (
  3. "fyne.io/fyne"
  4. "fyne.io/fyne/app"
  5. "fyne.io/fyne/widget"
  6. )
  7. func main() {
  8. myApp := app.New()
  9. myWin := myApp.NewWindow("Hello")
  10. myWin.SetContent(widget.NewLabel("Hello Fyne!"))
  11. myWin.Resize(fyne.NewSize(200, 200))
  12. myWin.ShowAndRun()
  13. }

运行结果如下:

每日一库之44:fyne - 图1

fyne的使用很简单。每个fyne程序都包括两个部分,一个是应用程序对象myApp,通过app.New()创建。另一个是窗口对象,通过应用程序对象myApp来创建myApp.NewWindow("Hello")myApp.NewWindow()方法中传入的字符串就是窗口标题。

fyne提供了很多常用的组件,通过widget.NewXXX()创建(XXX为组件名)。上面示例中,我们创建了一个Label控件,然后设置到窗口中。最后,调用myWin.ShowAndRun()开始运行程序。实际上myWin.ShowAndRun()等价于

  1. myWin.Show()
  2. myApp.Run()

myWin.Show()显示窗口,myApp.Run()开启事件循环。

注意一点,fyne默认窗口大小是根据内容的宽高来设置的。上面我们调用myWin.Resize()手动设置了大小。否则窗口只能放下字符串Hello Fyne!

fyne包结构划分

fyne将功能划分到多个子包中:

  • fyne.io/fyne:提供所有fyne应用程序代码共用的基础定义,包括数据类型和接口;
  • fyne.io/fyne/app:提供创建应用程序的 API;
  • fyne.io/fyne/canvas:提供Fyne使用的绘制 API;
  • fyne.io/fyne/dialog:提供对话框组件;
  • fyne.io/fyne/layout:提供多种界面布局;
  • fyne.io/fyne/widget:提供多种组件,fyne所有的窗体控件和交互元素都在这个子包中。

Canvas

fyne应用程序中,所有显示元素都是绘制在画布(Canvas)上的。这些元素都是画布对象(CanvasObject)。调用Canvas.SetContent()方法可设置画布内容。Canvas一般和布局(Layout)容器(Container)一起使用。canvas子包中提供了一些基础的画布对象:

  1. package main
  2. import (
  3. "image/color"
  4. "math/rand"
  5. "fyne.io/fyne"
  6. "fyne.io/fyne/app"
  7. "fyne.io/fyne/canvas"
  8. "fyne.io/fyne/layout"
  9. "fyne.io/fyne/theme"
  10. )
  11. func main() {
  12. a := app.New()
  13. w := a.NewWindow("Canvas")
  14. rect := canvas.NewRectangle(color.White)
  15. text := canvas.NewText("Hello Text", color.White)
  16. text.Alignment = fyne.TextAlignTrailing
  17. text.TextStyle = fyne.TextStyle{Italic: true}
  18. line := canvas.NewLine(color.White)
  19. line.StrokeWidth = 5
  20. circle := canvas.NewCircle(color.White)
  21. circle.StrokeColor = color.Gray{0x99}
  22. circle.StrokeWidth = 5
  23. image := canvas.NewImageFromResource(theme.FyneLogo())
  24. image.FillMode = canvas.ImageFillOriginal
  25. raster := canvas.NewRasterWithPixels(
  26. func(_, _, w, h int) color.Color {
  27. return color.RGBA{uint8(rand.Intn(255)),
  28. uint8(rand.Intn(255)),
  29. uint8(rand.Intn(255)), 0xff}
  30. },
  31. )
  32. gradient := canvas.NewHorizontalGradient(color.White, color.Transparent)
  33. container := fyne.NewContainerWithLayout(
  34. layout.NewGridWrapLayout(fyne.NewSize(150, 150)),
  35. rect, text, line, circle, image, raster, gradient))
  36. w.SetContent(container)
  37. w.ShowAndRun()
  38. }

程序运行结果如下:

每日一库之44:fyne - 图2

canvas.Rectangle是最简单的画布对象了,通过canvas.NewRectangle()创建,传入填充颜色。

canvas.Text是显示文本的画布对象,通过canvas.NewText()创建,传入文本字符串和颜色。该对象可设置对齐方式和字体样式。对齐方式通过设置Text对象的Alignment字段值,取值有:

  • TextAlignLeading:左对齐;
  • TextAlignCenter:中间对齐;
  • TextAlignTrailing:右对齐。

字体样式通过设置Text对象的TextStyle字段值,TextStyle是一个结构体:

  1. type TextStyle struct {
  2. Bold bool
  3. Italic bool
  4. Monospace bool
  5. }

对应字段设置为true将显示对应的样式:

  • Bold:粗体;
  • Italic:斜体;
  • Monospace:系统等宽字体。

我们还可以通过设置环境变量FYNE_FONT为一个.ttf文件从而使用外部字体。

canvas.Line是线段,通过canvas.NewLine()创建,传入颜色。可以通过line.StrokeWidth设置线段宽度。默认情况下,线段是从父控件或画布的左上角右下角的。可通过line.Move()line.Resize()修改位置。

canvas.Circle是圆形,通过canvas.NewCircle()创建,传入颜色。另外通过StrokeColorStrokeWidth设置圆形边框的颜色和宽度。

canvas.Image是图像,可以通过已加载的程序资源创建(canvas.NewImageFromResource()),传入资源对象。或通过文件路径创建(canvas.NewImageFromFile()),传入文件路径。或通过已构造的image.Image对象创建(canvas.NewImageFromImage())。可以通过FillMode设置图像的填充模式:

  • ImageFillStretch:拉伸,填满空间;
  • ImageFillContain:保持宽高比;
  • ImageFillOriginal:保持原始大小,不缩放。

下面程序演示了这 3 种创建图像的方式:

  1. package main
  2. import (
  3. "image"
  4. "image/color"
  5. "fyne.io/fyne"
  6. "fyne.io/fyne/app"
  7. "fyne.io/fyne/canvas"
  8. "fyne.io/fyne/layout"
  9. "fyne.io/fyne/theme"
  10. )
  11. func main() {
  12. a := app.New()
  13. w := a.NewWindow("Hello")
  14. img1 := canvas.NewImageFromResource(theme.FyneLogo())
  15. img1.FillMode = canvas.ImageFillOriginal
  16. img2 := canvas.NewImageFromFile("./luffy.jpg")
  17. img2.FillMode = canvas.ImageFillOriginal
  18. image := image.NewAlpha(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}})
  19. for i := 0; i < 100; i++ {
  20. for j := 0; j < 100; j++ {
  21. image.Set(i, j, color.Alpha{uint8(i % 256)})
  22. }
  23. }
  24. img3 := canvas.NewImageFromImage(image)
  25. img3.FillMode = canvas.ImageFillOriginal
  26. container := fyne.NewContainerWithLayout(
  27. layout.NewGridWrapLayout(fyne.NewSize(150, 150)),
  28. img1, img2, img3)
  29. w.SetContent(container)
  30. w.ShowAndRun()
  31. }

theme.FyneLogo()是 Fyne 图标资源,luffy.jpg是磁盘中的文件,最后创建一个image.Image,从中生成canvas.Image

每日一库之44:fyne - 图3

最后一种是梯度渐变效果,有两种类型canvas.LinearGradient(线性渐变)和canvas.RadialGradient(放射渐变),指从一种颜色渐变到另一种颜色。线性渐变又分为两种水平线性渐变垂直线性渐变,分别通过canvas.NewHorizontalGradient()canvas.NewVerticalGradient()创建。放射渐变通过canvas.NewRadialGradient()创建。我们在上面的示例中已经看到了水平线性渐变的效果,接下来一起看看放射渐变的效果:

  1. func main() {
  2. a := app.New()
  3. w := a.NewWindow("Canvas")
  4. gradient := canvas.NewRadialGradient(color.White, color.Transparent)
  5. w.SetContent(gradient)
  6. w.Resize(fyne.NewSize(200, 200))
  7. w.ShowAndRun()
  8. }

运行效果如下:

每日一库之44:fyne - 图4

放射效果就是从中心向周围渐变。

Widget

窗体控件是一个Fyne应用程序的主要组成部分。它们能适配当前的主题,并且处理与用户的交互。

Label

标签(Label)是最简单的一个控件了,用于显示字符串。它有点类似于canvas.Text,不同之处在于Label可以处理简单的格式化,例如\n

  1. func main() {
  2. myApp := app.New()
  3. myWin := myApp.NewWindow("Label")
  4. l1 := widget.NewLabel("Name")
  5. l2 := widget.NewLabel("da\njun")
  6. container := fyne.NewContainerWithLayout(layout.NewVBoxLayout(), l1, l2)
  7. myWin.SetContent(container)
  8. myWin.Resize(fyne.NewSize(150, 150))
  9. myWin.ShowAndRun()
  10. }

第二个widget.Label\n后面的内容会在下一行渲染:

每日一库之44:fyne - 图5

Button

按钮(Button)控件让用户点击,给用户反馈。Button可以包含文本,图标或两者皆有。调用widget.NewButton()创建一个默认的文本按钮,传入文本和一个无参的回调函数。带图标的按钮需要调用widget.NewButtonWithIcon(),传入文本和回调参数,还需要一个fyne.Resource类型的图标资源:

  1. func main() {
  2. myApp := app.New()
  3. myWin := myApp.NewWindow("Button")
  4. btn1 := widget.NewButton("text button", func() {
  5. fmt.Println("text button clicked")
  6. })
  7. btn2 := widget.NewButtonWithIcon("icon", theme.HomeIcon(), func() {
  8. fmt.Println("icon button clicked")
  9. })
  10. container := fyne.NewContainerWithLayout(layout.NewVBoxLayout(), btn1, btn2)
  11. myWin.SetContent(container)
  12. myWin.Resize(fyne.NewSize(150, 50))
  13. myWin.ShowAndRun()
  14. }

上面创建了一个文本按钮和一个图标按钮,theme子包中包含一些默认的图标资源,也可以加载外部的图标。运行:

每日一库之44:fyne - 图6

点击按钮,对应的回调就会被调用,试试看!

Box

盒子控件(Box)就是一个简单的水平或垂直的容器。在内部,Box对子控件采用盒状布局(Box Layout),详见后文布局。我们可以通过传入控件对象给widget.NewHBox()widget.NewVBox()创建盒子。或者调用已经创建好的widget.Box对象的Append()Prepend()向盒子中添加控件。前者在尾部追加,后者在头部添加。

  1. func main() {
  2. myApp := app.New()
  3. myWin := myApp.NewWindow("Box")
  4. content := widget.NewVBox(
  5. widget.NewLabel("The top row of VBox"),
  6. widget.NewHBox(
  7. widget.NewLabel("Label 1"),
  8. widget.NewLabel("Label 2"),
  9. ),
  10. )
  11. content.Append(widget.NewButton("Append", func() {
  12. content.Append(widget.NewLabel("Appended"))
  13. }))
  14. content.Append(widget.NewButton("Prepend", func() {
  15. content.Prepend(widget.NewLabel("Prepended"))
  16. }))
  17. myWin.SetContent(content)
  18. myWin.Resize(fyne.NewSize(150, 150))
  19. myWin.ShowAndRun()
  20. }

我们甚至可以嵌套widget.Box控件,这样就可以实现比较灵活的布局。上面的代码中添加了两个按钮,点击时分别在尾部和头部添加一个Label

每日一库之44:fyne - 图7

Entry

输入框(Entry)控件用于给用户输入简单的文本内容。调用widget.NewEntry()即可创建一个输入框控件。我们一般保存输入框控件的引用,以便访问其Text字段来获取内容。注册OnChanged回调函数。每当内容有修改时,OnChanged就会被调用。我们可以调用SetReadOnly(true)设置输入框的只读属性。方法SetPlaceHolder()用来设置占位字符串,设置字段Multiline让输入框接受多行文本。另外,我们可以使用NewPasswordEntry()创建一个密码输入框,输入的文本不会以明文显示。

  1. func main() {
  2. myApp := app.New()
  3. myWin := myApp.NewWindow("Entry")
  4. nameEntry := widget.NewEntry()
  5. nameEntry.SetPlaceHolder("input name")
  6. nameEntry.OnChanged = func(content string) {
  7. fmt.Println("name:", nameEntry.Text, "entered")
  8. }
  9. passEntry := widget.NewPasswordEntry()
  10. passEntry.SetPlaceHolder("input password")
  11. nameBox := widget.NewHBox(widget.NewLabel("Name"), layout.NewSpacer(), nameEntry)
  12. passwordBox := widget.NewHBox(widget.NewLabel("Password"), layout.NewSpacer(), passEntry)
  13. loginBtn := widget.NewButton("Login", func() {
  14. fmt.Println("name:", nameEntry.Text, "password:", passEntry.Text, "login in")
  15. })
  16. multiEntry := widget.NewEntry()
  17. multiEntry.SetPlaceHolder("please enter\nyour description")
  18. multiEntry.MultiLine = true
  19. content := widget.NewVBox(nameBox, passwordBox, loginBtn, multiEntry)
  20. myWin.SetContent(content)
  21. myWin.ShowAndRun()
  22. }

这里我们实现了一个简单的登录界面:

每日一库之44:fyne - 图8

Checkbox/Radio/Select

CheckBox是简单的选择框,每个选择是独立的,例如爱好可以是足球、篮球,也可以都是。创建方法widget.NewCheck(),传入选项字符串(足球,篮球)和回调函数。回调函数接受一个bool类型的参数,表示该选项是否选中。

Radio是单选框,每个组内只能选择一个,例如性别,只能是男或女(?)。创建方法widget.NewRadio(),传入字符串切片和回调函数作为参数。回调函数接受一个字符串参数,表示选中的选项。也可以使用Selected字段读取选中的选项。

Select是下拉选择框,点击时显示一个下拉菜单,点击选择。选项非常多的时候,比较适合用Select。创建方法widget.NewSelect(),参数与NewRadio()完全相同。

  1. func main() {
  2. myApp := app.New()
  3. myWin := myApp.NewWindow("Choices")
  4. nameEntry := widget.NewEntry()
  5. nameEntry.SetPlaceHolder("input name")
  6. passEntry := widget.NewPasswordEntry()
  7. passEntry.SetPlaceHolder("input password")
  8. repeatPassEntry := widget.NewPasswordEntry()
  9. repeatPassEntry.SetPlaceHolder("repeat password")
  10. nameBox := widget.NewHBox(widget.NewLabel("Name"), layout.NewSpacer(), nameEntry)
  11. passwordBox := widget.NewHBox(widget.NewLabel("Password"), layout.NewSpacer(), passEntry)
  12. repeatPasswordBox := widget.NewHBox(widget.NewLabel("Repeat Password"), layout.NewSpacer(), repeatPassEntry)
  13. sexRadio := widget.NewRadio([]string{"male", "female", "unknown"}, func(value string) {
  14. fmt.Println("sex:", value)
  15. })
  16. sexBox := widget.NewHBox(widget.NewLabel("Sex"), sexRadio)
  17. football := widget.NewCheck("football", func(value bool) {
  18. fmt.Println("football:", value)
  19. })
  20. basketball := widget.NewCheck("basketball", func(value bool) {
  21. fmt.Println("basketball:", value)
  22. })
  23. pingpong := widget.NewCheck("pingpong", func(value bool) {
  24. fmt.Println("pingpong:", value)
  25. })
  26. hobbyBox := widget.NewHBox(widget.NewLabel("Hobby"), football, basketball, pingpong)
  27. provinceSelect := widget.NewSelect([]string{"anhui", "zhejiang", "shanghai"}, func(value string) {
  28. fmt.Println("province:", value)
  29. })
  30. provinceBox := widget.NewHBox(widget.NewLabel("Province"), layout.NewSpacer(), provinceSelect)
  31. registerBtn := widget.NewButton("Register", func() {
  32. fmt.Println("name:", nameEntry.Text, "password:", passEntry.Text, "register")
  33. })
  34. content := widget.NewVBox(nameBox, passwordBox, repeatPasswordBox,
  35. sexBox, hobbyBox, provinceBox, registerBtn)
  36. myWin.SetContent(content)
  37. myWin.ShowAndRun()
  38. }

这里我们实现了一个简单的注册界面:

每日一库之44:fyne - 图9

Form

表单控件(Form)用于对很多Label和输入控件进行布局。如果指定了OnSubmitOnCancel函数,表单控件会自动添加对应的Button按钮。我们调用widget.NewForm()传入一个widget.FormItem切片创建Form控件。每一项中一个字符串作为Label的文本,一个控件对象。创建好Form对象之后还能调用其Append(label, widget)方法添加控件。

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Form")
  4. nameEntry := widget.NewEntry()
  5. passEntry := widget.NewPasswordEntry()
  6. form := widget.NewForm(
  7. &widget.FormItem{"Name", nameEntry},
  8. &widget.FormItem{"Pass", passEntry},
  9. )
  10. form.OnSubmit = func() {
  11. fmt.Println("name:", nameEntry.Text, "pass:", passEntry.Text, "login in")
  12. }
  13. form.OnCancel = func() {
  14. fmt.Println("login canceled")
  15. }
  16. myWindow.SetContent(form)
  17. myWindow.Resize(fyne.NewSize(150, 150))
  18. myWindow.ShowAndRun()
  19. }

使用Form能大大简化表单的构建,我们使用Form重新编写了上面的登录界面:

每日一库之44:fyne - 图10

注意SubmitCancel按钮是自动生成的!

ProgressBar

进度条控件(ProgressBar)用来表示任务的进度,例如文件下载的进度。创建方法widget.NewProgressBar(),默认最小值为0.0,最大值为1.1,可通过Min/Max字段设置。调用SetValue()方法来控制进度。还有一种进度条是循环动画,它表示有任务在进行中,并不能表示具体的完成情况。

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("ProgressBar")
  4. bar1 := widget.NewProgressBar()
  5. bar1.Min = 0
  6. bar1.Max = 100
  7. bar2 := widget.NewProgressBarInfinite()
  8. go func() {
  9. for i := 0; i <= 100; i ++ {
  10. time.Sleep(time.Millisecond * 500)
  11. bar1.SetValue(float64(i))
  12. }
  13. }()
  14. content := widget.NewVBox(bar1, bar2)
  15. myWindow.SetContent(content)
  16. myWindow.Resize(fyne.NewSize(150, 150))
  17. myWindow.ShowAndRun()
  18. }

在另一个 goroutine 中更新进度。效果如下:

每日一库之44:fyne - 图11

TabContainer

标签容器(TabContainer)允许用户在不同的内容面板之间切换。标签可以是文本或图标。创建方法widget.NewTabContainer(),传入widget.TabItem作为参数。widget.TabItem可通过widget.NewTabItem(label, widget)创建。标签还可以设置位置:

  • TabLocationBottom:显示在底部;
  • TabLocationLeading:显示在顶部左边;
  • TabLocationTrailing:显示在顶部右边。

看示例:

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("TabContainer")
  4. nameLabel := widget.NewLabel("Name: dajun")
  5. sexLabel := widget.NewLabel("Sex: male")
  6. ageLabel := widget.NewLabel("Age: 18")
  7. addressLabel := widget.NewLabel("Province: shanghai")
  8. addressLabel.Hide()
  9. profile := widget.NewVBox(nameLabel, sexLabel, ageLabel, addressLabel)
  10. musicRadio := widget.NewRadio([]string{"on", "off"}, func(string) {})
  11. showAddressCheck := widget.NewCheck("show address?", func(value bool) {
  12. if !value {
  13. addressLabel.Hide()
  14. } else {
  15. addressLabel.Show()
  16. }
  17. })
  18. memberTypeSelect := widget.NewSelect([]string{"junior", "senior", "admin"}, func(string) {})
  19. setting := widget.NewForm(
  20. &widget.FormItem{"music", musicRadio},
  21. &widget.FormItem{"check", showAddressCheck},
  22. &widget.FormItem{"member type", memberTypeSelect},
  23. )
  24. tabs := widget.NewTabContainer(
  25. widget.NewTabItem("Profile", profile),
  26. widget.NewTabItem("Setting", setting),
  27. )
  28. myWindow.SetContent(tabs)
  29. myWindow.Resize(fyne.NewSize(200, 200))
  30. myWindow.ShowAndRun()
  31. }

上面代码编写了一个简单的个人信息面板和设置面板,点击show address?可切换地址信息是否显示:

每日一库之44:fyne - 图12
每日一库之44:fyne - 图13

Toolbar

工具栏(Toolbar)是很多 GUI 应用程序必备的部分。工具栏将常用命令用图标的方式很形象地展示出来,方便使用。创建方法widget.NewToolbar(),传入多个widget.ToolbarItem作为参数。最常使用的ToolbarItem有命令(Action)、分隔符(Separator)和空白(Spacer),分别通过widget.NewToolbarItemAction(resource, callback)/widget.NewToolbarSeparator()/widget.NewToolbarSpacer()创建。命令需要指定回调,点击时触发。

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Toolbar")
  4. toolbar := widget.NewToolbar(
  5. widget.NewToolbarAction(theme.DocumentCreateIcon(), func() {
  6. fmt.Println("New document")
  7. }),
  8. widget.NewToolbarSeparator(),
  9. widget.NewToolbarAction(theme.ContentCutIcon(), func() {
  10. fmt.Println("Cut")
  11. }),
  12. widget.NewToolbarAction(theme.ContentCopyIcon(), func() {
  13. fmt.Println("Copy")
  14. }),
  15. widget.NewToolbarAction(theme.ContentPasteIcon(), func() {
  16. fmt.Println("Paste")
  17. }),
  18. widget.NewToolbarSpacer(),
  19. widget.NewToolbarAction(theme.HelpIcon(), func() {
  20. log.Println("Display help")
  21. }),
  22. )
  23. content := fyne.NewContainerWithLayout(
  24. layout.NewBorderLayout(toolbar, nil, nil, nil),
  25. toolbar, widget.NewLabel(`Lorem ipsum dolor,
  26. sit amet consectetur adipisicing elit.
  27. Quidem consectetur ipsam nesciunt,
  28. quasi sint expedita minus aut,
  29. porro iusto magnam ducimus voluptates cum vitae.
  30. Vero adipisci earum iure consequatur quidem.`),
  31. )
  32. myWindow.SetContent(content)
  33. myWindow.ShowAndRun()
  34. }

工具栏一般使用BorderLayout,将工具栏放在其他任何控件上面,布局后文会详述。运行:

每日一库之44:fyne - 图14

扩展控件

标准的 Fyne 控件提供了最小的功能集和定制化以适应大部分的应用场景。有些时候,我们需要更高级的功能。除了自己编写控件外,我们还可以扩展现有的控件。例如,我们希望图标控件widget.Icon能响应鼠标左键、右键和双击。首先编写一个构造函数,调用ExtendBaseWidget()方法获得基础的控件功能:

  1. type tappableIcon struct {
  2. widget.Icon
  3. }
  4. func newTappableIcon(res fyne.Resource) *tappableIcon {
  5. icon := &tappableIcon{}
  6. icon.ExtendBaseWidget(icon)
  7. icon.SetResource(res)
  8. return icon
  9. }

然后实现相关的接口:

  1. // src/fyne.io/fyne/canvasobject.go
  2. // 鼠标左键
  3. type Tappable interface {
  4. Tapped(*PointEvent)
  5. }
  6. // 鼠标右键或长按
  7. type SecondaryTappable interface {
  8. TappedSecondary(*PointEvent)
  9. }
  10. // 双击
  11. type DoubleTappable interface {
  12. DoubleTapped(*PointEvent)
  13. }

接口实现:

  1. func (t *tappableIcon) Tapped(e *fyne.PointEvent) {
  2. log.Println("I have been left tapped at", e)
  3. }
  4. func (t *tappableIcon) TappedSecondary(e *fyne.PointEvent) {
  5. log.Println("I have been right tapped at", e)
  6. }
  7. func (t *tappableIcon) DoubleTapped(e *fyne.PointEvent) {
  8. log.Println("I have been double tapped at", e)
  9. }

最后使用:

  1. func main() {
  2. a := app.New()
  3. w := a.NewWindow("Tappable")
  4. w.SetContent(newTappableIcon(theme.FyneLogo()))
  5. w.Resize(fyne.NewSize(200, 200))
  6. w.ShowAndRun()
  7. }

运行,点击图标控制台有相应输出:

每日一库之44:fyne - 图15

  1. 2020/06/18 06:44:02 I have been left tapped at &{{110 97} {106 93}}
  2. 2020/06/18 06:44:03 I have been left tapped at &{{110 97} {106 93}}
  3. 2020/06/18 06:44:05 I have been right tapped at &{{88 102} {84 98}}
  4. 2020/06/18 06:44:06 I have been right tapped at &{{88 102} {84 98}}
  5. 2020/06/18 06:44:06 I have been left tapped at &{{88 101} {84 97}}
  6. 2020/06/18 06:44:07 I have been double tapped at &{{88 101} {84 97}}

输出的fyne.PointEvent中有绝对位置(对于窗口左上角)和相对位置(对于容器左上角)。

Layout

布局(Layout)就是控件如何在界面上显示,如何排列的。要想界面好看,布局是必须要掌握的。几乎所有的 GUI 框架都提供了布局或类似的接口。实际上,在前面的示例中我们已经在fyne.NewContainerWithLayout()函数中使用了布局。

BoxLayout

盒状布局(BoxLayout)是最常使用的一个布局。它将控件都排在一行或一列。在fyne中,我们可以通过layout.NewHBoxLayout()创建一个水平盒装布局,通过layout.NewVBoxLayout()创建一个垂直盒装布局。水平布局中的控件都排列在一行中,每个控件的宽度等于其内容的最小宽度(MinSize().Width),它们都拥有相同的高度,即所有控件的最大高度(MinSize().Height)。

垂直布局中的控件都排列在一列中,每个控件的高度等于其内容的最小高度,它们都拥有相同的宽度,即所有控件的最大宽度。

一般地,在BoxLayout中使用layout.NewSpacer()辅助布局,它会占满剩余的空间。对于水平盒状布局来说,第一个控件前添加一个layout.NewSpacer(),所有控件右对齐。最后一个控件后添加一个layout.NewSpacer(),所有控件左对齐。前后都有,那么控件中间对齐。如果在中间有添加一个layout.NewSpacer(),那么其它控件两边对齐。

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Box Layout")
  4. hcontainer1 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
  5. canvas.NewText("left", color.White),
  6. canvas.NewText("right", color.White))
  7. // 左对齐
  8. hcontainer2 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
  9. layout.NewSpacer(),
  10. canvas.NewText("left", color.White),
  11. canvas.NewText("right", color.White))
  12. // 右对齐
  13. hcontainer3 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
  14. canvas.NewText("left", color.White),
  15. canvas.NewText("right", color.White),
  16. layout.NewSpacer())
  17. // 中间对齐
  18. hcontainer4 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
  19. layout.NewSpacer(),
  20. canvas.NewText("left", color.White),
  21. canvas.NewText("right", color.White),
  22. layout.NewSpacer())
  23. // 两边对齐
  24. hcontainer5 := fyne.NewContainerWithLayout(layout.NewHBoxLayout(),
  25. canvas.NewText("left", color.White),
  26. layout.NewSpacer(),
  27. canvas.NewText("right", color.White))
  28. myWindow.SetContent(fyne.NewContainerWithLayout(layout.NewVBoxLayout(),
  29. hcontainer1, hcontainer2, hcontainer3, hcontainer4, hcontainer5))
  30. myWindow.Resize(fyne.NewSize(200, 200))
  31. myWindow.ShowAndRun()
  32. }

运行效果:

每日一库之44:fyne - 图16

GridLayout

格子布局(GridLayout)每一行有固定的列,添加的控件数量超过这个值时,后面的控件将会在新的行显示。创建方法layout.NewGridLayout(cols),传入每行的列数。

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Grid Layout")
  4. img1 := canvas.NewImageFromResource(theme.FyneLogo())
  5. img2 := canvas.NewImageFromResource(theme.FyneLogo())
  6. img3 := canvas.NewImageFromResource(theme.FyneLogo())
  7. myWindow.SetContent(fyne.NewContainerWithLayout(layout.NewGridLayout(2),
  8. img1, img2, img3))
  9. myWindow.Resize(fyne.NewSize(300, 300))
  10. myWindow.ShowAndRun()
  11. }

运行效果:

每日一库之44:fyne - 图17

该布局有个优势,我们缩放界面时,控件会自动调整大小。试试看~

GridWrapLayout

GridWrapLayoutGridLayout的扩展。GridWrapLayout创建时会指定一个初始size,这个size会应用到所有的子控件上,每个子控件都保持这个size。初始,每行一个控件。如果界面大小变化了,这些子控件会重新排列。例如宽度翻倍了,那么一行就可以排两个控件了。有点像流动布局:

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Grid Wrap Layout")
  4. img1 := canvas.NewImageFromResource(theme.FyneLogo())
  5. img2 := canvas.NewImageFromResource(theme.FyneLogo())
  6. img3 := canvas.NewImageFromResource(theme.FyneLogo())
  7. myWindow.SetContent(
  8. fyne.NewContainerWithLayout(
  9. layout.NewGridWrapLayout(fyne.NewSize(150, 150)),
  10. img1, img2, img3))
  11. myWindow.ShowAndRun()
  12. }

初始:

每日一库之44:fyne - 图18

加大宽度:

每日一库之44:fyne - 图19

再加大宽度:

每日一库之44:fyne - 图20

BorderLayout

边框布局(BorderLayout)比较常用于构建用户界面,上面例子中的Toolbar一般都和BorderLayout搭配使用。创建方法layout.NewBorderLayout(top, bottom, left, right),分别传入顶部、底部、左侧、右侧的控件对象。添加到容器中的控件如果是这些边界对象,则显示在对应位置,其他都显示在中心:

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Border Layout")
  4. left := canvas.NewText("left", color.White)
  5. right := canvas.NewText("right", color.White)
  6. top := canvas.NewText("top", color.White)
  7. bottom := canvas.NewText("bottom", color.White)
  8. content := widget.NewLabel(`Lorem ipsum dolor,
  9. sit amet consectetur adipisicing elit.
  10. Quidem consectetur ipsam nesciunt,
  11. quasi sint expedita minus aut,
  12. porro iusto magnam ducimus voluptates cum vitae.
  13. Vero adipisci earum iure consequatur quidem.`)
  14. container := fyne.NewContainerWithLayout(
  15. layout.NewBorderLayout(top, bottom, left, right),
  16. top, bottom, left, right, content,
  17. )
  18. myWindow.SetContent(container)
  19. myWindow.ShowAndRun()
  20. }

效果:

每日一库之44:fyne - 图21

FormLayout

表单布局(FormLayout)其实就是一个 2 列的GridLayout,但是针对表单做了一些微调。

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Border Layout")
  4. nameLabel := canvas.NewText("Name", color.Black)
  5. nameValue := canvas.NewText("dajun", color.White)
  6. ageLabel := canvas.NewText("Age", color.Black)
  7. ageValue := canvas.NewText("18", color.White)
  8. container := fyne.NewContainerWithLayout(
  9. layout.NewFormLayout(),
  10. nameLabel, nameValue, ageLabel, ageValue,
  11. )
  12. myWindow.SetContent(container)
  13. myWindow.Resize(fyne.NewSize(150, 150))
  14. myWindow.ShowAndRun()
  15. }

运行效果:

每日一库之44:fyne - 图22

CenterLayout

CenterLayout将容器内的所有控件显示在中心位置,按传入的顺序显示。最后传入的控件显示最上层。CenterLayout中所有控件将保持它们的最小尺寸(大小能容纳其内容)。

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Center Layout")
  4. image := canvas.NewImageFromResource(theme.FyneLogo())
  5. image.FillMode = canvas.ImageFillOriginal
  6. text := canvas.NewText("Fyne Logo", color.Black)
  7. container := fyne.NewContainerWithLayout(
  8. layout.NewCenterLayout(),
  9. image, text,
  10. )
  11. myWindow.SetContent(container)
  12. myWindow.ShowAndRun()
  13. }

运行结果:

每日一库之44:fyne - 图23

字符串Fyne Logo显示在图片上层。如果我们把textimage顺序对调,字符串将会被图片挡住,无法看到。动手试一下~

MaxLayout

MaxLayoutCenterLayout类似,不同之处在于MaxLayout会让容器内的元素都显示为最大尺寸(等于容器的大小)。细心的朋友可能发现了,在CenterLayout的示例中。我们设置了图片的填充模式为ImageFillOriginal。如果不设置填充模式,图片的默认MinSize(1, 1)。可以fmt.Println(image.MinSize())验证一下。这样图片就不会显示在界面中。

MaxLayout的容器中,我们不需要这样处理:

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Max Layout")
  4. image := canvas.NewImageFromResource(theme.FyneLogo())
  5. text := canvas.NewText("Fyne Logo", color.Black)
  6. container := fyne.NewContainerWithLayout(
  7. layout.NewMaxLayout(),
  8. image, text,
  9. )
  10. myWindow.SetContent(container)
  11. myWindow.Resize(fyne.Size(200, 200))
  12. myWindow.ShowAndRun()
  13. }

运行结果:

每日一库之44:fyne - 图24

注意,canvas.Text显示为左对齐了。如果要居中对齐,设置其Alignment属性为fyne.TextAlignCenter

自定义 Layout

内置布局在子包layout中。它们都实现了fyne.Layout接口:

  1. // src/fyne.io/fyne/layout.go
  2. type Layout interface {
  3. Layout([]CanvasObject, Size)
  4. MinSize(objects []CanvasObject) Size
  5. }

要实现自定义的布局,只需要实现这个接口。下面我们实现一个台阶(对角)的布局,好似一个矩阵的对角线,从左上到右下。首先定义一个新的类型。然后实现接口fyne.Layout的两个方法:

  1. type diagonal struct {
  2. }
  3. func (d *diagonal) MinSize(objects []fyne.CanvasObject) fyne.Size {
  4. w, h := 0, 0
  5. for _, o := range objects {
  6. childSize := o.MinSize()
  7. w += childSize.Width
  8. h += childSize.Height
  9. }
  10. return fyne.NewSize(w, h)
  11. }
  12. func (d *diagonal) Layout(objects []fyne.CanvasObject, containerSize fyne.Size) {
  13. pos := fyne.NewPos(0, 0)
  14. for _, o := range objects {
  15. size := o.MinSize()
  16. o.Resize(size)
  17. o.Move(pos)
  18. pos = pos.Add(fyne.NewPos(size.Width, size.Height))
  19. }
  20. }

MinSize()返回所有子控件的MinSize之和。Layout()从左上到右下排列控件。然后是使用:

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Diagonal Layout")
  4. img1 := canvas.NewImageFromResource(theme.FyneLogo())
  5. img1.FillMode = canvas.ImageFillOriginal
  6. img2 := canvas.NewImageFromResource(theme.FyneLogo())
  7. img2.FillMode = canvas.ImageFillOriginal
  8. img3 := canvas.NewImageFromResource(theme.FyneLogo())
  9. img3.FillMode = canvas.ImageFillOriginal
  10. container := fyne.NewContainerWithLayout(
  11. &diagonal{},
  12. img1, img2, img3,
  13. )
  14. myWindow.SetContent(container)
  15. myWindow.ShowAndRun()
  16. }

运行结果:

每日一库之44:fyne - 图25

fyne demo

fyne提供了一个 Demo,演示了大部分控件和布局的使用。可使用下面命令安装,执行:

  1. $ go get fyne.io/fyne/cmd/fyne_demo
  2. $ fyne_demo

效果图:

每日一库之44:fyne - 图26

fyne命令

fyne库为了方便开发者提供了fyne命令。fyne可以用来将静态资源打包进可执行程序,还能将整个应用程序打包成可发布的形式。fyne命令通过下面命令安装:

  1. $ go get fyne.io/fyne/cmd/fyne

安装完成之后fyne就在$GOPATH/bin目录中,将$GOPATH/bin添加到系统$PATH中就可以直接运行fyne命令了。

静态资源

其实在前面的示例中我们已经多次使用了fyne内置的静态资源,使用最多的要属fyne.FyneLogo()了。下面我们有两个图片image1.png/image2.jpg。我们使用fyne bundle命令将这两个图片打包进代码:

  1. $ fyne bundle image1.png >> bundled.go
  2. $ fyne bundle -append image2.jpg >> bundled.go

第二个命令指定-append选项表示添加到现有文件中,生成的文件如下:

  1. // bundled.go
  2. package main
  3. import "fyne.io/fyne"
  4. var resourceImage1Png = &fyne.StaticResource{
  5. StaticName: "image1.png",
  6. StaticContent: []byte{...}}
  7. var resourceImage2Jpg = &fyne.StaticResource{
  8. StaticName: "image2.jpg",
  9. StaticContent: []byte{...}}

实际上就是将图片内容存入一个字节切片中,我们在代码中就可以调用canvas.NewImageFromResource(),传入resourceImage1PngresourceImage2Jpg来创建canvas.Image对象了。

  1. func main() {
  2. myApp := app.New()
  3. myWindow := myApp.NewWindow("Bundle Resource")
  4. img1 := canvas.NewImageFromResource(resourceImage1Png)
  5. img1.FillMode = canvas.ImageFillOriginal
  6. img2 := canvas.NewImageFromResource(resourceImage2Jpg)
  7. img2.FillMode = canvas.ImageFillOriginal
  8. img3 := canvas.NewImageFromResource(theme.FyneLogo())
  9. img3.FillMode = canvas.ImageFillOriginal
  10. container := fyne.NewContainerWithLayout(
  11. layout.NewGridLayout(1),
  12. img1, img2, img3,
  13. )
  14. myWindow.SetContent(container)
  15. myWindow.ShowAndRun()
  16. }

运行结果:

每日一库之44:fyne - 图27

注意,由于现在是两个文件,不能使用go run main.go,应该用go run .

theme.FyneLogo()实际上是也是提前打包进代码的,代码文件是bundled-icons.go

  1. // src/fyne.io/fyne/theme/icons.go
  2. func FyneLogo() fyne.Resource {
  3. return fynelogo
  4. }
  5. // src/fyne.io/fyne/theme/bundled-icons.go
  6. var fynelogo = &fyne.StaticResource{
  7. StaticName: "fyne.png",
  8. StaticContent: []byte{}}

发布应用程序

发布图像应用程序到多个操作系统是非常复杂的任务。图形界面应用程序通常有图标和一些元数据。fyne命令提供了将应用程序发布到多个平台的支持。使用fyne package命令将创建一个可在其它计算机上安装/运行的应用程序。在 Windows 上,fyne package会创建一个.exe文件。在 macOS 上,会创建一个.app文件。在 Linux 上,会生成一个.tar.xz文件,可手动安装。

我们将上面的应用程序打包成一个exe文件:

  1. $ fyne package -os windows -icon icon.jpg

上面命令会在同目录下生成两个文件bundle.exefyne.syso,将这两个文件拷贝到任何目录或其他 Windows 计算机都可以通过直接双击bundle.exe运行了。没有其他的依赖。

每日一库之44:fyne - 图28

fyne还支持交叉编译,能在 windows 上编译 mac 的应用程序,不过需要安装额外的工具,感兴趣可自行探索。

总结

fyne提供了丰富的组件和功能,我们介绍的只是很基础的一部分,还有剪切板、快捷键、滚动条、菜单等等等等内容。fyne命令实现打包静态资源和应用程序,非常方便。fyne还有其他高级功能留待大家探索、挖掘~

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. fyne GitHub:https://github.com/fyne-io/fyne
  2. fyne 官网:https://fyne.io/
  3. fyne 官方入门教程:https://developer.fyne.io/tour/introduction/hello.html
  4. Go 每日一库 GitHub:https://github.com/go-quiz/go-daily-lib