Rebuilding Bēhance in SwiftUI

Aviel Gross
10 min readAug 24, 2022

--

This year, our team at Behance built and launched a brand new Feed for the iOS app. The Feed is the experience all new users land in after signing up and our designer reimagined it from scratch. The original design required a lot of back and forth to navigate between projects. The new design aimed to deliver a continuous stream of inspiration that is fun, fast and effortless to browse.

Old design on the left, new on the right

There were three main aspects of the redesign that made it so different:

  1. Before, every project in the Feed only displayed its cover image. Now, we pull the content of the project. This means that we have gone from fetching projects with minimal, necessary data such as cover images, to fetching and rendering all of the details and presenting them inline within the Feed.
  2. Previously Works in Progress were shown as a carousel at the top, also hidden behind thumbnails. Now, they are incorporated into the Feed.
  3. Users can expand projects to view more metadata without leaving the Feed, surfacing information such as the description, statistics, associated tags, comments, etc.

The more we deviated from the old design and the more complex the gesture-driven interactions became, the less we felt we could repurpose the old architecture. We had to write a new Feed from scratch.

Pretty fast we realized that one of our biggest challenges will be this “scroll view inside a scroll view” design — the feed is a scroll view, but each project is its own scroll view inside it. And the design expected the transition to be fluid — the content does not move at all when opening a project, and when closing it — content offset stays exactly where it was. This meant either (1), a super complicated transition, or (2), a scroll view where each item is its own scroll view — which might be bad for memory, performance, and can expose weird bugs…

The more we looked into it the more we realized that the 2nd option of nested scroll views will result in a better experience, despite the risks. We still had to answer the question of whether this option will take too much memory or CPU. We decided to prototype the concept in both UIKit and SwiftUI. We wanted to learn how many hoops we’ll have to go through to achieve the desired solution with each technology, and measure the performance and memory in each solution.

Each prototype had to achieve:

  • A feed of some mock “Project” items.
  • Each such project has 5–10 high res images (we can’t show low res photos in a creative art app!)
  • Each project renders its images instantly — there shouldn’t be any frame drops (hitches) when scrolling.
  • Opening and closing each project does not scale, shift, or move, the content by even 1 pixel — only the black padding around it can change (see the gif above).

The UIKit prototype took a couple days to build, and measured a couple 100s lines of code. It was fine, but basic, and verbose. The SwiftUI prototype took about the same amount of time, but was less than 100 lines. More importantly, we realized 2 things:

  1. Reading through the UIKit code meant “ignoring”, with our eyes, a lot of boilerplate and pipeline code — which makes things harder to follow/find in the code. The SwiftUI code was simply… clear. It read almost like a pseudo structure from a UX designer…
  2. Thinking about each of the next steps (building the actual features, based on the prototype), we kept having the same feeling: In UIKit, every feature requires taking a minute to decide where the code even goes, and how to communicate state and events. In SwiftUI, it felt obvious and straightforward (SwiftUI can be challenging for complicated scenarios or navigation, but we felt that most other things become much simpler, almost magical).

The two prototypes also revealed the distinction between UIKit and SwiftUI when it comes to interacting with previously written code. Code is written once, but then needs to be read many, many times. Fixing a bug, refactoring for a new framework, adding a feature, removing a failed A/B test. Written code needs to be easy to reason about when reading it. This is arguably even more important than how easy it is to write the code. SwiftUI might be confusing for beginners to write — but it is damn nice to read. With the prototypes, we mostly felt this with all of the boilerplate UICollectionView code, compared to the minimalism of ScrollView, LazyVStack and ForEach… UIKit requires that every change or event must go within a “scenario” in code (viewDidLoad, cellForRow, heightForCell, didSelectCell, etc). This also means that we must think about all of the possible connections between those scenarios ahead of time (selecting a cell might show a modal, which might make viewWillDisappear be called…). In SwiftUI, we just declare what exists, optionally with a condition deciding whether it exists, and that’s it. We don’t care about all of the “events” that might cause it to happen/appear or change in any way. This removes a lot of the cognitive load when writing SwiftUI.

ScrollViews Babushka

Russian Nesting Dolls, or, a visual map of a common SwiftUI view tree • source

As mentioned earlier, the screen was built as a “feed of feeds”. Projects are built from “modules”. A module can be an image, text, video, audio, or custom HTML embed. Each project has an unknown number of “modules”. This means each project is its own scroll view. Our design requires a smooth transition when opening and closing a project — a simple modal screen won’t do here. In the end, we solved the transition with a mask animation.

Each item in the main feed scroll view, is in fact another scroll view, that is masked to show the shape of a “card”, like in the design. When tapping a project, we do 2 things:

  1. Animate the mask inset to zero — essentially “opening” the project.
  2. Disable scroll on the main feed, and enable scroll on the project con

Once the user closes the project, we simply do the reverse. This also means that a half-scrolled project will keep its visual content offset when closed back (as expected).

This natural transition between projects and feed, is what gives the new design the fluid and natural feeling we’ve been trying to achieve. Browsing content in the new feed can go as fast, or as casual as you are, allowing you to stay “in the zone” when looking for inspiration and viewing art in the app.

Dependency Injecting Navigation

