12

I add <div>s to a wrapper <div> and I need to be able to scroll to the last one added each time. How do I do this in Elm?

<div class="messages" style="height: 7em; overflow: scroll">
  <div>Anonymous: Hello</div>
  <div>John: Hi</div>
</div>

Intuitively, it seems like I could call a port that runs the JavaScript code element.scrollTop = element.scrollHeight:

AddChatMessage chatMessage ->
  ( { model | chatMessages = model.chatMessages ++ [ chatMessage ] } , scrollToBottomPort "div.messages" )

The problem is scrollToBottom gets called before the model gets updated. So no big deal, I convert that into a Task. But still, even though the model gets updated first now, the view does not get updated yet. So I end up scrolling to the 2nd item from the bottom!

This maybe leads to a more general question I'm curious about in Elm, how do you run a Cmd after the view is updated due to a change in the model?

at.
  • 50,922
  • 104
  • 292
  • 461
  • You could look at the official TodoMVC elm app. I think it does just that. (On mobile now, otherwise would have shared link). – wintvelt Dec 09 '16 at 09:48
  • @wintvelt, Here's the TodoMVC Elm app: https://github.com/evancz/elm-todomvc/blob/master/Todo.elm. I didn't see anything in there about scrolling, only a `Dom.focus` call. `index.html` and the CSS file didn't provide any indication of auto-scrolling either. – at. Dec 10 '16 at 16:51
  • You're right. It's not in the todomvc app. My bad. Will add an answer below. – wintvelt Dec 10 '16 at 17:37

4 Answers4

8

You could use a port to scroll to the bottom of a list.
In that case, you need to set a javascript to wait for 1 AnimationFrame before doing the scroll. To make sure your list is rendered.

An easier way to do this is to use Elm's Dom.Scroll library.
And use the toBottom function.

If you include that in your code like this:

case msg of
    Add ->
        ( model ++ [ newItem <| List.length model ]
        , Task.attempt (always NoOp) <| Scroll.toBottom "idOfContainer"
        )

    NoOp ->
        model ! []

It should work. I've made a working example here in runelm.io

wintvelt
  • 13,855
  • 3
  • 38
  • 43
  • Didn't know about `Dom.Scroll`! Thanks. I had previously tried `Task.perform` with a JavaScript `port` call and JavaScript wasn't seeing the last item. I wonder if it's because `Dom.Scroll` works on the virtual dom and so it sees the last item and can scroll down to it. While JavaScript can't see the real dom being updated quite yet... – at. Dec 10 '16 at 22:33
6

As of Elm 0.19, with functions Browser.Dom.getViewportOf and Browser.Dom.setViewportOf you can go to the bottom of the container everytime the new element is added to it.

jumpToBottom : String -> Cmd Msg
jumpToBottom id =
    Dom.getViewportOf id
        |> Task.andThen (\info -> Dom.setViewportOf id 0 info.scene.height)
        |> Task.attempt (\_ -> NoOp)
viam0Zah
  • 25,949
  • 8
  • 77
  • 100
MooMoo
  • 1,086
  • 12
  • 22
1

Dom.Scroll is definitely the way to go and is easy to implement.

  1. Set the enclosing HTML element you want to scroll to have a distinct ID.
  2. Make sure said element has styling of overflow-y: scroll or overflow-x: scroll.
  3. Send a command from whatever message actually causes that element to be displayed/opened (the message opens/displays the element while the command you fire will do the scroll). Note you use a command because altering the DOM is technically a side-effect.

Looking at the documentation for say scrolling to the bottom: toBottom : Id -> Task Error (), you can see that it needs to be a Task, so you can wrap into Task.attempt. For example: Task.attempt (\_ -> NoOp) (Dom.Scroll.toBottom Id)

You will then need a function to convert a Result Error () into a Msg, but since scrolling is either unlikely to fail or has little downside if it does, you can set up a dummy function like (\_ -> NoOp) as a quick hack instead.

brownmagik352
  • 398
  • 3
  • 12
  • 3
    Instead of `(\_ -> NoOp)`, write `always NoOp`. `always` is a wrapper for this common pattern :-). – Qqwy Oct 13 '18 at 00:23
1

Since version 0.19 Dom is deprecated instead you can set scroll position using Html.Attribute.property function.

Example

import Html exposing (Html, text)
import Html.Attributes exposing (class, property)
import Json.Encode as Encode


view : Model -> Html Msg
view model =
    div [ class "flow-editor"
        , property "scrollLeft" (Encode.float model.scrollLeft)
        , property "scrollTop" (Encode.float model.scrollTop)
        ]
        [ text "placeholder" ]
Milan Jaric
  • 5,556
  • 2
  • 26
  • 34
  • Dom deprecated comes from [its README](https://github.com/elm-lang/dom/tree/1.1.1). And I don't think your answer is applicable. The question is about scrolling from an update function. – Berend de Boer Apr 30 '19 at 23:15
  • I do agree with you, but I prefer not manipulate DOM inside model logic. For me , it is totally fine to do calculations in update, but setting calculated value to element, that should be view concern. – Milan Jaric May 28 '19 at 11:13