SwiftUI in Action: Prototyping an Interactive 3D Carousel Experience

Jesse Stauffer
Thumbtack Engineering
10 min readNov 10, 2023

The Importance of Prototyping

Imagine this scenario: You’re a front-end engineer who just joined a Design Share meeting. Your Designer fires up Figma and shares the finalized designs for a new user-facing feature. At first glance, you are blown away by their design of a beautiful, net-new UI component. Then reality sets in and you start to sweat, thinking about the seemingly difficult implementation that lies ahead. At this stage, it’s easy to quickly propose an alternative solution — something that gets the job done but may not be as polished or pretty.

This has been a common experience I have had working as an iOS engineer on the Home Care team at Thumbtack. The Home Care team is working to redefine the Thumbtack experience, which often means deviating from existing components from our design system to test out new ones. In my experiences, I have learned that this initial knee-jerk reaction to descope is sometimes misleading. Without further investigation, it’s not wise to quickly determine that something is too time-consuming to pursue. In order to gather an accurate understanding of the amount of work required for a new feature, our team has turned to prototyping.

By building out a prototype, we are able to dive into the code to better understand the challenges, as well as provide an initial demo of a particular feature for Product and Design. Design tools like Figma are great for communicating static, visual ideas but oftentimes lack specificity related to animations, transitions, and the overall “feel” of an experience. We have found prototyping to be an effective way to de-risk projects by facilitating early design shares. In some cases, these early prototypes have completely influenced the direction of a project.

The Birth of a New Carousel

Recently, my team was presented with a very cool design for a new type of carousel. Unlike our standard horizontally scrolling carousels, this one included rotating cards with 3D effects and sleek animations.

The requirement was for each of the cards in the carousel to rotate as the top card was dragged. As the user drags the top card, the other cards update their position, scale, z-index (how far back the card appears in 3D space), and opacity accordingly.

As soon as I saw this new UI component, I assumed it would be pretty high-scope to build, considering I would have to manually implement a bunch of complex gesture recognizers. However, in order to validate my gut reaction, I decided to set off down the path of building a prototype.

Taking SwiftUI for a Spin

Our iOS engineers have recently embarked on the journey of migrating our existing UIKit-based design system to SwiftUI. If you aren’t familiar, SwiftUI is a modern UI framework developed by Apple. It has gained recent popularity as it greatly simplifies the process of creating user interfaces by providing a declarative and intuitive way to design elements. At Thumbtack, interested iOS engineers have volunteered time outside of product work to begin building out existing components from scratch in SwiftUI. Because of our recent shift to SwiftUI, I decided to take a crack at building seemingly complex UI in the new framework.

Creating and Laying Out Views

To start, we’ll first need to lay out the cards on the screen. Because we are working with 3D space, we can use a ZStack. A ZStack is a container view that stacks its child views in the Z-dimension. By using a ZStack, we’ll be able to make some cards appear over the top of other cards. Within the ZStack, we can implement a ForEach loop that iterates through each card object in our cards array and constructs the individual card UI for each card. While this logic places the cards on the screen, it does not yet transform the cards according to their position in the carousel.

ZStack {
ForEach(0 ..< cards.count, id: \.self) { index in
cardView(card: cards[index])
}
}

The code above uses a ViewBuilder called cardView that lays out all the underlying UI. A ViewBuilder is essentially just a function that returns a view. In our example, the ViewBuilder is creating a VStack (a vertical stack of views) that contains an AsyncImage, some Text, a Button, and any other content we want to be displayed on the cards. If we want the card to perform an action when tapped, we can add a TapGesture that executes our preferred action.

The Unit Circle

Our carousel consists of cards that are laid out along a 3D, circular path. Below is a visualization of what the carousel looks like from a birds-eye view if it contains 5 cards.

From the visualization, we can see that all of the cards should be laid out around the perimeter of the circle, with equal spacing between each card. If we know that a circle has a total of 360 degrees (or 2π radians), then we can simply take this number divided by the total number of cards to calculate the number of radians that should separate each card from the next. We can also assign each card an index that will be used to uniquely identify them.

In order to make the carousel look the way we want, we’ll need to apply visual transformations to each card, independently. For example, while the front-most card will be displayed at full 100% scale, the other cards should be scaled down appropriately to create the illusion that they are located further back in 3D space.

To properly calculate these transformations, we’ll use the front cards as a reference. The front card will be displayed at full scale, full opacity, etc. and we’ll gently tone down each transformation for the other cards as their distance grows from the front card. Because of this, we’ll need a way to identify the distance between the front card and the card to which we are applying transformations. To do this, we’ll create a helper function that does just that.

private func distanceFromFrontCard(_ index: Int) -> Double {
return Double(index - indexOfCardAtFront).remainder(dividingBy: Double(cards.count))
}

The value of indexOfCardAtFront will be initialized to 0 , but will be updated as the carousel rotates.

Transforming Views

If you look at the original design of the carousel, you can see that each card has different applied transformations depending on its location in the carousel. For example, the front-most card is displayed at full 100% scale, while the other cards gradually get smaller and smaller the further they are from the front. In order to apply these transformations, we’ll use a variety of ViewModifiers. A ViewModifier can be thought of as a function that can be called on a view that results in a modified version of the original view. Here is a breakdown of the various ViewModifiers we’ll use:

The scaleEffect modifier scales a view’s output by the provided size amounts. In our use case, we want the front-most card in the carousel to be displayed at 100% scale, while the others appear smaller and smaller the farther away they are from the front card. To do this, we can utilize the following function:

