Find which button is pressed in a Grid in SwiftUI

Hello,

I'm trying to create sort of a menu view where buttons are paid out in a grid. Each menu button consists of an icon and a title. And I have multiple menus like this throughout the app.

So I decided to create a reusable button view like this.

Code Block swift
struct MenuButton: View {
    let title: String
    let icon: Image
    var action: () -> Void
    var body: some View {
        Button(action: {
            action()
        }) {
            VStack {
                icon
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(minWidth: 40, idealWidth: 50, maxWidth: 60, minHeight: 40, idealHeight: 50, maxHeight: 60)
                    .padding(.bottom, 3)
                Text(title)
                    .foregroundColor(.black)
                    .font(.system(size: 15, weight: .bold))
                    .multilineTextAlignment(.center)
                    .minimumScaleFactor(0.7)
                    .lineLimit(2)
            }
            .padding(10)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .aspectRatio(1, contentMode: .fill)
        .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.blue, lineWidth: 0.6))
    }
}


To keep the Menus organised, I opted to use an enum.

Code Block swift
enum Menu {
    enum UserType: CaseIterable, CustomStringConvertible {
        case new
        case existing
        var description: String {
            switch self {
            case .new:
                return "New User"
            case .existing:
                return "Existing User"
            }
        }
        var icon: String {
            switch self {
            case .new:
                return "new_user"
            case .existing:
                return "existing_user"
            }
        }
    }
    enum Main: CaseIterable, CustomStringConvertible {
        case create
        case search
        case notifications
        var description: String {
            switch self {
            case .create:
                return "Create"
            case .search:
                return "Search"
            case .notifications:
                return "Notifications"
            }
        }
        var icon: String {
            switch self {
            case .create:
                return "create"
            case .search:
                return "search"
            case .notifications:
                return "notifications"
            }
        }
    }
}


And I display he menu grid like so.

Code Block swift
struct ContentView: View {
    private let columns = [
        GridItem(.flexible(), spacing: 20),
        GridItem(.flexible(), spacing: 20)
    ]
    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 20) {
                ForEach(Menu.UserType.allCases, id: \.self) { item in
                    MenuButton(title: item.description, icon: Image(item.icon), action: {})
                }
            }
            .padding(.horizontal)
            .padding([.top, .bottom], 20)
        }
    }
}


So far so good. The menu displays properly. But this is where I've hit a snag. I can't figure out a way to find which button user taps on.

I could include the menu type inside the MenuButton view itself and pass it back in the button tap closure.

Code Block swift
struct MenuButton: View {
let item: Menu.UserType
var action: (_ item: Menu.UserType) -> Void
var body: some View {
Button(action: {
action(item)
}) {
// ...
}
// ...
}
}


But this makes the MenuButton view couple of one menu type and not reusable.

So I was wondering if there's another, better way to handle this. Any suggestions, ideas would be appreciated.

Thanks.

Accepted Reply

But it's giving me the following error.

In Swift, nested type does not work as a constraining something, but is just a namespace.

If you want to put some constraint for the generic argument, you may need to introduce another protocol:
Code Block
protocol MenuItem {}
extension Menu.UserType: MenuItem {}
extension Menu.Main: MenuItem {}
struct MenuButton<ItemType: MenuItem>: View {
//...
}


Replies

But this makes the MenuButton view couple of one menu type and not reusable.

Why don't you make your MenuButton generic?
Code Block
struct MenuButton<ItemType>: View {
let item: ItemType
//...
var action: (ItemType) -> Void
var body: some View {
Button(action: {
action(item)
}) {
//...
}
//...
}
}


Thanks for the response @OOPer.

I'm struggling to figure out how to incorporate generics with how I've created my objects, enums inside enums. Would it still work in my case?

I tried this.

Code Block swift
struct MenuButton<T: Menu>: View {
}

But it's giving me the following error.

Type 'T' constrained to non-protocol, non-class type 'Menu'


But it's giving me the following error.

In Swift, nested type does not work as a constraining something, but is just a namespace.

If you want to put some constraint for the generic argument, you may need to introduce another protocol:
Code Block
protocol MenuItem {}
extension Menu.UserType: MenuItem {}
extension Menu.Main: MenuItem {}
struct MenuButton<ItemType: MenuItem>: View {
//...
}


