Software developer

Developer of webapps and more since 2016

29 Sep 2020

442
A step by step scroll effect in Elm

Tags

Here is an example of what I mean: with a desktop browser, go on this page, put the cursor under a project in the horizontal list (1920px wide screen minimum) and start scrolling. You will see a smooth scroll and the current element in the liste changes at a normal timing.

When you listen to scroll events in a browser, a scroll event is triggered every time the scroll delta has changed. You can't use this event to change the current element of your list, it would change every time the scroll event is triggered.

The following solution works :


module Main exposing (main)

import Browser
import Html exposing (Html, div)
import Html.Attributes exposing (style)
import Html.Events.Extra.Wheel as Wheel
import List.Extra
import Time

main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

type WheelDirection
    = Up
    | Down

type Msg
    = OnMouseWheel WheelDirection
    | SetScrollTimer Time.Posix

type alias Data =
    { id: Int
    }

type alias Model =
    { list : List Data
    , current : Data
    , scrollTimer : Float
    }


init : () -> ( Model, Cmd Msg )
init _ =
 ({ list = [{id = 1}, {id = 2}, {id = 3}, {id = 4}]
  , current = {id = 1}
  , scrollTimer = 0
  }
 , Cmd.none )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        OnMouseWheel direction ->
            let
                nextData =
                    if model.scrollTimer >= 3 then
                        case List.Extra.findIndex (\fromIter -> fromIter.id == model.current.id) model.list of
                            Just index ->
                                case direction of
                                    Up ->
                                        let
                                            next =
                                                List.Extra.getAt (index + 1) model.list -- we take the next one in the list
                                        in
                                        next

                                    Down ->
                                        let
                                            next =
                                                List.Extra.getAt (index - 1) model.list -- we take the previous one in the list
                                        in
                                        next

                            Nothing ->
                                Maybe.Just model.current -- current not found in list, default value, must not happen

                    else
                       Maybe.Just model.current -- if the scrollTimer is minus than 3 (which is an arbitrary value) then we keep the current value. This prevents scroll overflow

            in
            ( { model
                | current = case nextData of
                              Just data ->
                                  data
                              Nothing ->
                                  model.current -- next is not found, we must be at the beginning or the end of the list, keep current, or maybe could go to the opposite beginning, ending.
                , scrollTimer =
                    case nextData of
                        Just data ->
                            if model.current.id == data.id then -- if current == next, keep the same scrolltimer and continue incrementation
                               model.scrollTimer

                            else if model.scrollTimer >= 3 then -- if current /= next, then current has changed, we reset the scrollTimer
                               0

                            else
                               model.scrollTimer
                        Nothing ->

                            model.scrollTimer

              }
            , Cmd.none
            )

        SetScrollTimer time ->
            ( { model | scrollTimer = model.scrollTimer + 1 }, Cmd.none )


onMouseWheel : Wheel.Event -> Msg
onMouseWheel wheelEvent =
    if wheelEvent.deltaY > 0 then
        OnMouseWheel Up

    else
        OnMouseWheel Down


view : Model -> Html Msg
view model =
    let
        currentIndex =
            let
                maybeIndex =
                    model.list
                    |> List.Extra.findIndex (\fromIter-> fromIter.id == model.current.id)
            in
            case maybeIndex of
                Just index ->
                    index

                Nothing ->
                    0 -- set it to 0 by default if not found in list
    in
    div[style "display" "flex"
       , style "justify-content" "center"
       , style "align-items" "center"]
       [ div
          [style "position" "relative"
          ]
          (model.list
            |> List.indexedMap (\ index fromIter ->
                div
                    [ Wheel.onWheel onMouseWheel
                     , style
                         "transform"
                          ("translateX(" ++ String.fromInt ((currentIndex * 335) - 300 - (index * 300)) ++ "px)"
                          ++ "scale(" ++ if index == currentIndex then String.fromFloat 1.05 else String.fromFloat 0.95 ++ ")") -- must take in account the margin between the elements, here it doesn't, 300 is the width of the element
                     , style "transition" "all .3s ease"
                     , style "background" "#bbb"
                     , style "width" "300px"
                     , style "height" "150px"
                     , style "margin" "35px"
                     , style "position" "absolute"] []
             )
          )
       ]


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [  Time.every 350 SetScrollTimer ]

The point is to have a timer which is incremented at an interval, running in the background. I set it at 350ms. This interval is reset every time we change the current element. We change the current Element only if the timer is upon a value. I set it at 3.

this means "change the current element of the list only if it has not changed in the last (3 * 350ms) = 1050ms"

Add a smooth animation to it and it's done.

You can check the code running in this EllieApp (no touch device, mouse wheel only)