(Or maybe it'd be better called a "viewport-limited table view")
I originally meant to write this two months ago, but got a little busy with doing other stuff. Oops.
Be sure to read Elm's homepage if you want to know what it is. For me, it's like a Haskell for the browser where the tooling actually works out of the box and compiler errors actually make sense (Haskellers welcome to spam me with links and argue otherwise). Anyway, let's get started.
Also, Czaplicki is kind of hard to write and he's not an old man, so I will be impolite and refer to Evan Czaplicki as simply "Evan".
Edit: I will warn you that none of these snippets are meant to compile. They are included because they are more terse and descriptive than what I can write. If you want to get something that compiles, please refer to the complete source in the repository linked below.
Setup
Make sure to go through the motions in http://elm-lang.org/install. Then, you'll want to create a elm-package.json
file.
{
"version": "1.1.0",
"summary": "a scroll table in elm",
"repository": "https://github.com/justinwoo/elm-scroll-table.git",
"license": "MIT",
"source-directories": [
"."
],
"exposed-modules": [],
"dependencies": {
"elm-lang/core": "2.1.0 <= v < 3.0.0", // optional on first setup
"evancz/elm-html": "4.0.1 <= v < 5.0.0",
"evancz/start-app": "2.0.0 <= v < 3.0.0"
},
"elm-version": "0.15.0 <= v < 0.16.0"
}
Edit: as someone on IRC pointed out, you don't really need to even make this file yourself -- you can just run elm package install
and then fill in the dots.
If you leave out dependencies
from the file, you'll just have to install them. Thankfully, installing dependencies is sane in Elm. Just run elm install PROJECT_IDENTIFIER
(e.g. evancz/elm-html
) and it will get going.
Of course, the three things we need will be
- elm-lang/core: the core libs for Elm
- evancz/elm-html: Evan's HTML lib
- evancz/start-app: Evan's "standard Elm architecture" lib (known to some as "the 'original' redux")
When you do elm package install
, it'll give you a whole bunch of stuff about what will be done and whatnot. Mostly you just need to answer "y" to just about every prompt that asks you for confirmation.
Making
The big picture
The most basic Elm programs have a main
binding that is either just Html or a Signal of Html (meaning Html just keeps coming down over time). So really your first program could be something like this:
module BestProgramEver where
import Html exposing (...)
main = h1 [] [text "YEAHHHHHHHHHHH"]
But let's get to what we're really doing. StartApp.Simple
(previously just StartApp
in version 1) exposes a method called start
of the following type: source
start : Config model action -> Signal Html
-- for which Config model action is actually
type alias Config model action =
{ model : model
, view : Address action -> model -> Html
, update : action -> model -> model
}
-- so really you pass in a record of {model, view, update} to start and it will produce a Signal of Html
-- where Address is from Signal and Html is from Html (there's a reason why it's Html.Html, but it's not too important to know right now)
So in our app we have something like this in total:
module ScrollTable where
import Html exposing (..)
import StartApp.Simple exposing (start)
main =
start
{
model = initializedModel,
view = view,
update = update
}
Let's start defining some of these parts.
Model
All right, whenever we're doing a simple scroll table, I think this is sufficient for the model:
type alias VisibleIndices = List Int
type alias Model =
{
height: Int,
width: Int,
rowCount: Int,
rowHeight: Int,
visibleIndices: VisibleIndices
}
Record typing is so nice. Really don't want to work with langs that can't offer at least conditional record typing anymore...
Anyway, let's also get the math down for how calculating the visible indices happens:
calculateVisibleIndices : Model -> Int -> Model
calculateVisibleIndices model scrollTop =
let
{
rowHeight,
rowCount,
height
} = model
firstRow = scrollTop // rowHeight
visibleRows = (height + 1) // rowHeight
lastRow = firstRow + visibleRows
in
{model | visibleIndices <- [firstRow..lastRow]}
(//
is integer division. A little confusing at first, but it's kinda useful for guaranteed division behavior, I guess.)
So this function takes our model
(whole app state), associates a new value for visibleIndices
, and then passes that as the new state. Pretty neat, right? (It's like flux, if flux made more sense)
So now that we have this part, we can make updates work.
Update
Update is a function that takes an Action and applies to the current model and then returns a new model. It's like scan (or reduce or fold or inject or whatever you call it). We can define it like so:
type Action
= NoOp |
UserScroll Int
update : Action -> Model -> Model
update action model =
case action of
NoOp -> model
UserScroll scrollTop -> calculateVisibleIndices model scrollTop
_ -> model
So in the case of NoOp
or anything not matched, it'll just return the model as-is. In the case of UserScroll Int
, it'll match the second case where we'll use whatever's returned from calculateVisibleIndices
. Simple enough, right?
View
Most of this is the same, but with an added bonus: we can just make compiling break if we don't satisfy types or try to use things incorrectly. This is pretty cool. Here's some view code:
type alias RowViewProps =
{
key: String,
index: Int,
rowHeight: Int,
columnWidths: List Int
}
rowView : RowViewProps -> Html
rowView props =
let
{
key,
index,
rowHeight,
columnWidths
} = props
trStyle = style [
("position", "absolute"),
("top", toString (index * rowHeight) ++ "px"),
("width", "100%"),
("borderBottom", "1px solid black")
]
[firstCol, secondCol, thirdCol] = columnWidths
in
tr [trStyle, Html.Attributes.key key] [
td [style [("width", (toString firstCol) ++ "px")]] [
text (toString (index))],
td [style [("width", (toString secondCol) ++ "px")]] [
text (toString (index * 10))],
td [style [("width", (toString thirdCol) ++ "px")]] [
text (toString (index * 100))]]
type alias TableViewProps =
{
rowCount: Int,
rowHeight: Int,
visibleIndices: VisibleIndices,
columnWidths: List Int
}
tableView : TableViewProps -> Html
tableView props =
let
{
rowCount,
rowHeight,
columnWidths,
visibleIndices
} = props
rows = map (\index ->
rowView
{
key = toString (index % (length visibleIndices)),
index = index,
rowHeight = rowHeight,
columnWidths = columnWidths
})
visibleIndices
in
table [style [("height", toString (rowCount * rowHeight) ++ "px")]] [
tbody [] rows]
Of course, to use Html.Attributes
, you need to import it, so make sure you've done import Html.Attributes exposing (id, class, style)
or whatever. The key
here is the property used in virtual-dom to mark nodes as the same so that virtual-dom can update them smartly. (You've probably also used this in React, but most likely to get rid of the stupid console warning message about child elements needing to have keys. Most people usually just use array indices for this, which makes for some real shitty performance sometimes (especially if you have an expensive component like one that renders a graph onto the DOM directly) and mostly just doesn't really help most people who don't have to worry about having a million DOM nodes. But this isn't supposed to be my anti-React User/"Developer" Experience rant, it's supposed to be about Elm!)
Then we have our last bit, which actually involves firing off an action.
import Html.Events exposing (on)
import Json.Decode as Json
-- DOM helper
scrollTop : Json.Decoder Int
scrollTop =
Json.at ["target", "scrollTop"] Json.int
view : Signal.Address Action -> Model -> Html
view address model =
let
{
height,
width,
rowCount,
rowHeight,
visibleIndices
} = model
in
div [] [
h1 [style [("text-align", "center")]] [text "Scroll Table!!!!"],
div [id "app-container"] [
div
[
class "scroll-table-container",
style
[
("margin", "auto"),
("position", "relative"),
("overflowX", "hidden"),
("border", "1px solid black"),
("height", (toString height) ++ "px"),
("width", (toString width) ++ "px")
],
on "scroll" scrollTop (Signal.message address << UserScroll)
]
[
tableView
{
rowCount = rowCount,
rowHeight = rowHeight,
visibleIndices = visibleIndices,
columnWidths =
[
300,
300,
300
]
}]]]
And so if you expect the properties given to the scroll table container, you'll see that we use Html.Events.on
in order to attach a fancy callback. Using scrollTop
to process the event object so that we can traverse it as JSON to get to e.target.scrollTop
(meanwhile typing it as an integer), we can get this passed as a message to our application Action
address with UserScroll
. Thus, our cycle is complete and we send messages back to our update function.
Bootstrapping
To actually get this to work, we do need an initial model (unless we want to start from nothing). This we can do:
initialModel: Model
initialModel =
{
height = 500,
width = 800,
rowCount = 10000,
rowHeight = 30,
visibleIndices = []
}
initializedModel = calculateVisibleIndices initialModel 0
There, now our app is complete.
Demo
Check it out here: http://justinwoo.github.io/elm-scroll-table
Repo
The repo for this lives here: https://github.com/justinwoo/elm-scroll-table
Conclusion
Overall, making this was pretty fun, and the incremental changes I made when I initially made this threw big compilation errors at my face, but the error messages were clear and good at letting me know what was missing or what types didn't match. You can see more of my initial reactions in the README, but this really is way less frustrating than Javascript, and the compiler being fast helps a lot too.
Thoughts? Comments? Disagreements? Praises??? (surely not) Give me a shout on Twitter (@jusrin00)!!! Hopefully this was a little bit useful or someone out here. I know a lot of people kind of get confused about the whole Json.Decode
part at first, so maybe just that part might be useful.
If you made it this far without closing the tab or falling asleep, thanks!
Further Reading
- Elmlang homepage: http://elm-lang.org/
- Elm architecture tutorial: https://github.com/evancz/elm-architecture-tutorial/
- Learning FP the hard way: Experiences in the Elm language: https://gist.github.com/ohanhi/0d3d83cf3f0d7bbea9db
- StartApp.Simple source: https://github.com/evancz/start-app/blob/master/src/StartApp/Simple.elm