Designing cross-platform GUI apps with Fyne
Platform Agnostic
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.
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).
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.
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).
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!
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.
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
- Getting Started documentation: https://developer.fyne.io/started/
- Creating your own layout code: https://developer.fyne.io/explore/container
- Cloud storage with Fyne: https://youtu.be/Izm7l5SXmN8
- Cross-platform compiling: https://developer.fyne.io/started/cross-compiling
- fyne-cross: https://github.com/fyne-io/fyne-cross
- Geoffrey: https://fynelabs.com/geoffrey
- To-do app source code: https://github.com/andydotxyz/linuxmagazine-todoapp
- Other Fyne apps: https://apps.fyne.io
- Fyne tutorials and documentation: https://developer.fyne.io
- FyshOS: https://fyshos.com/desktop/