Fyne 定制扩展部件详解

摘要

Fyne 目前是 Golang 中最流行的 UI 库之一,但是流行不意味着好用,不仅官方文档十分简洁(简陋),网络资源也少得可怜,特别是关于定制扩展部件方面。针对上述问题,本文在使用 Fyne 定制部件方面结合官方文档做了一些探索:首先对定制部件需实现的相关接口进行介绍;其次设计了一个包含文字、背景、鼠标事件等多个特性的部件,对其进行了简单实现,并且详细介绍了其中的关键步骤和实现过程。

接口介绍

通常自定义小部件需要实现两个接口—— fyne.Widgetfyne.WidgetRenderer ,前者用于确定部件的属性和行为,后者用于确定如何将部件渲染到屏幕上。

  1. fyne.Widget
1
2
3
4
type Widget interface {
	CanvasObject
	CreateRenderer() WidgetRenderer
}

fyne.Widget 主要有两个属性,其中 CanvasObject 表示组件中的主要内容,如图片、图标、文字、背景等; CreateRenderer 则用于创建一个 fyne.WidgetRenderer 类型的渲染器供渲染时调用。实现该接口即代表实现了一个 fyne 可用组件,但是只实现该接口的组件没有任何意义,因为我们还得为组件定义是否隐藏、大小、尺寸等函数,为了降低工作量官方为我们提供了一个封装了常用方法的基础组件对象 widget.BaseWidget ,通常自定义组件直接继承 widget.BaseWidget 即可,比如官方提供的 Menu 便是如此:

1
2
3
4
5
6
7
8
9
type Menu struct {
	BaseWidget
	alignment     fyne.TextAlign
	Items         []fyne.CanvasObject
	OnDismiss     func()
	activeItem    *menuItem
	customSized   bool
	containsCheck bool
}
  1. fyne.WidgetRenderer
1
2
3
4
5
6
7
type WidgetRenderer interface {
	Destroy()
	Layout(Size)
	MinSize() Size
	Objects() []CanvasObject
	Refresh()
}

fyne.WidgetRenderer 组要负责部件的实际渲染工作,比如定义了部件的尺寸、颜色、背景、动画、事件等内容,其中每个方法的含义如下:

  • Destroy() :用于清理内存,防止内存泄漏,通常可不用实现。
  • Layout(Size) :用于定义部件中元素相对于本部件的实际布局位置。
  • MinSize() :返回本部件的最小尺寸,也是部件的实际显示尺寸。
  • Objects() :返回本部件中所有需要展示的元素。
  • Refresh() :部件实现刷新的方法,比如更新颜色、大小等。
    每个部件必须要通过 CreateRenderer() 方法返回一个 fyne.WidgetRenderer 实例,但是如果只是轻度自定义组件的话,也可以通过继承的方式重用其它已有的渲染器。

具体实现

需求

本文基于 Fyne 实现一个自定义所需功能如下:

  1. 包含文字元素,且文字大小、颜色可设置。
  2. 包含背景元素,且背景颜色可设置,能够跟随鼠标事件变化。
  3. 包含监听部件内的鼠标移入、移动、移出事件。
  4. 包含监听焦点事件、键盘按键事件,即能够通过 Tab 键选择本部件。
  5. 鼠标经过部件时需变为指针样式,且组件背景跟随变化。

代码

为了实现上述需求,我们定义部件如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type CustomWidget struct {
	widget.BaseWidget
	Text             *canvas.Text
	Background       *canvas.Rectangle
	OnTapped         func()
	hovered, focused bool
}

// 初始化构造
func NewCustomWidget(text string) *CustomWidget {
	widget := &CustomWidget{
		Text:       canvas.NewText(text, color.Black),
		Background: canvas.NewRectangle(&color.RGBA{A: 50}),
		hovered:    false,
	}
	widget.ExtendBaseWidget(widget)
	return widget
}

func (w *CustomWidget) CreateRenderer() fyne.WidgetRenderer {
	w.ExtendBaseWidget(w)
	// 定义需要在部件上实际展示的元素,且排列越靠前的元素越先渲染为底层。
	objects := []fyne.CanvasObject{
		w.Background,
		w.Text,
	}
	w.Background.Refresh()
	w.Text.Refresh()
	return &customWidgetRenderer{
		background: w.Background,
		widget:     w,
		text:       w.Text,
		objects:    objects,
	}
}

