This is a step by step tutorial of how I implemented Scrubbable Photos Grid in JavaScript.
Google Photos uses virtual scrubbable photos grid. Scrubbable photos grid is laying out all your photos in grid so that users can jump to any random year/month/day in their timeline. This grid is virtual so that we are efficiently using the user's resources like cpu, memory, disk and network.
In this blog post I will cover how to implement such a grid in JavaScript, whether we can substitute this with a simple navigation mechanism for jumping to random year/month/day is separate design discussion all together.
This implementation will use many techniques to make this virtual grid as close to actual grid including
- Estimating and maintaining grid height as close to actual
- Loading only visible photos to DOM
- Detaching not visible photos from DOM
- Positioning photos absolutely inside grid
- Adjusting scroll position to compensate layout shifts in some scenarios
Design
Throughout the blog I will give summary of design decisions Google has taken for their scrubbable grid, but I will recommend to check this Google Design Blog for details.
We are going to keep our design goals same as stated in Google Design Blog:
- "Scrubbable Photos - the ability to quickly jump to any part of the photo library.
- Justified Layout - fill the width of the browser and preserve the aspect-ratio of each photo (no square crops).
- 60fps Scrolling - ensuring the page remains responsive even when looking at many thousands of photos.
- Instantaneous Feel - minimize the time waiting for anything to load."
Google has gone extra mile to ensure row heights are uniform and near the target height while implementing justified layout, we will just use Flicker's justified layout lib for this demo.
Implementation
I will be implementing this step by step, each step is a commit in the scrubbable-photos-grid Github Repo. If you want to skip all detailed explanation and check final implementation, check this Glitch.
Our grid will be divided into sections, sections will have segments, segments will have tiles and tiles will have an image tag. Will cover reasons for these divisions in detail when we need to add them.
I chose JavaScript to demonstrate how things should happen at runtime, you should be able to implement this in any client side framework of your choice.
Step 1 - Dividing whole grid in sections and estimating their heights
In contrast to pagination and infinite scrolling, scrubbable grid always has all the photos present in the grid giving correct representation of finite height and size of scroll knob. This also gives the user ability to jump to any random time in the whole timeline of photos instantly.
An inefficient implementation of this will load metadata like width and height of all photos in the whole timeline as soon as page loads. Using the width and height of each photo we can allocate space on page for actual height of the grid. This will waste a lot of bandwidth and initial load time will be unacceptable.
To reduce this initial metadata payload, we will divide the whole timeline into virtual sections and estimate the height of each section to get the estimated height of the grid. Our initial payload will consist of an array of all sections with a number of images in those sections. Simplest way to divide the whole grid into sections is to have a section per month in the timeline. So if users timeline spans 10 years, our initial payload will consist of max 120 sections and number of photos in each section.
We start by adding basic html with a grid div
as a container of our grid.
Apis used are simulated in api.js
, it basically depends on included store.json
for all section details. We add the getSections
api to retrieve all sections and count of images inside each section. Apis are using random latency of 50-550ms.
Sample output of getSections
api
We add script.js
for loading our grid. Our entry point is loadUi
, in this we call getSections
api. After getting all sections we allocate space by creating an empty div
for each section with estimated height.
Β
As described in Google Design Blog, to estimate height of sections we will assume average aspect ratio of all images being 3:2, approximate total width of all images in that section if laid horizontally and divide it by our grid width to approximate height of section when these images are wrapped at grid width.
Next we add basic style.css
to highlight sections
Step 1 Result - Observe that all empty section divs with estimated heights are created on load and give estimated height to the whole grid and scroll knob.
Step 2 - Populating section with segments
While sections are virtual divisions of the whole grid to minimize initial load resources, segments are visible divisions of the grid for users to navigate and see photos in logical groups. We are going to use static segments for each day, but can be dynamic based on location or more granular time slots based on the number of photos a user has in a day.
We add getSegments(sectionId)
api to retrieve all segments of a section and images inside each segment.
Here is a sample output of a getSegments(sectionId)
call
Next we add populateSection(sectionDiv)
method in script.js
to populate a section div. While populating a section div we call getSegments(sectionId)
api, get segment html for all inner segments, add it to section div and update its height to 100% from the estimated height set initially.
For generating segment html we use justified-layout lib. It gives us an absolute layout for all the tiles inside the segment. We generate individual tile html using this absolute layout and add it as segment childs.
We call populateSection
eagerly in populateGrid
for all sections to demonstrate how populated sections will look like in ui and in DOM.
Finally we make tiles absolutely positioned relative to segments and highlight segments and tiles in style.css
Step 2 Result - Observe all sections and segments eagerly loaded.
Step 3 - Lazily load and unload sections
While in previous step we eagerly loaded all sections on page load for demo, we want to attach sections when they are about to come in viewport and detach when they go out of viewport. We will use intersection observer to implement this.
First we create sectionObserver
IntersectionObserver with handleSectionIntersection
as the intersection handler and use 200px
of margin so that intersection will be triggered when our sections cross virtual viewport of actual viewport + 200px extra on both sides vertically.
We handle intersection events by populating incoming sections and detaching outgoing sections from the virtual viewport. As populating a section needs fetching segments which is async, actual population can go out of order from intersection order. To mitigate this we maintain lastSectionUpdateTimes
for all sections and only populate if this section was not updated meanwhile.
We detach the section by removing all child segments and not changing height.
We attach sectionObserver
to all sections instead of eagerly loading all sections in populateGrid
.
Step 3 Result - Observe how section divs are getting loaded and unloaded as we scroll.
Step 4 - Moving segments and sections to absolute positioning
Ideally browsers will calculate positioning changes of all segments and sections efficiently when height of some section changes. But if we want to make sure we control all positioning changes, we can move both segments and sections to absolute positioning. Our sections will be positioned absolutely within the grid and segments will be positioned absolutely within sections. Our tiles are already positioned absolutely within segments.
First we set sections and segments to absolute positioning and remove margins in css.
Next we maintain top
, height
and lastUpdateTime
of all sections as a state in sectionStates
. We initialize it in populateGrid
and use it while creating initial detached sections.
Next we update populateSection
to generate segments with absolute positioning, calculating top of each segment. We calculate the new height of the section, check if it has changed, in case it is changed, we move all next sections by adding heightDelta
to their tops. We also keep sectionStates
in sync of these changes.
We don't need to keep the old height any more after detaching the section now, because absolute height remains the same after removing child segments.
Step 4 Result - Observe how absolute positioning is maintained by updating the top of all following section divs.
Step 5 - Adjust scroll position in case of layout shift
At this point if you try to scroll down, sections will get attached and detached as expected and scrolling will feel normal. This works as the user scrolls linearly, sections get attached, their height changes, top of further sections increases, grid height changes, whole layout changes and yet we don't feel jumps while scrolling. This is because all layout changes are after current scroll position.
This will change if we allow random jumps to the scroll position. e.g. If we jump to a random scroll position on page load, we will be in state with some detached sections with estimated height prior to our current scroll position. Now if we scroll up, sections will get attached before scroll position and will result in layout changes before scroll position. It will feel like the whole page is jumping when we scroll. To try this just add the following to Step 4 commit inside loadUi
and try scrolling up.
Page Jumping - Observe how it feels like page is jumping to new position while scrolling
To fix this, we check if our current scroll position is ahead of the section for which we adjusted height and adjust scroll by heightDelta
at end of populateSection
.
Final Result - Observe how page jumping is fixed by adjusting scroll position
Final Glitch - Open live app. Use rewind in Glitch to see each step in action.
Whats next
There is lot to add to this, here are some things you can try:
- Add actual images.
- Go through google blog and add improvements they mentioned.
- Cancel ongoing api call to fetch segments when that section goes out of the virtual viewport to save bandwidth when the user is scrolling fast. We can even defer fetching when the speed of scrolling is high.
- Add intersection observers to tiles to load low-res thumbnails for distant tiles and high-res thumbnails for nearer ones.
- Add Google Photos like timeline instead of scroll knob, with this user will be able to jump to any year/month.
- Implement whole thing in some framework like React, you can create components for Section, Segment and Tile.
- This demo assumes that the grid consumes the whole viewport width, you can use grid container's width. It is currently not handling viewport resize also.
- One can even make open source component for scrubbable grid.
Resources
- Google Design Blog - Building the Google Photos Web UI
- Flickr's Justified Layout Lib - justified-layout npm
- Intersection Observer - Intersection Observer Api - Web Apis - MDN
- Github Repo for this implementation, with commits for each Step - scrubbable-photos-grid
- Live App with Source Code for this implementation - Glitch
If you made it this far, kudos to you! I enjoyed implementing this and even more, writing about it. This is my first tech article in long time, so any feedback is much appretiated.
Also published in JavaScript In Plain English on Medium
Top comments (0)