Designing cross-platform GUI apps with Fyne

Platform Agnostic

© Lead Image © Ion Chiosea, 123RF.com

© Lead Image © Ion Chiosea, 123RF.com

Author(s):

The Fyne toolkit offers a simple way to build native apps that work across multiple platforms. We show you how to build a to-do list app to demonstrate Fyne's power.

One of the biggest challenges in modern app development is the requirement to develop a separate codebase for each platform that you want to support. Fortunately, the Fyne graphical user interface (GUI) toolkit lets you program your app once and then deploy it rapidly to multiple devices.

Fyne is an open source GUI toolkit built for the Go programming language, a popular general-purpose compiled programming language created by Google a little over 10 years ago. Go is frequently used for cloud services and infrastructure projects. However, the Fyne toolkit makes it possible to quickly build native GUIs for desktop and mobile devices. One of a number of GUI toolkits for Go, Fyne aims to be the simplest to use, appealing to both beginners and experienced GUI developers.

Fyne's idiomatic design makes it easy for Go developers to rapidly learn its principles. Fyne allows developers to create their own custom widgets thanks to its rich widget set and extensibility. With graphical elements inspired by Material Design, Fyne will feel familiar to many end users, while remaining open to theming by app developers. Fyne is fully unit tested and supports rigorous test-driven development. Released under the same permissive BSD licence as Go, Fyne is supported by an active development community.

In this article, I will show you how to set up your system to program your first app with Go and Fyne. I will cover the basic APIs for showing windows, laying out content, and using the standard widgets, along with storing data and working with event handlers. By the end of this article, you will have a complete graphical application running on your computer and smartphone device.

Setup

To get started with Fyne, you need to install a few developer tools including Git, Go, and a C compiler. Assuming you are working on a Linux desktop with an X11 setup, you also need to install some libraries. Don't worry – this only applies to developing Fyne apps. Your users won't need any of these dependencies to run the finished apps. Fyne (like Go) focuses on single binary compiler output with zero dependencies. As long as your users have a working graphics driver, your apps will work without any additional setup.

You should use your computer's package manager to install the necessary tools. All distributions are different, but the most common systems will need to use the following commands. For Debian and Ubuntu, use:

sudo apt-get install golang gcc libgl1-mesa-dev xorg-dev

On Fedora, use:

sudo dnf install golang gcc libXcursor-devel libXrandr-develmesa-libGL-devel libXi-devellibXinerama-devel libXxf86vm-devel

For Arch Linux, use:

sudo pacman -S go xorg-server-devel libxcursor libxrandr libxinerama libxi

You also need at least Go v1.14 for Fyne code to compile. You can adapt the above commands for your package manager or find the full list in the Fyne documentation [1].

Getting Started

Once you've installed the necessary tools, you are now ready to build a basic Go app to make sure that everything is working. First, make a new folder and set it up as a Go project using go mod init (Listing 1).

Listing 1

Create a Project

> mkdir todoapp
> cd todoapp
> go mod init todoapp
go: creating new go.mod: module todoapp

You now have a folder that contains a go.mod file. You can test to see if the Go compiler is working by quickly sending some basic file content to main.go and then building the app (Listing 2). All you need is the package name (main) and a simple main() function to let the compiler know you are building an application instead of a library.

Listing 2

Test the Install

:> echo "package main
func main() {}" > main.go
> go build

If your build completes without error, then it has succeeded. You will see a file called todoapp. You can run todoapp at this stage, but you will not get output because you have not built your app yet!

Showing a Window

I'll now demonstrate how to write some code to display a window onscreen. First, you need to install the Fyne library into your project with go get. The following command will download the Fyne project's source code from GitHub and store it in the Go cache. It then marks Fyne as a dependency of your project so that your code editor or IDE can make sensible suggestions for autocompletion and compilation:

> go get fyne.io/fyne/v2@latest
go: added fyne.io/fyne/v2 v2.3.4

After installing the Fyne library, you next must create a new application instance, which is the variable that will be used to create new windows, access storage, and use other important features. You can create a new app by inserting the following into your main func:

a := app.New()

However, it is best practice to identify your app, which can be done using:

app.NewWithID("com.example.myapp")

The code should be added to the top of your main function. This app package is from the import path fyne.io/fyne/v2/app (your IDE will probably automatically add this).

In the new app, you can create a new window by simply requesting it with:

w := app.NewWindow("TODO")

This sets up the variable w for the new window. The window will have the title "TODO" which may be displayed in your window border. At this point, you could build the app. However, without any content, the window would be fairly useless, so I'll show you how to add a widget.

Adding a Widget

In Fyne, an app can be created by drawing basic types (called CanvasObjects) to a position within the window. These items are available in the fyne.io/fyne/v2/canvas package. However, you can use a collection of pre-made widgets (fyne.io/fyne/v2/widget) that provides several interactive widgets for the standard user controls and visual items found in most apps.

You can create a new label widget to display the text "TODO App" by simply calling widget.NewLabel("TODO App"). Then use the SetContent method to place this widget inside the previously created window. The line of code now becomes:

w.SetContent(widget.NewLabel("TODO App"))

Finally, you must show the window. Because GUI toolkits also have an event loop that manages how the app runs and listens for user input, you also need to run the app. Conveniently, Fyne has a method that simultaneously shows the window and runs the application:

w.ShowAndRun()

That is all there is to creating your first graphical app.

Running the App

To run the app, you can use go run, or you could build it using go build and then later run the executable that was created. However, because you have added a new library, you should run go mod tidy first to ensure the project metadata is up-to-date. After this, you can run the app with:

> go mod tidy
go: finding module for package fyne.io/fyne/v2/app
go: found fyne.io/fyne/v2/app in fyne.io/fyne/v2 v2.3.4
> go run .

Telling the Go compiler to run "." means it runs all the files in the current directory (main.go for your app). Your app should now look something like Figure 1. Depending on your desktop settings, the app may appear with a light or a dark theme.

Figure 1: The app is shown here using a light theme, but a dark theme is also possible depending on your desktop settings.

Building the User Interface

