DEV Community

Cover image for Creating a TODO app in Fyne an Go
Vincenzo
Vincenzo

Posted on

Creating a TODO app in Fyne an Go

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.

It should look like this:
Todo Example

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
Enter fullscreen mode Exit fullscreen mode

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()
}

Enter fullscreen mode Exit fullscreen mode

Now before running it let's reference fyne and tidy the go modules

$ go get fyne.io/fyne/v2@latest
$ go mod tidy
Enter fullscreen mode Exit fullscreen mode

After that is done, let's try to run it

$ go run .
Enter fullscreen mode Exit fullscreen mode

You should see somewhere in your Desktop a small application window that looks like this:
Small Hello world

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.

  1. Create the models for your app to show
  2. 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

Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Now it should look more like this
Bigger app

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()))
Enter fullscreen mode Exit fullscreen mode

This is what we get
Stringified todo

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Which I personally dislike, The first one is syntactic sugar for the second one.

Anyway the app will look like this now:
Centered

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()
Enter fullscreen mode Exit fullscreen mode

This will add a button at the bottom and on click it will print on the console standard output "Add w as clicked!"

This is what it looks like:
Image description

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()
Enter fullscreen mode Exit fullscreen mode

HBox
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!") }),
            ),
Enter fullscreen mode Exit fullscreen mode

will look like this
grid

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(),
            ),
Enter fullscreen mode Exit fullscreen mode

Nesting another border in the border bottom:
Nested borders

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,
            ),
Enter fullscreen mode Exit fullscreen mode

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:
desc placeholder

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()
        }
    }
Enter fullscreen mode Exit fullscreen mode

this will disable the button if the text length is less than 3 character:

disabled btn

and enable it otherwise
enabled

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])
                }),
Enter fullscreen mode Exit fullscreen mode

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)
                }),
Enter fullscreen mode Exit fullscreen mode

and it will look like this:
Simple List

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(""),
                    )
                },
Enter fullscreen mode Exit fullscreen mode

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)
                }),
Enter fullscreen mode Exit fullscreen mode

and this is what it looks like now

with box

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)
    }

Enter fullscreen mode Exit fullscreen mode

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)
                }),

Enter fullscreen mode Exit fullscreen mode

this other casting bit is the one I found hard to get right too

                    diu, _ := di.(binding.Untyped).Get()
                    todo := diu.(models.Todo)
Enter fullscreen mode Exit fullscreen mode

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)
                }),
Enter fullscreen mode Exit fullscreen mode

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 = ""
    })
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 (3)

Collapse
 
soulsbane profile image
Paul Crane

Really great post! Thanks!

Collapse
 
korvinprim profile image
Shashatkin Anton • Edited

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.

Collapse
 
vikkio88 profile image
Vincenzo

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