Published on

Notification Stack Animation in SwiftUI

Authors

Notification Stack Animation in SwiftUI

By Emmanuele Corporente

How to set up an animation like the notification stack in the lockscreen in just a few steps. Expanded View

The trick is to use a ZStack and to play with the offset modifier of every View:

func getCardOffset(index: Int) -> CGFloat {
    let count = Constants.stackLimit

    guard index < Constants.stackLimit else { return -(CGFloat(count) * 10) }
    return -(CGFloat(count - index) * 10)
}

As can be seen, we have defined a number of constants representing the maximum number of elements visible below the first. In our case, stackLimit is 3.

While 10 is the y offset we give at the bottom of every View:

.offset(x: 0, y: getCardOffset(index: index))

What is also important is the opacity we assign to every View based on the zIndex:

func getOpacity(with index: Int) -> Double {
    guard !isExpanded else {
        return 1
    }

    switch index {
    case 0: return 1
    case 1: return 0.75
    case 2: return 0.5
    default: return 1
    }
}

You may have noticed we have an isExpanded variable, which is responsible for changing the state of the View from closed to open and vice-versa:

@State var isExpanded = false

For this reason, it is necessary to modify the getCardOffset function, taking this parameter into account, showing all views as a list and adding this guard check first:

guard !isExpanded else { return CGFloat(index * 75) }

The value 75 is the height of the View; you could get this value using GeometryReader or, as in my case, using a static number if the height of the View will never change.

Full Code

At this point, you're probably looking for the full code, so here it is:

struct MatchStackView: View {
    let matches: [Match]
    @State var isExpanded = false

    init(matches: [Match]) {
        self.matches = matches
    }

    var body: some View {
        ZStack(alignment: .bottom) {
            header

            ForEach(Array(matches.enumerated()), id: \.element) { index, match in
                NavigationLink {
                    DetailView(matchId: match.id)
                } label: {
                    MatchView(matchId: match.id)
                        .geometryGroup()
                }
                .disabled(!isExpanded)
                .zIndex(Double(matches.count - index))
                .offset(x: 0, y: getCardOffset(index: index))
                .opacity(getOpacity(with: index))
                .padding(.horizontal, getHorizontalPadding(index: index))
            }
        }
        .padding(.top, 45)
        .padding(.bottom, bottomPadding)
    }
}

extension MatchStackView {
    var header: some View {
        Text("Header Title")
            .overlay(alignment: .topTrailing) {
                Button {
                    withAnimation {
                        isExpanded = false
                    }
                } label: {
                    HStack {
                        Text("Show less")
                        Image("arrow-up")
                            .frame(width: 14)
                    }
                    .padding(8)
                    .background(
                        LinearGradient(
                            colors: [primaryGradientBackground, secondaryGradientBackground],
                            startPoint: .leading,
                            endPoint: .trailing
                        )
                    )
                    .cornerRadius(20)
                    .padding(.trailing, 20)
                }
            }
            .offset(y: -85)
            .opacity(isExpanded ? 1 : 0)
    }

    struct Constants {
        static var stackLimit: Int { 3 }
    }
}

private extension MatchStackView {
    var bottomPadding: CGFloat {
        guard isExpanded else { return 0 }

        let count = matches.count
        return CGFloat(count - 1) * 75
    }

    func getCardOffset(index: Int) -> CGFloat {
        guard !isExpanded else { return CGFloat(index * 75) }
        let count = Constants.stackLimit

        guard index < Constants.stackLimit else { return -(CGFloat(count) * 10) }
        return -(CGFloat(count - index) * 10)
    }

    func getHorizontalPadding(index: Int) -> CGFloat {
        guard !isExpanded else {
            return 0
        }

        switch index {
        case 0: return 0
        case 1: return 5
        case 2: return 10
        default: return 10
        }
    }

    func getOpacity(with index: Int) -> Double {
        guard !isExpanded else {
            return 1
        }

        switch index {
        case 0: return 1
        case 1: return 0.75
        case 2: return 0.5
        default: return 1
        }
    }
}

Final Result

The final result should look like this:

  • isExpanded = false
    Collapsed View

  • isExpanded = true
    Expanded View

If you're wondering what that .geometryGroup() modifier is, at WWDC 2023, Apple introduced this new modifier that addresses some animation anomalies that were previously difficult to handle or couldn't be handled at all.

In our case, it helps to collapse or reduce every component contained in our View, such as Text, Image, Shape, etc., in a smoother and more compact animation.

Keep in mind, this is only needed and available for iOS 17+.

Tip

If you want to support me and see the animation working on a real project, you can download my app on the App Store at this link:

Football LiveScore by KICKOFF