Thanks a lot @OOPer. Made some good progress thanks to your guidance. One last question if I may.

So I made a MenuItem protocol, made the sub-enums under Menu conform to it, made the MenuButton accept it as a generic parameter.

Code Block swift
/* MenuItem */
protocol MenuItem {
var title: String { get }
var image: String { get }
}
/* Menu */
enum Menu {
enum UserType: CaseIterable, MenuItem {
case new
case existing
var title: String {
/ / ..
}
var image: String {
/ / ..
}
}
}
/* MenuViewObservable */
class MenuViewObservable: ObservableObject {
var onButtonTap: ((MenuItem) -> Void)!
}
/* UserTypeMenuView */
struct UserTypeMenuView: View {
@ObservedObject var observable: MenuViewObservable
/ / ..
var body: some View {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(Menu.UserType.allCases, id: \.self) { item in
MenuButton<Menu.UserType>(item: item, action: observable.onButtonTap)
}
}
}
}
}
/* MenuButton */
struct MenuButton<T: MenuItem>: View {
let item: T
var action: (T) -> Void
var body: some View {
Button(action: {
action(item)
}) {
/ / ..
}
}
}


Everything works great. Now I can get the title of the menu from where I'm subscribing to the closure.

Code Block swift
observable.onButtonTap = { item in
print(item.title)
}


Is there a way to actually get the type of the enum here? So I can do something like this.

Code Block swift
observable.onButtonTap = { item in
switch item {
case .new:
/ / ..
case .existing:
/ / ..
}
}


When I print out the type of it like this print(type(of: item)), I see the UserType in the console. But I can't access it.

Is there a way to actually get the type of the enum here? So I can do something like this.

Unfortunately, with declaring var onButtonTap: ((MenuItem) -> Void)!, you are disposing the type info to tell to Swift compiler.
(By the way, you should not use implicitly unwrapped Optional here. Better use explicit Optional or non-Optional.)

You can make MenuViewObservable generic:
Code Block
class MenuViewObservable<ItemType: MenuItem>: ObservableObject {
var onButtonTap: ((ItemType) -> Void) = {_ in}
}


But this may or may not work depending on the actual usage of observable.
Oh I see. That's unfortunate but I can work around it by comparing the title property values. Thanks a ton for all your help.

btw I changed the ObservableObjects to use generics. It doesn't seem to have an effect on functionality-wise. Everything works ok.

However (this issue actually persisted before changing it to generics too), the SwiftUI preview for this view doesn't get rendered.

Code Block swift
class MenuViewObservable<T: MenuItem>: ObservableObject {
var onButtonTap: ((MenuItem) -> Void) = { _ in }
}
struct UserTypeMenuView: View {
@ObservedObject var observable: MenuViewObservable<Menu.UserType>
private let columns = [
GridItem(.flexible(), spacing: 20),
GridItem(.flexible(), spacing: 20)
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(Menu.UserType.allCases, id: \.self) { item in
MenuButton<Menu.UserType>(item: item, action: observable.onButtonTap)
}
}
.padding(.horizontal)
.padding([.top, .bottom], 20)
}
}
}
struct UserTypeMenuView_Previews: PreviewProvider {
static var previews: some View {
UserTypeMenuView(observable: MenuViewObservable<Menu.UserType>())
}
}


Shows the following error.

'Menu' is ambiguous for type lookup in this context






btw I changed the ObservableObjects to use generics. It doesn't seem to have an effect on functionality-wise. 

When you make MenuViewObservable generic, better use the generic type as the argument type of the closure:
Code Block
class MenuViewObservable<T: MenuItem>: ObservableObject {
var onButtonTap: ((T) -> Void) = { _ in } //<- Use `T`, not `MenuItem`
}



Shows the following error.

I cannot reproduce the same error with your currently shown code. Something hidden in your project may be affecting.
For example, don't you have another Menu type in you project, including imported modules?

For example, don't you have another Menu type in you project, including imported modules?

This is a brand new project I'm working on and I'm still creating the UIs. So no other modules/third-party frameworks or anything added yet. And no other objects named Menu either. Very strange. I'm going to replicate this menu part only on another project and see what happens.