buttonswiftuishapesswift-property-wrapper

SwiftUI - Is there a way to store a shape and then call it for .contentShape?


So I'm trying to set up a LOT of buttons that do the same thing when tapped, but have different content shapes. Some of these are custom shapes, some are rectangles, and all have modifiers. I wanted to set up a ForEach loop that would create a button with specific properties passed in from another file, and it all works, except for the content shapes.

I have a struct which will hold all the information of each button:

struct ObservableObjectButton {
    let tappableArea: any Shape
    let imageName: String
    let emoteName: String
    let message: String
}

And a class that holds @Published values that need to be accessed across the app. It holds a demo instance of the above struct so I can test with only one button: (Note: A Location is a custom type that holds an array of ObserveableObjects and a few other properties. A LocationName is an enum that allows me to access any Location by name.)

class RootController: ObservableObject {
    @Published var currentEmoteName = "Thoughtful Susie"
    @Published var currentMessage = "This is default text."
    @Published var currentMode = PlayMode.none
    @Published var currentLocation = allLocations[.backyard]!
    
    func observeObject(emoteName: String, message: String) {
        showComment = true
        currentEmoteName = emoteName
        currentMessage = message
    }
    
    static let allLocations: [LocationName: Location] = [
        .backyard: Location(
            backgroundImageName: "test",
            observeableObjects: [
                ObservableObjectButton(    
                    tappableArea: Rectangle()
                        .offset(x: 405, y: 660)
                        .size(width: 515, height: 90),
                    imageName: "Test",
                    emoteName: "test",
                    message: "Test"
                )
            ]
        )
    ]
}

And finally, a SwiftUI file which holds a ForEach to display only the buttons at the current location:

@StateObject private var rootController = RootController()

if rootController.currentMode == .observe {
     let buttons = rootController.currentLocation.observeableObjects
                        
     ForEach(buttons, id: \.imageName) { button in                    
          Button {
              rootController.observeObject(
              emoteName: button.emoteName, 
              message: button.message
              )
          } label: {
              Image(button.imageName)
          }
          .contentShape(button.tappableArea)
     }
}

The contentShape modifier is completely unhappy with this arrangement. With the code as it, it gives me the error "No exact matches in reference to static method 'buildExpression'" on the contentShape line. If I bring the shape I'm trying to reference over to the view it's called, like so:

if rootController.currentMode == .observe {
     let buttons = rootController.currentLocation.observeableObjects
     let tappableArea = Rectangle()
                        .offset(x: 405, y: 660)
                        .size(width: 515, height: 90)
                        
     ForEach(buttons, id: \.imageName) { button in
                            
          Button {
              rootController.observeObject(
              emoteName: button.emoteName, 
              message: button.message
              )
          } label: {
              Image(button.imageName)
          }
          .contentShape(tappableArea)
     }
}

Then the code runs just fine with no errors. The only difference I can see is that with this method, the shape is stored as "some Shape", whereas with my earlier method it's stored as "any Shape". I can't find any way to store it as anything else that would allow me to store all my custom buttons, and trying to convert it to "some Shape" or "Shape" yield a new error: "'any Shape' cannot be constructed because it has no accessible initializers".

I can't say I have any idea what the difference is between "Shape", "some Shape", "any Shape" and "AnyShape", and I can't really find explanations for this either. For anyone who DOES know the difference or otherwise why this isn't working, please do tell me what you can! I can't really think of another way to get my code to work.

UPDATE: NOTES FOR SOLUTION Thanks to timbre timbre for the solution, I've marked it as the answer. However, while implementing this solution I ran across the error: "Function declares an opaque return type 'some View', but the return statements in its body do not have matching underlying types" on step #3. For anyone who ran into this same issue, the solution is to add "@ViewBuilder" before your function and then remove all "return"s from your code. My reformatted code looks as such:

extension View {
    @ViewBuilder func contentShape(_ tappableArea: TappableArea) -> some View {
        switch tappableArea {
        case let .rectangle(offset, size):
            self.contentShape(
                Rectangle()
                    .offset(x: offset.x, y: offset.y)
                    .size(width: size.width, height: size.height)
            )
        //Repeat for all sequential buttons
        }
    }
}

Solution

  • Maybe you already considered this, as it's a very simple solution, but you could:

    1. Create your own enum, which defines shapes:
    enum MyShape {
        case rectangle(offset: CGPoint, size: CGSize)
        // ...
    }
    
    1. Use that enum in ObservableObjectButton instead of an actual shape:
    struct ObservableObjectButton {
        let tappableArea: MyShape
        let imageName: String
        let emoteName: String
        let message: String
    }
    
    1. Create your own contentShape modifier, which accepts MyShape as an argument, and applies it correspondingly:
    extension View {
        
        func contentShape(myShape: MyShape) -> some View {
            switch myShape {
            case let .rectangle(offset, size):
                return self
                .contentShape(Rectangle()
                    .offset(x: offset.x, y: offset.y)
                    .size(width: size.width, height: size.height))
            // ...
            }
        }
    }
    
    1. Now you can define your tappable area in ObservableObjectButton:
    ObservableObjectButton(
        tappableArea: .rectangle(
            offset: CGPoint(x: 405, y: 660),
            size: CGSize(width: 515, height: 90),
        // ...
    
    1. And you apply your modifier to the button in the view:
    ForEach(buttons, id: \.imageName) { button in
        Button { // ...
        }
        .contentShape(button.tappableArea) 
    

    This solution also properly isolates button data from implementation details (i.e. your ObservableObjectButton doesn't need to know how exactly you make area tapable in UI).