banner
Pi3

Pi3

记录学习、生活和成长
github
telegram
x
zhihu
email

快速入门Fyne框架:进阶篇

快速入门 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)
加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。