Welcome to how I structure a multi-page application using the
Elm architecture
in the Crux framework.
Writing this because I found a lot of single page app examples but haven't come
across how people handle multiple pages.
The first 4 things is how I structure the core (Rust), 5 is then the
shell (Android) side.
(p.s. I don't explain Crux at all, I am assuming your familiar with it. I would
recommend the design overview
as a quick way to understand this blog)
Starting out with the data types I
structure The Model
Then continuing with the types I structure
the Events
Finally jumping
into The Code
and show how the above heavily influence the structure
Above we have the Screens type, which hides all the magic, then anything
I want persistent across screens I need to also keep at this level so in
my case user_messages and example_data we keep out of Screens
Model.screens
The magic... A massive enum :D
// order is alphabetical to be in line with file browser
#[derive(Clone, Debug)]pubenumScreens{ InitialScreen(InitialScreenData), InputFieldsTagsScreen(InputFieldsAndTagsScreenData), PostWriteReviewScreen(PostWriteReviewScreenData), SelectOrgBucketMeasurementScreen(Box<SelectOrgBucketMeasurementScreenData>), SettingPreviousConnectionsScreen(SettingsPreviousConnectionsActionsScreenData), SettingsScreen,}
With Rust allowing enum variants to hold internal data I make a variation per
screen appended with Screen and create a new data type per screen appended
with ScreenData, the naming scheme was helpful for myself to keep track of
what I have where.
Why the box?
Very simply clippy or the compiler suggested it once and I went with it, at some
point I should review and maybe convert others. The reason was about the enum
data types being wildly different sizes.
Moving screens
Originally thought moving screens would be problematic/annoying but using
Rust's strong typing we can get some nice wins from this. First is we can
take unvalidated user input held as option<String> and validate
it converting it to just a String or another strong type, simplifying
logic on later screens. We can see this between my first 2 screens
initial>SelectOrgBucketMeasurement.
Secondly we can leverage TryFrom quite nicely so we can do
~ let screen2: Screen2data = screen1data.tryinto()
allowing us to easily move to screen2 with the Err giving us a nice
message to relay back to the user
Events
Top level Events
Similar to Model with Event we can associate data with the enums,
this time though we are just wrapping up more enums as sub events
per screen, so...
// order is alphabetical to be in line with file browser
#[derive(Serialize, Deserialize, Clone, Debug)]pubenumEvent{ EvInitialScreen(ScEvInitial), EvInputFieldsAndTagsEventsScreen(ScEvInputFieldsAndTags), EvPostWriteReviewScreen(ScEvPostWriteReview), EvSettingsScreen(ScEvSettings), EvSelectOrgBucketMeasurementScreen(ScEvSelectOrgBucketMeasurement), EvSettingsPreviousConnectionsScreen(ScEvSettingPreviousConnections), EvGlobal(GlEvGlobal),}
As the Event type crosses the FFI boundary I found it much nicer for type
completion in Kotlin to prepend the Ev for event variation and ScEv for
the internal data on each event variation.
Screen event
Internally the screen events
// order is alphabetical to be in line with file browser
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]pubenumScEvInitial{ AppendPreviousConnection(PreviousConnection),#[serde(skip)] AppendPreviousConnectionResult(PersistentStorageOutput), InfluxBucketsConnectionTest,#[serde(skip)] InfluxBucketsConnectionTestResponse(crux_http::Result<crux_http::Response<BucketsResult>>), LoadPreviousConnections,#[serde(skip)] LoadPreviousConnectionsResult(PersistentStorageOutput), QuickActionsLoad,#[serde(skip)] QuickActionsLoadResult(PersistentStorageOutput), QuickActionsUserSelected(UiStringIndex), LoadSettingsScreen, SetApiToken(String), SetBaseUrl(String), UserSelectedPreviousConnection(UiStringIndex),}
Above is all the events I have on the initial screen, this does make
calling an event in Android quite a long as we need to specify screen then event
e.g.
Originally I had global events in the top level but figured it was just nicer
to wrap them up in another enum so they have a nice place to grow. 🌱
The Code
app.rs (Rust entry point)
First the Event enum and how that influences the code structure.
With the above picture we can see why I ordered things alphabetically, because
for each ScEv<name> I have made a folder screens/<name>/mod.rs. Keeping it
alphabetical helps with easily jumping between the enum folder and vice versa.
Top level update function
Now all my top level Events are really a group of screen events my top level
update function, is just passing the event onto a function in the mod.rs of
the corresponding folder e.g. event of type EvInitialScreen will call a
function in screens/initial/mod.rs, I took the naming scheme of _
screen_events
so we get initial_screen_events
The actuality of the function has a bit of global setup, so for me setting up
android logging and some messing with
the user_messages. But after all that we have the same match statement and
keeping the alphabetical order to help find
things, so as long as we don't have a semicolon at the end we can quite quickly
verify that all the return commands are
actually coming from screen/<name>/mod.rs
We shall get to why mod.rs has 400 lines before we get to the only function in
the file, but here is one of ourscreen/<name>/mod.rs::<name>_screen_events
functions.
First now we are one level down we can get the screen data out of the model,
sadly this can't be a thing we can assert at build time but in general the
folder structure makes this easy to maintain. This allows us to pass sreenData
to our functions reducing scope the functions can effect and saving dealing
with repeatedly asserting the screen model.
In general, we have repeated the same trick we had in app.rs now in
screen/<name>/mod.rs this time all our events correspond to a file. The enum
variant / file name / function
all have the same name (within naming conventions) e.g. variant
BackButtonPressed() /
file name back_button_pressed.rs / function fn back_button_pressed() {}.
Again keeping alphabetical order to make things easier to find.
We can see, although it gets bigger so long as im not terminating the match with
a semicolon Rust will guarantee we are passing the output of each match arm as
our output to the function
Screen Events & Model
After watching both the Events enum and Model struct grow in their respective
top level files I decided to pull them into the screen/<name>/mod.rs as well.
This made a lot of sense as mod.rs is the entry point for the file it is now
also where the Events and Data is defined, keeping the majority of my work on a
screen in 1 folder. Downside mod.rs gets very big, my order was:
Model
Events
Update function
which is how the update function ends up 400 lines down :D
In the images above we see the alphabetical ordering again, and the tryFrom
mentioned earlier on.
Full file
Function files
Tiny subset of the function files to see how everything works.
Load quick actions
In the bellow we have an example of a very small function but this is great to
show the testing of the commands
pubfnload_quick_actions()->Command<Effect, Event>{trace!("load_quick_actions called");persistent_storage::load(QUICK_ACTIONS_FILENAME.to_owned()).then_send(construct_response_event)}fnconstruct_response_event(body: PersistentStorageOutput)-> Event{ EvInitialScreen(ScEvInitial::QuickActionsLoadResult(body))}modtests{usesuper::*;usecrate::screens_common::test_common::test_shell;#[test]fntest_load_quick_actions(){letmut commands =load_quick_actions();// confirm the correct events are called
let effects = commands.effects().collect();insta::assert_debug_snapshot!( effects,@r#"[
PersistentStorage(
Request(
Load("quick_actions",),
),
),
]"#);test_shell(effects);let events:Vec<Event>= commands.events().collect();insta::assert_debug_snapshot!( events,@r#" [
EvInitialScreen(
QuickActionsLoadResult(
FileData("Just a fake test",),
),
),
]"#);}}
since there is no branching only one test is needed and really all im doing is
confirming I called the right construct_response_event function but as I know
this function is being called from both the match in app.rs then screen/<name>/mod.rs
I can be extremely confident that this function with very little scope for
mutability is the side effect of the loadQuickActions event and that it is now
100% tested.
Load quick actions result
Obviously these functions don't stay so small, here's a more average one
Global events wise I found it nicer to group them like a screen at the moment I
group them in a global folder I am tempted to put them in screens/global.
Other than that it's the same structure.
screens_common is quite simply anything that I could generalise across screens
functions are top level and data types are under screens_common/model. Stuff
in here will be imported in multiple screen functions.
Global event propagation
A trick I found with some global events was that if the event changes depending
on what screen its on we can just make an event in the screens folder
so for the global back button in android
I implement back depending which screen im on, this will help as well for when
I move to iOS because they don't have a global back button, so I will just call
these events directly
UiModel (ViewModel)
Last core organisation I did was the UiModel, renamed it UiModel instead of
ViewModel for fewer chars, and it made more sense to me.
Maybe this could belong in the screens/<name>/mod.rs but those files are big
enough and the UiModel crosses the FFi so made sense to keep it separate.
Organisation wise very similar setup to screens but now instead of folders I
have a file per screen.
90% of this is just from methods :D
Android
Organising jetpack compose
Lastly the Android (shell) side, we can keep a surprisingly similar layout
so instead of a match statement per screen we have a when statement working
in a very similar way, from there was can call a compose method in a separate
file so now every screen has a file under ui/<screen name>
Utilising preview
Then with the lovely @preview annotation we can just build out our UI's while
staying all within the one file, I suspect if these files grow much more I may
move to a folder structure similar to core.
Conclusion
In conclusion my folder structure in core (Rust) is
├── app.rs
├── capabilities
│  └── persistent_storage.rs
├── global
│  ├── mod.rs
│  ├── function1.rs
│  └── function2.rs
├── lib.rs
├── model.rs
├── model_ui
│  ├── mod.rs
│  ├── screens
│  │  ├── scren1.rs
│  │  └── screen2.rs
├── screens
│  ├── screen1
│  │  ├── mod.rs
│  │  ├── function1.rs
│  │  └── function2.rs
│  ├── screen2
│  │  ├── mod.rs
│  │  └── function1.rs
├── screens_common
│  ├── mod.rs
│  ├── common_function1.rs
│  ├── common_function2.rs
│  ├── model
│  │  ├── mod.rs
│  │  ├── common_type1.rs
│  │  └── common_type2.rs
└── shared.udl
Naming schemes
Events
Ev<screen name> for event variation
ScEv<Scren name> for the variation data
keeping it alphabetical for easy searching.
Model Screens
<screen name>Screen for variations
<screen name>ScreenData for variation data
Ui Model (realising I don't stick to this scheme but should update my code)
Everything is prepended with Ui to keep an easy distinction for what
goes over FFI and what doesn't
Ui<screen name>Screen for variations
Ui<screen name>ScreenData for variation data
Pros
The majority of my time is spent in just a single folder working on 1 screen
good separation of functions for testing
simple naming scheme to follow
Cons
not 100% how this will work if I swap from multi screens to 1 large screen and
multi panes