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

SwiftUI自定义分段控件:保留切换动画并解决视图加载卡顿

问题解决:保留分段控件动画同时避免视图切换卡顿

问题原因

当你用withAnimation包裹selectedIndex的修改时,SwiftUI会将这个动画应用到所有依赖于selectedIndex的视图更新,包括ViewOne和ViewTwo的切换。如果这两个视图的初始化/渲染成本较高,就会导致动画期间的卡顿。

解决方案

将分段控件的动画作用域限制在控件内部,不让外部的视图切换继承该动画。以下是两种简洁的实现方式:

方式一:用.animation修饰符绑定控件内部动画

移除onTapGesture中的withAnimation,改为在自定义分段控件的根视图上添加.animation修饰符,仅让控件内部UI响应selectedIndex变化的动画:

修改后的CustomSegmentedControl代码:

import SwiftUI

struct SegmentedControlItem {
    var name: String? = nil
    var iconString: String? = nil
}

struct CustomSegmentedControl: View {
    @Binding var selectedIndex: Int
    let options: [SegmentedControlItem]
    
    var body: some View {
        ZStack(alignment: .center) {
            GeometryReader { geo in
                RoundedRectangle(cornerRadius: 16)
                    .foregroundColor(.white)
                    .cornerRadius(16)
                    .padding(4)
                    .frame(width: geo.size.width / CGFloat(options.count))
                    .shadow(color: Color("Shadow Color").opacity(0.1), radius: 4, x: 0, y: 2)
                    .offset(x: geo.size.width / CGFloat(options.count) * CGFloat(selectedIndex), y: 0)
            }
            .frame(height: 48)
            
            HStack(spacing: 0) {
                ForEach((0..<options.count), id: \.self) { index in
                    HStack(spacing: 6) {
                        if let iconString = options[index].iconString {
                            Image(systemName: iconString)
                        }
                        if let name = options[index].name {
                            Text(name)
                        }
                    }
                    .font(.custom("Inter-Medium", size: 16))
                    .foregroundColor(selectedIndex == index ? Color("Primary Color") : Color.black.opacity(0.5))
                    .frame(maxWidth: .infinity)
                    .frame(height: 48)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        // 直接修改selectedIndex,不再用withAnimation包裹
                        selectedIndex = index
                    }
                }
            }
        }
        .background(
            RoundedRectangle(cornerRadius: 18)
                .fill(Color.wireframe)
        )
        // 仅让当前控件内部的UI响应selectedIndex变化的动画
        .animation(.easeInOut(duration: 0.150), value: selectedIndex)
    }
}

#Preview {
    CustomSegmentedControl(
        selectedIndex: .constant(0),
        options: [
            SegmentedControlItem(name: "One"),
            SegmentedControlItem(name: "Two")
        ]
    )
}

方式二:用本地状态变量隔离动画作用域

在CustomSegmentedControl内部创建一个本地状态变量,用于控制控件的动画效果,再同步到绑定的selectedIndex,这样外部视图不会感知到内部的动画:

修改后的CustomSegmentedControl代码:

import SwiftUI

struct SegmentedControlItem {
    var name: String? = nil
    var iconString: String? = nil
}

struct CustomSegmentedControl: View {
    @Binding var selectedIndex: Int
    @State private var localSelectedIndex: Int // 本地状态控制内部动画
    let options: [SegmentedControlItem]
    
    // 初始化时同步外部选中状态到本地
    init(selectedIndex: Binding<Int>, options: [SegmentedControlItem]) {
        self._selectedIndex = selectedIndex
        self._localSelectedIndex = State(initialValue: selectedIndex.wrappedValue)
    }
    
    var body: some View {
        ZStack(alignment: .center) {
            GeometryReader { geo in
                RoundedRectangle(cornerRadius: 16)
                    .foregroundColor(.white)
                    .cornerRadius(16)
                    .padding(4)
                    .frame(width: geo.size.width / CGFloat(options.count))
                    .shadow(color: Color("Shadow Color").opacity(0.1), radius: 4, x: 0, y: 2)
                    // 使用本地状态控制偏移动画
                    .offset(x: geo.size.width / CGFloat(options.count) * CGFloat(localSelectedIndex), y: 0)
            }
            .frame(height: 48)
            
            HStack(spacing: 0) {
                ForEach((0..<options.count), id: \.self) { index in
                    HStack(spacing: 6) {
                        if let iconString = options[index].iconString {
                            Image(systemName: iconString)
                        }
                        if let name = options[index].name {
                            Text(name)
                        }
                    }
                    .font(.custom("Inter-Medium", size: 16))
                    // 使用本地状态控制文字颜色
                    .foregroundColor(localSelectedIndex == index ? Color("Primary Color") : Color.black.opacity(0.5))
                    .frame(maxWidth: .infinity)
                    .frame(height: 48)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        withAnimation(.easeInOut(duration: 0.150)) {
                            localSelectedIndex = index
                        }
                        // 同步到外部绑定变量,无动画
                        selectedIndex = index
                    }
                }
            }
        }
        .background(
            RoundedRectangle(cornerRadius: 18)
                .fill(Color.wireframe)
        )
        // 同步外部状态变化到本地
        .onChange(of: selectedIndex) { newIndex in
            localSelectedIndex = newIndex
        }
    }
}

#Preview {
    CustomSegmentedControl(
        selectedIndex: .constant(0),
        options: [
            SegmentedControlItem(name: "One"),
            SegmentedControlItem(name: "Two")
        ]
    )
}

效果说明

两种方式都能保留分段控件的滑动/颜色切换动画,同时让ViewOne和ViewTwo的切换瞬间完成,不会因为动画导致卡顿。如果需要给视图切换添加独立的动画,可以单独在视图切换处添加.transition或withAnimation,与分段控件的动画互不干扰。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.01 20:53:20