SwiftUI contextmenu wrong location - swiftui

I am trying to add a contextmenu to images I display, but when I longpress, the contextmenu opens in a wrong location (see image)
The code I use is:
ForEach(self.document.instruments) { instrument in
Image(instrument.text)
.resizable()
.frame(width: 140, height: 70)
.position(self.position(for: instrument, in: geometry.size))
.gesture(self.singleTapForSelection(for: instrument))
.gesture(self.dragSelectionInstrument(for: instrument))
.shadow(color: self.isInstrumentSelected(instrument) ? .blue : .clear, radius: 10 * self.zoomScale(for: instrument))
.contextMenu {
Button {
print("Deleted selected")
} label: {
Label("Delete", systemImage: "trash")
}
}
}
Update of issue
The issue I still face (as my last comment), is that when I drag an image, there seems to be a delay, as the image remains on its position for a short moment and then moves to the dragged position. I already found out that when I have the contextMenu in the code, the delay dragging happens, when commented out, it works fine.
This is the code I have:
ForEach(self.document.instruments) { instrument in
Image(instrument.text)
.resizable()
.frame(width: 140, height: 70)
.contextMenu {
Button {
print("Deleted selected")
} label: {
Label("Delete", systemImage: "trash")
}
}
.position(self.position(for: instrument, in: geometry.size))
.shadow(color: self.isInstrumentSelected(instrument) ? .blue : .clear, radius: 10 * self.zoomScale(for: instrument))
.gesture(self.singleTapForSelection(for: instrument))
.gesture(self.dragSelectionInstrument(for: instrument))
}
I even tried moving the gestures above the contextmenu, but didn't work.
Please help
Latest Update
Nm, it was only on the simulator, on my physical iPad it works fine

The problem is that your code applies the contextMenu modifier after the position modifier.
Let's consider this slightly modified example:
ZStack {
GeometryReader { geometry in
ForEach(self.document.instruments, id: \.id) { instrument in
Image(instrument.text)
.frame(width: 140, height: 70)
.position(self.position(for: instrument, in: geometry.size))
.contextMenu { ... }
}
}
}
In the SwiftUI layout system, a parent view is responsible for assigning positions to its child views. A view modifier acts as the parent of the view it modifies. So in the example code:
ZStack is the parent of GeometryReader.
GeometryReader is the parent of ForEach.
ForEach is the parent of contextMenu.
contextMenu is the parent of position.
position is the parent of frame.
frame is the parent of Image.
Image is not a parent. It has no children.
(Sometimes the parent is called the “superview” and the child is called the “subview”.)
When contextMenu needs to know where to draw the menu on the screen, it looks at the position given to it by its parent, the ForEach, which gets it from the GeometryReader, which gets it from the ZStack.
When Image needs to know where to draw its pixels on the screen, it looks at the position given to it by its parent, which is the frame modifier, and the frame modifier gets the position from the position modifier, and the position modifier modifies the position given to it by the contextMenu.
This means that the position modifier does not affect where the contextMenu draws the menu.
Now let's rearrange the code so contextMenu is the child of position:
ZStack {
GeometryReader { geometry in
ForEach(self.document.instruments, id: \.id) { instrument in
Image(instrument.text)
.frame(width: 140, height: 70)
.contextMenu { ... }
.position(self.position(for: instrument, in: geometry.size))
}
}
}
Now the contextMenu gets its position from the position modifier, which modifies the position given to it by the ForEach. So in this scenario, the position modifier does affect the where the contextMenu draws the menu.

Related

How to align a view bottom trailing, overlaid on another view that clips over the edge of the screen in SwiftUI?