在自定义部件 CustomWidget 中继承了 widget.BaseWidget 并定义了 TextBackground 基本属性来表示文字和背景, OnTapped 用来指向组件点击事件的处理函数。该部件包含 CreateRenderer 方法,它返回一个 fyne.WidgetRenderer 实例,该实例的具体实现在后文给出。为了使其能够监听鼠标的移入、移动、移出事件,为自定义部件添加以下方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 鼠标进入时
func (w *CustomWidget) MouseIn(e *desktop.MouseEvent) {
	//需实现 desktop.Hoverable 接口的所有3个函数,否则无效
	fmt.Println("call MouseIn", e.Position)
	w.hovered = true
	w.Background.FillColor = &color.RGBA{A: 100}
	w.Background.Refresh()
	w.Refresh()
}

// 鼠标移动时
func (w *CustomWidget) MouseMoved(*desktop.MouseEvent) {
	fmt.Println("call MouseMoved")
}

// 鼠标移出时
func (w *CustomWidget) MouseOut() {
	fmt.Println("call MouseOut")
	w.hovered = false
	w.Background.FillColor = &color.RGBA{A: 50}
	w.Background.Refresh()
	w.Refresh()
}

想要部件能够监听鼠标事件,实际上实现 Fyne 中 desktop.Hoverable 接口的 MouseInMouseMovedMouseOut 三个方法即可,需要注意的是必须只有完全实现所有三个方法才算实现 desktop.Hoverable 接口。在 MouseInMouseOut 中我们通过 Background.FillColor 来设置组件背景,以实现背景更换效果。为了让组件能够触发焦点事件,为其添加以下方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 焦点锁定时
func (w *CustomWidget) FocusGained() {
	////需实现 fyne.Focusable 接口的所有4个函数,否则无效
	fmt.Println("call FocusGained")
	w.focused = true
	w.Refresh()
}

// 焦点失去时
func (w *CustomWidget) FocusLost() {
	fmt.Println("call FocusLost")
	w.focused = false
	w.Refresh()
}

// 处理输入事件
func (w *CustomWidget) TypedRune(rune) {
}

// 处理键盘事件
func (w *CustomWidget) TypedKey(ev *fyne.KeyEvent) {
	if ev.Name == fyne.KeySpace {
		w.Tapped(nil)
	}
}

让组件实现 fyne.Focusable 接口的 FocusGainedFocusLostTypedRuneTypedKey 四个方法,即可实现监听焦点事件和键盘处理事件,与监听鼠标事件类似必须实现所有四个方法才会生效。前面我们在 CreateRenderer() 方法中引用了自定义的渲染器,其详细定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type customWidgetRenderer struct {
	widget     *CustomWidget
	text       *canvas.Text
	background *canvas.Rectangle
	objects    []fyne.CanvasObject
}

// 部件内布局
func (r *customWidgetRenderer) Layout(size fyne.Size) {
	r.background.Resize(size)
	// 不同元素通过其自身的 Move 函数来修改其相对于部件的位置
	r.text.Move(fyne.NewPos(0, 0))
}

// 部件包含所有的最小布局
func (r *customWidgetRenderer) MinSize() fyne.Size {
	// 通常为最大元素的尺寸或者所有元素尺寸之和
	return r.text.MinSize()
}

// 刷新
func (r *customWidgetRenderer) Refresh() {
	r.text.Refresh()
	r.background.Refresh()
	r.Layout(r.widget.Size())
	//canvas.Refresh(r.widget)
}

func (r *customWidgetRenderer) Objects() []fyne.CanvasObject {
	return r.objects
}

func (r *customWidgetRenderer) Destroy() {
}

渲染器 customWidgetRenderer 有点像 CustomWidget 的翻版,其元素除了固定包含 CustomWidget[]fyne.CanvasObject 外还有 CustomWidget 中期望需要展示的内容, objects 即所有展示元素的列表,通常为除 CustomWidget 外的其它所有元素。渲染器的大部分方法都有着固定写法,一般只需要详细定义 LayoutMinSizeRefresh 方法即可。

效果展示

基于以上代码我们可以创建一个简陋的测试窗口:

1
2
3
4
5
6
7
8
9
func main() {
	myApp := app.New()
	myWindow := myApp.NewWindow("Custom Widget Example")
	myLabel := components.NewCustomWidget("OldText")
	myContainer := container.NewCenter(myLabel)
	myWindow.SetContent(myContainer)
	myWindow.Resize(fyne.NewSize(100, 100))
	myWindow.ShowAndRun()
}

上述代码测试结果如下图:
https://cdn.jsdelivr.net/gh/wefantasy/FileCloud/img/202307311122738.gif

参考