最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

ios - Custom MenuView: How to place the menu outside the view hierarchy when active above all other views? - Stack Overflow

programmeradmin4浏览0评论

I have created a simple custom MenuButton. When tapped, the button shows some menu items above itself. While this works fine, I would like to add a overlay view for the complete screen, which dims and blocks and the underlying views. Tapping on the overlay should dismiss the menu.

This creates two problems:

  • Without the overlay the MenuButton has some custom size, e.g. 50x50 and can be placed normally within the its surrounding view hierarchy. When adding the overlay the MenuButton is as big as the screen and can thus not be placed properly any more.

  • When showing the overlay while the menu is active, it can only views which are blow itself in the view hierarchy.

Is there a clean solution to solve this?

struct MenuButton: View {
    @State private var isExpanded = false
    let buttons: [String]
    
    
    let buttonSize: CGFloat = 60
    let itemButtonSize: CGFloat = 50
    
    var body: some View {
        ZStack {
            // Overlay
            /*Color.black.opacity(0.2)
                .edgesIgnoringSafeArea(.all)
                .opacity(isExpanded ? 1 : 0)
                .onTapGesture {
                    isExpanded = false
                }*/
            
            ForEach(buttons.indices, id: \.self) { index in
                VStack {
                    Image(systemName: buttons[index])
                        .frame(width: itemButtonSize, height: itemButtonSize)
                        .background(Color(.systemGray6))
                        .clipShape(Circle())
                }
                .offset(
                    x: 0,
                    y: isExpanded ? Double(index+1) * (-itemButtonSize - 20) : Double(index+1) * (-itemButtonSize + 20)
                )
                .opacity(isExpanded ? 1 : 0)
                
                .animation(isExpanded ? .spring(response: 0.2, dampingFraction: 0.5, blendDuration: 0).delay(Double(index) * 0.05) : .easeOut(duration: 0.2), value: isExpanded)
            }
            
            Button {
                withAnimation {
                    isExpanded.toggle()
                }
            } label: {
                Image(systemName: isExpanded ? "xmark" : "plus")
                    .frame(width: buttonSize, height: buttonSize)
                    .foregroundColor(.gray)
                    .background(Color(.systemGray6))
                    .clipShape(Circle())
            }
        }
    }
}


struct MenuView: View {
    var body: some View {
        VStack {
            Spacer()
            
            HStack {
                Spacer()
                
                MenuButton(buttons: ["circle", "star", "bell"])
                    .padding()
                
            }
            
            Text("Bottom")
        }
    }
}


#Preview {
    MenuView()
}

I have created a simple custom MenuButton. When tapped, the button shows some menu items above itself. While this works fine, I would like to add a overlay view for the complete screen, which dims and blocks and the underlying views. Tapping on the overlay should dismiss the menu.

This creates two problems:

  • Without the overlay the MenuButton has some custom size, e.g. 50x50 and can be placed normally within the its surrounding view hierarchy. When adding the overlay the MenuButton is as big as the screen and can thus not be placed properly any more.

  • When showing the overlay while the menu is active, it can only views which are blow itself in the view hierarchy.

Is there a clean solution to solve this?

struct MenuButton: View {
    @State private var isExpanded = false
    let buttons: [String]
    
    
    let buttonSize: CGFloat = 60
    let itemButtonSize: CGFloat = 50
    
    var body: some View {
        ZStack {
            // Overlay
            /*Color.black.opacity(0.2)
                .edgesIgnoringSafeArea(.all)
                .opacity(isExpanded ? 1 : 0)
                .onTapGesture {
                    isExpanded = false
                }*/
            
            ForEach(buttons.indices, id: \.self) { index in
                VStack {
                    Image(systemName: buttons[index])
                        .frame(width: itemButtonSize, height: itemButtonSize)
                        .background(Color(.systemGray6))
                        .clipShape(Circle())
                }
                .offset(
                    x: 0,
                    y: isExpanded ? Double(index+1) * (-itemButtonSize - 20) : Double(index+1) * (-itemButtonSize + 20)
                )
                .opacity(isExpanded ? 1 : 0)
                
                .animation(isExpanded ? .spring(response: 0.2, dampingFraction: 0.5, blendDuration: 0).delay(Double(index) * 0.05) : .easeOut(duration: 0.2), value: isExpanded)
            }
            
            Button {
                withAnimation {
                    isExpanded.toggle()
                }
            } label: {
                Image(systemName: isExpanded ? "xmark" : "plus")
                    .frame(width: buttonSize, height: buttonSize)
                    .foregroundColor(.gray)
                    .background(Color(.systemGray6))
                    .clipShape(Circle())
            }
        }
    }
}


struct MenuView: View {
    var body: some View {
        VStack {
            Spacer()
            
            HStack {
                Spacer()
                
                MenuButton(buttons: ["circle", "star", "bell"])
                    .padding()
                
            }
            
            Text("Bottom")
        }
    }
}


#Preview {
    MenuView()
}

Share Improve this question asked Jan 20 at 9:16 Andrei HerfordAndrei Herford 18.7k24 gold badges107 silver badges254 bronze badges 0
Add a comment  | 

2 Answers 2

Reset to default 0

