tech, volunteers, public safety, collective intelligence, articles, tools, code and ideas
In this part, we’ll build a simple user interface for a Flipper Zero app.
The Flipper Zero is a digital signals multi-tool device, with some fun applications. It has an infrared module, a sub-GHz radio, RFID and NFC capability, iButton, USB, a screen, input controls, and GPIO pins. It’s small enough to fit in your hand, and it can communicate with your home appliances, or help you to learn about the signals that fly around our world. It’s also extremely customisable.
The Flipper’s firmware provides a range of library functions and structures to support user interfaces. There are some simple options, but anything more complex than the simplest apps will need to display a variety of views grouped into scenes.
The UI for this tutorial will have 3 scenes:
The user can choose an option from the menu, which will then result in their seeing either popup 1 or popup 2. (It’s very simple!)
The code for this tutorial is at: instantiator/flipper-zero-tutorial-app
For simplicity, the tutorial app is contained in a single, self-contained .c
file. When building an app any more complex than this, I recommend breaking it into multiple files, and making the functions you wish to share available by creating and importing .h
header files.
NB. Without header files, as here, references must come after that particular resource has been defined, or they will not compile. In effect, the code “reads backwards”. The entrypoint is the last function defined in test_app.c
, as it must refer to functions that came before it - and so on…
A lot of the code I’ll be sharing here is derived from the work I did building the resistance calculator app, and you’re welcome to sift through that code and plunder what you need for your own projects.
In turn, that app relies heavily on the patterns provided in Derek Jamison’s basic scenes tutorial. Derek has been kindly supporting and debugging my work in the background, and has produced a vast library of valuable learning resources. Check out his YouTube channel:
Derek also maintains a wiki, full of information for new coders:
This part of the tutorial series would not have been possible without the help and support of various people in the Flipper Zero community. Many thanks to:
Flipper Zero provides a few of ways to build an app interface. The simplest is to create and register a ViewPort
, and register a function for redrawing it which will be called each time view_port_update
is called.
Although reasonably simple, this approach doesn’t benefit from all the components available through the firmware. You’ll have to design your own interface. It does seem to be reasonably popular amongst developers of simple games on the Flipper, though. If you want to see an example of this approach, check out the tag application (currently under development). In particular: tag_ui.c
, tag_app_loop.c
In this tutorial, we’ll explore another approach, which supports the simplification and management of more complex UI structures. It uses the various UI components available through the Flipper firmware.
A user interface is broken down into views, each of which is a visual component (such as a menu, popup, file browser, or text input…)
For a guide to the various components, I recommend Brodan’s Visual Guide to Flipper Zero GUI Modules. The most adaptable of these is the
Widget
view, which deserve a tutorial of its own. For an example of its use, see the resistor editing scene in the Resistance Calculator app: scene_edit.c
We’ll take a look at a the Menu
and Popup
components in a little more detail as a part of this tutorial.
Scenes are a layer of abstraction above views, allowing you to define a number of scene handler functions - responsible for rendering the view on entering a scene, destroying it on exit, and responding to events while the scene is active.
SceneManager
and ViewDispatcher
A SceneManager
(supported by a number of scene_manager_*
functions) contains and manages all details of the scenes known to the app. It’s responsible for ensuring that the functions that govern the active scene are called during entry, exit, and when events are received.
Views are managed by a ViewDispatcher
(with a number of view_dispatcher_
prefixed functions), called from the handler functions passed in to the SceneManager
.
To define all the scenes for your app, provide all the handler functions as a SceneManagerHandlers
struct
to the scene_manager_alloc
function.
Scenes are indexed numerically, and so it’s helpful to create an enum to track their indices, eg.
typedef enum {
TestAppScene_MainMenu,
TestAppScene_FirstPopup,
TestAppScene_SecondPopup,
TestAppScene_count
} TestAppScene;
NB. That TestAppScene_count
value at the end of the enum is a handy way to safely obtain the size of the enum
even if you change its contents later. Just ensure it’s always the last item.
Views can be reused, and so there may be fewer views than scenes, eg.:
typedef enum {
TestAppView_Menu,
TestAppView_Popup
} TestAppScene;
With these enums defined, we’re ready to set up the main struct
containing the app’s state, and then go on to allocate and initialise everything.
In the tutorial app, this takes place in the test_app_init
function, which goes on to call out to functions that will initialise the scene manager, and view dispatcher:
TestApp* app = malloc(sizeof(TestApp));
test_app_scene_manager_init(app);
test_app_view_dispatcher_init(app);
test_app_scene_manager_init
is reasonably simple. It allocates memory for a SceneManager
.
app->scene_manager = scene_manager_alloc(&test_app_scene_event_handlers, app);
The second parameter is simply the context, passed back to the active scene’s handler methods when they are called. The first parameter, SceneManagerHandlers* test_app_scene_event_handlers
has already been defined above. It’s a collection of all the on_enter
, on_exit
, and on_event
handler functions for all the scenes:
/** collection of all scene on_enter handlers - in the same order as their enum */
void (*const test_app_scene_on_enter_handlers[])(void*) = {
test_app_scene_on_enter_main_menu,
test_app_scene_on_enter_popup_one,
test_app_scene_on_enter_popup_two};
/** collection of all scene on event handlers - in the same order as their enum */
bool (*const test_app_scene_on_event_handlers[])(void*, SceneManagerEvent) = {
test_app_scene_on_event_main_menu,
test_app_scene_on_event_popup_one,
test_app_scene_on_event_popup_two};
/** collection of all scene on exit handlers - in the same order as their enum */
void (*const test_app_scene_on_exit_handlers[])(void*) = {
test_app_scene_on_exit_main_menu,
test_app_scene_on_exit_popup_one,
test_app_scene_on_exit_popup_two};
/** collection of all on_enter, on_event, on_exit handlers */
const SceneManagerHandlers test_app_scene_event_handlers = {
.on_enter_handlers = test_app_scene_on_enter_handlers,
.on_event_handlers = test_app_scene_on_event_handlers,
.on_exit_handlers = test_app_scene_on_exit_handlers,
.scene_num = TestAppScene_count};
test_app_view_dispatcher_init
has a little more to do.
app->view_dispatcher = view_dispatcher_alloc();
view_dispatcher_enable_queue(app->view_dispatcher);
app->menu = menu_alloc();
app->popup = popup_alloc();
The views themselves initially handle events and navigation, but we want to pass those to the scene manager so that they’re available to the active scene’s handler functions.
// assign callback that pass events from views to the scene manager
view_dispatcher_set_event_callback_context(app->view_dispatcher, app);
view_dispatcher_set_custom_event_callback(
app->view_dispatcher,
test_app_scene_manager_custom_event_callback);
view_dispatcher_set_navigation_event_callback(
app->view_dispatcher,
test_app_scene_manager_navigation_event_callback);
// add views to the dispatcher, indexed by their enum value
view_dispatcher_add_view(
app->view_dispatcher,
TestAppView_Menu,
menu_get_view(app->menu));
view_dispatcher_add_view(
app->view_dispatcher,
TestAppView_Popup,
popup_get_view(app->popup));
With all the structure to support scenes in place, the scene functions will complete the test app. Each scene has 3 functions:
*_on_enter
- initiating the view and scene resources*_on_event
- handling inputs and custom events*_on_exit
- freeing resources used by the sceneOf these, the main menu scene TestAppScene_MainMenu
is the most complex.
The menu view itself has an additional callback of its own:
test_app_menu_callback_main_menu
is provided with a value from TestAppMenuSelection
indicating the selection that the user made. This is then used to determine which event from TestAppEvent
to send to the scene’s custom event handler function with a call to scene_manager_handle_custom_event
.
Sending events on to the scene manager, rather than handling them in the menu’s callback, is good practise - ensuring that the active scene is sent the event, and that the scene’s
_on_event
handler is the place where actions are initiated. This pattern ensures that your application’s decision-making logic for each scene is easy to locate.
When entering a scene, the test_app_scene_on_enter_main_menu
function is called. This is responsible for setting up the scene, by initiating the view for the scene (and any other resources it needs), and then instructing the view dispatcher to switch to the appropriate view, with a call to: view_dispatcher_switch_to_view
First, the menu view is reset:
menu_reset(app->menu);
Then each menu item is added. Each is given an id from TestAppMenuSelection
- this is used in test_app_menu_callback_main_menu
to identify the user’s selection:
menu_add_item(
app->menu,
"First popup",
NULL, // previously: &I_one,
TestAppMenuSelection_One,
test_app_menu_callback_main_menu,
app);
menu_add_item(
app->menu,
"Second popup",
NULL, // previously: &I_two,
TestAppMenuSelection_Two,
test_app_menu_callback_main_menu,
app);
The last 3 values passed into the menu indicate what action to take:
test_app_menu_callback_main_menu
- this is the callback function to invoke if the user selects this itemTestAppMenuSelection_One|_Two
- this is an enum value (effectively an int32_t
) which is provided to the callback function as index
- indicating which item the user selectedapp
- this is the context to provide to the callback functionFinally the view dispatcher is instructed to switch to this view:
view_dispatcher_switch_to_view(app->view_dispatcher, TestAppView_Menu);
In the first iteration of this code, menu_add_item
was provided with pointers to icons generated by the build process (&I_one
, &I_two
).
These icons are static - ie. they don’t have multiple frames and their frame rate is initialised to 0
. Unfortunately, the menu module always tries to animate them, and expects a non-zero frame rate.
For now, these icons are replaced with NULL
, and in that case the menu module uses a default “puzzle piece” animated icon.
NB. I haven’t yet figured out how to create multi-frame animated resources. The Icon
struct
seems to be immutable, so it’s not trivial to adjust them once created.
The menu scene’s event handling function, test_app_scene_on_event_main_menu
is given a SceneManagerEvent
to interpret. It returns a bool
indicating if it handled the event or not. This is a reasonably simple struct:
event.type
is a SceneManagerEventType
with 3 possible values:
SceneManagerEventTypeCustom
- these are events we have passed to the scene manager, and can be for anything. They often represent significant events, such as user interactions, or inputs.SceneManagerEventTypeBack
- this event indicates that the user is attempting to go back in the app (ie. with the back button). If not handled, the scene manager will do the right thing and pass the user to the previous scene.SceneManagerEventTypeTick
- this event indicates that the scene has been sent a tick, and should refresh. It’s also an opportunity to update any models that the application updates over time. Similarly, if not handled, the scene manager will take care of this.SceneManagerEventTypeCustom
is the type of event that the test_app_menu_callback_main_menu
function creates when it invokes scene_manager_handle_custom_event
.
event.event
contains the value from TestAppEvent
that was passed in, ie. TestAppEvent_ShowPopupOne
or TestAppEvent_ShowPopupTwo
. These events should be reasonably clear - they are instructions to switch the scene, using scene_manager_next_scene
.
This logic is handled in two nested switch statements:
bool consumed = false;
switch(event.type) {
case SceneManagerEventTypeCustom:
switch(event.event) {
case TestAppEvent_ShowPopupOne:
scene_manager_next_scene(app->scene_manager, TestAppScene_FirstPopup);
consumed = true;
break;
case TestAppEvent_ShowPopupTwo:
scene_manager_next_scene(app->scene_manager, TestAppScene_SecondPopup);
consumed = true;
break;
}
break;
default: // eg. SceneManagerEventTypeBack, SceneManagerEventTypeTick
consumed = false;
break;
}
return consumed;
Finally, the test_app_scene_on_exit_main_menu
function very simply clears up the menu, using menu_reset
.
TestApp* app = context;
menu_reset(app->menu);
NB. This does not free the menu’s memory allocation, but it does clear away the menu items and content of the menu.
Although we also do this reset when entering the menu scene, it’s good practise to clear up a resource on exit to ensure that it’s not occupying space when not in use.
The popup scenes, TestAppScene_FirstPopup
and TestAppScene_SecondPopup
, are much simpler than the menu scene.
The most complex part of each is their _on_enter
function, that sets up the popup. eg. test_app_scene_on_enter_popup_one
resets the popup, and adds several pieces of content (the context, header, icon, and text), before finally instructing the view dispatcher to switch to the popup view, with a call to: view_dispatcher_switch_to_view
popup_reset(app->popup);
popup_set_context(app->popup, app);
popup_set_header(app->popup, "Popup One", 64, 10, AlignCenter, AlignTop);
popup_set_icon(app->popup, 10, 10, &I_cvc_36x36);
popup_set_text(app->popup, "One! One popup. Ah ah ah...", 64, 20, AlignLeft, AlignTop);
view_dispatcher_switch_to_view(app->view_dispatcher, TestAppView_Popup);
Neither popup scene handles any events - just returning false
in their _on_event
functions and leaving it to the scene manager to do the right thing.
NB. Popups can ordinarily handle use inputs, often giving users a choice between a couple of options. This tutorial does not, for the sake of simplicity.
Finally, each popup’s _on_exit
function simply clears away the content, with another call to popup_reset
.
The code block that sets up the popup previously contained this line:
view_set_context(popup_get_view(app->popup), app);
That’s incorrect. The intention is to set the callback context, so that the _on_event
callback would get our app
as the context parameter. However, this is the popup’s method to set its callback context:
popup_set_context(app->popup, app);
It’s easier to think of it like this: The_view is inside the popup, so its callback context should be the popup. It’s created by the popup, and its context it set automatically, so we don’t need to adjust the view’s context.
In a few places, the code refers to Icon
pointers, prefixed with I_
. These are created automatically by ufbt
during the build process, from resources found in the images/
directory.
There are a few images in the directory:
cvc_36x36.png
(a tiny image of Count von Count), which becomes I_cvc_36x46
one.png
(a 10x10 icon representing 1 in roman numerals as ‘i’), becomes I_one
two.png
(a 10x10 icon representing 2 in roman numerals as ‘ii’), becomes I_two
Adding more 1-bit png files to this directory will result in their being compiled into the app and available to the code as Icon
resources.
If you haven’t already, get a copy of the code from:
Now, you can build the application:
$ ufbt
scons: Entering directory `/Users/lewiswestbury/.ufbt/current/scripts/ufbt'
CC /Users/lewiswestbury/src/personal/test_app/test_app.c
CDB /Users/lewiswestbury/src/personal/test_app/.vscode/compile_commands.json
LINK /Users/lewiswestbury/.ufbt/build/test_app_d.elf
INSTALL /Users/lewiswestbury/src/personal/test_app/dist/debug/test_app_d.elf
APPMETA /Users/lewiswestbury/.ufbt/build/test_app.fap
FAP /Users/lewiswestbury/.ufbt/build/test_app.fap
INSTALL /Users/lewiswestbury/src/personal/test_app/dist/test_app.fap
APPCHK /Users/lewiswestbury/.ufbt/build/test_app.fap
Target: 7, API: 26.0
Provided ufbt
and your Flipper agree on the firmware version, you can deploy it to your Flipper to try it out:
$ ufbt launch
scons: Entering directory `/Users/lewiswestbury/.ufbt/current/scripts/ufbt'
python3 "/Users/lewiswestbury/.ufbt/current/scripts/runfap.py" -s /Users/lewiswestbury/.ufbt/build/test_app.fap -t /ext/apps/Examples/test_app.fap
APPCHK /Users/lewiswestbury/.ufbt/build/test_app.fap
Target: 7, API: 26.0
2023-05-06 23:38:36,824 [INFO] Using flip_Akurisau on /dev/cu.usbmodemflip_Akurisau1
2023-05-06 23:38:36,877 [INFO] Installing "/Users/lewiswestbury/.ufbt/build/test_app.fap" to /ext/apps/Examples/test_app.fap
2023-05-06 23:38:36,916 [INFO] Sending "/Users/lewiswestbury/.ufbt/build/test_app.fap" to "/ext/apps/Examples/test_app.fap"
100%, chunk 1 of 1
2023-05-06 23:38:37,108 [INFO] Launching app: "Applications" /ext/apps/Examples/test_app.fap
If your firmware and ufbt
don’t agree, your Flipper will tell you about it. You can update the SDK you’re building against with:
ufbt update --channel=[dev|rc|release]
You can change the firmware on your device using the qFlipper app, or:
ufbt flash_usb
(Swap for ufbt flash
if you’re using an ST link.)
This walk-through has hopefully covered everything required to initiate and launch a simple UI for a Flipper Zero app.
The code for this tutorial is available at: instantiator/flipper-zero-tutorial-app
Feel free to plunder it for your own app. I highly recommend reading it through, and then taking a look at some other tutorials too to ensure you get a fully rounded view of the patterns for UI development on the Flipper.
In future parts, we’ll cover: