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)
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。