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 theMenuButton
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 theMenuButton
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
2 Answers
Reset to default 0You 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 settingisExpanded
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))
}
}