private func cardScale(for index: Int) -> Double {
let scale = 1.0 - abs(distanceFromFrontCard(index)) * cardShrinkFactor
return max(scale, .leastNonzeroMagnitude)
}

In the code snippet above, cardShrinkFactor is a constant that represents the scale factor used when shrinking cards. If we have a cardShrinkFactor value of 0.25, then while the front card will appear at 100% scale, the next card will appear at 75% scale, and so on. Because a scale value of 0% technically results in an invisible view, we use the max() function in the return statement to return the smallest non-zero value instead.

The offset modifier moves a view by a provided X and Y value. We’ll only pass in an X value for our implementation since our cards will appear directly next to each other in horizontal space. We’ll set the offset of each of the cards according to where they should exist on the unit circle.

private func cardXOffset(for index: Int) -> Double {
let angle = radiansPerCard * distanceFromFrontCard(index)
return sin(angle) * pathRadius
}

The opacity modifier changes the transparency of a particular view. With this modifier, we can pass in a value between 0 and 1, where 0 is a fully transparent view and 1 is a fully opaque view. Even though our carousel only displays three cards at a time, we may need to ultimately support an infinite number of total cards. Because of this, we can use opacity to ensure additional cards do not show in the UI until they are cycled to the front three positions in the carousel.

private func cardOpacity(for index: Int) -> Double {
let distanceFromDraggingCard = abs(distanceFromDraggingCard(index))
return distanceFromDraggingCard > 1 ? 0.0 : 1.0
}

The zIndex modifier lets us control the display order of overlapping views. In our carousel, we want the front-most card to appear over the top of the other cards. To accomplish this, we can set the zIndex for all of the cards accordingly, where the top card has a value of 1 and the back card has a value of 0 . The logic for this is very similar to the logic we already implemented for setting the card scale factor.

private func cardZIndex(for index: Int) -> Double {
let zIndex = 1.0 - abs(distanceFromFrontCard(index)) * cardShrinkFactor
return max(zIndex, .leastNonzeroMagnitude)
}

Now that the cards appear the way we want them to, we can switch gears to getting the carousel to respond to touches accordingly.

Dragging Cards

The next step in building out our 3D carousel component is making the carousel interactive. To do this, we will add a DragGesture to our entire ZStack of carousel cards. A DragGesture allows our app to execute a block of code anytime the user drags their finger across a specific UI element.

In our case, anytime a drag happens on the carousel, we will perform the following actions:

  • Move the card being dragged accordingly
  • Snap the front-most card to the front
var dragGesture: some Gesture {
DragGesture(minimumDistance: 30)
.onChanged { value in
moveCardBeingDragged(dragDistance: value.translation.width)
}
.onEnded { value in
let dragVelocity = value.predictedEndLocation.x - value.startLocation.x
withAnimation {
snapCardBeingDraggedToFront(dragVelocity: dragVelocity)
}
}
}

private func moveCardBeingDragged(dragDistance: Double) {
...
}

private func snapCardBeingDraggedToFront(dragVelocity: Double) {
...
}

In the code above, we are calling a separate function for both of the actions listed above. In the onChanged block, we are calling a function called moveCardBeingDragged which will handle updating the card’s X offset. Separately, in the onEnded block, we are calling snapCardBeingDraggedToFront which is responsible for determining the card that should be in the front position after the drag gesture has ended.

moveCardBeingDragged

As the user’s finger location changes throughout a drag on the cards, we need to update the offset of the front card. In a typical UIKit implementation, we would have to manually update all of the additional card transformations to accurately reflect their new positions on the unit circle. However, the value of SwiftUI shines through as we tap into @State variables. Because our front-most card location is defined as a @State variable, anytime it changes, SwiftUI will automatically re-render all of the views that depend on it. This means that once we change cardBeingDragged , all of our other card transformations will be updated automatically!

private func moveCardBeingDragged(dragDistance: Double) {
// by dividing by the card width, we normalize the dragDistance, making the card offset less drastic and more smooth
let cardOffset = dragDistance / frontCardSize.width
cardBeingDragged = frontCard - cardOffset
}

snapCardBeingDraggedToFront

Once a swipe gesture ends, we need to determine which of the cards in motion should become the new front-most card. To identify this, we first check to see if a drag’s velocity (the speed at which a user drags) exceeds some threshold constant called dragVelocityThreshold . If it does, we identify the card that is closest to the front and snap it into position. Again, since all other cards use the front card as a reference, their transformations happen automatically. To make this look prettier, we can wrap the entire call to snapCardBeingDraggedToFront in a withAnimation block.

Conclusion

Once I had completed a work-in-progress prototype, I was able to generate a build and share it with cross-functional partners. In the end, while the prototype I constructed wasn’t chosen for implementation in favor of a simpler user experience, the time invested in building and sharing it with stakeholders was far from wasted. It allowed us to make informed decisions early on, preventing us from dedicating excessive effort to creating something more polished, only to find it shelved.

Prototyping remains an indispensable step in our development process, providing us with a platform to exchange ideas and assess implementation feasibility from the earliest stages. SwiftUI played a pivotal role in this endeavor. The automatic rendering of views based on changes to state variables eliminated the need for manual updates to card transformations, and SwiftUI Previews streamlined the development process.

While this specific carousel component didn’t make it into production, prototyping continues to be an invaluable tool for our team, enabling us to explore fresh concepts and iterate rapidly, ultimately helping us strike the right balance between impact and effort.

--

--