iosswiftanimationswiftui

How to resolve SwiftUI animation conflicts


I built a custom picker that reveals the picker items when tapped. Now I'm trying to use two of those pickers in another view that only shows the picker that the user is currently interacting with and hides the other one but there is a glitch in the picker animation such that hiding the picker pauses for a moment before completely disappearing.

Here is a screen recording of the behaviour - https://imgur.com/a/gItlRIt

Here is the code below:

struct ConceptOptionsView: View {
    @State private var aspectRatio: AspectRatio = .sixteenbynine
    @State private var imageModel: ImageModel = .flux(.schnell)
    @State private var resolution: ImageResolution = .seventwenty
    @State private var activePicker: ActivePicker? = nil
    
    enum ActivePicker {
        case model
        case resolution
    }
    
    var body: some View {
        VStack(spacing: 0) {
            Rectangle()
                .fill(.gray.opacity(0.3))
                .frame(height: 0.5)
                .frame(maxWidth: .infinity)
            VStack(alignment: .leading, spacing: 20) {
                
                HStack {
                    if activePicker == nil || activePicker == .model {
                        PillSelectorView(
                            title: "Model",
                            icon: "sparkle",
                            selection: $imageModel,
                            items: ImageModel.pickerItems,
                            didBeginSelection: {
                                activePicker = .model
                            },
                            didEndSelection: {
                                activePicker = nil
                            }
                        )
                        .transition(.move(edge: .leading))
                    }
                    Spacer()
                    if activePicker == nil || activePicker == .resolution {
                        PillSelectorView(
                            title: "Resolution",
                            icon: "camera.metering.center.weighted.average",
                            selection: $resolution,
                            items: ImageResolution.pickerItems,
                            didBeginSelection: { activePicker = .resolution },
                            didEndSelection: { activePicker = nil }
                        )
                        .transition(.move(edge: .trailing))
                    }
                }
                
                Text("The options you select will be the starting point when you want to generate a new image")
                    .font(.footnote)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)
                    .padding(.horizontal, 20)
            }
            .padding()
            Spacer()
        }
        .background(.ultraThinMaterial)
        .animation(.snappy, value: activePicker)
    }
}
struct PillSelectorItem<SelectionValue: Hashable>: Identifiable {
    let id = UUID()
    let value: SelectionValue
    let title: String
    
    init(
        value: SelectionValue,
        title: String
    ) {
        self.value = value
        self.title = title
    }
}

struct PillSelectorView<SelectionValue: Hashable>: View {
    let title: String
    let icon: String
    @Binding var selection: SelectionValue
    let items: [PillSelectorItem<SelectionValue>]
    var didBeginSelection: () -> Void
    var didEndSelection: () -> Void
    
    @State private var isSelecting: Bool = false

    var body: some View {
        VStack(alignment: .leading, spacing: 5) {
            Text(title)
                .font(.caption.weight(.medium))
                .foregroundStyle(.secondary)
                .padding(.leading, 10)
            HStack {
                Button {
                    withAnimation {
                        isSelecting.toggle()
                    }
                } label: {
                    HStack {
                        Image(systemName: icon)
                            .imageScale(.small)
                        if !isSelecting {
                            Text(items.first(where: { $0.value == selection })?.title ?? "Item")
                                .font(.subheadline.weight(.medium))
                        }
                    }
                    .padding(.horizontal)
                    .padding(.vertical, 12)
                    .frame(maxWidth: isSelecting ? 40 : .infinity)
                    .background(.gray.opacity(0.1), in: .rect(cornerRadius: 20))
                }
                .buttonStyle(.plain)

                if isSelecting {
                    ScrollView(.horizontal) {
                        HStack {
                            ForEach(items) { item in
                                Button {
                                    withAnimation {
                                        selection = item.value
                                        isSelecting = false
                                    }
                                } label: {
                                    Text(item.title)
                                        .font(.footnote.weight(.medium))
                                        .foregroundStyle(
                                            item.value == selection ? .primary : .secondary
                                        )
                                        .padding(.vertical, 10)
                                        .padding(.horizontal, 10)
                                        .overlay {
                                            Capsule()
                                                .fill(
                                                    item.value == selection ? .gray.opacity(0.1) : .clear
                                                )
                                                .stroke(
                                                    item.value == selection
                                                        ? .primary : Color.gray.opacity(0.3), lineWidth: 1.0
                                                )
                                        }
                                }
                                .buttonStyle(.plain)
                            }
                        }
                    }
                    .scrollIndicators(.hidden)
                    .transition(.move(edge: .trailing))
                }
            }
        }
        .onChange(of: isSelecting) { oldValue, newValue in
            newValue ? didBeginSelection() : didEndSelection()
        }
    }
}

Solution

  • Inside ConceptOptionsView, you are using a .move transition for the PillSelectorView as they are revealed and hidden. But inside PillSelectorView there is another .move transition on the ScrollView. So there is some duplication here.

    The animation can be improved with the following changes:

    1. In ConceptOptionsView, change both the transitions to .opacity. For example:
    PillSelectorView(
        // ...
    )
    .transition(.opacity)
    // .transition(.move(edge: .leading))
    
    1. In PillSelectorView, combine the .move transition with .opacity:
    ScrollView(.horizontal) {
        // ...
    }
    .scrollIndicators(.hidden)
    .transition(.move(edge: .trailing).combined(with: .opacity))
    
    1. Also in PillSelectorView, add the modifier .drawingGroup() to the label of the Button. This keeps the text together with the shape while the animation is happening:
    Text(item.title)
        // + all existing modifiers
        .drawingGroup()
    

    Alternatively, if your target is iOS 17 or above, you can add the modifier .geometryGroup() to the ScrollView instead. This actually works a bit better.

    Here is how it looks with these changes:

    Animation

    Btw, it seems a bit strange to be showing the capsule shape as an overlay with a semi-transparent fill. This may be why the pill labels have a fuzzy border in the middle. You might want to consider showing the shape in the background of the label instead. Better still, use a custom ButtonStyle.

    Also, you will notice that the text below the picker is moving up when isSelecting is true. The reason is because a different font size is being used for this mode (.footnote instead of .subheadline) and the vertical padding is also different (10 instead of 12).