LoginSignup
3
2

More than 5 years have passed since last update.

Making a scroll table with Elm

Last updated at Posted at 2015-09-16

(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

  1. elm-lang/core: the core libs for Elm
  2. evancz/elm-html: Evan's HTML lib
  3. 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

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2