Overall, building the new feed took us just over 6 months. During this time we kept encountering more and more cases where SwiftUI allowed us to do complicated things, in a simple fashion. This was true for complex layout and animations, and especially, Dependency Injection and event handling. We even ended up rebuilding our entire tabs navigation stack using SwiftUI.

Our new app structure consists of a main AppView that the root View Controller presents on launch. This AppView has a custom tab bar view that we built. Each tab is a view, where the new feed view is the only pure SwiftUI tab. All other tabs are UIViewControllerRepresentable of the view controllers we already had before. We might slowly migrate them to SwiftUI too, but it will take a while.

Our navigation state, which allows us to route navigation events between tabs and screens, is AppNavigation. This is an @ObservedObject the AppView has, and is passed as @EnvironmentObject down the view tree to views that need to send any NavigationAction (an enum with cases for all possible targets). For example, a simple “leaf view” avatar button somewhere might look like this:

The AppView also keeps things like the state of the badge for the notifications tab, status bar style, and the “What’s New” screen presentation. Overall though, it looks something like this:

Each tab function returns a view, and CustomTabBar initializer takes a custom @resultBuilder. To avoid re-creating the view controllers (or feed) when switching tabs, we are doing 2 things:

  1. navigation retains the view controllers, and provides them to the tab views (which they use in makeUIViewController(), instead of making a new view controller).
  2. CustomTabBar view tree always contains all of the tabs. Hidden ones just have .opacity(0). This ensures they stay in the view tree, but the system does not spend any resources to render them.

This sounds a bit like a hack — but we’ve done many memory and CPU tests, and this is what worked best for us. The app takes about 250mb of RAM on launch, and usually stays around 300–500mb RAM during usage in the foreground. Most of this is actually cached content, not the view hierarchy.

So this is a super duper high level of our new navigation infra — it works well for us for 3 things:

  1. Mixing a SwiftUI tab (feed, hopefully more soon), with the existing UIKit tabs.
  2. Navigating from any SwiftUI view to any place in the app (same tab, modal view controller, modal SwiftUI view, another UIKit tab, etc.)
  3. Switching tabs. Before, that required some pipeline code and delegates. Now it’s as easy as: navigation.selectedTab = .profile.

Navigating between other UIKit tabs is still done like before: Using delegates, orchestrated by a TabBarRouter — the object that creates and keeps the AppView’s Hosting Controller, and some other things (analytics manager, auth, user object, etc.).

There is only one thing this system doesn’t solve yet: Events from UIKit into our SwiftUI feed. For example — a user might compose a new project from their profile tab, and we will want to then switch to the feed tab, showing that new project in the feed (similar to posting a photo on Instagram). Doing that would require us to pass the project. Once that need arises (likely when we start migrating other tabs to SwiftUI), we might expose the navigation intent as a published state on navigation, and views (like feed) can observe it.

Always Be Caching

Our last major challenge was how we manage fetching, caching, and rendering. We had to deliver a ton of visual content, 100s of high res images, on launch. We needed to get them fast, and show them fast, while keeping the app always running smoothly, not dropping any frames. Getting there was a journey, and after many iterations, we reached the final solution.

Each page of feed consists of 10 items. When we get the GraphQL response, we don’t add the items to the UI immediately. Instead, a flow powered by Swift Concurrency and Combine Framework (yes we use both, and both are useful!) handles incrementally caching and sending items to the UI. First, we grab the urls to the first 5 images of each project, set for the correct size based on the current view’s content width. Then we fetch them, and resize them to 25% of their size. Only then we send the item to the UI. We send items as they are ready, but in the order we received them from backend — to preserve ranking order. This means that the 1st item appears on the screen fast, and usually by the time the user scrolls to the 2nd or 3rd item, all 10 are already passed to the UI.

Once an image appears on the screen we show the cached, 25% sized version, while fetching the full res image to put on top. In newer devices with plenty of memory, NSURLSession cache keeps the full res version from our caching phase, and it’s shown instantly. If memory is more constrained — the image is fetched from network. This is also why we fetch full res for the screen and then downsize, instead of fetching a 25% image in the first place.

Given that 25% size is not like a fully-blurred image (a-la Medium app), unless network conditions are seriously bad, it’s almost impossible to notice any of that: When quickly scrolling — the 25% images seem like full res. Once you stop at a specific image, the actual full res is shown in less than half a second, without any weird “flashing” or “glitching” (in our tests, no one could notice, and couldn’t be bothered with it even once they did).

Summary

Going with SwiftUI was a large bet, but we are very happy with how it came out. Did we have to go through some hoops and write a few workarounds? Definitely! SwiftUI is a very early technology, and it’s simply unfair to compare it to UIKit or AppKit. Both have been around and improved over more than 15 years… Apple themselves said that this transition is a process, and UIKit interop was built for a reason. You should not feel bad using it when a need arises. Do what works for your product — not what is “the new hotness”. Despite the few quirks, the rest of our work became so fast and so easy thanks to SwiftUI, that we believe it was far worth the workarounds — some of which already have solutions in iOS 15 and 16 — which we plan to use once we drop older iOS support.

We hope you’ll enjoy using the new feed, and can’t wait to hear how Bēhance helps you get inspiration for your next creative project!

--

--