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
}
}
}
Maybe you already considered this, as it's a very simple solution, but you could:
enum MyShape {
case rectangle(offset: CGPoint, size: CGSize)
// ...
}
ObservableObjectButton
instead of an actual shape:struct ObservableObjectButton {
let tappableArea: MyShape
let imageName: String
let emoteName: String
let message: String
}
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))
// ...
}
}
}
ObservableObjectButton(
tappableArea: .rectangle(
offset: CGPoint(x: 405, y: 660),
size: CGSize(width: 515, height: 90),
// ...
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).