快速入门Fyne框架:进阶篇

Keywords: #技术 #Golang #Fyne
Table of Contents

快速入门 Go 语言 Fyne UI 框架进阶篇,自定义主题配置。

fyne_go.jpg

前言

在上一篇笔记中,我们学习了如何快速使用 Fyne 框架编写一个简易的 markdown 编辑器。其中提到了几个问题:

  1. Fyne 不支持热重载
  2. 自定义中文字体需要编写 theme.goutil.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{})

image.png

image.png

这里没有设置输入框 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.pngimage2.png 两张图片,则它们对应的资源名称就为 resourceImage1PngresourceImage2Png

默认命名规则为,文件名开头字母一定大写,文件后缀名开头一定大写,其他保持原样。即 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})

这也是上一篇教程中引入中文字体的方法。这个方法没有将字体文件捆绑在程序中,而是在程序执行时读取字体文件,所以这种自定义字体的方式,需要带上字体文件一起分发,也不是很方便。

image.png


用同样的方法,我们可以分别设置字体的粗体、斜体和等宽体显示的样式:

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)