I have a view which consists of a background image/animation and a foreground control buttons. I want to .overlay the buttons on top of the background, to be aligned at the bottom leading, center, and trailing - see screenshot below (doesn't show the actual background).
However, the background has to be an animation. I'm using a wrapper view around AVPlayer for this. The video that gets played is portrait, but it gets filled to landscape as is thus wider than screen. I need it to fill the vertical space of the screen. That means that I have to use .frame(maxWidth: .infinity, maxHeight: .infinity).scaledToFill().
That leaves vertical alignment just fine - the gray top-center badge and the bottom-center button are rendered at the right places - however, it messes with the horizontal alignment. The bottom-right button aligns itself w.r.t. the video instead of the screen, and is thus aligned out of the bounds of the screen (see the second image).
I need the background to fill the screen, and the overlay to be aligned properly. The videos are recorded portrait, but AVPlayer makes them landscape with black filling on the sides, so unless that can be tweaked, I can't change the videos' aspect-ratios.
The most beneficial thing for me would be to learn how to align components w.r.t. the screen, not the parent in the overlay stack. Is there are way to achieve that? If not, is there a workaround to fix my problem (make the buttons align properly along the horizontal)?
Code
The code below isn't the source of truth, and just generates the demos. It is provided since a gentleman in the comments politely asked for it. The images are the ultimate source of truth. The actual code is much bigger, with a mechanism of randomly (and based on app state) choosing an AVPlayer to play an mp4 video. I don't think that this should be important (encapsulation and stuff), but if it is, tell me why it affects the code structure in the comments, and I will add more details.
A function used in the demos below is:
private func bigBgImage() -> some View {
self.backgroundChooser.render()
.resizable()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.scaledToFill()
}
...where backgroundChooser.render() is to be taken as a blackbox. You can just drop in a dummy Image.
Desired layout
This demo is achieved through a dummy image in a GeometryReader. This is a workaround to bound the overlays despite the bigBgImage having maxWidth: .infinity. Since GeometryReader messes with the animation alignment, I don't want to use it in the final code. Nevertheless, here's the snippet:
GeometryReader { _ in
self.bigBgImage()
}
.overlay(alignment: .top) { self.title() }
.overlay(alignment: .bottom) { self.resetButton() }
.overlay(alignment: .bottomTrailing) { self.toggleButton() }
Undesired layout
self.bigBgImage()
.overlay(alignment: .top) { self.title() }
.overlay(alignment: .bottom) { self.resetButton() }
.overlay(alignment: .bottomTrailing) { self.toggleButton() }
The mechanism behind backgroundChooser is too complex to contain in an SO question in a meaningful way. One can just drop in any image
In this scenario we should separate controls from content. A possible approach is to have independent screen layer (bound always to screen geometry), then move content into background, and controls into overlays, like
Color.clear // << always tight to screen area
//.ignoresSafeArea() // << optional, if needed
.background(self.bigBgImage()) // now independent, but not clipped
.overlay(alignment: .top) { self.title() } // << all on screen
.overlay(alignment: .bottom) { self.resetButton() }
.overlay(alignment: .bottomTrailing) { self.toggleButton() }
So I found a workaround involving manually setting an x offset for the background.
Building on Asperi's partial answer, we add a GeometryReader and offset the background by half the width:
Color.clear
.background() {
GeometryReader { geo in
self.bigBgImage()
.offset(x: -geo.size.width / 2)
}
}
.overlay(alignment: .top) { self.title() }
.overlay(alignment: .bottom) { self.resetButton() }
.overlay(alignment: .bottomTrailing) { self.toggleButton() }
I found that the problem was your .frame and .scaleToFill() after the Image.
Here I have a different solution. (Code is below the image)
Use screen max width and height instead of .infinity with overlay
struct DemoView: View {
var body: some View {
ZStack {
bigBigImage
.overlay(alignment: .top) {
Button("Top") {
}
.padding()
.background(.black)
.cornerRadius(10)
}
.overlay(alignment: .bottom) {
Button("Center") {
}
.padding()
.background(.black)
.cornerRadius(10)
}
.overlay(alignment: .bottomTrailing) {
Button("Leading") {
}
.padding()
.background(.black)
.cornerRadius(10)
.padding(.trailing)
}
}
}
var bigBigImage: some View {
Image("Swift")
.resizable()
.scaledToFill() //here
.frame(maxWidth: UIScreen.main.bounds.width, maxHeight: UIScreen.main.bounds.height) //here
.edgesIgnoringSafeArea(.all)
}
}

In SwiftUI, how to fill Path with text color on top of a material?

I'm drawing icons on a toolbar with a material background. The Text and symbol Images are white, but if I draw my own Path, it's gray.
struct ContentView: View {
var body: some View {
HStack {
Text("Hi")
Image(systemName: "square.and.arrow.up.fill")
Path { p in
p.addRect(CGRect(origin: .zero, size: .init(width: 20, height: 30)))
}.fill()
.frame(width: 20, height: 30)
}
.padding()
.background(.regularMaterial)
}
}
I get the same result with .fill(), .fill(.foreground), or .fill(.primary).
Why is it gray? How do I get it to match the white text color?
I find it weird that .white or .black work, but .primary doesn't.
Upon discovering the Material documentation, I found this interesting snippet:
When you add a material, foreground elements exhibit vibrancy, a context-specific blend of the foreground and background colors that improves contrast. However using foregroundStyle(_:) to set a custom foreground style — excluding the hierarchical styles, like secondary — disables vibrancy.
Seems like you have to force a different color (see previous edit which I used the environment color scheme), since hierarchical styles such as .primary won't work by design.
Luckily there is a way around this - you can use colorMultiply to fix this problem. If you set the rectangle to be .white, then the color multiply will make it the .primary color.
Example:
struct ContentView: View {
var body: some View {
HStack {
Text("Hi")
Image(systemName: "square.and.arrow.up.fill")
Path { p in
p.addRect(CGRect(origin: .zero, size: .init(width: 20, height: 30)))
}
.foregroundColor(.white)
.colorMultiply(.primary)
.frame(width: 20, height: 30)
}
.padding()
.background(.regularMaterial)
}
}
There is no issue with code, your usage or expecting is not correct! Text and Image in that code has default Color.primary with zero code! So this is you, that messing with .fill() you can delete that one!
struct ContentView: View {
var body: some View {
HStack {
Text("Hi")
Image(systemName: "square.and.arrow.up.fill")
Path { p in
p.addRect(CGRect(origin: .zero, size: .init(width: 20, height: 30)))
}
.fill(Color.primary) // You can delete this line of code as well! No issue!
.frame(width: 20, height: 30)
}
.padding()
.background(Color.secondary.cornerRadius(5))
}
}

SwiftUI: contentShape not affecting onHover area

I thought contentShape() would affect the hover "area" the same way it affects the clickable area.
The following image is an example where the hover should not be triggered.
Full example code:
struct ContentView: View {
#State var hovering: Bool = false
var body: some View {
Rectangle()
.frame(width: 120, height: 120)
.foregroundColor(hovering ? Color.white : Color.red)
.clipShape(Circle())
.contentShape(Circle())
.onHover { hovering in
self.hovering = hovering
}
.onTapGesture {
print("Click")
}
.padding(24)
}
}
Is there a way to clip the hover area like the click area?
Add a clear background that cancels the hover value on hover. It’s not going to be perfect as mouse tracking in SwiftUI has lag errors.

Why tap Gesture of Button get triggered even out side of it's frame in SwiftUI?

I was testing accuracy of recognizing tap Gesture of a simple Button in SwiftUI, which I noticed it get activated even outside of its frame! I wanted be sure that I am not messing with label or other thing of Button, there for I build 2 different Buttons to find any improvement, but both of them get triggered in outside of given frame. I made a gif for showing issue.
gif:
code:
struct ContentView: View {
var body: some View {
Spacer()
Button("tap me!") {
print("you just tapped Button1!")
}.font(Font.largeTitle.bold()).background(Color.red)
Spacer()
Button(action: {
print("you just tapped Button2!")
}, label: {
Text("tap me!").font(Font.largeTitle.bold()).background(Color.red)
})
Spacer()
}
}
update:
some one said:
Do you see circle on click? - It is a size of active tap spot (kind of finger), and as you see, from your own demo, it overlaps red square - so gesture detected. That's it
I made another gif to show it is completely wrong to thinking like that, in this gif, even with overlaps tap get not registered!
I don't have an answer why this happens other than maybe there is some built-in functionality that increases the tappable area where possible for a better user experience? Anyway, if you put another tappable button behind it or next to it, you'll notice that this no longer happens and the tappable area is exactly where you'd expect it. Therefore, I wouldn't worry about it. But if you need to clip the tap area to the exact frame, you could add a clear background to the button and add a tap event that doesn't doesn't do anything, but takes priority on that location.
var body: some View {
VStack {
// Button behind button will take priority tap.
ZStack {
Button(action: {
print("tap 2")
}, label: {
Text("tap me!")
.padding()
.font(Font.largeTitle.bold())
.background(Color.green)
})
Button(action: {
print("tap 1")
}, label: {
Text("tap me!")
.font(Font.largeTitle.bold())
.background(Color.red)
})
}
// Clear background with "fake" tap event
Button(action: {
print("tap 3")
}, label: {
Text("tap me!")
.font(Font.largeTitle.bold())
.background(Color.red)
})
.padding()
.background(Color.black.opacity(0.001).onTapGesture {
//print("tap 4")
})
}
}
I think it's a built-in functionality to expand the tappable area:
Color.red
.frame(width: 30.0, height: 30.0)
.gesture(DragGesture(minimumDistance: 0).onEnded({ (value) in
// Tap with Apple Pencil: location is exactly the frame
// Tap with finger or an capacitance pen: the hit location is inaccurate
print(value.startLocation)
}))
Solution (Bug: If the view is inside a ScrollView, this will block scroll gesture):
let size = CGSize(width: 200.0, height: 200.0)
Color.red
.frame(width: size.width, height: size.height)
.gesture(DragGesture(minimumDistance: 0).onEnded({ (value) in
print(value.startLocation)
if value.startLocation.x >= 0 && value.startLocation.y >= 0 && value.startLocation.x <= size.width && value.startLocation.y <= size.height {
print("inside frame")
// action ...
} else {
print("outside frame")
}
}))

how can I make onTapGesture works only if user tap on circle not inside all frame of Circle in SwiftUI? [duplicate]

This question already has an answer here:
SwiftUI: How to make entire shape recognize gestures when stroked?
(1 answer)
Closed 2 years ago.
I am using this down code for reading tap of user on Circle, the problem is here that it works on all part of frame 100X100 but I expect that it should work only on color filled Circle, how can I solve this issue?
struct ContentView: View {
var body: some View {
Circle()
.fill(Color.red)
.frame(width: 100, height: 100, alignment: .center)
.onTapGesture {
print("tap")
}
}
}
A nice little hack would be to add a background layer to the circle (it can't be 100% clear or it wouldn't render, so I made it opacity 0.0001 which looks clear) and then add another tapGesture onto that layer. The new gesture will take priority in the background area and we can just leave it without an action so nothing happens.
Circle()
.fill(Color.red)
.frame(width: 100, height: 100, alignment: .center)
.background(
Color.black.opacity(0.0001).onTapGesture { }
)
.onTapGesture {
print("tap")
}