- Published on
Notification Stack Animation in SwiftUI
- Authors
- Name
- Gemix
- @Gemix95
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.
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
isExpanded = true
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: