Flexible Mobile Content Layout

By Tyler Sheaffer

How do you give your server fine-grained control over your mobile content layout, without losing the benefits of a native experience?

When we were facing this challenge last summer, we had an API that was “dumb” in the sense that it just sent down a list of sorted Program models to the mobile client. The client was responsible for all decisions about layout — visual cell style, grouping of cells into sections, section titles, section scroll behavior, and the action to take once the cell is tapped.

We whiteboarded out these client responsibilities and realized we could translate them all into a static JSON syntax sent by the API. This had the following benefits:

  • Allowed us to intelligently customize the layout for each user without needing to expose the aggregate data from different processing jobs
  • Avoided duplicating the business logic of this intelligent layout on every platform (iOS, Android, Apple TV, Samsung TV etc.)
  • Let us hotfix issues much more quickly, without App Store Review
  • Simplified the client’s contract with the API, making the whole system more backwards-compatible and easier to test
  • Gave all versions of the client the latest features without waiting for them to update the app binary, which sometimes takes several weeks

The JSON Syntax

{
    "sections": \[
        {
            "style": "block-styled-fat",
            "cells": \[
                {
                    "subtitle": "Stephen Fry",
                    "action": {
                        "type": "track",
                        "icon\_type": "play",
                        "id": "nNpBWr7A9"
                    },
                    "background": {
                        "url": "...",
                        "content\_type": "image/jpeg",
                        "size": 614792,
                        "width": 2400,
                        "height": 2400,
                        "dominant\_colors": \[
                            "#342c35",
                            "#83a3cb",
                            "#b5b6c9"
                        \]
                    },
                    "tooltip": {
                        "id": "first\_sleep\_story",
                        "text": "Try your first sleep story here"
                    },
                    "duration": "24 min"
                },
                ...
            \]
        },
        ...
    \],
    "programs": \[
        {
            "id": "BVLV98v",
            "title": "Blue Gold",
            "subtitle": "Non-Fiction",
            "description": "Let master storyteller Stephen Fry...",
            "author": "Phoebe Smith",
            "meditation\_type": "sleep",
            "narrator": {
                "id": "41FcQWqIX",
                "name": "Stephen Fry",
                "bio": "Stephen John Fry is an English...",
                "headshot": {
                    "url": "...",
                    "content\_type": "image/jpeg",
                    "size": 80004,
                    "width": 1000,
                    "height": 1002,
                    "dominant\_colors": \[
                        "#aeaeae",
                        "#2b2b2b",
                        "#474747"
                    \]
                }
            },
            "tracks": \[
                {
                    "id": "nNpBWr7A9",
                    "title": "Blue Gold",
                    "type": "audio",
                    "audio": {
                        "url": "...",
                        "content\_type": "audio/ogg",
                        "size": 19257647,
                        "duration": 1473.898667
                    }
                }
            \]
        },
        ...
    \]
}

I’ll walk us through a few of the nice aspects of this system.

Cell Styles

Each section has a style that applies to all its cells. The latest clients support nine cell styles (we initially launched with five). Here is a dummy example with many different styles all on one screen:

Styling Data vs Raw Data

If you look closely at the JSON, you’ll notice some apparent inconsistencies. For example, why is cell.duration coming back as a string “24 minutes” while program.track[0].audio.duration is coming back as a float 1473.898667 ?

The answer is that the sections represent styling data while the program is raw. We give the client the raw Program in order to build the session player and program details screens, track activity analytics etc. However on the cell, all fields are for styling. By formatting the raw duration into a human-readable string, we avoid making the client do that translation on its own, which would limit the server’s power and flexibility. This is one small example, but we support several other similar style-formatted fields for different contexts:

  • cell.progress tells the client to display a little progress bar x percent full on the cell. This is a lot cleaner than passing back the aggregate session stats and letting the client calculate that itself ✅
  • cell.tooltip tells the client to point a tooltip at the cell. The client only shows each tooltip once (based on the tooltip.id) 👆
  • cell.gradient determines the beautiful gradient color scheme for certain cell styles 😍
  • cell.decorator has a color and text which display as a little badge on the cell. We use this to indicate NEW or COMING SOON content 🚨
  • cell.action.icon_type determines the little icon, if any, on the right side of the cell. The client determines the placement and visual style, the server just passes a simple string enum 🔒

Actions

Each cell has an action, with thetype indicating the code-path for the client to take. This is where the flexibility of the system really shines. Any cell style can have any action type. The latest clients support ten different action types:

  • Content Actions: program and track push the program details screen or the session player, respectively, using action.id for program or track lookup
  • Deeplink Actions: breathe, scenes, profile, settings, signup, login push the respective screens within the app
  • External Actions: url pushes a webview based on the action.url field
  • Recursive: sections requires a nested action.sections structure within the action itself. This allows us to nest functionality. We currently use this for the By Narrator section on production, but it’s super flexible.

Caching

Like any technical decision, this system comes with some tradeoffs. Most notable is the raw size of the response. It’s quick to calculate in code on the API, but the size can reach several hundred kilobytes. gzipping helps, but to reduce bandwidth further we’ve leveraged HTTP ETags and the Cache-Control header to avoid sending every time. Given the difficulty of invalidating a server-side cache of the full response (e.g. some of the logic includes random selection from a list), we do have to run the code to calculate the full response value each time while only caching the sub-queries (e.g. the user’s session activity stats). Then the system ETags the response right before it gets sent, and if it matches the client’s request header we send a 304 Not Modified

Parting Wisdom 🙏

At Calm, iteration speed is extremely important to us. One of our goals as an engineering organization is to never have to tell somebody with an awesome new idea “Sorry, we can’t do that.” Providing a lot of flexibility on our content layout within the app is a huge piece of that, because it means we can launch new ideas super fast.

We can write a few lines of code on the API, and within a few minutes all users would see a sexy new cell on the app’s homepage. We can run a test showing the newest content to only a small holdout group before we’re sure how it’ll be received. We can launch new tooltips during onboarding without waiting for App Store Review. We can customize the whole experience for every user based on complex Machine Learning models fit to their own data.


Calm is hiring! You can see our open job listings at calm.com/jobs

Previous
Previous

Internationalization With React-Intl