You could try showing the menu using fullScreenCover(isPresented:onDismiss:content:). Some additional techniques that you might like to use are as follows:

  • Set .presentationBackground(Color.clear) to hide the default background.
  • A full screen cover is normally shown with a slide-up transition from the bottom edge. This can be disabled by setting disablesAnimations = true on the transaction used to show the cover.
  • To implement your own animations, add an additional boolean state variable isShowing and use this for triggering the animations. Set the flag to true in .onAppear.
  • Hide the menu by setting isShowing to false, with animation. When the animation completes, hide the cover by setting isExpanded to false.

Here is the updated example to show it working:

struct MenuButton: View {
    @State private var isExpanded = false
    @State private var isShowing = false
    let buttons: [String]

    let buttonSize: CGFloat = 60
    let itemButtonSize: CGFloat = 50

    var body: some View {
        Button {
            var trans = Transaction()
            trans.disablesAnimations = true
            withTransaction(trans) {
                isExpanded.toggle()
            }
        } label: {
            Image(systemName: isExpanded ? "xmark" : "plus")
                .frame(width: buttonSize, height: buttonSize)
                .foregroundColor(.gray)
                .background(Color(.systemGray6))
                .clipShape(Circle())
        }
        .fullScreenCover(isPresented: $isExpanded) {
            ZStack {
                Color.black
                    .opacity(0.2)
                    .edgesIgnoringSafeArea(.all)
                    .opacity(isShowing ? 1 : 0)
                    .onTapGesture {
                        withAnimation {
                            isShowing = false
                        } completion: {
                            isExpanded = false
                        }
                    }
                ForEach(buttons.indices, id: \.self) { index in
                    VStack {
                        Image(systemName: buttons[index])
                            .frame(width: itemButtonSize, height: itemButtonSize)
                            .background(Color(.systemGray6))
                            .clipShape(Circle())
                    }
                    .offset(
                        x: 0,
                        y: isShowing ? Double(index+1) * (-itemButtonSize - 20) : Double(index+1) * (-itemButtonSize + 20)
                    )
                    .opacity(isShowing ? 1 : 0)

                    .animation(isShowing ? .spring(response: 0.2, dampingFraction: 0.5, blendDuration: 0).delay(Double(index) * 0.05) : .easeOut(duration: 0.2), value: isShowing)
                }
            }
            .presentationBackground(Color.clear)
            .onAppear {
                withAnimation {
                    isShowing = true
                }
            }
            .onDisappear { isShowing = false }
        }
    }
}

Here's a solution, abstracted into a ViewModifier, that wraps a view with a full screen ZStack similar to your approach:


struct CustomMenu<DefaultMenu: View, ExpandedMenu: View>: ViewModifier {

    @Binding var expandMenu: Bool
    var defaultMenu: () -> DefaultMenu
    var expandedMenu: () -> ExpandedMenu

    func body(content: Content) -> some View {
        ZStack {
            content
            Background()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .ignoresSafeArea(.all)
        .overlay(alignment: .bottomTrailing) {
            VStack {
                if !expandMenu {
                    defaultMenu()
                } else {
                    expandedMenu()
                }
            }
            .padding()
        }
    }
    
    @ViewBuilder
    private func Background() -> some View {
        if expandMenu {
            Rectangle()
                .fill(.ultraThinMaterial)
                .onTapGesture {
                    withAnimation { expandMenu = false }
                }
        }
    }
}

extension View {
    func customMenu<DefaultMenu: View, ExpandedMenu: View>(
        expandMenu: Binding<Bool>,
        defaultMenu: @escaping () -> DefaultMenu,
        expandedMenu: @escaping () -> ExpandedMenu
    ) -> some View {
        modifier(CustomMenu(expandMenu: expandMenu, defaultMenu: defaultMenu, expandedMenu: expandedMenu))
    }
}

which is then used as follows:

struct ContentView: View {

    @State private var expandMenu = false

    var body: some View {
        VStack(spacing: 64) {
            Image(systemName: "dog.fill").resizable().frame(width: 100, height: 100).foregroundStyle(.blue.gradient)
            Image(systemName: "cat.fill").resizable().frame(width: 100, height: 100).foregroundStyle(.orange.gradient)
        }
        .padding()
        .customMenu(expandMenu: $expandMenu) {
            DefaultMenu()
        } expandedMenu: {
            ExpandedMenu()
        }
    }
    
    @ViewBuilder
    private func DefaultMenu() -> some View {
        VStack(spacing: 16) {
            Button("add", systemImage: "plus") {
                withAnimation { expandMenu = true }
            }
            .labelStyle(.iconOnly)
        }
    }

    @ViewBuilder
    private func ExpandedMenu() -> some View {
        VStack(spacing: 16) {
            Button("add", systemImage: "plus") {}
                .labelStyle(.iconOnly)
            Button("add", systemImage: "plus") {}
                .labelStyle(.iconOnly)
            Button("add", systemImage: "plus") {}
                .labelStyle(.iconOnly)
            Button("xmark", systemImage: "xmark") {
                withAnimation { expandMenu = false }
            }
            .labelStyle(.iconOnly)
        }
        .transition(.scale(scale: 0, anchor: .bottom))
    }

}

与本文相关的文章

发布评论

评论列表(0)

  1. 暂无评论