You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何解决SwiftUI自定义Picker切换时的动画冲突问题

自定义Picker隐藏时动画卡顿问题解决

问题描述

开发了一个自定义Picker,点击后展开显示选项列表。在另一个视图中使用两个该类型的Picker,设置仅显示用户当前交互的Picker、隐藏另一个,但隐藏Picker时出现动画卡顿:会先停顿片刻再完全消失。

相关代码

ConceptOptionsView

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

PillSelectorView 及相关结构

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

解决方案

1. 分离动画作用域,避免父子动画冲突

父视图的全局动画会与子视图内部的动画叠加,导致过渡卡顿。移除父视图的全局动画,改为在切换activePicker时使用显式动画:

修改ConceptOptionsView中Picker的回调部分:

// Model Picker的回调
didBeginSelection: {
    withAnimation(.snappy) {
        activePicker = .model
    }
},
didEndSelection: {
    withAnimation(.snappy) {
        activePicker = nil
    }
}

// Resolution Picker的回调
didBeginSelection: { 
    withAnimation(.snappy) {
        activePicker = .resolution 
    }
},
didEndSelection: { 
    withAnimation(.snappy) {
        activePicker = nil 
    }
}

同时移除父视图底部的.animation(.snappy, value: activePicker)。

2. 优化Picker按钮的布局稳定性

原按钮的frame(maxWidth: isSelecting ? 40 : .infinity)会导致宽度突变,配合过渡动画容易引发布局计算延迟。修改为固定最小宽度,并给文本添加淡入淡出过渡:

修改PillSelectorView的按钮label:

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))
                .transition(.opacity)
        }
    }
    .padding(.horizontal)
    .padding(.vertical, 12)
    .frame(minWidth: 40, maxWidth: .infinity)
    .background(.gray.opacity(0.1), in: .rect(cornerRadius: 20))
}

3. 隔离ScrollView的动画影响

父视图的过渡动画会影响内部ScrollView的布局,给ScrollView添加动画禁用:

ScrollView(.horizontal) {
    // 原有内容
}
.scrollIndicators(.hidden)
.transition(.move(edge: .trailing))
.animation(nil, value: isSelecting)

说明

以上修改通过分离动画作用域避免冲突,优化布局减少计算延迟,隔离滚动容器的动画影响,从而解决隐藏Picker时的卡顿问题。

内容的提问来源于stack exchange,提问作者Tobi Omotayo

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 00:18:09