I have a rather simple animation I am trying to achieve where the selected item in a list gets a background applied. The goal is that the grey box behind the selected item sort of morphs from previous position to the next one smoothly.
I achieved this using matchedGeometryEffect
so that SwiftUI could match the background views between the two states even though technically they have a different ID path. However this effort is thwarted by List
Here is the example project. All you need to do to break the animation is surround the buttons in a List.
Why does List break this animation? Is there any way to get around that?
struct AnimationButtonStyle: ButtonStyle {
var isCurrent: Bool
var animationNamespace: Namespace.ID
var backgroundView: some View {
Color.gray
.cornerRadius(8)
.matchedGeometryEffect(id: "Shape", in: animationNamespace)
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
isCurrent ? backgroundView : nil
)
.opacity(configuration.isPressed ? 0.5 : 1.0)
}
}
struct ContentView: View {
enum cases: String, CaseIterable {
case foo = "Foo"
case bar = "Barrrrrr"
case bat = "Battttttttttttttt"
}
@Namespace private var animationNamespace
@State var animatedCurrentCase: cases = .foo
@State var currentCase: cases = .foo
@State var isAnimating: Bool = false
var body: some View {
VStack {
// Without the list this animation will work
List {
Section {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(cases.allCases.enumerated()), id: \.offset) { index, theCase in
var isCurrent: Bool { theCase == animatedCurrentCase }
Button {
isAnimating = true
Task { @MainActor in
animatedCurrentCase = theCase
try? await Task.sleep(nanoseconds: 200_000_000)
currentCase = theCase
isAnimating = false
}
} label: {
Label {
Text(theCase.rawValue)
} icon: {
VStack(alignment: .leading) {
Text("\(index)")
}
.frame(width: isCurrent ? 16 + 4 : 16)
}
}
.disabled(isAnimating)
.buttonStyle(AnimationButtonStyle(isCurrent: isCurrent, animationNamespace: animationNamespace))
.animation(.smooth, value: animatedCurrentCase)
}
}
}
}
Spacer().frame(height: 10)
Text("Selected: \(currentCase.rawValue)")
.font(.title)
}
.padding()
}
}
I would suggest removing all the extra code for animating the selected case, because it's unnecessary. Specifically:
animatedCurrentCase
and isAnimating
.Task
..disabled
modifier..animation
modifier.If you then use withAnimation
to perform the update inside the button closure, it works.
Other suggestions:
Use let
where you can, instead of var
. In particular, the properties in AnimationButtonStyle
can use let
.
.cornerRadius
is deprecated, use .clipShape
with a RoundedRectangle
instead, or just put a RoundedRectangle
in the background.
The.background
modifier that you were using is also deprecated. Suggest re-factoring by using a trailing closure and if-statement.
Here is the updated example:
struct AnimationButtonStyle: ButtonStyle {
let isCurrent: Bool
let animationNamespace: Namespace.ID
var backgroundView: some View {
RoundedRectangle(cornerRadius: 8)
.fill(.gray)
.matchedGeometryEffect(id: "Shape", in: animationNamespace)
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background {
if isCurrent {
backgroundView
}
}
.opacity(configuration.isPressed ? 0.5 : 1.0)
}
}
struct ContentView: View {
enum cases: String, CaseIterable {
case foo = "Foo"
case bar = "Barrrrrr"
case bat = "Battttttttttttttt"
}
@Namespace private var animationNamespace
@State var currentCase: cases = .foo
var body: some View {
VStack {
List {
Section {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(cases.allCases.enumerated()), id: \.offset) { index, theCase in
let isCurrent = theCase == currentCase
Button {
withAnimation(.smooth) {
currentCase = theCase
}
} label: {
Label {
Text(theCase.rawValue)
} icon: {
VStack(alignment: .leading) {
Text("\(index)")
}
.frame(width: isCurrent ? 16 + 4 : 16)
}
}
.buttonStyle(
AnimationButtonStyle(
isCurrent: isCurrent,
animationNamespace: animationNamespace
)
)
}
}
}
}
Spacer().frame(height: 10)
Text("Selected: \(currentCase.rawValue)")
.font(.title)
}
.padding()
}
}