You may have heard about our foray into writing a native app using Elm Native UI. Purple Train accomplishes its goals quite well for us. It’s not only a useful product, it’s also a learning experience for us. We decided that it’s only fair for us to try to push the boundaries of the technology.
After we submitted the first Elm version to the Apple App Store, I entered what I thought would be a simple issue on the project: when you open the Stop Picker, it should be scrolled to the stop that’s selected instead of being at the top of the list. It became a prime example of why you should not tell a developer that something is “just a small change”.
Imperativeness in a land of Declarations
The biggest problem is that when the Stop Picker opens, we want code to execute that affects the state of the UI. The Elm Architecture builds its UIs in a declarative manner. You say “There’s a scroll view here and it contains a list of buttons” and the display layer takes care of laying that out on the screen for you. There’s no place to say “When the box is displayed, execute this code”.
In order to get this to work, we decided to play nice with declarations instead
of trying to fight them. We wanted a way to declare that a given element should
be marked as the scroll target when the box is rendered. You’ve undoubtedly seen
this pattern when using a loop to generate some HTML <option>
tags and one is
<option selected="true">
. We had to make the function that generates the
button be able to tell if it’s the selected one. We also had to be able to pass
the selected button down the chain so the function can figure that out.
The code to generate a button in the Stop Picker originally looked like this:
stopButton : Stop -> Node Msg
stopButton stop =
pickerButton (PickStop stop) stop
Where pickerButton
called Element.touchableHighlight
to create the button
and PickStop stop
is the message we send our event loop.
We changed the stopButton
function to this:
stopButton : Maybe Stop -> Stop -> Node Msg
stopButton highlightStop stop =
case highlightStop of
Nothing ->
pickerButton (PickStop stop) stop
Just pickedStop ->
if stop == pickedStop then
highlightPickerButton (PickStop stop) stop
else
pickerButton (PickStop stop) stop
And we pass in the already-selected stop or Nothing
if there isn’t one.
The main difference between pickerButton
and highlightPickerButton
is that
they pass a scrollTarget
property to the touchableHighlight
call. This lets
the underlying component know where it should scroll to on load.
This whole setup lets us define what’s special about the situation using Elm
instead of using JS. We tried a few ways of letting Elm handle React Native’s
componentDidMount
callback, but ultimately had to fall back to JavaScript.
The State of the UI
Since Elm Native UI is built on top of React Native, it’s possible to drop down into that more imperative land of JavaScript to do things we can’t do (or, at least, we haven’t yet specified a way to do) in Elm. Thankfully, since Elm compiles to JavaScript, it’s rather straightforward to craft some JavaScript code that Elm is able to use.
What we need here is a React component that knows that it needs to find and
scroll to a sub-element once it’s mounted into the UI. We’ll need to hook into
the componentDidMount
callback because we need values (height, specifically)
from the rendered state of the component.
In the file app/Native/ScrollWrapper.js
, we created a component subclass that
looks like this:
const _user$project$Native_ScrollWrapper = function () {
var ScrollView = require('ScrollView');
class ScrollWrapper extends ScrollView {
componentDidMount() {
// Find the child node that has the `scrollTarget` property
// Scroll to that node with `this.scrollTo();`
}
}
return {
view: ScrollWrapper
};
}();
Because we named the const
“_user$project$Native_ScrollWrapper
”, we’ll be
accessing it in Elm as Native.ScrollWrapper
. We also need this Elm code in
app/ScrollWrapper.elm
:
module ScrollWrapper exposing (view)
import NativeUi exposing (Node, Property)
import Native.ScrollWrapper
view : List (Property msg) -> List (Node msg) -> Node msg
view =
NativeUi.customNode "ScrollWrapper" Native.ScrollWrapper.view
Once we have this in place, we can import ScrollWrapper
in our Elm view and we
can replace the call to Element.scrollView
with ScrollWrapper.view
.
Now that all this is in place, we can refresh the app in the iOS Simulator and see the results:
Getting Things Done
I admit, this isn’t the best thing to have to do. I’d much prefer that we can specify this kind of extension in pure Elm. That’s the point of writing Elm, after all. But it’s very useful that we can interact with JavaScript in this way. Even if we could write new functionality to extend the framework in Elm, there is a lot of existing code written in JavaScript that could be very useful to include in a project. It’s comforting to know that it’s fairly easy to access should we need it.