I've been testing for 3 hours now, and I don't understand why this behavior persists. I've tried:
- All animation types (.linear, .smooth), transitions.
- mainactor, async, dispatchQueue, on main etc.
(Deselect of the same element is animated acceptably)
Minimal Reproducible Example:
struct ChipsView: View {
let title: String
@Binding var selectedString: String
private var isSelected: Bool { selectedString == title }
private let selectedColor = Color.red
private let unselectedColor = Color.gray
private let selectedFont: Font.Weight = .bold
private let unselectedFont: Font.Weight = .light
var body: some View {
Text(title)
.font(.system(
size: 56,
weight: isSelected ? selectedFont : unselectedFont
))
.foregroundColor(isSelected ? selectedColor : unselectedColor)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.bouncy(duration: 0.3)) {
selectedString = isSelected ? "" : title
}
}
}
}
struct ChipsView_CustomPreview: View {
@State var selectedString: String = ""
var body: some View {
HStack {
ForEach(["11","22","33","44","55"], id: \.self) { str in
ChipsView(title: str, selectedString: $selectedString)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
}
}
#Preview {
ChipsView_CustomPreview()
}
I've been testing for 3 hours now, and I don't understand why this behavior persists. I've tried:
- All animation types (.linear, .smooth), transitions.
- mainactor, async, dispatchQueue, on main etc.
(Deselect of the same element is animated acceptably)
Minimal Reproducible Example:
struct ChipsView: View {
let title: String
@Binding var selectedString: String
private var isSelected: Bool { selectedString == title }
private let selectedColor = Color.red
private let unselectedColor = Color.gray
private let selectedFont: Font.Weight = .bold
private let unselectedFont: Font.Weight = .light
var body: some View {
Text(title)
.font(.system(
size: 56,
weight: isSelected ? selectedFont : unselectedFont
))
.foregroundColor(isSelected ? selectedColor : unselectedColor)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.bouncy(duration: 0.3)) {
selectedString = isSelected ? "" : title
}
}
}
}
struct ChipsView_CustomPreview: View {
@State var selectedString: String = ""
var body: some View {
HStack {
ForEach(["11","22","33","44","55"], id: \.self) { str in
ChipsView(title: str, selectedString: $selectedString)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
}
}
#Preview {
ChipsView_CustomPreview()
}
Share
Improve this question
edited Feb 15 at 10:40
Benzy Neez
21.3k3 gold badges14 silver badges36 bronze badges
asked Feb 15 at 2:00
zef_szef_s
671 silver badge7 bronze badges
1
- It's as easy as this: just change the layout so that each label has a FIXED WIDTH. At the moment it's correctly doing what you tell it to, animating everything together. (Alternately, and this would be more normal, you do the two animations one after the other.) – Fattie Commented Feb 15 at 16:01
1 Answer
Reset to default 0The way that SwiftUI performs animations is to examine the start state and end state and then interpolate the stages in-between. For some reason, it is getting confused with the end state for either the text being selected or the text being de-selected, when these are changing simultaneously. I would say it's a bug.
A workaround is to use different animations for selection and de-selection. This means using a second flag. The second flag (which I've called isNowSelected
) can be made dependent on the first flag using an .onChange
handler.
Another issue is that the numbers move a little when the animation is happening. This is because the text is wider when the bold font is in effect. A way to prevent the movement is to determine the footprint for the bold font using a hidden placeholder, then show the visible text as an overlay.
Here is an updated version with the workarounds applied. I found that it is important for the de-selection animation to have a slightly different duration to the selection animation.
struct ChipsView: View {
let title: String
@Binding var selectedString: String
private var isSelected: Bool { selectedString == title }
private let selectedColor = Color.red
private let unselectedColor = Color.gray
private let selectedFont: Font.Weight = .bold
private let unselectedFont: Font.Weight = .light
@State private var isNowSelected = false
var body: some View {
Text(title)
.fontWeight(selectedFont)
.hidden()
.overlay {
Text(title)
.fontWeight(isSelected || isNowSelected ? selectedFont : unselectedFont)
.foregroundStyle(isSelected || isNowSelected ? selectedColor : unselectedColor)
.contentShape(Rectangle())
.onTapGesture {
selectedString = isSelected ? "" : title
}
.onChange(of: isSelected) { oldVal, newVal in
isNowSelected = newVal
}
// Animation used for selection
.animation(.bouncy(duration: 0.3), value: isSelected)
// Animation used for de-selection. It is important that
// the duration is different to the selection animation
.animation(.bouncy(duration: 0.28), value: isNowSelected)
}
.font(.system(size: 56))
}
}
Ps. I would always recommend using a simulator for testing animations, not Preview.