Hey there,
So, I've tried to make a non-trivial container component to try to apply all the lessons I've been learning in working with Elm and the Elm Architecture. You can find it here:
https://github.com/TheSeamau5/TabbedPages and you can try locally the example under src/Main.elm
The idea behind this component is that it is a container component that has two container components. (super meta i know) It's the good old, tabs + pages combo.
1) There are tabs at the top, and when you select a tab a different page appears.
2) You can swipe the pages left and right to go to the next page and previous pages.
3) The pages animate with physics-based animations using my work-in-progress library
elm-spring.
4) The tabs have a little strip that sits under the currently selected tab which also moves using physics-based animations
5) The tab strip also moves in lockstep with the swiping of the pages.
6) Both the tabs and the pages are completely generic and do not care what their children are.
7) Both the tabs and the pages provide contexts in order to feed data to the children. In the example, I use this to change the color of the selected tab item.
Unfortunately, it would be a bit too much work to write a tutorial on this, but allow me to just show the main types. First of all, I use the following type aliases
type alias Init options state = Vector -> options -> state
type alias Update action state = action -> state -> state
type alias View action state = Address action -> state -> Html
The "Init" is the type of the initializers. Initializers take a size, an options object and return a state.
The "Update" function is just the simple reducer that updates the state given an action.
The "View" function is just the function to view a state given an address.
Here are the types:
Label.elm (A simple component)
init : Init Options State
update : Update Action State
view : View Action State
Tabs.elm (A container component of unknown number of unknown children that communicates a context to its children)
init : Update Context itemState
-> Init itemOptions itemState
-> Init (Options itemOptions) (State itemState)
update : Update Context itemState
-> Update itemAction itemState
-> Update (Action itemAction) (State itemState)
view : View itemAction itemState
-> View (Action itemAction) (State itemState)
SwipePages.elm (A container component of unknown number of unknown children that communicates a context to its children)
init : Update Context pageState
-> Init pageOptions pageState
-> Init (Options pageOptions) (State pageState)
update : Update Context pageState
-> Update pageAction pageState
-> Update (Action pageAction) (State pageState)
view : View pageAction pageState
-> View (Action pageAction) (State pageState)
TabbedPages.elm (A container component with a tabs container and a pages container, both synchronized, both have unknown number of unknown children and both communicate with their children)
init : Update Tabs.Context tabState
-> Init tabOptions tabState
-> Update Pages.Context pageState
-> Init pageOptions pageState
-> Init (Options tabOptions pageOptions) (State tabState pageState)
update : Update Tabs.Context tabState
-> Update tabAction tabState
-> Update Pages.Context pageState
-> Update pageAction pageState
-> Update (Action tabAction pageAction) (State tabState pageState)
view : View tabAction tabState
-> View pageAction pageState
-> View (Action tabAction pageAction) (State tabState pageState)
Some quick lessons I've learned:
1) The most practical way to apply a context is via a simple reducer. "Update Context state". You can still use function composition and it is much easier to work with. (no need to produce a single action or to wonder what to do when no actions can be produce)
Here are the ones I use in the code for illustration
applyPagesContext : Update Pages.Context Label.State
applyPagesContext context state =
state
|> Label.update (Label.Resize context.size)
applyTabsContext : Update Tabs.Context Label.State
applyTabsContext context state =
if context.isSelected
then
state
|> Label.update (Label.SetColor Color.red)
|> Label.update (Label.Resize context.size)
else
state
|> Label.update (Label.SetColor Color.blue)
|> Label.update (Label.Resize context.size)
2) Initializers for container components that communicate contexts require the context reducer. Concretely, if this weren't the case, at the start of the application, none of the tabs would "look" selected (i.e. all the labels would be blue). You need to apply the context then in order to see the red color from the very beginning.
3) Container components that try to synchronize two components will need to intercept almost all of the child actions. Just go under TabbedPages.elm and you'll see the update function. It looks long but it's actually super simple. The gist of it is: when the tabs receive an action to select the third tab, tabbed pages intercepts this to make sure the pages also select the third pages.
4) It is important to be very explicit with actions such as Resize. If you do any animations, this action could potentially break everything. Make sure to handle it with care such that everything gets repositioned accurately.
Finally, I'd like to add that the code is far from perfect (and it is not devoid of bugs). I also wanted to add touch support but I couldn't figure out how.
In any case, I think it illustrates how one could make such generic components that also have this synchronization going on. Also, I'd like to invite anyone interested to try their hand at improving the code. There is a lot of redundant and very similar code. And, there's a lot to gain in finding ways to get code like this to be simpler and easier to use. For example, one question I can think of: Is the fact that the update part of TabbedView is so explicit and redundant a good or a bad thing. Note that a lot of frameworks handle this with two-way data-binding.
If there are any questions about the code or the ideas, I'd be glad to answer,
Hassan Hayat