Infinite Y-Axis Calendar View Scroll
Daveyon Mayne 😻
Posted on January 3, 2024
I admired the ScrollView
on iOS. When done right, you get a smooth infinite scrolling of the y-axis while it handles loading of data onto the view. A similar design was done for the DayOne iOS app; it's an infinite scroll back in the past as it's a journaling app so that make sense. I'm building a similar app but for farmers for my app called ektarOS (FarmSwop).
# index.html.erb: ie farmswop.com/calendar
<div class="journal-calendar-container">
<header class="journal-calendar-header bg-earth-50 z-50 dark:bg-gray-850 dark:text-bubble-800 grid grid-cols-7 p-1">
<span class="journal-calendar-header__item">mon</span>
<span class="journal-calendar-header__item">tue</span>
<span class="journal-calendar-header__item">wed</span>
<span class="journal-calendar-header__item">thu</span>
<span class="journal-calendar-header__item">fri</span>
<span class="journal-calendar-header__item">sat</span>
<span class="journal-calendar-header__item">sun</span>
</header>
<div id="journal-container" class="h-screen overflow-y-auto">
<%= render "journals/calendar/scrollable", direction: "past" %>
<%= render "journals/calendar/scrollable", direction: "future" %>
</div>
</div>
As you can see, I'm rendering journals/calendar/scrollable
twice; the top one that fetches past data while the other fetches present and future data. Let's take a look at the partial:
views/journals/calendar/scrollable
:
<%= turbo_frame_tag "#{direction}_journal_timeline",
src: journal_timeline_path(direction: direction, month_year: local_assigns[:month_year] || nil ),
loading: :lazy, target: "_top" do %>
<p>Loading...</p>
<% end %>
On page load, we make two requests; one for past data and the other for present/future data. journal_timeline_path
renders a repeat of views/journals/calendar/scrollable
in an odd way:
<%= turbo_frame_tag :"#{@direction}_journal_timeline" do %>
<div class="flex flex-col flex-grow">
<% if @direction == "past" %>
<%= render "journals/calendar/scrollable", month_year: @month_year, direction: @direction %>
<%= render "shared/journal_calendar",direction: @direction, calendar_data: @calendar_data %>
<% else %>
<%= render "shared/journal_calendar", direction: @direction, calendar_data: @calendar_data %>
<%= render "journals/calendar/scrollable", month_year: @month_year, direction: @direction %>
<% end %>
</div>
<% end %>
For past data, I try to keep journals/calendar/scrollable
from not being in view until the user scrolls up. It's not perfect as yet, we have to use JavaScript for this section. I'll come to that later.
shared/journal_calendar
:
<%= turbo_frame_tag :"#{direction}_journal_timeline" do %>
<article class="bg-earth-100 dark:bg-gray-850 invisible" data-controller="journal" data-direction="<%= direction %>">
<div class="bg-earth-50 dark:bg-gray-840 dark:text-bubble-900 px-4 py-1 text-sm font-stdmedium">
<%= @date.strftime('%B %Y') %>
</div>
<div class="flex items-center justify-between overflow-x-auto">
<table class="w-full">
<thead>
</thead>
<tbody>
<% calendar_data.each do |week| %>
<tr>
<% week.each do |day| %>
<td class="journal__cell <%= day.present? ? 'journal__cell--on' : 'journal__cell--off' %>">
<!-- Your rendered cell -->
<%# render "shared/journal_cal_cell", day: %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
</article>
<% end %>
The Calendar Controller
Calendar days are server-side rendered. No JavaScript is used to render the dates nor its data.
class Journals::CalendarController < ApplicationController
def index; end
end
And that's it! Literally! Joking! Journals::CalendarController
is only needed to render the index
page. If you look back at views/journals/calendar/scrollable
, it fetches journal_timeline_path
. Here's that controller:
class Journals::TimelineController < ApplicationController
def index
@direction = params[:direction]
@month_year = params[:month_year].present? ? Date.parse(params[:month_year]) : Date.today
case @direction
when "past"
@month_year -= 1.month
end
@date = @month_year.beginning_of_month
@calendar_data = Journal::Timeline.new(start_date: @date, bucket: @bucket).calendar_data
case @direction
when "past"
@month_year -= 1.month
else
@month_year += 1.month
end
end
end
You may see references to "buckets", they are not relevant to this post. Most of this code I copied directly from my code editor.
class Journal::Timeline
def initialize(start_date: nil, bucket: nil)
@start_date = start_date
@bucket = bucket
end
def calendar_data
generate_calendar_data
end
private
def generate_calendar_data
calendar_data = []
current_date = @start_date
week = []
# Determine the number of empty cells before the first day of the month
empty_cells = (current_date.wday - 1) % 7
empty_cells = 6 if empty_cells < 0
# Calculate the date of the first day of the current month
first_day_of_current_month = current_date.beginning_of_month
# Add days from the previous month, if needed
empty_cells.downto(1) do |i|
date = first_day_of_current_month.prev_day(i)
day_data = initialize_day_data(date)
week << {} # day_data
end
# Generate calendar data for the entire month
while current_date.month == @start_date.month
day_data = initialize_day_data(current_date)
week << day_data
if current_date.sunday?
calendar_data << week
week = []
end
current_date = current_date.tomorrow
end
# Add the last week to the calendar data
calendar_data << week unless week.empty?
calendar_data
end
def initialize_day_data(date)
# Get the data you need by date
journals = Recording.select do |rec|
observed_at_date = rec.recordable.observed_at.to_date if rec.recordable.observed_at.present?
created_at_date = rec.recordable.created_at.to_date
observed_at_date == date.to_date || created_at_date == date.to_date
end
# Your data format
{
day: date.day,
date: date.strftime("%d-%m-%Y"),
journals: journals
# Add any other data you need for the day, such as events
}
end
end
generate_calendar_data
took some time for me to get right. I hope it was well written?
The Stimulus JavaScripts!
Every time shared/journal_calendar
gets rendered, we execute some other code:
app/javascript/controllers/journal_controller.js
:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ['cell']
connect() {
const container = document.getElementById('journal-container');
const scrollTopBefore = container.scrollTop;
const direction = this.element.dataset.direction;
const newItemsHeight = this.element.clientHeight; // clientHeight/offsetHeight
if (direction === 'past') {
// Adjust the scroll position
container.scrollTop = scrollTopBefore + newItemsHeight;
this.element.classList.remove('invisible')
} else {
// Not sure I need this anymore.
this.element.classList.remove('invisible')
}
}
}
There's a bug with this JS. While the "past" data scrolls nicely on desktop, not so much on mobile devices. I suspect the issue with getting the height with clientHeight
. Any ways, I'll look on that later.
That's all there is for an infinite y-axis scroll. For initialize_day_data > journals
, you can put an empty array if you do not have any data and the infinite scrolling should work, but without any data.
Spot any bug(s)? Please let me know!
Have a great day! 👋
Posted on January 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.