Building a smooth Stories experience on iOS
November 11, 2020
An active community is core to the LinkedIn experience because it provides the foundation for interaction and engagement among our members. As part of that experience, we began introducing Stories this fall as a meaningful way to help members connect to their professional communities.
When launching a new product feature, it’s crucial that we build a cohesive experience across platforms for our members. In particular, we wanted to spotlight our journey on iOS to share some of the challenges we encountered in building out a smooth member experience. In this blog post, we’ll explore why we prioritized delivering a smooth viewing experience by reducing the playback gap and time on the loading screens.
In building out Stories, we evaluated smoothness by looking at the time between when the member navigates to a story and when the story is visible. The process can be triggered by one of the following actions: 1) launching a new story viewer from the top of feed, a notification, or messaging, 2) tapping on the screen edges for the previous or next story media, or 3) swiping horizontally to another story.
For example, here is a breakdown of the time when the viewer is launched with the first option, tapping on a story bubble:
Timeline for launching a story viewer without any optimization.
The total loading time is a sum of three stages:
- t1: Initialize and prepare ViewControllers
- t2: Load story items from API
- t3: Prepare story item by loading image or buffering player
Strategies for preloading
The solution to reducing loading time is to apply preloading wherever possible. We have implemented preloading in three layers to fetch story items and prepare story media before they are actually visible to members:
Preloading stories and story items on Feed and in story viewer
Preloading in the feed
When the story list is loaded for the top of the feed, the first unwatched story item is attached to the story for the first five stories. The resource loader will prepare story items after they are loaded by loading its image in memory or creating a new video player instance and preparing its video content.
Timeline for launching a story viewer with preloading on Feed
By applying this preload, preparation for the story item is done before the user taps on a story bubble. Thus, t3, the time spent on loading an image or buffering video player, is eliminated from the total loading time.
Preloading adjacent stories
When a story viewer is launched, an empty screen with a spinner is displayed until the story items are loaded. We identify this as a negative member experience, especially for members who may be on a slow network and may find themselves stuck on a blank screen for a few seconds. Our solution is to load story items in the background, and open the story viewer once data is loaded. While loading story items, an animation of a progress ring starts around the story bubble to indicate that the data is being loaded, during which members can continue to browse other activities from Feed.
Timeline for launching a story viewer with preloading on Feed and between story viewers
Preloading story items are also applied in adjacent story viewers even though they are not visible to members, meaning that as you watch one story, the immediate previous and subsequent stories will also begin to preload. We begin loading story items when the story viewer is initialized and will begin to prepare the first unwatched story item. From there, when the member navigates to another story viewer, the story media is already ready to play.
Preloading story items in the same story
One Story can hold multiple items, so it is important to reduce loading/buffering time when switching between story items as well. The preloading plan we use for story items is to use a sliding window of two forward items and one item in the backward direction. For example, if we launch a story viewer of six story items and the starting index is 3, story items 2, 4, and 5 will be preloaded while the member is watching story item 3. When story item 3 is finished and story viewer navigates to item 4, story item 6 will also subsequently begin to preload. This optimization will ensure that the following and previous story items are ready to play when the viewer navigates to it without occupying too many resources.
Story viewer pagination
Loading stories at the top of the Feed module is implemented in a paginated way, meaning that the first page of 10 stories are initially returned from the backend. As the user scrolls the story list from left to right and approaches its end, the following page of 10 stories will then be fetched from the backend.
Pagination in top of Feed stories module
However, this means that if we launch the viewer with only the first page of stories, that is all that users will see, unless they exit the story viewer and manually scroll the list for more stories. Ideally when users navigate in the story viewer, we should provide as many stories as possible within the viewer for continuous engagement with the feature.
Triggering the next page to start loading
Story pagination is already implemented within the collection view for story bubbles, so members can manually scroll the collection view while navigating through different story viewers. This can be done by adding a delegate between the story viewer and the top of the Feed module so that whenever a new story viewer is presented, the collection view from the top of the Feed module will scroll to that story bubble. In this way, when members continue tapping through their stories, the collection view will eventually scroll to the end, and trigger the loading of the next page:
Navigating in story viewer will update top of Feed stories position
Dynamically updating the story viewer with new data
Now that the collection view automatically loads more stories, the next focus was to return members to the newly loaded stories. Instead of creating another delegate between the top of Feed module and story viewers, we used the same data source for stories in both places. As more stories are loaded, the story viewers will automatically prepare to introduce more pages.
Workflow for story viewer pagination.
Here are the steps how this design works for us:
- When launching the LinkedIn app, stories are fetched from the backend to build the top of the Feed module.
- When the user taps on a story bubble, its story viewer is launched with the tapped story.
- Story viewers use the same data source that the Feed module relies on to construct its layouts.
- While the user navigates through story viewers, the collection view in the top of the Feed module will scroll to the corresponding story bubble.
- Once the end of the story list is reached, the system begins fetching the next page of stories.
- When new stories are loaded, story viewers will get a callback to update its internal data and prepare for more pages of viewers.
Presenting and dismissing
Presenting and dismissing the animation
Another way we wanted to increase smoothness and create delightful experiences was by creating a custom transition when entering and leaving the story viewer. Animations help connect the dots between a member interaction and the outcome of that interaction.
The custom transition in presenting and dismissing stories was built using a transitioningDelegate and UIModalPresentationCustom as the presentation style. We wanted the story viewer popping out of the bubble to quickly immerse members into the Stories experience. The transitioning delegate uses a custom driver class to handle animations based on the state of the animation. Property animators are used to easily handle changes to this state when and if the user interacts (using the fractionComplete property on the transition animator).
For the act of dismissing, we added a gesture recognizer to the story viewer after the presentation transition is complete, responsible for allowing the user to pull down the story viewer to dismiss. We also needed a version of dismissing the view controller when the member taps the close button. Using isInteractive on the transition context allowed us to easily determine what to do (if isInteractive is false, we fire off the animation as we do for presenting).
One of the main challenges we ran into was validating all of our gestures to be routed to the appropriate interaction handlers. On the story viewer, we have quite a few gesture recognizers active at the same time: swiping left and right to navigate between stories, tapping to navigate between story items, and pulling down to dismiss the viewer. We chose to refactor many of these recognizers into a single class that we use as a general way of dispatching events to the appropriate handler
Along with dismissing animation of the story viewer, we wanted to indicate the loading of a story once tapped as seen above. This allows the user to receive visual feedback that their tap has been received, which is especially useful on slower or unreliable connections.
The loading animation consists of an animating ring around the user's profile picture which also resizes itself simultaneously. The animation's duration is indefinite—it continues until the story is actually loaded and the story viewer is ready to launch.
In building this loading circle, we had to consider how to encapsulate the animation logic away from the view and how to avoid bloating. In the first consideration specifically, we had to decide whether or not to use animation completion blocks for animation chaining. To address the latter issue, we encapsulated the animation logic into a utility class, which contains static methods to return CABasicAnimations and CAAnimationGroups with the desired animation properties. To address the second issue, we went with setting the beginTime of each animation rather than using animation completion blocks. Specifically, we set the beginTime of each CAAnimationGroup to match the design specifications, and chained all the animations together using beginTime.
One of the challenges in building this animation was managing and changing the multiple CAShapeLayers involved. It’s deceptively simple; there were multiple CAShapeLayers involved that had to be considered to achieve the desired animation effect. One takeaway is to make sure the CAShapeLayers have the correct bounds and frame to avoid strangely positioned animations. If an animation is animating from seemingly random places, the engineer should check on the bounds and frame properties.
The successful launch of Stories on iOS is the result of multiple teams’ efforts across different platforms. We would like to acknowledge Yasir Khan for building the player pool and providing advice on story foundation code, Cezary Wojcik for setting up the fundamental architecture for story viewer and animation, and Dafeng Jin for designing and implementing stories preload mechanism on iOS. Also a big thanks to the Stories core team: Mauroof Ahmed, Isha Patel, Luis Molina, Josh Zhang, and Bo Peng!