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.
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
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.
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.
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
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
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.
getSegments(sectionId) api to retrieve all segments of a section and images inside each segment.
Here is a sample output of a
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.
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
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.
sectionObserver to all sections instead of eagerly loading all sections in
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
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.
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.
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
Final Glitch - Open live app. Use rewind in Glitch to see each step in action.
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.
- 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.