Stack Sheet Animation in SwiftUI: A Step-by-Step Guide

Rohit Saini
6 min readMay 2, 2024

In this article, we’ll explore the concept of stack sheet animation in SwiftUI, a powerful and visually appealing way to present multiple views in a single screen. We’ll break down the code, step by step, to help you understand the implementation and integrate it into your own projects.

What is Stack Sheet Animation?

Photo by Amanda Jones on Unsplash

Stack sheet animation is a UI pattern that allows multiple views to be stacked on top of each other, with each view sliding in and out of view as the user interacts with the screen. This animation provides a seamless and engaging user experience, making it perfect for apps that require multiple screens to be presented in a single view.

The Code

Let’s dive into the code, which consists of several components:

  1. ContentView: The main view that presents the stack sheet animation.
  2. StackUI: A custom view that represents a single stack item.
  3. StackUIViewModel: A view model that manages the stack items and their states.
  4. StackModel: A data model that represents a single stack item.

Step 1: ContentView

The ContentView is the main view that presents the stack sheet animation. It contains a VStack with a ZStack that holds the stack items.

struct ContentView: View {
@StateObject private var viewModel: StackUIViewModel = StackUIViewModel(stacks: [])

var body: some View {
VStack {
ZStack {
ForEach(viewModel.stacks, id: \.id) { stack in
StackUI(content: {
// Switch statement to determine the view to display
}, isPresented: $viewModel.stacks[viewModel.stacks.firstIndex(where: { $0.id == stack.id })!].isOpened,
stackHeight: stack.height,
dismissStack: { height in
viewModel.removeStack(height: height)
})
}
}
// Button to add a new stack item
Button {
viewModel.addStack(model: StackModel(type: .v1, height: 800))
} label: {
Text("Start")
.font(.largeTitle)
.fontWeight(.black)
}
}
}
}

Step 2: StackUI

The StackUI is a custom view that represents a single stack item. It takes three parameters: content, isPresented, dismissStack , and stackHeight.

struct StackUI<Content: View>: View {
@Binding private var isPresented: Bool
let content: Content
let stackHeight: CGFloat
private var dismissStack: CompletionCallback?

init(@ViewBuilder content: () -> Content,
isPresented: Binding<Bool>,
stackHeight: CGFloat,
dismissStack: CompletionCallback?) {
self.content = content()
_isPresented = isPresented
self.stackHeight = stackHeight
self.dismissStack = dismissStack
}

var body: some View {
ZStack(alignment: .bottom) {
if isPresented {
// Background color with opacity
Color.black
.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
withAnimation {
isPresented = false
}
}
.allowsHitTesting(false)

// Content view with corner radius and animation
VStack {
content
}
.frame(maxWidth: .infinity)
.frame(maxHeight: stackHeight)
.background(Color.white)
.cornerRadius(16, corners: .topLeft)
.cornerRadius(16, corners: .topRight)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.tag(UUID().uuidString)
.onTapGesture {
withAnimation(.spring(response: 5, dampingFraction: 5, blendDuration: 5)) {
dismissStack?(stackHeight)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea()
.animation(.spring(response: 0.4, dampingFraction: 0.6, blendDuration: 0.2), value: isPresented)
}
}

Step 3: StackUIViewModel

The StackUIViewModel manages the stack items and their states. It contains an array of StackModel objects and provides methods to add and remove stack items.

final class StackUIViewModel: ObservableObject {
@Published var stacks: [StackModel]
init(stacks: [StackModel]) {
self.stacks = stacks
}

func addStack(model: StackModel) {
stacks.append(model)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
for index in 0..<self.stacks.count {
self.stacks[index].isOpened = true
}
}
}

func removeStack(height: CGFloat) {
for index in 0..<stacks.count {
if stacks[index].height < height {
stacks[index].isOpened = false
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self.stacks = self.stacks.filter({$0.height >= height})
}
}
}

Step 4: StackModel and Enum

The StackModel represents a single stack item, with properties for id, type, isOpened, and height.

struct StackModel {
let id: String = UUID().uuidString
let type: ScreenType
var isOpened: Bool = false
let height: CGFloat
}

enum ScreenType {
case v1
case v2
case v3
case none
}

How it Works

When the user taps the “Start” button, a new stack item is added to the StackUIViewModel with a height of 800. The StackUI view is then presented with the corresponding content view (e.g., view1, view2, etc.).

When the user taps on a stack item, the dismissStack callback is triggered, which removes the stack item from the StackUIViewModel and animates the dismissal of the stack item.

if we have 4 stack on top of each other and user clicks on the bottom one (first stack in the list) it dismiss the 3 stack which are on top of first stack that is how the navigation works in this custom stack animation which the most tricky part to achieve here.

The StackUI view uses a combination of ZStack, VStack, and animation modifiers to create the stack sheet animation effect. The transition modifier is used to animate the presentation and dismissal of the stack items.

Source Code

//
// ContentView.swift
// BuildUI
//
// Created by Rohit Saini on 11/05/23.
//

import SwiftUI

typealias CompletionCallback = (CGFloat) -> ()

struct ContentView: View {
@StateObject private var viewModel: StackUIViewModel = StackUIViewModel(stacks: [])
var body: some View {
VStack {
ZStack {
ForEach(viewModel.stacks, id: \.id) { stack in
StackUI(content: {
switch stack.type {
case .v1:
view1
case .v2:
view2
case .v3:
view3
default:
ZStack{
LinearGradient(gradient: Gradient(colors: [.black, .white]), startPoint: .top, endPoint: .bottom)
.edgesIgnoringSafeArea(.all)
Text("Empty Stack")
.font(.largeTitle)
}

}
}, isPresented: $viewModel.stacks[viewModel.stacks.firstIndex(where: { $0.id == stack.id })!].isOpened,
stackHeight: stack.height,
dismissStack: { height in
viewModel.removeStack(height: height)
})

}
}
if viewModel.stacks.isEmpty {
Button {
viewModel.addStack(model: StackModel(type: .v1,
height: 800))
} label: {
Text("Start")
.font(.largeTitle)
.fontWeight(.black)
}
}
}
}
}

extension ContentView {
var view1: some View {
ZStack {
// Background gradient
LinearGradient(gradient: Gradient(colors: [.purple, .blue]), startPoint: .top, endPoint: .bottom)
.edgesIgnoringSafeArea(.all)
Button {
viewModel.addStack(model: StackModel(type: .v2,
height: 700))
} label: {
Text("View 2")
.fontWeight(.black)
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.black)
.cornerRadius(5)
.padding()
}
}
}

var view2: some View {
ZStack {
// Background gradient
LinearGradient(gradient: Gradient(colors: [.orange, .green]), startPoint: .top, endPoint: .bottom)
.edgesIgnoringSafeArea(.all)
Button {
viewModel.addStack(model: StackModel(type: .v3,
height: 600))
} label: {
Text("View 3")
.fontWeight(.black)
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.black)
.cornerRadius(5)
.padding()
}
}
}

var view3: some View {
ZStack {
// Background gradient
LinearGradient(gradient: Gradient(colors: [.red, .yellow]), startPoint: .top, endPoint: .bottom)
.edgesIgnoringSafeArea(.all)
Button {
viewModel.addStack(model: StackModel(type: .none,
height: 500))
} label: {
Text("View 4")
.fontWeight(.black)
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.black)
.cornerRadius(5)
.padding()
}
}
}
}


struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

// Custom StackUI view
struct StackUI<Content: View>: View {
@Binding private var isPresented: Bool
let content: Content
let stackHeight: CGFloat
private var dismissStack: CompletionCallback?

init(@ViewBuilder content: () -> Content,
isPresented: Binding<Bool>,
stackHeight: CGFloat,
dismissStack: CompletionCallback?) {
self.content = content()
_isPresented = isPresented
self.stackHeight = stackHeight
self.dismissStack = dismissStack
}

var body: some View {
ZStack(alignment: .bottom) {
if isPresented {
Color.black
.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
withAnimation {
isPresented = false
}
}
.allowsHitTesting(false)

VStack {
content
}
.frame(maxWidth: .infinity)
.frame(maxHeight: stackHeight)
.background(Color.white)
.cornerRadius(16, corners: .topLeft)
.cornerRadius(16, corners: .topRight)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.tag(UUID().uuidString)
.onTapGesture {
withAnimation(.spring(response: 5, dampingFraction: 5, blendDuration: 5)) { // Dismiss Animation
dismissStack?(stackHeight)
}
}
}

}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea()
.animation(.spring(response: 0.4, dampingFraction: 0.6, blendDuration: 0.2), value: isPresented)
}
}

final class StackUIViewModel: ObservableObject {
@Published var stacks: [StackModel]
init(stacks: [StackModel]) {
self.stacks = stacks
}

func addStack(model: StackModel) {
stacks.append(model)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
for index in 0..<self.stacks.count {
self.stacks[index].isOpened = true
}
}
}

func removeStack(height: CGFloat) {
for index in 0..<stacks.count {
if stacks[index].height < height {
stacks[index].isOpened = false
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self.stacks = self.stacks.filter({$0.height >= height})
}
}
}

struct StackModel {
let id: String = UUID().uuidString
let type: ScreenType
var isOpened: Bool = false
let height: CGFloat
}

enum ScreenType {
case v1
case v2
case v3
case none
}

Conclusion

In this article, we’ve explored the concept of stack sheet animation in SwiftUI, breaking down the code into smaller components to help you understand the implementation. By using a combination of custom views, view models, and data models, we’ve created a powerful and visually appealing way to present multiple views in a single screen.

I hope this article has been helpful in your SwiftUI journey. Happy coding!

--

--

Rohit Saini

कर्म मुझे बांधता नहीं, क्योंकि मुझे कर्म के प्रतिफल की कोई इच्छा नहीं |