快速入门 Go 语言 Fyne UI 框架进阶篇,自定义主题配置。
- 参考课程:Go 语言 + Fyne 快速上手教程
- 参考文档:Fyne.io
- 笔记仓库:InkkaPlum 频道的 Golang 及 Fyne 框架教程的代码和文字版教程。
前言#
在上一篇笔记中,我们学习了如何快速使用 Fyne 框架编写一个简易的 markdown 编辑器。其中提到了几个问题:
- Fyne 不支持热重载
- 自定义中文字体需要编写
theme.go
和util.go
下面,我们同样通过 markdown 编辑器的例子来逐一解决这些问题。
热重载#
可以通过 Air 工具来实现 Fyne 的热重载功能。此工具的开发者当初是因为 Gin
缺乏实时重载的功能而开发,那么也顺便解决了 Fyne 学习者的问题。
GitHub 项目地址:air: ☁️ Live reload for Go apps
配置非常简单:
安装 Air 工具
go install github.com/air-verse/air@latest
打开项目目录,运行以下命令,这会将具有默认设置的 .air.toml
配置文件初始化到当前目录。
air init
在这之后,你只需执行 air
命令,无需额外参数,它就能使用 .air.toml
文件中的配置了。
air
通过执行 air
指令替代原先的 go run .
指令启动项目,项目就可以支持热重载了。
自定义主题#
主题接口#
Fyne 的主题实际上就是一个接口fyne.Theme
:
type Theme interface {
Color(ThemeColorName, ThemeVariant) color.Color // 颜色
Font(TextStyle) Resource // 字体
Icon(ThemeIconName) Resource // 图标
Size(ThemeSizeName) float32 // 大小
}
要实现自定义的主题,我们只需要定义一个自定义主题并实现这个接口中的所有函数,即实现这个接口。
比如我定义了一个自定义主题 myTheme
:
type myTheme struct {}
在这里,官方文档中提到,最好通过断言我们实现的这个接口,以便编译错误时更接近错误的位置。即通过以下代码实现断言:
var _ fyne.Theme = (*myTheme)(nil)
举个例子方便理解:比如当我们写了以上断言代码后,由于没有实现接口的全部函数,导致程序报错,这时程序会直接定位到 theme.go
中的错误。比如以下报错信息:
.\theme.go:29:20: cannot use (*myTheme)(nil) (value of type *myTheme) as fyne.Theme value in variable declaration: *myTheme does not implement fyne.Theme (missing method Size)
但是,如果我们没有写以上断言的代码后,程序由于没有实现接口而报错,程序也许会定位到 main.go
中,错误位置不够清晰,难以寻找到真实的错误原因。比如以下报错信息:
.\main.go:16:24: cannot use &myTheme{…} (value of type *myTheme) as fyne.Theme value in argument to a.Settings().SetTheme: *myTheme does not implement fyne.Theme (missing method Size)
因此,在这里使用断言代码,断言我们实现的接口的好处在于:提高代码的健壮性和可维护性。
自定义颜色#
接下来,我们想要实现一个自定义的主题,就可以新建一个 theme.go
文件,在其中去实现 Fyne 定义的 Theme 接口。
比如我们想要自定义颜色,就去实现 Color
函数:
// 导入颜色包,这里需要从 "image/color" 导入
import "image/color"
// 定义一个名为 myTheme 的结构体,假设它实现了 fyne.Theme 接口
type myTheme struct {
// 这里可以定义其他与主题相关的字段
}
// Color 方法实现了 fyne.Theme 接口中的 Color 方法
// name 是颜色名称,variant 是主题变体
func (m myTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
// 检查颜色名称是否为背景色
if name == theme.ColorNameBackground {
// 如果是亮色主题
if variant == theme.VariantLight {
// 返回白色作为背景色
return color.RGBA{R: 254, G: 200, B: 216, A: 0xff}
}
// 否则暗色主题,返回黑色作为背景色
return color.RGBA{R: 149, G: 125, B: 173, A: 0xff}
}
// 对于其他颜色名称,调用默认主题的 Color 方法
return theme.DefaultTheme().Color(name, variant)
}
当然,前面提到过,要想使自定义主题有效,就必须要让我们的 myTheme
去实现 Theme
的所有方法。因此,即使我们只是设置一个主题颜色,我们仍然需要实现其他方法。如果不需要修改,则可直接返回默认值,但方法必须被实现!
func (m myTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}
func (m myTheme) Size(name fyne.ThemeSizeName) float32 {
return theme.DefaultTheme().Size(name)
}
func (m myTheme) Font(style fyne.TextStyle) fyne.Resource {
return theme.DefaultTheme().Font(style)
}
最后,只需要在 main.go
中添加以下代码,则可以将我们自定义的主题引入程序中。
app.Settings().SetTheme(&myTheme{})
这里没有设置输入框
theme.ColorNameInputBackground
的颜色,所以显示了默认的黑白颜色。
以上就是设置一个自定义背景颜色的代码和效果,如果我们想要同时设置其他控件的颜色,我们可以利用 Go 语言的 switch
语言来逐一判断并设置颜色。
// Color 方法实现了 fyne.Theme 接口中的 Color 方法
// m 是指向 myTheme 结构体的指针,允许方法修改 myTheme 的状态
// n 是颜色名称,v 是主题变体
func (m *myTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color {
switch n {
case theme.ColorNameBackground: // 背景色
return color.RGBA{190, 233, 255, 1}
case theme.ColorNameButton: // 按钮颜色
return color.RGBA{0, 122, 255, 255}
case theme.ColorNameDisabledButton: // 禁用按钮颜色
return color.RGBA{142, 142, 147, 255}
case theme.ColorNameHover: // 悬停时的颜色
return color.RGBA{230, 230, 230, 255}
case theme.ColorNameFocus: // 焦点时的颜色
return color.RGBA{255, 165, 0, 255}
case theme.ColorNameShadow: // 阴影颜色
return color.RGBA{0, 0, 0, 50}
default: // 其他没有匹配到的控件 设置为默认主题颜色
return theme.DefaultTheme().Color(n, v)
}
}
同理,要想自定义字体、图标和大小,也是利用相似的步骤,实现对应的方法即可。
func (m myTheme) Icon(name fyne.ThemeIconName) fyne.Resource {……}
func (m myTheme) Size(name fyne.ThemeSizeName) float32 {……}
func (m myTheme) Font(style fyne.TextStyle) fyne.Resource {……}
不过在自定义字体和图标之前,需要先要了解和学习一下关于 Fyne 的捆绑资源操作。
捆绑资源#
bundle#
我们都知道基于 Go 的应用程序通常构建为单个二进制可执行文件,这使得我们分发使用十分的方便,Fyne 应用程序也是如此。
但不幸的是 GUI 应用程序通常需要额外的资源来呈现用户界面,比如图片、字体、音频等等。为了应对这一挑战,Go 应用程序可以将资产捆绑到二进制文件本身中。
Fyne 工具包更喜欢使用 fyne bundle
,因为它具有各种好处,我们将在下面探讨。
如果我们想要将一张图片捆绑到应用程序中,使得程序在运行时可以被使用,则我们可以执行下面的指令来捆绑资源:
fyne bundle -o bundled.go image.png
同时以上代码会在文件夹中生成一个 bundled.go
文件,其中直接包含了我们捆绑的图片的二进制数据,这样我们就可以在程序中使用图片,而不需要带上这张图片。
事实上,它的所有二进制信息已经在代码变量中了。比如下方 bundled.go
文件中的 StaticContent
就存放了图片 image.png
的所有二进制数据。
var resourceImagePng = &fyne.StaticResource{
StaticName: "image.png",
StaticContent: []byte{...}
}
不过需要注意的是,重复执行 fyne bundle -o bundled.go image.png
会相互覆盖。如果想捆绑多个资源,可以使用 -append
追加资源:
fyne bundle -o bundled.go image1.png
fyne bundle -o bundled.go -append image2.png
接着,就可以在程序中使用这些捆绑的资源。比如在 canvas 上加载一个图像:
img := canvas.NewImageFromResource(resourceImagePng)
注意这里的 resourceImagePng
资源名称是有规则的。比如我捆绑了 image1.png
和 image2.png
两张图片,则它们对应的资源名称就为 resourceImage1Png
和 resourceImage2Png
。
默认命名规则为,文件名开头字母一定大写,文件后缀名开头一定大写,其他保持原样。即
resource<Name><Ext>
自定义图标#
了解了 Fyne 的捆绑资源操作,接上一节,我们就可以通过绑定资源来自定义图标。
比如,我们捆绑了一张 icon.jpeg
图片,想要让它替换掉默认的 Home 图标。只需要在 theme.go
中修改实现 Icon
方法的代码:
func (m myTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
if name == theme.IconNameHome {
return resourceIconJpeg
}
return theme.DefaultTheme().Icon(name)
}
这样就可以将默认的 Home 图标,替换为自己的图片。在代码中同样使用 Home 图标即可。比如下面使用 Home 图标 theme.HomeIcon()
创建了一个图标按钮。
widget.NewButtonWithIcon("Home", theme.HomeIcon(), func() {})
Fyne 内置了一些常用的图标。在 主题图标 |Fyne.io 中可以查看所有图标。
自定义字体#
对于自定义字体也可以是类似的方法。通过 fyne bundle
将字体文件绑定进 bundle.go
,在 theme.go
中实现 Font
方法。
func (m myTheme) Font(style fyne.TextStyle) fyne.Resource {
return resourceNotoSansHansRegularTtf
}
在 main.go
中设置主题:
app.Settings().SetTheme(&myTheme{})
这样就完成了字体的引入,但是这样的方法也有缺点,就是体积非常大!NotoSansHans-Regular.ttf
字体体积为 8.5 MB,但是引入了 bundle.go
文件后,bundle.go
的文件体积就变为了 21.3 MB。
还有一种方法就是先在 theme.go
中的 myTheme
结构体中增加一个字体字段:
type myTheme struct {
font fyne.Resource
}
接着就是实现 Font 方法,这里只需要返回结构体中的 Font 字段的数据。
func (m *myTheme) Font(s fyne.TextStyle) fyne.Resource {
return m.font
}
具体的读取字体数据的步骤,我们可以新建一个 util.go
文件或者也可以直接写在 theme.go
中,在其中实现:
package main
import (
"os" // 导入 os 包,用于文件操作
"log" // 导入 log 包,用于日志记录
)
// loadFont 函数用于加载指定路径的字体文件
// 参数 fontPath 是字体文件的路径,返回值是字体文件的字节切片
func loadFont(fontPath string) []byte {
// 使用 os.ReadFile 读取字体文件
// fontData 存储读取到的字节数据,err 存储可能发生的错误
fontData, err := os.ReadFile(fontPath)
if err != nil {
// 如果读取字体文件时发生错误,记录错误信息并终止程序
log.Fatalf("无法加载字体文件: %v", err)
}
// 返回加载的字体文件数据
return fontData
}
最后就可以在 main.go
中引入这个字体,具体方法为:
// 使用 loadFont 函数加载指定路径的字体文件,并将其作为静态资源创建
customFont := fyne.NewStaticResource("NotoSansHans.ttf", loadFont("NotoSansHans-Regular.ttf"))
// 设置应用程序的主题为自定义主题,并将加载的字体应用于该主题
// myTheme 是自定义主题的结构体,其中包含字体信息
app.Settings().SetTheme(&myTheme{font: customFont})
这也是上一篇教程中引入中文字体的方法。这个方法没有将字体文件捆绑在程序中,而是在程序执行时读取字体文件,所以这种自定义字体的方式,需要带上字体文件一起分发,也不是很方便。
用同样的方法,我们可以分别设置字体的粗体、斜体和等宽体显示的样式:
type myTheme struct {
regular fyne.Resource
bold fyne.Resource
italic fyne.Resource
monospace fyne.Resource
}
func (m *myTheme) Font(s fyne.TextStyle) fyne.Resource {
if s.Monospace {
return m.monospace
}
if s.Bold {
return m.bold
}
if s.Italic {
return m.italic
}
return m.regular
}
利用类型的思路,还有更高阶全面的设置方法,同样要另外带上字体文件。
theme.go
type myTheme struct {
regular, bold, italic, boldItalic, monospace fyne.Resource
}
func (t *myTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
return theme.DefaultTheme().Color(name, variant)
}
func (t *myTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}
func (m *myTheme) Font(style fyne.TextStyle) fyne.Resource {
if style.Monospace {
return m.monospace
}
if style.Bold {
if style.Italic {
return m.boldItalic
}
return m.bold
}
if style.Italic {
return m.italic
}
return m.regular
}
func (m *myTheme) Size(name fyne.ThemeSizeName) float32 {
return theme.DefaultTheme().Size(name)
}
func (t *myTheme) SetFonts(regularFontPath string, monoFontPath string) {
t.regular = theme.TextFont()
t.bold = theme.TextBoldFont()
t.italic = theme.TextItalicFont()
t.boldItalic = theme.TextBoldItalicFont()
t.monospace = theme.TextMonospaceFont()
if regularFontPath != "" {
t.regular = loadCustomFont(regularFontPath, "Regular", t.regular)
t.bold = loadCustomFont(regularFontPath, "Bold", t.bold)
t.italic = loadCustomFont(regularFontPath, "Italic", t.italic)
t.boldItalic = loadCustomFont(regularFontPath, "BoldItalic", t.boldItalic)
}
if monoFontPath != "" {
t.monospace = loadCustomFont(monoFontPath, "Regular", t.monospace)
} else {
t.monospace = t.regular
}
}
func loadCustomFont(env, variant string, fallback fyne.Resource) fyne.Resource {
variantPath := strings.Replace(env, "Regular", variant, -1)
res, err := fyne.LoadResourceFromPath(variantPath)
if err != nil {
fyne.LogError("Error loading specified font", err)
return fallback
}
return res
}
main.go
// 设置主题
t := &myTheme{}
t.SetFonts("./assets/font/Consolas-with-Yahei Regular Nerd Font.ttf", "")
// 注意"./assets/font"目录下有4个文件:
// Consolas-with-Yahei Bold Nerd Font.ttf
// Consolas-with-Yahei BoldItalic Nerd Font.ttf
// Consolas-with-Yahei Italic Nerd Font.ttf
// Consolas-with-Yahei Regular Nerd Font.ttf
app.Settings().SetTheme(t)