iosswiftanimationswiftui

Font is twitching, animation bug - SwiftUI


I've been testing for 3 hours now, and I don't understand why this behavior persists. I've tried:

  1. All animation types (.linear, .smooth), transitions.
  2. mainactor, async, dispatchQueue, on main etc.

(Deselect of the same element is animated acceptably)

enter image description here

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()
}


Solution

  • The 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))
        }
    }
    

    Animation

    Ps. I would always recommend using a simulator for testing animations, not Preview.