I admired theScrollView
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 calledektarOS (FarmSwop).
# index.html.erb: ie farmswop.com/calendar<divclass="journal-calendar-container"><headerclass="journal-calendar-header bg-earth-50 z-50 dark:bg-gray-850 dark:text-bubble-800 grid grid-cols-7 p-1"><spanclass="journal-calendar-header__item">mon</span><spanclass="journal-calendar-header__item">tue</span><spanclass="journal-calendar-header__item">wed</span><spanclass="journal-calendar-header__item">thu</span><spanclass="journal-calendar-header__item">fri</span><spanclass="journal-calendar-header__item">sat</span><spanclass="journal-calendar-header__item">sun</span></header><divid="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 renderingjournals/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 ofviews/journals/calendar/scrollable
in an odd way:
<%=turbo_frame_tag:"#{@direction}_journal_timeline"do%><divclass="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 keepjournals/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%><articleclass="bg-earth-100 dark:bg-gray-850 invisible"data-controller="journal"data-direction="<%=direction%>"><divclass="bg-earth-50 dark:bg-gray-840 dark:text-bubble-900 px-4 py-1 text-sm font-stdmedium"><%=@date.strftime('%B %Y')%></div><divclass="flex items-center justify-between overflow-x-auto"><tableclass="w-full"><thead></thead><tbody><%calendar_data.eachdo|week|%><tr><%week.eachdo|day|%><tdclass="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.
classJournals::CalendarController<ApplicationControllerdefindex;endend
And that's it! Literally! Joking!Journals::CalendarController
is only needed to render theindex
page. If you look back atviews/journals/calendar/scrollable
, it fetchesjournal_timeline_path
. Here's that controller:
classJournals::TimelineController<ApplicationControllerdefindex@direction=params[:direction]@month_year=params[:month_year].present??Date.parse(params[:month_year]):Date.todaycase@directionwhen"past"@month_year-=1.monthend@date=@month_year.beginning_of_month@calendar_data=Journal::Timeline.new(start_date:@date,bucket:@bucket).calendar_datacase@directionwhen"past"@month_year-=1.monthelse@month_year+=1.monthendendend
You may see references to "buckets", they are not relevant to this post. Most of this code I copied directly from my code editor.
classJournal::Timelinedefinitialize(start_date:nil,bucket:nil)@start_date=start_date@bucket=bucketenddefcalendar_datagenerate_calendar_dataendprivatedefgenerate_calendar_datacalendar_data=[]current_date=@start_dateweek=[]# Determine the number of empty cells before the first day of the monthempty_cells=(current_date.wday-1)%7empty_cells=6ifempty_cells<0# Calculate the date of the first day of the current monthfirst_day_of_current_month=current_date.beginning_of_month# Add days from the previous month, if neededempty_cells.downto(1)do|i|date=first_day_of_current_month.prev_day(i)day_data=initialize_day_data(date)week<<{}# day_dataend# Generate calendar data for the entire monthwhilecurrent_date.month==@start_date.monthday_data=initialize_day_data(current_date)week<<day_dataifcurrent_date.sunday?calendar_data<<weekweek=[]endcurrent_date=current_date.tomorrowend# Add the last week to the calendar datacalendar_data<<weekunlessweek.empty?calendar_dataenddefinitialize_day_data(date)# Get the data you need by datejournals=Recording.selectdo|rec|observed_at_date=rec.recordable.observed_at.to_dateifrec.recordable.observed_at.present?created_at_date=rec.recordable.created_at.to_dateobserved_at_date==date.to_date||created_at_date==date.to_dateend# 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}endend
generate_calendar_data
took some time for me to get right. I hope it was well written?
The Stimulus JavaScripts!
Every timeshared/journal_calendar
gets rendered, we execute some other code:
app/javascript/controllers/journal_controller.js
:
import{Controller}from"@hotwired/stimulus"exportdefaultclassextendsController{statictargets=['cell']connect(){constcontainer=document.getElementById('journal-container');constscrollTopBefore=container.scrollTop;constdirection=this.element.dataset.direction;constnewItemsHeight=this.element.clientHeight;// clientHeight/offsetHeightif(direction==='past'){// Adjust the scroll positioncontainer.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 withclientHeight
. Any ways, I'll look on that later.
That's all there is for an infinite y-axis scroll. Forinitialize_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! 👋
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse