Flutter/MX: The Red Pill Begins
One article in a series of posts and videos introducing Flutter/MX, a ClojureDart library that wraps Google’s multi-platform Flutter GUI framework.
Last time, we did no more than unbox Flutter/MX and take the briefest look at how the Matrix state manager — the “MX” in Flutter/MX — satisfies the Wikipedia description of the movie Matrix red pill:
“…a potentially unsettling or life-changing truth” — Wikipedia
In this “live coding” post we will take the next step and do some typical Matrix coding, evolving a silly card game that, however silly, lets us experience first hand a different way of coding.
What unsettling changes?
As we work through the code, the reader might be surprised by several qualities of Matrix that run against GUI framework conventional wisdom:
- no separation of concerns. View and model interact freely;
- no “pure” view functions, artificially limited to props or arranged context. Widget build logic can find and use any state in the app. Similarly, event handlers can alter any state designated as “inputs”;
- no Flux-like separate store. Matrix arranges unidirectional dataflow through view and model properties “in place”;
- no publish or subscribe. Reactive dependencies are identified automatically. State change propagates automatically.
Gasp. Raising an obvious first question.
Does it work?
Matrix began its evolution twenty-five years ago, and has been applied at enterprise scale. A few examples:
- a Common Lisp Windows desktop clinical drug trial manager;
- an interactive, AI Algebra tutor. 100KLOC of Common Lisp pushing JS over the wire;
- a Hacker News “Who’s Hiring” search tool. That is the native JS version of Matrix; and
- a commercial app for beach game equipment rentals, built with Flutter/MX by Benjamin Cherry.
But as Morpheus said to Neo, no one can be told about the Matrix. We have to see it.
We did not see much unsettling when unboxing the red pill because we stopped just after implementing the Flutter “Hello, world” counter app:
Nice and minimal, perfect for “Hello, world”. But the state management white whale we seek involves multiple state values and multiple UI controls with overlapping mutation. We must code a more substantial app before we can feel the Flutter/MX red pill.
In fact, we did do a bit more. Let us begin with a review of that, then continue down the rabbit hole from there.
The finished product of this post can be found in this gist. To join in the coding fun, we recommend cloning the full Flutter/MX repo and working from the starting point of this article. Having the f/mx source handy will help. And the author will help, too. Find me here or on the #Clojurians Slack in the #clojuredart or #matrix channels.
Baby steps.
After recreating the classic Flutter Counter App, we tossed in just a bit more functionality, albeit of unobvious purpose. We displayed as many “+” icons as the count, and imposed a limit of three.
The added complexity is that the application structure (the “+” icons) grows with the counter, not just property values. Check the code below for <-!!!
pointers to reactivity.
(defn main []
(.ensureInitialized w/WidgetsFlutterBinding)
(fx/run-fx-app
(material-app
{:title "Flutter Demo"
:theme (m/ThemeData
.colorScheme (m/ColorScheme.fromSeed
.seedColor m/Colors.deepPurple))}
(home-page .title "Flutter/MX Counter"))))
(defn home-page [.title]
(scaffold
{:appBar (app-bar {:title (m/Text title)
:backgroundColor (within-ctx [me ctx]
(-> (m/Theme.of ctx) .-colorScheme .-inversePrimary))})
:floatingActionButton
(floating-action-button
{:tooltip "Increment"
:disabledElevation 1
:foregroundColor (cF (when-not (mav :enabled?) ; <-!!!
m/Colors.grey))
:onPressed (cF (when (mav :enabled?) ; <-!!!
(dart-cb []
(mupdate! (fmu :counter) ; <-!!!
:value inc))))}
{:enabled? (cF (< (mget (fmu :counter) :value) 3))} ; <-!!!
(m/Icon m/Icons.add))}
(center
(column {:mainAxisAlignment m/MainAxisAlignment.center}
(m/Text "We have pushed the button N times:")
(text {:style (within-ctx [me ctx]
(-> (m/Theme.of ctx) .-textTheme .-headlineMedium))}
{:name :counter
:value (cI 0)} ; <- !!!
(str (mget me :value))) ; <-!!!
(row {:mainAxisAlignment m/MainAxisAlignment.spaceEvenly}
(mapv (fn [i]
(m/Icon m/Icons.add))
(range (mget (fmu :counter) ; <-!!!
:value))))))))
In this snippet…
(row {:mainAxisAlignment m/MainAxisAlignment.spaceEvenly}
(mapv (fn [i]
(m/Icon m/Icons.add))
(range (mget (fmu :counter) :value)))))
…we see a row
using the fmu
“family search up” utility to locate the widget named :counter
and read its :value
property, all to decide how many “add” icons to build.
Here we see the FAB widget tracking the same count to decide if it is enabled, and how to adjust its appearance and interactivity:
(floating-action-button
{:tooltip "Increment"
:disabledElevation 1
:foregroundColor (cF (when-not (mav :enabled?)
m/Colors.grey))
:onPressed (cF (when (mav :enabled?)
(dart-cb []
(mupdate! (fmu :counter) :value inc))))}
{:enabled? (cF (< (mget (fmu :counter) :value) 3))}
(m/Icon m/Icons.add))}
Quick note for Flutter novices: making
onPressed
null is the official way to disable the FAB, incidentally activating thedisabledElevation
.
Aside: this enabled?
property is of local interest, mattering only to the appearance and behavior of this widget. We want it to be reactive, but there is no reason for it to live in a global store, or to grind out the supporting boilerplate. The hierarchy of widgets is its own uni-directional store, with its own structure defining an implicit schema
Note also the absence of publish or subscribe syntax: we merely read the enabled?
property via the mget
API, and Matrix wires up the reactive dependency for us. On the flip side, there is no need to define or dispatch an action, we just mupdate!
the counter value. Matrix uses the silently recorded dependency graph to propagate the change, efficiently and glitch-free.
Down the rabbit hole.
The astute reader may recognize that even this baby step demonstrates all the red pill ingredients listed at the outset, such as transparency and global reach. Great, but we do not yet feel the difference. Let us go deeper down the rabbit hole, with a series of refinements ending in a usable card game, however silly.
Oh. The “+” icons are cards?
Here is what happened. Once I hit the contrived limit of three, I was reminded of a card game, perhaps a variant of poker in which we surrender cards to get new ones. So I decided to support discards as a first refinement.
For fun, take a moment to see if you can figure out how to implement discards in a reactive world. Here is the spec:
- each “+” in the row will be considered a playing card numbered from one up to and including the current count;
- when the user taps a card, we will consider it discarded;
- discarded cards are no longer shown; and
- discards do not count towards the limit, so a discard will require re-enabling the FAB if we had been at the limit.
Your turn.
Before reading on, try imagining a discard implementation in our brave new Matrix paradigm. Some Matrix hints:
- widgets can have ad hoc properties beyond those expected by Flutter. Note the
enabled?
andvalue
properties above, and thename
property that supports global reach by naming our implicit schema; - properties can be designated as “inputs”, which can be freely mutated by imperative code. We use these for state arriving from outside the Matrix, such as an XHR response or a user input; and
- properties can also be so-called “formulas”, arbitrary code that can read any other app property to decide a value.
Your challenge: how can we implement discards? Rough pseudo-code is fine for now. To make it easier, just work on hiding discards. Later, we will re-enable the FAB “+” after a discard drops us below the limit.
If you like, think out the reactive pseudo-code before reading on.
Implementing discards
Step one is a new property where we can statefully record discards.
- we name the property
discards
; - syntax note: because it is a custom property, it goes in a second map after a first map of properties intended for Flutter. (If a widget has no properties for Flutter, we still must provide an empty map preceding custom properties;)
- syntax note #2: everything after the optional first two maps implicitly defines Flutter
:child
,:children
, or in rare occasions something like the contents of aText
, thehome
of a MaterialApp, or thebody
of a Scaffold. They are all wrapped transparently in a formula; - because the user controls
discards
, we make it an “input”, using thecI
macro, short for “cell Input”; - we arbitrarily attach this property to the
row
containing the cards. We could place it higher, on thescaffold
even, but decide to make it as low as possible in the widget hierarchy for now, to minimizesetState
impact. And if our game evolves to where I can “split” a hand, the Scaffold will be too high in the app hierarchy to serve two hands; - we name the
row
“hand”, as in a “hand of cards”, so other widget formulas can find this state by name; and - while we are at it, let us start numbering the “cards” from one.
Our solution, incidentally hard-coding a discard of “2” for a quick test:
(row {:mainAxisAlignment m/MainAxisAlignment.spaceEvenly}
{:name :hand
:discards (cI #{2})}
(mapv (fn [i]
(when-not (contains? (mget me :discards) i)
(m/Icon m/Icons.add)))
(range 1 (inc (mget (fmu :counter) :value)))))
Running that code, we see that bumping the count to two does not show a second icon, because of the hard-coded discard, but bumping to three does. Yay. Now let us implement a true discard gesture.
The discard gesture
We want to treat a tap on one of the cards as a discard gesture. To detect taps on a card, we need a widget more capable than an Icon
; this is Flutter, after all. We considered GestureDetector
and InkWell
, then noticed the convenience widget, IconButton
.
Since we will be needing some f/mx magic in the handler, we go with thefx/icon-button
proxy for IconButton
. With an IconButton
, we can supply an onPressed
handler to add the chosen card to the set of discards
. With fx/icon-button
, the handler can navigate the app to read and mutate state reactively.
(row {:mainAxisAlignment m/MainAxisAlignment.spaceEvenly}
{:name :hand
:discards (cI #{})}
(mapv (fn [i]
(when-not (contains? (mget me :discards) i)
(fx/icon-button
{:onPressed (dart-cb []
(mupdate! (fasc :hand) :discards
conj i))
:icon (m/Icon m/Icons.add)})))
(range 1 (inc (mget (fmu :counter) :value)))))
Now we can tap a card to discard it. It disappears. Yay. But we still max out after three cards, discards or not. That brings us to the next step: re-enabling the FAB “+” icon once a discard brings us under the limit.
Replacing discards
To replace our discards, we need the FAB “+” back in action. Our starting point on the FAB, somewhat abbreviated, shows a simple test for the enabled?
property:
(floating-action-button
{:foregroundColor (cF (when-not (mav :enabled?)
m/Colors.grey))
:onPressed (cF (when (mav :enabled?)
(dart-cb []
(mupdate! (fmu :counter) :value inc))))}
{:enabled? (cF (< (mget (fmu :counter) :value) 3))}
(m/Icon m/Icons.add))
Fortunately, we DRYed this widget out by giving enabled?
its own ad hoc property, so once we adjust that the foregroundColor
and onPressed
states will follow without attention.
Ain’t reactive grand?
Again, before looking at this next snippet, imagine how you might modify the enabled rule to account for discards. Ready? The new enabled?
rule, no longer counting discards toward the limit:
{:enabled? (cF (< (- (mget (fmu :counter) :value)
(count (mget (fmu :hand) :discards))) 3))}
We reach the limit, discard, and draw more cards until the limit is reached again, and again the FAB is disabled. Matrix connects the reactive counter
, discards
, and enabled?
properties silently. Life is good.
Now this should be unsettling: the FAB simply asks the “hand” for its “discards”? Shocking. But it works, is self-documenting, and will survive a re-factoring that shifts GUI structure about.
The blue pill of Flux, props, lifting, hooks, context, subscriptions, and actions was a reasonable try at managing state, but there was a simpler way.
Show discards
Life is good, but now we decide the game will require allowing discards to be re-claimed. But we cannot reclaim what we cannot see.
As a first step, see if you can imagine how to show discards instead of hiding them, with a different icon, perhaps circle_outlined.
Ready?
(row {:mainAxisAlignment m/MainAxisAlignment.spaceEvenly}
{:name :hand
:discards (cI #{})}
(mapv (fn [i]
;; 1. ===> stop excluding discards
(fx/icon-button
{:key (m/ValueKey i)
:onPressed (dart-cb []
(mupdate! (fasc :hand) :discards
conj i)) ;; <=== needs work for un-discard. RSN
:icon (cF (m/Icon ;; ===> 2. use discards to decide icon
(if (contains? (mav :discards) i)
m/Icons.circle_outlined m/Icons.add)))}))
(range 1 (inc (mget (fmu :counter) :value)))))
Allow un-discard
Now for the un-discard gesture. Easy, we think. Tapping a discarded card now means “un-discard”. Imagine a solution, if you like, then read on.
(fx/icon-button
{:key (m/ValueKey i)
:onPressed (dart-cb []
(mupdate! (fasc :hand) :discards
;; =V= treat press of discard as "un-discard"
(if (contains? (mav :discards) i) disj conj) i))
:icon (cF (m/Icon (if (contains? (mav :discards) i)
m/Icons.circle_outlined m/Icons.add)))})
Too easy? Declarative code is like that. But we have created a “cheat”. Can you see it?
Blocking the un-discard “cheat”
Here is a fun puzzle. How did our new code allow us to get more than the limit of three cards?
The cheat is easy: hit the limit of three, discard, “deal” another card to get back to the limit, then un-discard. Boom. Four cards.
Let’s block that cheat. If you like, see if you can imagine a solution before reading on.
:onPressed (dart-cb []
(when-not (and (contains? (mav :discards) i)
(>= (- (mget (fmu :counter) :value)
(count (mget (fmu :hand) :discards))) 3))
(mupdate! (fasc :hand) :discards
(if (contains? (mav :discards) i) disj conj) i)))
It works, but now we have two widgets worried about the card limit, and several places checking if i
is in the discarded
set. It is DRY time!
DRY out the above
Give the card a discarded?
property, and give the hand an at-max?
property. We also leverage convenience macros muv
and mav
for navigating about in search of relevant state.
(row {:mainAxisAlignment m/MainAxisAlignment.spaceEvenly}
{:name :hand
:discards (cI #{})
:at-max? (cF (>= (- (muv :counter)
(count (mav :discards))) 3))}
(mapv (fn [i]
(fx/icon-button
{:key (m/ValueKey i)
:onPressed (dart-cb []
(when-not (and (mav :discarded?)
(mav :at-max?))
(mupdate! (fasc :hand) :discards
(if (mav :discarded?) disj conj) i)))
:icon (cF (m/Icon (if (mav :discarded?)
m/Icons.circle_outlined m/Icons.add)))}
{:discarded? (cF (contains? (mav :discards) i))}))
(range 1 (inc (mget (fmu :counter) :value)))))
And remember to use our new at-max?
property on the FAB button:
:enabled? (cF (not (mget (fmu :hand) :at-max?)))
Alert user to our cheat block
The cheat is now blocked, but the user does not know why un-discarding gestures are ignored. We use f/mx utility user-alert
to clue them in:
:onPressed (dart-cb []
(if (and (mav :discarded?)
(mav :at-max?))
(fx/user-alert ctx "Un-discarding would put us over the limit.")
(mupdate! (fasc :hand) :discards
(if (mav :discarded?) disj conj) i)))
Now test well, trying the kind of sequences that can break code that seems to work. A good one is to discard one, hit the limit again, confirm the discard cannot be un-discarded, now discard another and confirm the first card now can be un-discarded. But not the second! Also, involve the first and last cards to check for boundary errors.
Moral: The declarative/functional paradigm makes code easier to get right.
Note, by the way, that the onPressed
handler was free to look about the app for any state it needed to do its job. Libraries based on view functions supported by stores and subscriptions struggle here.
Code decomposition
It is great that the app works well as we make it more complex, but the lexical footprint is getting out of hand. f/mx macros do their best, but Flutter’s own fine-grained widgetry limits what can be achieved. Fortunately, we can subdivide lexically without rewiring.
We will not drill down into the details, but f/mx apps are self-organizing at run-time, so we can refactor just by juggling chunks of code. It also means these chunks can be moved into standalone functions, to break things up and at the same time give meaningful names to otherwise anonymous chunks.
So we move this code into a parameterless function hand-of-cards
. May as well do the same with the FAB — the “dealer” — and with the playing cards:
(defn home-page [.title]
(scaffold
{:appBar (app-bar {:title (m/Text title)
:backgroundColor (within-ctx [me ctx]
(-> (m/Theme.of ctx) .-colorScheme .-inversePrimary))})
:floatingActionButton
(dealer-fab)}
(center
(column {:mainAxisAlignment m/MainAxisAlignment.center}
(m/Text "We have pushed the button N times:")
(text {:style (within-ctx [me ctx]
(-> (m/Theme.of ctx) .-textTheme .-headlineMedium))}
{:name :counter
:value (cI 0)}
(str (mget me :value)))
(hand-of-cards)))))
(defn dealer-fab []
(floating-action-button
{:tooltip "Increment"
:disabledElevation 1
:foregroundColor (cF (when-not (mav :enabled?)
m/Colors.grey))
:onPressed (cF (when (mav :enabled?)
(dart-cb []
(mupdate! (fmu :counter) :value inc))))}
{:name :fab
:enabled? (cF (not (mget (fmu :hand) :at-max?)))}
(m/Icon m/Icons.add)))
(defn hand-of-cards []
(row {:mainAxisAlignment m/MainAxisAlignment.spaceEvenly}
{:name :hand
:discards (cI #{})
:at-max? (cF (>= (- (muv :counter)
(count (mav :discards))) 3))}
(mapv (fn [i]
(playing-card i))
(range 1 (inc (mget (fmu :counter) :value))))))
(defn playing-card [i]
(fx/icon-button
{:key (m/ValueKey i)
:onPressed (dart-cb []
(if (and (mav :discarded?)
(mav :at-max?))
(fx/user-alert ctx "Un-discarding would put us over the limit.")
(mupdate! (fasc :hand) :discards
(if (mav :discarded?) disj conj) i)))
:icon (cF (m/Icon (if (mav :discarded?)
m/Icons.circle_outlined m/Icons.add)))}
{:discarded? (cF (contains? (mav :discards) i))}))
Much better!
Add a difficulty option
Now that we have lexical granularity to go with property granularity, let us add some structural complexity and see how f/mx holds up.
How about a radio group where the player can choose a level of difficulty? The widget is easy, but how will other widgets detect its selection?
Let us first build the widget. Here we see mostly Flutter mechanics, but note how the requisite groupValue
property of each RadioListTile
is kept consistent with the user input selected
:
(defn game-difficulty []
(fx/row {:mainAxisAlignment m/MainAxisAlignment.center}
{:name :difficulty
:selected (cI :hard)}
(mapv (fn [dgr]
(fx/sized-box {:width 144}
(fx/radio-list-tile
{:controlAffinity m/ListTileControlAffinity.leading
:title (m/Text (str/capitalize (name dgr)))
:value dgr
:groupValue (cF (mav :selected))
:onChanged (dart-cb [v]
(mset! (fasc :difficulty) :selected v))})))
[:easy :hard])))
Not shown, we add (game-difficulty)
above the counter display, since it is a global game property. So now we have a difficulty
widget initialized to “hard”, and need to think up some way of making things harder.
Implementing “hard” mode
Let us prevent un-discards in “hard” mode, and again be nice about explaining why we ignore the tap. You might want to try your hand at this one.
Pro tip: (muv :widget-name :prop-name)
will expand to (mget (fmu :widget-name) :prop-name)
. (We hate typing.)
See if you can work out the new onPressed
handler before looking below.
:onPressed (dart-cb []
(if (mav :discarded?)
(cond
(= :hard (muv :difficulty :selected))
(fx/user-alert ctx "Un-discarding not allowed in Hard mode.")
(mav :at-max?)
(fx/user-alert ctx "Un-discarding puts us over the limit.")
:else (mupdate! (fasc :hand) :discards disj i))
(mupdate! (fasc :hand) :discards conj i)))
Just as formulas have un-limited reach to decide their values, handlers can retrieve state freely to decide their behavior.
Hard mode: Block that cheat!
We have not gotten into the game semantics yet, but suppose we are headed towards a variation on blackjack, one in which the user has to hit an exact value by discarding wisely. That means “un-discard” will be very helpful, and limiting it will make play harder. So…can you spot a subtle “cheat” made possible by our implementation of hard mode? Can you prevent it? See what you can do without looking below. Remember to let the user know why!
Tip #1: The cheat would be figuring out the answer in easy mode, then changing to hard mode before answering, getting credit for a hard win.
Tip #2: Flutter onChange
handlers can ignore a change, and it will be as if it never happened; nothing needs to be undone when we spot the cheat, other than let the user know why we ignore the tap.
Our solution:
(fx/radio-list-tile
{:controlAffinity m/ListTileControlAffinity.leading
:title (m/Text (str/capitalize (name dgr)))
:value dgr
:groupValue (cF (mav :selected))
:onChanged (dart-cb [v]
(cond
(pos? (muv :counter))
(fx/user-alert ctx "Difficulty cannot be changed during a hand.")
;--
:else (mset! (fasc :difficulty) :selected v)))})
Thanks to global reach, our difficulty RadioListTiles can check the counter value to see if the game has started.
Now let us start on a game, a variation on blackjack in which we continue drawing cards until we hit twenty-one and win, or go over and lose.
Game on!
To begin with, let us show the user their running total so they do not have to add their cards mentally. We first clean up a bit, creating an explicit dealt
property, then use that to compute a new held-sum
property:
(defn hand-of-cards []
(row {:mainAxisAlignment m/MainAxisAlignment.spaceEvenly}
{:name :hand
:discards (cI #{})
:dealt (cF (range 1 (inc (mget (fmu :counter) :value))))
:held-sum (cF (apply + (remove #(contains? (mav :discards) %)
(mav :dealt))))
:at-max? (cF (>= (- (muv :counter)
(count (mav :discards))) 3))}
(mapv (fn [i] (playing-card i))
(mav :dealt))))
Good thing we broke that code out into its own function! Now we can add a display of the held-sum
, using a new value-descr
helper:
(center
(column {:mainAxisAlignment m/MainAxisAlignment.center}
(game-difficulty)
(m/Text "We have pushed the button N times:")
(text {:style (within-ctx [me ctx]
(-> (m/Theme.of ctx) .-textTheme .-headlineMedium))}
{:name :counter
:value (cI 0)}
(str (mget me :value)))
(hand-of-cards)
(value-descr (str (muv :hand :held-sum))
"sums the held cards")))
And a screen shot reflecting my design mastery:
Next we need a goal for the user.
Show a random goal
Why not just use 21? Because we are dealing cards in serial fashion and…
(+ 1 2 3 4 5 6) => 21 Oops!
ie, We have to avoid triangular numbers. Think bowling pins:
Here are some helpers we can use to select a random, non-triangular goal:
(defn is-triangular [n]
(let [basis (int (Math/floor (Math/sqrt (* 2 n))))]
(= n (/ (* basis (inc basis)) 2))))
(defn triangulars-not [max]
(remove is-triangular?
(range (inc max))))
One solution: extend our :hand
widget with yet another property:
:goal (rand-nth (triangulars-not 32))
Aside: Why no cF
to make a reactive formula? Look again. No dependencies! Properties do not need to be reactive.
And now we display the goal, perhaps just below the button count:
(value-descr (str (muv :hand :goal))
"is our goal!")
Call a winner! or loser. :(
Deciding the game is easy now:
- hitting the goal wins;
- exceeding the goal loses; and
- if under the goal, no decision.
We add a new outcome
property to the hand
widget:
:outcome (cF (cond
(= (mav :held-sum) (mav :goal)) :win
(> (mav :held-sum) (mav :goal)) :lose))
And display it when the game is decided, starting with a separate function/component:
(defn outcome []
(fx/visibility
{:visible (cF (not (nil? (muv :hand :outcome))))}
(fx/image
{:image (cF (m/AssetImage
(if (= :win (muv :hand :outcome))
"image/trophy.jpeg" "image/game-over.png")))
:height 256})))
Adding (outcome)
to our MVP:
(defn home-page [.title]
(scaffold
{:appBar (app-bar {:title (m/Text title)
:backgroundColor (within-ctx [me ctx]
(-> (m/Theme.of ctx) .-colorScheme .-inversePrimary))})
:floatingActionButton
(dealer-fab)}
(center
(column {:mainAxisAlignment m/MainAxisAlignment.center}
(game-difficulty)
(m/Text "We have pushed the button N times:")
(text {:style (within-ctx [me ctx]
(-> (m/Theme.of ctx) .-textTheme .-headlineMedium))}
{:name :counter
:value (cI 0)}
(str (mget me :value)))
(value-descr (str (muv :hand :goal))
"is our goal!")
(hand-of-cards)
(value-descr (str (muv :hand :held-sum))
"sums the held cards")
(outcome)))))
And try our luck…
Great! That was so much fun that we want to play again! How?
New game
For our last refinement, let us give the user a way to start a new game. And we will let you handle this one. Mostly. We do need to explain a new kind of property, cFI
, one that starts as a cF
formula to compute an initial value then immediately morphs into a cI
input which we can mset!
in response to a user gesture.
Aside:
cFI
properties are reminiscent of classic OOP constructor functions, where initial properties of a new instance can be computed based on other properties and program state, after which they can be modified imperatively.
Here is a new helper:
(defn random-goal []
(rand-nth (triangulars-not 22)))
…and here is how we use it to start the first game: our new goal
property for the hand
widget:
:goal (cFI (random-goal))
Make that change, and test to make sure the game still works. All good? Now we can implement “new game”.
Implementing “new game”
Things to consider as a Matrix developer to implement “new game”:
- hint: what reactive property can we use to detect “game over”?
- hint #2: we do not need the “+” FAB once the game is over. In fact, it should be disabled! Or…gee,
Icons.restore
looks like “start over”; and - hint #3: along with our new
cFI
goal, our app has three othercI
inputs: the count, discards, and difficulty.
Again, if you like, try your hand at implementing “new game” before reading further. Our solution:
(defn dealer-fab []
(floating-action-button
{:tooltip "Increment"
:onPressed (cF (dart-cb []
(cond
(muv :hand :outcome) (do
(mset! (fmu :counter) :value 0)
(mset! (fmu :hand) :discards #{})
(mset! (fmu :hand) :goal (random-goal)))
:else (mupdate! (fmu :counter) :value inc))))}
{:name :fab}
(m/Icon (if (muv :hand :outcome)
m/Icons.restore m/Icons.add))))
A GitHub gist with the final version of our little game is here.
Unfinished business
We could go on like this, but we are far enough down the rabbit hole to have a good feel for programming with Matrix and Flutter/MX. Here are some likely next steps the reader might like to try:
- disable the “un-discard” feature once an outcome is reached;
- display a history of games played in a sidebar or separate tab;
- come up with an “average” difficulty, and think of more ways to vary the game difficulty; and
- important, and one we will execute in a sequel: move all the game properties from widgets to a single “game”
model
, the Matrix term for an object. Then “new game” will look like:
(mset! (fmu :app) :game (mk-game))
That last would be an especially valuable exercise, because we would see more clearly that Matrix can manage domain as well as view usefully. It will also make the “new game” implementation trivial.
The Unsettling Red Pill
In no particular order, here are the perhaps unsettling differences we have encountered:
- view functions are not a thing. Individual properties are declarative, not entire views, with a non-obvious connection between subscriptions and view elements;
- no separate store. The set of reactive app properties forms a uni-directional DAG, one detected automatically by Matrix internals. No boilerplate definitions needed, and no mapping by the developer from the view code they are trying to write and the store schema;
- Matrix manages the implicit DAG rigorously and efficiently to guarantee overall state consistency. Look for the section on “Data Integrity” here;
- an f/mx widget can have ad hoc properties, akin to
hooks,
as needed to deliver app behavior, without defining new classes. Unlike hooks, Matrix properties are globally accessible; - formulas and handlers tend to be small, making them easy to get right, understand later, debug, and refactor;
- unfettered “reach” — navigation throughout the application, both to read and write state — means formulas and handlers are written naturally, calling on related app state as needed, wherever it resides; and
- rules and handlers deal naturally with a mix of model and view state. In a GUI app, model and view are intimately connected. Separating those concerns just means extra work rejoining them with subscriptions and actions.
All very well, but the problem remains, even after the vicarious experience of following this post:
No one can be told what is the Matrix. You must see it for your self. — Morpheus
Your red pill is here.