PAYATHON 2026

SwiftUI 如何实现带动画的 L 形自定义菜单?

数据小许

结论

可以把菜单按钮和展开面板放进同一个容器,用 overlay(alignment: .topTrailing) 将面板锚定在按钮右侧,再用 transition 添加展开动画。下面的示例采用“顶部是横向按钮、菜单向下展开”的 L 形布局。要改变 L 形的方向,可以调整面板的对齐位置。

当前的 VStack 会把下拉列表放在整条胶囊按钮下方,而列表宽度又设为 pillWidth,所以展开后看起来像上下堆叠的矩形区域。用 GeometryReader 在 onAppear 中记录宽度也会增加状态管理。如果菜单宽度固定,直接指定面板宽度即可。

调整布局和动画

保留现有的 row(_:),把按钮和菜单放进同一个 ZStack。面板对齐到右上角,再根据按钮高度和间距向下偏移:

struct ContentView: View {
    @State private var isExpanded = false

    private let height: CGFloat = 40
    private let menuWidth: CGFloat = 180
    private let spacing: CGFloat = 3

    var body: some View {
        ZStack(alignment: .topTrailing) {
            HStack(spacing: spacing) {
                Button {
                    withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
                        isExpanded.toggle()
                    }
                } label: {
                    HStack(spacing: 3) {
                        Image(systemName: "person.crop.square.on.square.angled.fill")
                        Text("1 Sign")
                            .fontWeight(.regular)
                    }
                    .padding(.horizontal, 16)
                    .frame(height: height)
                    .background(Color.gray.opacity(0.2))
                    .clipShape(
                        CustomCorners(
                            tl: height / 2,
                            tr: 2,
                            bl: height / 2,
                            br: 2
                        )
                    )
                }
                .buttonStyle(.plain)

                Image(systemName: "chevron.right")
                    .rotationEffect(.degrees(isExpanded ? 90 : 0))
                    .foregroundColor(.gray)
                    .frame(width: height, height: height)
                    .background(Color.gray.opacity(0.2))
                    .clipShape(
                        CustomCorners(
                            tl: 2,
                            tr: height / 2,
                            bl: 2,
                            br: height / 2
                        )
                    )
            }
            .zIndex(1)

            if isExpanded {
                VStack(alignment: .leading, spacing: 15) {
                    row("2 Sign")
                    row("3 Sign")
                    row("4 Sign")
                }
                .padding(.vertical, 17)
                .frame(width: menuWidth)
                .background {
                    RoundedRectangle(cornerRadius: 28)
                        .fill(Color.gray.opacity(0.2))
                }
                .offset(y: height + spacing)
                .transition(.move(edge: .top).combined(with: .opacity))
                .zIndex(0)
            }
        }
        .padding(40)
    }

    private func row(_ title: String) -> some View {
        HStack {
            Text(title)
            Spacer()
            Image(systemName: "chevron.right")
        }
        .padding(.horizontal, 16)
        .frame(height: 28)
    }
}

ZStack(alignment: .topTrailing) 会让展开面板与按钮右边缘对齐,.offset(y: height + spacing) 则将面板移到按钮下方。withAnimation 驱动展开和收起,.transition 控制面板插入或移除时从顶部滑入并淡入。若要让菜单左边缘对齐,可将容器改为 ZStack(alignment: .topLeading)。调整对齐锚点和偏移量,也能改变 L 形的方向。

示例中的 menuWidth 是固定值。如果按钮或面板宽度需要随内容变化,可以用布局测量来同步尺寸;如果宽度固定,就可以删掉原先的 pillWidth 和 GeometryReader。

放入导航栏时

导航栏工具栏通常会限制项目的可用空间,展开内容可能因此被裁切,也可能影响导航栏布局。可以用工具栏按钮触发展开,再由页面内容层显示自定义面板;也可以按菜单需求使用系统 Menu。具体表现取决于导航容器和平台布局。仅凭当前代码无法判断你遇到的问题,建议先在页面主体中验证菜单的锚点、尺寸和动画,再移入工具栏测试。

备注:内容仅供参考。