In this small tutorial I will explain something that usually is the exercise that allow you to understand how to use a UI framework (at least in the web world), and we will end up step by step in building a TODO app in Go using the Fyne UI Framework.
I will assume that you have at least some knowledge of programming, but it would also be great if you have some knowledge of go, personally I found it the easiest Programming language to learn, you can just find more on how to do that in their learning resources.
It would also be better if you follow the installing instruction of fyne in here.
What are we going to build
It is simple really, just a small app that will allow you to keep a list of things to do, that you can check off if you once done them.
Setup Project structure
Go has had a bad past for how everything used to be stored in a GOPATH folder, libs, vendors, your own personal projects, but since v1.14, with the introduction of go modules everything makes more sense and is way easier to understand.
We want to init our project, so create a new folder then init the module
$ mkdir todoapp && cd todoapp
$ go mod init todoapp
$ touch main.go
Some people suggest you to call the module with the url of the github repo you will use to version control it, but it does not really matter if you are not planning to use it elsewhere but in this binary.
open this folder in your favourite code editor and drop this on your main.go
package main
import (
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/widget"
)
func main() {
a := app.New()
w := a.NewWindow("TODO App")
w.SetContent(widget.NewLabel("TODOs will go here"))
w.ShowAndRun()
}
Now before running it let's reference fyne and tidy the go modules
$ go get fyne.io/fyne/v2@latest
$ go mod tidy
After that is done, let's try to run it
$ go run .
You should see somewhere in your Desktop a small application window that looks like this:
Nothing much to look at, but if you reached this point you are pretty much all set and from now on it will all be great.
You can now do 2 things.
- Create the models for your app to show
- Design the UI
I tend to create (usually with TDD) the models the app will show first, so I know what I need to bind in the view, especially in this case where we only have 1 model (Todo
) I guess we will be all set quite soon with that.
Todo Model
I would make a folder called models
and drop in 2 files
$ ls models
todo.go
todo_test.go
I won't go too much into the testing otherwise we will just take forever, so I will just show what todo.go declares
package models
import "fmt"
type Todo struct {
Description string
Done bool
}
func NewTodo(description string) Todo {
return Todo{description, false}
}
by default the constructor will set the Done
property as false
.
I also added a func String() string
function to implement the Stringer interface.
So I can stringify my todo if I wanted to
func (t Todo) String() string {
return fmt.Sprintf("%s - %t", t.Description, t.Done)
}
Now let's try to show one in our fyne app, but first maybe, let's make that window a bit bigger
func main() {
a := app.New()
w := a.NewWindow("TODO App")
// ADDING THIS HERE
w.Resize(fyne.NewSize(300, 400))
Now it should look more like this
great, now let's create a TODO and show it in the same window.
w.Resize(fyne.NewSize(300, 400))
t := models.NewTodo("Show this on the window")
w.SetContent(widget.NewLabel(t.String()))
Brilliant.
Now it is time to create the actual Interface.
Ux Design
Fyne, like many other Desktop UI frameworks has layouts that can be contained to define how the widgets and items will be positioned around the available space in the window.
There are loads and they are all showcased in here and in the Containers/Layouts section.
Let's try one, let's push that todo in the center.
// make sure you import the right one
import "fyne.io/fyne/v2/container"
// this ↑
w.SetContent(
container.NewCenter(
widget.NewLabel(t.String()),
),
)
w.ShowAndRun()
in the api examples you can see that this can also be achieved with this form
w.SetContent(
container.New(
layout.NewCenterLayout(),
widget.NewLabel(t.String()),
),
)
w.ShowAndRun()
Which I personally dislike, The first one is syntactic sugar for the second one.
Anyway the app will look like this now:
Nice! but we want one Text entry and a button on the side, to input and add the todo to a list don't we? Well let's combine stuff and use the Border layout.
w.SetContent(
container.NewBorder(
nil, // TOP of the container
// this will be a the BOTTOM of the container
widget.NewButton("Add", func() { fmt.Println("Add was clicked!") }),
nil, // Right
nil, // Left
// the rest will take all the rest of the space
container.NewCenter(
widget.NewLabel(t.String()),
),
),
)
w.ShowAndRun()
This will add a button at the bottom and on click it will print on the console standard output "Add w as clicked!"
Now let's add the entry at the side of the button, in another container type, HBox, this will stack the item horizontally
w.SetContent(
container.NewBorder(
nil, // TOP of the container
container.NewHBox(
widget.NewEntry(),
widget.NewButton("Add", func() { fmt.Println("Add was clicked!") }),
),
nil, // Right
nil, // Left
// the rest will take all the rest of the space
container.NewCenter(
widget.NewLabel(t.String()),
),
),
)
w.ShowAndRun()
But unfortunately it does not look right, we want them to take all of the space available.
We can try a few more:
container.NewGridWithColumns(
2,
widget.NewEntry(),
widget.NewButton("Add", func() { fmt.Println("Add was clicked!") }),
),
or
container.NewBorder(
nil, // TOP
nil, // BOTTOM
nil, // Left
// RIGHT ↓
widget.NewButton("Add", func() { fmt.Println("Add was clicked!") }),
// take the rest of the space
widget.NewEntry(),
),
Nesting another border in the border bottom:
It does not really matter, it is a matter of personal preference but I will go with this last one.
Cleanup and Todo Creation
We now have designed our UI will look like, but let's try to clean up the code as the container tree is quite messy already.
I will move the button and text entry creation before the .SetContent
call like so:
newtodoDescTxt := widget.NewEntry()
newtodoDescTxt.PlaceHolder = "New Todo Description..."
addBtn := widget.NewButton("Add", func() { fmt.Println("Add was clicked!") })
w.SetContent(
container.NewBorder(
nil, // TOP of the container
container.NewBorder(
nil, // TOP
nil, // BOTTOM
nil, // Left
// RIGHT ↓
addBtn,
// take the rest of the space
newtodoDescTxt,
),
declaring those widget before will allow us to modify properties and add handlers to them before getting them into the actual content tree.
In this example I added a placeholder to the entry text so it is clearer what it is for:
Now let's make the "Add"
button do something, and even disable it if the text is empty or too short.
newtodoDescTxt := widget.NewEntry()
newtodoDescTxt.PlaceHolder = "New Todo Description..."
addBtn := widget.NewButton("Add", func() { fmt.Println("Add was clicked!") })
addBtn.Disable()
newtodoDescTxt.OnChanged = func(s string) {
addBtn.Disable()
if len(s) >= 3 {
addBtn.Enable()
}
}
this will disable the button if the text length is less than 3 character:
Great! Now let's try to build the last piece of the UX we are missing, the List of todos.
Lists in Fyne
There are 2 types of List widget you can use for this purpose, the first one is a static type of list, the second is one which content is linked to the data it will show.
Simple List has a simple API.
widget.NewList(
// func that returns the number of items in the list
func() int {
return len(data)
},
// func that returns the component structure of the List Item
func() fyne.CanvasObject {
return widget.NewLabel("template")
},
// func that is called for each item in the list and allows
// you to show the content on the previously defined ui structure
func(i widget.ListItemID, o fyne.CanvasObject) {
o.(*widget.Label).SetText(data[i])
}),
in our particular example we want each list item to have a label for the Todo description and a checkbox to show whether the todo is marked as done or not.
Just to try out what it looks like let's create this data
as a slice of Todos.
data := []models.Todo{
models.NewTodo("Some stuff"),
models.NewTodo("Some more stuff"),
models.NewTodo("Some other things"),
}
// then on the last func of list we just replace `data[i]` with
func(i widget.ListItemID, o fyne.CanvasObject) {
o.(*widget.Label).SetText(data[i].Description)
}),
as you can see there we are getting the o
CanvasObject
and type casting it to a *widget.Label
that is because we know that the function before creates that particular widget, as I said before though we need a Label and a Checkbox, they need to go in a container also, so we can kind of do the same with did with the bottom bar and space them so the label takes most of the space.
func() fyne.CanvasObject {
return container.NewBorder(
nil, nil, nil,
// left of the border
widget.NewCheck("", func(b bool) {}),
// takes the rest of the space
widget.NewLabel(""),
)
},
Something like this.
But unfortunately this will throw a compile time as the o CanvasObject
is not a *widget.Label
anymore.
We need to cast it to a Container
, then get the widgets nested within using indexes, and cast them all to what we know they are (as we defined them we should know).
func(i widget.ListItemID, o fyne.CanvasObject) {
ctr, _ := o.(*fyne.Container)
// ideally we should check `ok` for each one of those casting
// but we know that they are those types for sure
l := ctr.Objects[0].(*widget.Label)
c := ctr.Objects[1].(*widget.Check)
l.SetText(data[i].Description)
c.SetChecked(data[i].Done)
}),
and this is what it looks like now
Personally I find that casting thing quite weird in the API and getting the order of the components within a container is a bit hit and miss, might need to try compile a couple of time to see whether [1]
is Label
or Check
for real.
Anyway we got there but if we add another todo to that data
the list won't reflect the changes, as we are just using a static simple list, if we want to make it dynamic, and we need to for our app, we need to use binding
.
Binding and Dynamic Lists
Binding is explained in the docs quite well for simple types, but not at all for Struct types, which is what annoyed me the first time I tried fyne.
What you do really is create a DataList
, add the items from our slice of todos and use another widget api to render the list.
It will look quite similar to what we did here, but in our case, since the type of DataList we create will be of Untyped
type, we will have to add one step more than a primitive type, to cast the data item to our own models.Todo
struct.
here it is how you create the data list
data := []models.Todo{
models.NewTodo("Some stuff"),
models.NewTodo("Some more stuff"),
models.NewTodo("Some other things"),
}
todos := binding.NewUntypedList()
for _, t := range data {
todos.Append(t)
}
it would be nice if you could set the items on creation maybe but the API does not allow to do that yet.
then this is how the list creation looks like now
widget.NewListWithData(
// the binding.List type
todos,
// func that returns the component structure of the List Item
// exactly the same as the Simple List
func() fyne.CanvasObject {
return container.NewBorder(
nil, nil, nil,
// left of the border
widget.NewCheck("", func(b bool) {}),
// takes the rest of the space
widget.NewLabel(""),
)
},
// func that is called for each item in the list and allows
// but this time we get the actual DataItem we need to cast
func(di binding.DataItem, o fyne.CanvasObject) {
ctr, _ := o.(*fyne.Container)
// ideally we should check `ok` for each one of those casting
// but we know that they are those types for sure
l := ctr.Objects[0].(*widget.Label)
c := ctr.Objects[1].(*widget.Check)
diu, _ := di.(binding.Untyped).Get()
todo := diu.(models.Todo)
l.SetText(todo.Description)
c.SetChecked(todo.Done)
}),
this other casting bit is the one I found hard to get right too
diu, _ := di.(binding.Untyped).Get()
todo := diu.(models.Todo)
We get a DataItem which is a binding.Untyped
underneath, we need to Get()
it, the cast it to our model, then we can finally use it.
I usually move the functions within a list to separate functions and make a small method on the models package to handle that type of casting, so it looks a bit less cluttered.
something like this
// in models
func NewTodoFromDataItem(item binding.DataItem) Todo {
v, _ := item.(binding.Untyped).Get()
return v.(Todo)
}
// so in the list function will look like so
func(di binding.DataItem, o fyne.CanvasObject) {
ctr, _ := o.(*fyne.Container)
// ideally we should check `ok` for each one of those casting
// but we know that they are those types for sure
l := ctr.Objects[0].(*widget.Label)
c := ctr.Objects[1].(*widget.Check)
/*
diu, _ := di.(binding.Untyped).Get()
todo := diu.(models.Todo)
*/
todo := models.NewTodoFromDataItem(di)
l.SetText(todo.Description)
c.SetChecked(todo.Done)
}),
Anyway, now let's do the last step, how to add a new todo?
Just use the data list on the addBtn
func
like so
addBtn := widget.NewButton("Add", func() {
todos.Append(models.NewTodo(newtodoDescTxt.Text))
newtodoDescTxt.Text = ""
})
and once you click on it, it will magically add it to the list, and show a new list item on the List component.
As a small nice feature, we need also to clear the text entry so we are ready to add another one.
We could also use the Prepend
method instead of Append
so the Todo will take the first place in the list instead of the last.
Other notes
If you want to change the actual items it is better to create a slice of
*models.Todo
so they will use the real value of those rather than a clone.There is no
Remove
api in the DataList for now, so to remove something you need to hack around with the slice within.
// to remove them all for example you should do something like this
list, _ := todos.Get()
list = list[:0]
t.Set(list)
If you are interested in the code example I uploaded it on my github here: github.com/vikkio88/fyne-tutorials/tree/main/todoapp
If you want to see a more complex example with some syntax sugar and sexy db persistence layer using clover you can look at this gtodos.
I also added an example branch without the db just for "fun" here
And finally, if you are interested in a more complex app example I posted here about me rewriting a Password Manager in Fyne and managing in a couple of days to create and distribute the app using github actions.
Muscurd-ig.
That is all folks, please let me know what you think or if something is not clear.
See you next time.
Top comments (4)
Thank you for a helpful tutorial.
Really great post! Thanks!
Hi! Thank you very much for your article and code! I have a problem with deleting an item from the "todo" list := binding.A new untyped list()". I found a method that was supposed to help with this todos.removeListener(diu[0]). But nothing came out for me Error: "it is impossible to use day 0 as a binding.The value of the data listener in the argument for todos.removeListener: the{} interface does not implement binding.DataListener (missing dataChanged method)". Could you help with that? And it would be great to see an article with the continuation of code development that already exists.
remove listener doesn't remove items, unfortunately there's no method out of the box for that yet, you need to do what I did in the example. get the slice, splice it then set it again