Fyne 的默认 UI 风格偏向简洁、扁平、克制,对于许多需要强视觉表现的应用来说显得过于朴素。然而,Fyne 从底层设计就预留了极其充分的定制接口。无论是全局主题风格、组件内部渲染逻辑、自定义动画效果,还是 Canvas 原始绘图,开发者都有足够的灵活性来实现从精美到炫酷的任意视觉风格。本文将系统性地讲解 Fyne UI 定制的完整知识体系,并结合一个企业级深色主题的实战项目,展示如何将这些技术融会贯通。
主题系统:Theme 接口完整解析
Fyne 的主题系统通过一个核心接口 fyne.Theme 来控制全局视觉风格。每个应用全局只有一个当前生效的主题,通过 app.Settings().SetTheme() 进行切换。
Theme 接口定义
type Theme interface {
Color(name ThemeColorName, variant ThemeVariant) color.Color
Font(style TextStyle) Resource
Icon(name ThemeIconName) Resource
Size(name ThemeSizeName) float32
}
这四个方法分别控制颜色、字体、图标和尺寸。其中 ThemeVariant 用于区分亮色和暗色模式,取值为 theme.VariantLight 或 theme.VariantDark。要实现一个完整的主题,必须覆盖所有这四个方法的返回值。
完整的自定义 Theme 实现
package main
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
// BlueTheme 一个完整的自定义主题
type BlueTheme struct {
regularFont fyne.Resource
boldFont fyne.Resource
}
func (t *BlueTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
switch name {
case theme.ColorNameBackground:
if variant == theme.VariantDark {
return color.RGBA{20, 22, 30, 255}
}
return color.RGBA{242, 244, 248, 255}
case theme.ColorNameForeground:
if variant == theme.VariantDark {
return color.RGBA{230, 232, 240, 255}
}
return color.RGBA{30, 30, 35, 255}
case theme.ColorNamePrimary:
return color.RGBA{50, 130, 230, 255}
case theme.ColorNameButton:
return color.RGBA{50, 130, 230, 255}
case theme.ColorNameDisabled:
return color.RGBA{120, 120, 130, 255}
case theme.ColorNameError:
return color.RGBA{220, 70, 70, 255}
case theme.ColorNamePlaceHolder:
return color.RGBA{160, 160, 170, 255}
case theme.ColorNameInputBackground:
if variant == theme.VariantDark {
return color.RGBA{35, 38, 50, 255}
}
return color.RGBA{255, 255, 255, 255}
case theme.ColorNameHover:
return color.RGBA{60, 142, 245, 255}
case theme.ColorNameShadow:
if variant == theme.VariantDark {
return color.RGBA{0, 0, 0, 80}
}
return color.RGBA{0, 0, 0, 30}
default:
// 对于未覆盖的颜色,回退到默认主题
return theme.DefaultTheme().Color(name, variant)
}
}
func (t *BlueTheme) Font(style fyne.TextStyle) fyne.Resource {
if style.Bold && t.boldFont != nil {
return t.boldFont
}
if t.regularFont != nil {
return t.regularFont
}
return theme.DefaultTheme().Font(style)
}
func (t *BlueTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}
func (t *BlueTheme) Size(name fyne.ThemeSizeName) float32 {
switch name {
case theme.SizeNameText:
return 15
case theme.SizeNameHeadingText:
return 24
case theme.SizeNameSubHeadingText:
return 18
case theme.SizeNameCaptionText:
return 12
case theme.SizeNamePadding:
return 10
case theme.SizeNameInlineIcon:
return 22
case theme.SizeNameScrollBar:
return 10
case theme.SizeNameScrollBarSmall:
return 6
case theme.SizeNameInputBorder:
return 1.5
case theme.SizeNameInputRadius:
return 6
default:
return theme.DefaultTheme().Size(name)
}
}
func main() {
a := app.New()
a.Settings().SetTheme(&BlueTheme{})
win := a.NewWindow("自定义 Theme")
win.SetContent(container.NewVBox(
widget.NewLabelWithStyle("主标题", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabel("普通正文文本"),
widget.NewEntry(),
widget.NewButton("主操作按钮", nil),
widget.NewButton("次要按钮", nil),
))
win.Resize(fyne.NewSize(400, 300))
win.ShowAndRun()
}
Fyne 内置的主题颜色常量非常多,包括但不限于:ColorNameBackground(背景)、ColorNameForeground(前景文字)、ColorNamePrimary(主题主色)、ColorNameButton(按钮颜色)、ColorNameInputBackground(输入框背景)、ColorNameHover(悬停状态)、ColorNameFocus(焦点状态)、ColorNameError(错误提示)、ColorNameSuccess(成功状态)。完整列表可以参考 Fyne 的 theme 包源码或官方文档。
颜色、字体与尺寸定制详解
颜色系统的语义化
在设计主题时,建议遵循语义化颜色的原则,即颜色值不直接与具体颜色名称绑定,而是根据用途命名。这样可以保证应用在不同模式(亮色/暗色)下的切换更为自然。
// 语义化颜色定义示例
const (
brandPrimary = theme.ColorNamePrimary
brandSecondary = theme.ColorNameSecondary
surfaceDefault = theme.ColorNameBackground
surfaceElevated = theme.ColorNameInputBackground
textPrimary = theme.ColorNameForeground
textSecondary = theme.ColorNamePlaceHolder
textDisabled = theme.ColorNameDisabled
borderDefault = theme.ColorNameInputBorder
stateHover = theme.ColorNameHover
stateFocus = theme.ColorNameFocus
feedbackError = theme.ColorNameError
feedbackSuccess = theme.ColorNameSuccess
)
字体嵌入与多字体管理
中文字体是 Fyne 应用中常见的痛点。Fyne 默认字体不支持中文,需要嵌入自定义字体:
package main
import (
_ "embed"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
//go:embed assets/NotoSansSC-Regular.ttf
var fontRegular []byte
//go:embed assets/NotoSansSC-Bold.ttf
var fontBold []byte
type ChineseTheme struct{}
func (c *ChineseTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color {
return theme.DefaultTheme().Color(n, v)
}
func (c *ChineseTheme) Font(style fyne.TextStyle) fyne.Resource {
if style.Bold {
return fyne.NewStaticResource("font-bold", fontBold)
}
return fyne.NewStaticResource("font-regular", fontRegular)
}
func (c *ChineseTheme) Icon(n fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(n)
}
func (c *ChineseTheme) Size(n fyne.ThemeSizeName) float32 {
return theme.DefaultTheme().Size(n)
}
func main() {
a := app.New()
a.Settings().SetTheme(&ChineseTheme{})
win := a.NewWindow("中文支持")
win.SetContent(widget.NewLabel("这是一段中文文本,使用思源黑体渲染"))
win.ShowAndRun()
}
图标系统与图标字体集成
除了主题图标外,Fyne 支持加载任何图片资源作为图标。更高级的做法是加载图标字体(Icon Fonts),如 Material Design Icons 或 Font Awesome:
package main
import (
_ "embed"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
//go:embed assets/MaterialIcons-Regular.ttf
var iconFontData []byte
// IconFontIcon 使用图标字体渲染图标
type IconFontIcon struct {
widget.Icon
char rune
color color.Color
}
func NewIconFontIcon(char rune, c color.Color) *IconFontIcon {
i := &IconFontIcon{char: char, color: c}
i.ExtendBaseWidget(i)
return i
}
func (i *IconFontIcon) MinSize() fyne.Size {
return fyne.NewSize(24, 24)
}
对于更完整的图标字体支持,建议将图标字体嵌入为应用资源,通过自定义 Widget 将其渲染为 canvas.Text,配合图标文字的 Unicode 码点即可显示任意图标。
自定义布局:Layout 接口实现
当 Fyne 内置的布局(VBox、HBox、Grid、Border)无法满足需求时,可以实现 fyne.Layout 接口创建自定义布局。
Layout 接口定义
type Layout interface {
Layout(objects []CanvasObject, size Size)
MinSize(objects []CanvasObject) Size
}
Layout 方法负责计算每个 CanvasObject 的位置和大小,MinSize 则返回布局所需的最小空间。
流式布局(Flow Layout)实现
package main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
// FlowLayout 流式布局:子元素从左到右排列,超出宽度自动换行
type FlowLayout struct {
horizontalGap float32
verticalGap float32
}
func NewFlowLayout(hGap, vGap float32) *FlowLayout {
return &FlowLayout{horizontalGap: hGap, verticalGap: vGap}
}
func (f *FlowLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
x, y := float32(0), float32(0)
maxHeightInRow := float32(0)
for _, obj := range objects {
if !obj.Visible() {
continue
}
objMinSize := obj.MinSize()
// 判断是否需要换行
if x+objMinSize.Width > size.Width && x > 0 {
x = 0
y += maxHeightInRow + f.verticalGap
maxHeightInRow = 0
}
obj.Move(fyne.NewPos(x, y))
obj.Resize(objMinSize)
x += objMinSize.Width + f.horizontalGap
if objMinSize.Height > maxHeightInRow {
maxHeightInRow = objMinSize.Height
}
}
}
func (f *FlowLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
maxW, totalH := float32(0), float32(0)
for _, obj := range objects {
if !obj.Visible() {
continue
}
size := obj.MinSize()
if size.Width > maxW {
maxW = size.Width
}
totalH += size.Height + f.verticalGap
}
return fyne.NewSize(maxW, totalH-f.verticalGap)
}
func main() {
a := app.New()
w := a.NewWindow("自定义布局")
// 创建多个标签模拟标签云
tags := []string{
"Go", "Fyne", "Golang", "GUI", "跨平台",
"桌面应用", "Mobile", "Custom Layout",
"Widget", "Theme", "Canvas", "Animation",
}
var objects []fyne.CanvasObject
for _, tag := range tags {
label := widget.NewLabel(tag)
objects = append(objects, label)
}
w.SetContent(container.New(NewFlowLayout(15, 10), objects...))
w.Resize(fyne.NewSize(400, 300))
w.ShowAndRun()
}
绝对定位布局
package main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
// AbsoluteLayout 绝对布局:根据开发者指定的坐标精确放置元素
type AbsoluteLayout struct{}
func (a *AbsoluteLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
for _, obj := range objects {
if posable, ok := obj.(fyne.Positionable); ok {
pos := posable.Position()
obj.Move(pos)
}
}
}
func (a *AbsoluteLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
maxX, maxY := float32(0), float32(0)
for _, obj := range objects {
pos := obj.Position()
sz := obj.MinSize()
if pos.X+sz.Width > maxX {
maxX = pos.X + sz.Width
}
if pos.Y+sz.Height > maxY {
maxY = pos.Y + sz.Height
}
}
return fyne.NewSize(maxX, maxY)
}
func main() {
a := app.New()
w := a.NewWindow("绝对定位")
label1 := widget.NewLabel("左上角")
label1.Move(fyne.NewPos(20, 20))
label2 := widget.NewLabel("居中")
label2.Move(fyne.NewPos(150, 100))
label3 := widget.NewLabel("右下角")
label3.Move(fyne.NewPos(280, 220))
w.SetContent(container.New(&AbsoluteLayout{}, label1, label2, label3))
w.Resize(fyne.NewSize(400, 300))
w.ShowAndRun()
}
自定义渲染:WidgetRenderer 深入
自定义 Widget 的核心是 WidgetRenderer,它负责将 Widget 的抽象状态渲染为具体的可视化元素。
WidgetRenderer 接口
type WidgetRenderer interface {
Layout(size Size)
MinSize() Size
Objects() []CanvasObject
Refresh()
Destroy()
}
Objects() 返回该 Widget 包含的所有 Canvas 对象,Layout() 根据尺寸重新排列这些对象,Refresh() 在 Widget 状态变化时更新界面表现,Destroy() 释放渲染器资源。
自定义进度条组件
package main
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
// CircularProgress 圆形进度条自定义组件
type CircularProgress struct {
widget.BaseWidget
value float64 // 0-1
}
func NewCircularProgress() *CircularProgress {
cp := &CircularProgress{}
cp.ExtendBaseWidget(cp)
return cp
}
func (cp *CircularProgress) SetValue(v float64) {
cp.value = v
cp.Refresh()
}
func (cp *CircularProgress) CreateRenderer() fyne.WidgetRenderer {
bg := canvas.NewCircle(color.RGBA{60, 60, 70, 255})
bg.StrokeWidth = 8
bg.StrokeColor = color.RGBA{50, 130, 230, 255}
fill := canvas.NewCircle(color.Transparent)
fill.StrokeWidth = 8
fill.StrokeColor = color.RGBA{50, 180, 100, 255}
r := &circularProgressRenderer{
widget: cp,
bg: bg,
fill: fill,
}
r.update()
return r
}
type circularProgressRenderer struct {
widget *CircularProgress
bg *canvas.Circle
fill *canvas.Circle
}
func (r *circularProgressRenderer) Objects() []fyne.CanvasObject {
return []fyne.CanvasObject{r.bg, r.fill}
}
func (r *circularProgressRenderer) Layout(size fyne.Size) {
r.bg.Resize(size)
r.bg.Move(fyne.NewPos(0, 0))
r.fill.Resize(size)
r.fill.Move(fyne.NewPos(0, 0))
}
func (r *circularProgressRenderer) MinSize() fyne.Size {
return fyne.NewSize(80, 80)
}
func (r *circularProgressRenderer) Refresh() {
r.update()
}
func (r *circularProgressRenderer) Destroy() {}
func (r *circularProgressRenderer) update() {
// 根据进度值更新描边颜色或裁剪区域
// 实际实现需要使用路径渲染,此处为简化演示
}
func main() {
a := app.New()
w := a.NewWindow("自定义渲染")
cp := NewCircularProgress()
cp.SetValue(0.65)
w.SetContent(container.NewStack(cp))
w.Resize(fyne.NewSize(200, 200))
w.ShowAndRun()
}
在完整的圆形进度条实现中,需要使用 canvas.NewLine 或自定义路径来模拟圆弧。Fyne 的 canvas 包提供了基础的线段(Line)、矩形(Rectangle)、圆形(Circle)、文本(Text)、图像(Image)等基础图形,复杂形状可以通过这些基础图形组合实现。
Canvas 原始绘图
Canvas 提供了完全自定义绘图的最低层 API。虽然不涉及真正的像素级操作(Fyne 的 Canvas 依然是高层矢量图 API),但已经可以实现非常丰富的视觉效果。
基础绘图元素
package main
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
)
func main() {
a := app.New()
w := a.NewWindow("Canvas 绘图")
rect := canvas.NewRectangle(color.RGBA{50, 130, 230, 255})
rect.CornerRadius = 12 // 圆角矩形
rect.StrokeWidth = 2
rect.StrokeColor = color.White
rect.Resize(fyne.NewSize(200, 80))
rect.Move(fyne.NewPos(30, 30))
line := canvas.NewLine(color.RGBA{220, 70, 70, 255})
line.StrokeWidth = 3
line.Position1 = fyne.NewPos(30, 150)
line.Position2 = fyne.NewPos(280, 150)
circle := canvas.NewCircle(color.RGBA{40, 170, 120, 255})
circle.StrokeColor = color.White
circle.StrokeWidth = 2
circle.Resize(fyne.NewSize(100, 100))
circle.Move(fyne.NewPos(30, 200))
text := canvas.NewText("Hello Canvas", color.RGBA{230, 230, 230, 255})
text.TextSize = 20
text.TextStyle = fyne.TextStyle{Bold: true}
text.Move(fyne.NewPos(150, 220))
w.SetContent(container.NewWithoutLayout(
rect, line, circle, text,
))
w.Resize(fyne.NewSize(400, 350))
w.ShowAndRun()
}
动画与过渡效果
Fyne v2 开始提供了更丰富的动画系统,包括位置动画、尺寸动画和颜色动画。
完整动画示例
package main
import (
"image/color"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func main() {
a := app.New()
w := a.NewWindow("动画效果演示")
// 滑入效果的面板
panel := canvas.NewRectangle(color.RGBA{50, 130, 230, 255})
panel.CornerRadius = 12
panel.Resize(fyne.NewSize(300, 180))
panel.Move(fyne.NewPos(-320, 60))
// 滑入动画
slideAnim := canvas.NewPositionAnimation(
fyne.NewPos(-320, 60),
fyne.NewPos(50, 60),
time.Millisecond*500,
func(p fyne.Position) {
panel.Move(p)
canvas.Refresh(panel)
},
)
slideAnim.Curve = fyne.AnimationCurveEaseOut
// 颜色脉冲动画
pulseRect := canvas.NewRectangle(color.RGBA{220, 70, 70, 255})
pulseRect.Resize(fyne.NewSize(100, 40))
pulseRect.Move(fyne.NewPos(150, 280))
pulseAnim := canvas.NewColorRGBAAnimation(
color.RGBA{220, 70, 70, 255},
color.RGBA{255, 120, 120, 255},
time.Second,
func(c color.Color) {
pulseRect.FillColor = c
canvas.Refresh(pulseRect)
},
)
pulseAnim.AutoReverse = true
pulseAnim.RepeatCount = fyne.AnimationRepeatForever
// 尺寸展开动画
expandCard := canvas.NewRectangle(color.RGBA{40, 170, 120, 255})
expandCard.CornerRadius = 8
expandCard.Resize(fyne.NewSize(60, 60))
expandCard.Move(fyne.NewPos(50, 280))
expandBtn := widget.NewButton("展开", func() {
sizeAnim := canvas.NewSizeAnimation(
fyne.NewSize(60, 60),
fyne.NewSize(200, 60),
time.Millisecond*400,
func(s fyne.Size) {
expandCard.Resize(s)
canvas.Refresh(expandCard)
},
)
sizeAnim.Start()
})
go pulseAnim.Start()
w.SetContent(container.NewWithoutLayout(
panel, expandCard, pulseRect, expandBtn,
))
w.Resize(fyne.NewSize(450, 400))
w.Canvas().SetOnTypedKey(func(e *fyne.KeyEvent) {
if e.Name == fyne.KeySpace {
slideAnim.Start()
}
})
w.ShowAndRun()
}
Fyne 的动画 API 相比 Flutter 等框架较为基础,但它涵盖常见的位置、尺寸和颜色变化。更复杂的路径动画需要通过手动管理动画帧配合 time.Ticker 来实现。
响应式设计与 DPI 适配
不同平台的屏幕 DPI 差异很大,Fyne 通过 Size 接口统一管理像素大小。但开发者仍需注意设计上的响应式原则。
响应式容器实现
package main
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
// ResponsiveContainer 响应式容器:窗口窄时垂直排列,宽时水平排列
type ResponsiveContainer struct {
container *fyne.Container
children []fyne.CanvasObject
}
func NewResponsiveContainer(children ...fyne.CanvasObject) *ResponsiveContainer {
rc := &ResponsiveContainer{children: children}
rc.refresh(fyne.NewSize(400, 300))
return rc
}
func (rc *ResponsiveContainer) refresh(size fyne.Size) {
if size.Width < 500 {
rc.container = container.NewVBox(rc.children...)
} else {
rc.container = container.NewHBox(rc.children...)
}
}
func main() {
a := app.New()
w := a.NewWindow("响应式设计")
content := container.NewVBox(
widget.NewLabel("响应式布局示例"),
container.NewHBox(
widget.NewButton("操作1", nil),
widget.NewButton("操作2", nil),
widget.NewButton("操作3", nil),
),
)
w.SetContent(content)
w.Resize(fyne.NewSize(600, 400))
w.ShowAndRun()
}
深色模式切换
深色模式已成为现代应用的标准配置。Fyne 原生支持亮色/暗色主题切换,开发者只需在 Theme 中根据 variant 参数返回不同颜色即可。
package main
import (
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
type AdaptiveTheme struct{}
func (t *AdaptiveTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
if variant == theme.VariantDark {
switch name {
case theme.ColorNameBackground:
return color.RGBA{22, 24, 32, 255}
case theme.ColorNameForeground:
return color.RGBA{220, 222, 230, 255}
case theme.ColorNameInputBackground:
return color.RGBA{38, 40, 50, 255}
case theme.ColorNameShadow:
return color.RGBA{0, 0, 0, 120}
}
}
return theme.DefaultTheme().Color(name, variant)
}
func (t *AdaptiveTheme) Font(s fyne.TextStyle) fyne.Resource {
return theme.DefaultTheme().Font(s)
}
func (t *AdaptiveTheme) Icon(n fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(n)
}
func (t *AdaptiveTheme) Size(n fyne.ThemeSizeName) float32 {
return theme.DefaultTheme().Size(n)
}
func main() {
a := app.New()
a.Settings().SetTheme(&AdaptiveTheme{})
w := a.NewWindow("深色模式")
toggle := widget.NewButton("切换深色/浅色", func() {
if a.Settings().ThemeVariant() == theme.VariantLight {
a.Settings().SetTheme(&AdaptiveTheme{})
} else {
a.Settings().SetTheme(theme.LightTheme())
}
})
w.SetContent(container.NewVBox(
widget.NewLabel("当前主题会自动适配系统设置"),
widget.NewLabel("也可以手动切换"),
toggle,
))
w.Resize(fyne.NewSize(400, 250))
w.ShowAndRun()
}
完整实战:企业级深色主题
接下来综合运用前述知识,构建一套完整的企业级深色主题并展示实际效果。
package main
import (
"image/color"
_ "embed"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// EnterpriseTheme 企业级深色主题
type EnterpriseTheme struct {
brandColor color.Color
successColor color.Color
warningColor color.Color
dangerColor color.Color
infoColor color.Color
}
func NewEnterpriseTheme() *EnterpriseTheme {
return &EnterpriseTheme{
brandColor: color.RGBA{50, 120, 220, 255},
successColor: color.RGBA{40, 180, 100, 255},
warningColor: color.RGBA{240, 160, 50, 255},
dangerColor: color.RGBA{220, 70, 70, 255},
infoColor: color.RGBA{80, 170, 220, 255},
}
}
func (e *EnterpriseTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
switch name {
case theme.ColorNameBackground:
return color.RGBA{18, 20, 28, 255}
case theme.ColorNameForeground:
return color.RGBA{210, 212, 220, 255}
case theme.ColorNamePrimary:
return e.brandColor
case theme.ColorNameButton:
return e.brandColor
case theme.ColorNameHover:
return color.RGBA{65, 140, 240, 255}
case theme.ColorNameInputBackground:
return color.RGBA{30, 32, 42, 255}
case theme.ColorNameInputBorder:
return color.RGBA{55, 58, 72, 255}
case theme.ColorNamePlaceHolder:
return color.RGBA{130, 132, 140, 255}
case theme.ColorNameDisabled:
return color.RGBA{90, 92, 100, 255}
case theme.ColorNameDisabledButton:
return color.RGBA{60, 62, 70, 255}
case theme.ColorNameError:
return e.dangerColor
case theme.ColorNameSuccess:
return e.successColor
case theme.ColorNameShadow:
return color.RGBA{0, 0, 0, 100}
case theme.ColorNameFocus:
return color.RGBA{50, 120, 220, 200}
case theme.ColorNameScrollBar:
return color.RGBA{70, 72, 85, 255}
default:
return theme.DefaultTheme().Color(name, theme.VariantDark)
}
}
func (e *EnterpriseTheme) Font(style fyne.TextStyle) fyne.Resource {
return theme.DefaultTheme().Font(style)
}
func (e *EnterpriseTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}
func (e *EnterpriseTheme) Size(name fyne.ThemeSizeName) float32 {
switch name {
case theme.SizeNameText:
return 14
case theme.SizeNameHeadingText:
return 22
case theme.SizeNameSubHeadingText:
return 16
case theme.SizeNameCaptionText:
return 11
case theme.SizeNamePadding:
return 12
case theme.SizeNameInputBorder:
return 1.2
case theme.SizeNameInputRadius:
return 6
case theme.SizeNameScrollBar:
return 10
default:
return theme.DefaultTheme().Size(name)
}
}
// 状态标签组件
type StatusBadge struct {
widget.BaseWidget
text string
color color.Color
}
func NewStatusBadge(text string, c color.Color) *StatusBadge {
b := &StatusBadge{text: text, color: c}
b.ExtendBaseWidget(b)
return b
}
func (b *StatusBadge) CreateRenderer() fyne.WidgetRenderer {
bg := canvas.NewRectangle(b.color)
bg.CornerRadius = 4
label := canvas.NewText(b.text, color.White)
label.TextSize = 11
c := container.NewStack(bg, container.NewCenter(label))
return widget.NewSimpleRenderer(c)
}
func main() {
a := app.New()
a.Settings().SetTheme(NewEnterpriseTheme())
w := a.NewWindow("企业级深色主题 Demo")
w.Resize(fyne.NewSize(700, 500))
// 仪表盘卡片
createCard := func(title, value string, c color.Color) fyne.CanvasObject {
bg := canvas.NewRectangle(color.RGBA{28, 30, 42, 255})
bg.CornerRadius = 10
titleTxt := canvas.NewText(title, color.RGBA{130, 132, 142, 255})
titleTxt.TextSize = 12
valueTxt := canvas.NewText(value, color.RGBA{230, 232, 240, 255})
valueTxt.TextSize = 24
valueTxt.TextStyle = fyne.TextStyle{Bold: true}
indicator := canvas.NewRectangle(c)
indicator.Resize(fyne.NewSize(4, 40))
indicator.Move(fyne.NewPos(0, 10))
return container.NewStack(bg,
container.NewBorder(nil, nil, indicator, nil,
container.NewVBox(titleTxt, valueTxt)),
)
}
card1 := createCard("活跃用户", "12,847", color.RGBA{50, 120, 220, 255})
card2 := createCard("服务器负载", "68%", color.RGBA{40, 180, 100, 255})
card3 := createCard("告警数量", "3", color.RGBA{240, 160, 50, 255})
card4 := createCard("错误率", "0.12%", color.RGBA{220, 70, 70, 255})
dashboard := container.NewGridWithColumns(4, card1, card2, card3, card4)
// 配置表单
form := &widget.Form{
Items: []*widget.FormItem{
{Text: "服务名称", Widget: widget.NewEntry()},
{Text: "运行端口", Widget: widget.NewEntry()},
{Text: "日志级别", Widget: widget.NewSelect(
[]string{"DEBUG", "INFO", "WARN", "ERROR"}, nil)},
},
}
// 操作按钮
btnBar := container.NewHBox(
widget.NewButtonWithIcon("保存配置", theme.ConfirmIcon(), func() {}),
widget.NewButton("取消", func() {}),
)
w.SetContent(container.NewVBox(
widget.NewLabelWithStyle("企业监控面板", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
dashboard,
widget.NewSeparator(),
form,
btnBar,
))
w.ShowAndRun()
}
常见问题 FAQ
Q1: 自定义主题后部分组件颜色不生效?
A: Fyne 的很多组件内部会缓存颜色,主题切换后建议手动调用组件的 Refresh() 方法,或销毁重建窗口内容。
Q2: Fyne 是否支持真正的 CSS?
A: 不支持。Fyne 使用 Go 代码直接描述 UI 结构和样式,没有 CSS 层。如需 CSS 的自由度,考虑使用类似 Wails 的 Web 技术方案。
Q3: 自定义渲染的性能如何?
A: 单个自定义 Widget 性能很好,但如果大量使用 Canvas 手动布局和重绘,需要注意不要频繁调用 canvas.Refresh(),它会导致整个渲染管线重排。
Q4: 如何实现圆角图片?
A: Fyne 没有内置圆角裁剪图片的能力。一种方案是覆盖一个透明圆形遮罩,另一种方案是使用第三方图像处理库在加载时预先裁剪图片。
Q5: 字体文件太大怎么办?
A: 可以只提取需要的字符子集,使用 fontmin 等工具进行字体裁剪。Fyne 的 fyne bundle 也可以将裁剪后的字体嵌入二进制中。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。