快速入門 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)