Before you start building more complex content for your app, I need to explain Container types. Containers are what allow multiple elements to be arranged in an area, and they do so according to a layout. There are many standard containers for common types of positioning in Fyne. You can also add your own layout code. (I won't cover that in this article, but you can find out more online [2].)

These container types include VBox and HBox (for packing child items into vertical or horizontal boxes), Split (to allow users to drag a split bar separating two child items), or Stack (to allow multiple items to be drawn on top of each other). The most popular, and flexible, container is Border, which you will use twice in the to-do list app. The Border container takes four required fields for items to be positioned along the top, bottom, left, and right edges, and an optional fifth parameter for the content that should fill the remaining (central) space.

The app is split into two main areas: the top bar (where you will position the "New item" entry widget) and the to-do list area, which fills the main area. In essence, the app's entire layout is captured as:

container.NewBorder(head, nil, nil, nil, list)

The app uses a new package called container, which is from fyne.io/fyne/v2/container.

To make the top bar, you will use another Border container, which will have an Entry widget for the user input (Listing 3) and a new Button widget using an icon for adding content, which is available in the standard theme. (I will add code to respond to user input later.)

Listing 3

Input Widgets

01 input := widget.NewEntry()
02 input.SetPlaceHolder("New item")
03 add := widget.NewButtonWithIcon("",
04   theme.ContentAddIcon(), func() {
05   log.Println("new item tapped")
06 })
07 head := container.NewBorder(nil, nil, nil, add, input)

Listing 3 defines the app's user interface except for the to-do list in main area.

Displaying a Data List

To complete the user interface, you need to define the main to-do list. The List widget is ideal for this task, but it is a little more complex than the previously discussed widgets. List is a "collection widget" (along with Table and Tree) designed to handle lots of data at high performance.

I will create a list using widget.NewList. Rather than passing static data, widget.NewList uses callbacks to understand how content should be displayed. The three callbacks it uses define the number of list items, how an item is created, and what data each row should contain. This more complex setup means that Fyne can reuse widgets as a user scrolls, which is much faster and more efficient for CPU and memory.

For now, I will use dummy content in the sample to-do list. It will display five items, and each one will show "TODO Item" (as the data for the third callback doesn't yet exist to display). For each row in List, I use a Check widget to give each item a checkbox, which will allow users to mark completed items and display the associated text. This code, together with the earlier items, comprises the entire user interface. For simplicity, the graphical setup code is moved into a new function called loadUI as shown in Listing 4.

Listing 4

ui.go

01 package main
02
03 import (
04   "log"
05
06   "fyne.io/fyne/v2"
07   "fyne.io/fyne/v2/container"
08   "fyne.io/fyne/v2/theme"
09   "fyne.io/fyne/v2/widget"
10 )
11
12 func loadUI() fyne.CanvasObject {
13   list := widget.NewList(
14     func() int {
15       return 5
16     },
17     func() fyne.CanvasObject {
18       return widget.NewCheck("TODO Item",
19         func(bool) {})
20     },
21     func(id widget.ListItemID, o fyne.CanvasObject) {
22     })
23
24   input := widget.NewEntry()
25   input.SetPlaceHolder("New item")
26   add := widget.NewButtonWithIcon("",
27     theme.ContentAddIcon(), func() {
28     log.Println("new item tapped")
29   })
30   head := container.NewBorder(nil, nil, nil, add, input)
31
32   return container.NewBorder(head, nil, nil, nil, list)
33 }

Launching the GUI

Because the code is now in the loadUI function, you also need to update the main function to set the content to be the result of calling loadUI.

A list can be very small, but you want to see multiple items at the same time. To do this, you call Resize on Window with a suitable size (not pixels because a Fyne size will be the same across different output devices). The last three lines of the main function become:

w.SetContent(loadUI())
w.Resize(fyne.NewSize(200, 280))
w.ShowAndRun()

When you run the updated code, you will see the new user interface appear on your screen (Figure 2).

Figure 2: The full graphical layout for the to-do list app.

Remembering To-Dos

You want to be able to add items and mark them as done in your app. To do this, you will use a new global variable, todos, which is a slice of strings (denoted as []string). You will manipulate this slice when adding or deleting items, and your list will always draw this data's current state. First, you add a new todos variable to main.go. You also need to keep a reference to the list variable created earlier so it can be updated (shown in Listing 5).

Listing 5

Storing Data

var (
  todos []string
  list  *widget.List
)

Adding Items

When the user taps on the add button created in Listing 3, a new item will be added to the to-do list. To do this, you make a more useful tapped handler for the button. Instead of logging to the console, you will use the code in Listing 6, which asks for a new item to be added and then clears the input text to get ready for another to-do item.

Listing 6

Adding a To-Do Item via Tapping

add := widget.NewButtonWithIcon("",
theme.ContentAddIcon(),
func() {
  addTODO(input.Text)
  input.SetText("")
})

Because some users prefer to run an app from the keyboard, you will also want this functionality when the user hits Return or Enter. Thankfully, the Entry widget has an OnSubmitted callback that you can set as shown in Listing 7.

Listing 7

Adding a To-Do Item via the Keyboard

input.OnSubmitted = func(item string) {
  addTODO(item)
  input.SetText("")
}

Both handlers call a new addTODO function, which simply appends a new item to the slice and refreshes the list (see Listing 8).

Listing 8

data.go

01 package main
02
03 import "fyne.io/fyne/v2"
04
05 func addTODO(todo string) {
06   todos = append(todos, todo)
07   list.Refresh()
08 }
09
10 func deleteTODO(todo string) {
11   for i, text := range todos {
12     if text != todo {
13       continue
14     }
15
16     todos = append(todos[:i], todos[i+1:]...)
17     break
18   }
19   list.Refresh()
20 }

Populating the List

Now that you have some data to display, you need to tell the list how to update accordingly. To do this, you add some code to the third callback of the List widget. This code will set the Check widget's text and make sure that it is not checked (because the list currently only has pending to-do items). Listing 9 achieves this using a replacement callback function.

Listing 9

Displaying To-Do Content

01 func(id widget.ListItemID, o fyne.CanvasObject) {
02   check := o.(*widget.Check)
03   check.SetChecked(false)
04   check.Text = todos[id]
05   check.Refresh()
06 }

Line 1 of Listing 9 makes sure you can use the Check widget's functionality. The list does not know what type of content it displays, so you need to do a type cast to match the widget you created in the second callback function. With this in place, you will see your to-do list – all that is left is to delete them!

Deleting Items

The to-do list is only keeping track of pending items, so you want to remove any to-do item once it is checked. For this, you can set the OnChanged callback to your Check widget (Listing 10), much like you set OnSubmitted on the Entry widget.

Listing 10

Deleting To-Do Items

check.OnChanged = func(done bool) {
  if !done {
    return
  }
  deleteTODO(check.Text)
}

The OnChanged callback will be called if the item is checked or unchecked – which is why you first test that it is being marked as completed using the bool parameter. Assuming it should be deleted, you call the helper function deleteTODO from Listing 8.

The method shown in Listing 8 is a little complicated because there is no built-in way to delete an item from a slice in Go. Listing 8 performs "re-slicing," which sets the to-do list to a join of two other lists (a list for before and one for after the item is deleted). The code then refreshes the list as before, so your item disappears. If you run your app once more, you will see that it is empty, but you can add new items and mark them as done as often as you like, as shown in Figure 3.

Figure 3: You can now input your to-do items.

Storing Data

You now have a fully functional app to track your to-do items, but the app will forget the items when you exit. To remedy this, you can connect to a database or store data in a file. However, Fyne lets you store your data in Preferences, a simple key-value store used to easily keep track of user interface state and small data storage without worrying about file storage or connection handling. You can access this feature using the Preferences() method on the App type created earlier.

Loading To-Do Items

You need to ensure that any existing to-do items are loaded when the app starts. To do this, you can simply set up the todos variable to be the result of a load call. Immediately before calling loadUI, you need to call

list = loadTODOs(a.Preferences())

This call will pass your application preferences to a new loadTODOs function that will return a slice of all stored to-dos.

Saving the Data

Before you have data to parse, you need to save it! To do this, you will use another new method, saveTODOs, which will take the current slice to save and a reference to the preferences you will be using:

saveTODOs(todos, p)

Be sure to call this method inside deleteTODO and addTODO after updating the data.

The methods that actually do the work in this example are really simple – you just concatenate all the items to a single string using a joiner constant and then split the items out on load, as shown in Listing 11.

Listing 11

storage.go

01 package main
02
03 import "fyne.io/fyne/v2"
04
05 const joiner = "|"
06
07 func loadTODOs(p fyne.Preferences) []string {
08   all := p.String("items")
09   if all == "" {
10     return []string{}
11   }
12   return strings.Split(all, joiner)
13 }
14
15 func saveTODOs(items []string, p fyne.Preferences) {
16   allItems := strings.Join(items, joiner)
17   p.SetString("items", allItems)
18 }

Local Storage or Cloud

Regardless of your operating system or device, Listing 11 will store the data for you. However, many apps now want to have centrally stored information on a cloud service. The great news is that Fyne supports this too! Using exactly the same code from Listing 11, you can have a cloud back end for your data, which is set up by simply calling SetCloudProvider for your app with a suitable provider description. You can find more information about setting this up in a recent FyneConf video [3].

Packaging and Installing

Once you have a completed your app, the next step is make it more readily available either for yourself or others.

First, you need to add an icon before installing or sharing the app. Although Fyne is a vector graphics-based toolkit, you will need a bitmap image for the app icon. For greatest compatibility, create a 1024x1024 PNG file, name it Icon.png, and save it to the project folder. Once created, this will be used as the app icon. The best way to bundle apps is to use the fyne command-line tool:

> go install fyne.io/fyne/v2/cmd/fyne@latest

Once installed you can call fyne package to set up an app bundle. It will use the appropriate format for the current operating system. You can also install it at the same time using fyne install. This will package the app and put it into your standard app location (/usr/local/bin for most Linux distributions).

If you get a "command not found" message, be sure to check that the Go binary location (usually ~/go/bin) is in your $PATH. Once complete, the app will be available in your favorite app launcher (Figure 4).

Figure 4: You can launch the to-do app from your favorite launcher.

Sharing with Others

Before sharing your app, you should consider adding a tutorial for new users. This is really easy for a to-do list app because you can just add items to the UI when the app has not been used before by simply replacing the call to Properties.String with Properties.StringWithFallback. The fallback case will be used when the requested preference key has not previously been saved. Try the following:

p.StringWithFallback("items",
  "Do this item"+joiner+"Learn Fyne!"
  +joiner+"Build an app")

Now, you can package the code for distribution. The command fyne package will prepare a bundle (for a Linux host, this will create todoapp.tar.xz). The bundle will contain your binary, an icon file, and the metadata needed to install on other computers. These binaries are relocatable, so it will work for computers with a different configuration. New app users will see the welcome screen shown in Figure 5 with a tutorial and an icon!

Figure 5: The welcome screen now contains a tutorial and an app icon.

Building for Other Platforms

Another great feature of the fyne command-line tool is that it can package code for other platforms, just like the Go compiler. You can specify the operating systems that you want to bundle the app for, one at a time. For example:

> fyne package -os windows

Due to the graphical libraries required to compile (like the ones you installed at the beginning of the article), there are some additional requirements for cross-platform compilation. See the Fyne documentation for more information [4].

If you are familiar with a Docker or podman install, then you can use the fyne-cross [5] tool, which will handle all the complex developer setup process inside containers. As long as you have a container engine running, it should be as simple as:

> go install github.com/fyne-io/fyne-cross@latest
> fyne-cross android

Figure 6 shows how the same app will look when running on an iOS or Android mobile device. The package was created using fyne-cross and installed using standard developer tools.

Figure 6: The same app code shown here running on iOS.

Finally, if you are interested in automating your build for multiple platforms along with having the downloads hosted for you, check out build platform tools such as Geoffrey by Fyne Labs [6].

Conclusion

In less than 100 lines of code, you now have a graphical application that will work on the desktop or mobile device of your choice. You can find the full application source code on the project's GitHub page [7]. Development with Fyne is easy to get started with. I hope you will be inspired to explore the potential of this GUI toolkit further.

Visit the Fyne apps website [8] to see a list of other applications that have been developed using Fyne. To find out more about programming with Fyne, check out the Fyne documentation and tutorials [9]. As a Linux enthusiast, you may also be interested in trying out the full desktop environment that was built using Fyne, FyshOS [10].

Infos

  1. Getting Started documentation: https://developer.fyne.io/started/
  2. Creating your own layout code: https://developer.fyne.io/explore/container
  3. Cloud storage with Fyne: https://youtu.be/Izm7l5SXmN8
  4. Cross-platform compiling: https://developer.fyne.io/started/cross-compiling
  5. fyne-cross: https://github.com/fyne-io/fyne-cross
  6. Geoffrey: https://fynelabs.com/geoffrey
  7. To-do app source code: https://github.com/andydotxyz/linuxmagazine-todoapp
  8. Other Fyne apps: https://apps.fyne.io
  9. Fyne tutorials and documentation: https://developer.fyne.io
  10. FyshOS: https://fyshos.com/desktop/

The Author

Andrew Williams is a software engineer and entrepreneur based in Scotland, UK, with experience in many open source technologies, having been a core developer in large projects such as Enlightenment, EFL, Maven, and Fyne. He is the founder of the Fyne toolkit and CEO of Fyne Labs where they work to expand the possibilities of platform-agnostic app development. He is also the author of Building Cross-Platform GUI Applications with Fyne and Hands-On GUI Application Development in Go.