DEV Community

Simon Bundgaard-Egeberg for IT Minds

Posted on

Let's scroll to infinity!

The feed exercise. At this point in time, most apps have some kind of infinite scrollable feed to keep users interested.

The project I will be developing on here is written in Elixir, and uses the Phoenix Framework and liveview to create a webapp.

The why

Jascrafts is a project based knitting app I have created for my wife. When finishing a project, the users (my wife and her friends), can add some data about the project they finished, and optionally add an image. Furthermore, they can choose to share the picture on a feed, that the other users can see.

As the use of the feed grew, it was apparent that I could not just pull out all projects and show them, since that list would be long. And I hate the pages solution where you have to click a next page button.

Therefore: the infinite scroll! Luckily, Elixir is a very pragmatic language, and adding such a feature should not be too difficult.

The behind end

The first query I had looked something like this

defp feed_query() do
    from p in ProjectDetail,
      where: p.is_public == true,
      join: sp in assoc(p, :project),
      order_by: [desc: sp.finished_at],
      preload: [:project]
  end
Enter fullscreen mode Exit fullscreen mode

When rendering this on the front-end, there is nothing more than a simple loop and renders each element as it comes into view.

The basic idea of the back-end pagination is to fetch a pages worth of data, + 1 element.

def feed(%Jascrafts.Feed.Pagination{
        page: page,
        pr_page: pr_page
      }) do
    data = feed_query(page, pr_page) |> Repo.all()
    has_next_page = Enum.count(data) == pr_page + 1

    %{page: Enum.take(data, pr_page), has_next: has_next_page}
  end

  defp feed_query(page, pr_page) do
    from p in ProjectDetail,
      where: p.is_public == true,
      join: sp in assoc(p, :project),
      order_by: [desc: sp.finished_at],
      offset: ^((page - 1) * pr_page),
      limit: ^pr_page + 1,
      preload: [:project]
  end
Enter fullscreen mode Exit fullscreen mode

First, let's take a look on the feed query, this now has an offset and limit. The offset part page -1 * pr_page will make sure we only take out data from a specific point in our database. The limit is set to pr_page + 1.

Let's assume I have a pr_page of 12. If I can fetch > 12 elements out, I know that there is at least one more page of data, even if that next page only has 1 element. If I get <= 12 elements out, I know that I am on the last page.

With that logic we can compose this knowledge in the feed function.

The ahead end

Now this is where it gets sticky. If you don't know, a Phoenix app is server rendered.

Our object here is to listen to front-end events of a page end, and when we are within a specific scroll position, fetch more elements. But I don't want too much of this logic on the front-end.

Liveview hooks is the way to go. To set up a hook, we need a bit of javascript to create the frontend listener.

JavaScript interoperability

let Hooks = {};

let scrollAt = () => {
  let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
  let scrollHeight =
    document.documentElement.scrollHeight || document.body.scrollHeight;
  let clientHeight = document.documentElement.clientHeight;

  return (scrollTop / (scrollHeight - clientHeight)) * 100;
};

Hooks.InfiniteScroll = {
  page() {
    return this.el.dataset.page;
  },
  mounted() {
    this.pending = this.page();
    window.addEventListener("scroll", (e) => {
      if (this.pending == this.page() && scrollAt() > 90) {
        this.pending = this.page() + 1;
        this.pushEvent("load-more", {});
      }
    });
  },
  updated() {
    this.pending = this.page();
  },
};
Enter fullscreen mode Exit fullscreen mode

alt text

Above picture shows the container of my feed in the rendered HTML. Notice the data-page field. This is what glues it together with above JavaScript, and when the scrollAt position hits 90%, it will trigger the load-more and push that event over the liveview web socket connection.

To receive this event on the back-end, we need to implement a handle_event function.

@impl Phoenix.LiveView
def handle_event("load-more", _, %{assigns: assigns} = socket) do
  {:noreply, socket |> assign(page: assigns.page + 1) |> fetch()}
end

defp fetch(%{assigns: %{page: page, pr_page: per, has_next: true}} = socket) do
  %{page: projects, has_next: has_next} = Feed.feed(%Pagination{page: page, pr_page: per})
  assign(socket, projects: projects, has_next: has_next)
end

defp fetch(socket) do
  socket
end
Enter fullscreen mode Exit fullscreen mode

A lot of stuff here, and some of it I will leave to the reader to understand. The important part is that the handle event function triggers on the load-more event that is being sent via the JS snippet we wrote earlier.

Now that we are back in Elixir land, we can do all the smart things we want. In this case, we fetch an additional page of data, and send it back over the LiveView socket connection.

The cool thing about this is that the infinite scroll trigger has nothing to do with data. At this point, for any infinite scrollable list I would be inclined to create, all that I needed to do, was to implement the handle_event("load-more") function, and do the specific use-case stuff there.

Discussion (0)