SwiftUI: Adding .onDrag blocks clicking/selecting an item in the macOS sidebar - swiftui

I have a list in my macOS app’s sidebar. When I add the .onDrag modifier, dragging NSItemProvider works correctly, but clicking/selecting the item to trigger the NavigationLink gets blocked.
struct ContentView: View {
var body: some View {
NavigationView {
List(Data.items) { item in
NavigationLink(
destination: Text(item.text),
label: {
Text(item.text)
.lineLimit(5)
// Enabling dragging with .onDrag {} disables click and selection:
.onDrag { return NSItemProvider(object: item.url as NSURL) }
})
}
Text("Placeholder")
}
}
}
How can I add the drag behavior while keeping the normal list selection behavior intact?
Here ist the rest of the Minimal Reproducible Example:
import SwiftUI
#main
struct ListDragExampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct Data {
struct Item: Identifiable {
let id = UUID()
let text = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
let url = URL(string: "http://example.com")!
}
static let items = [
Item(),
Item(),
Item()
]
}

I am having the same issue, and from what I could find, it's actually a bug in SwiftUI. I may be able to contribute some more details to the discussion:
It's not directly related to a NavigationLink. I have a plain List with custom item views and it's presenting the same erratic behavior;
In my custom item view, there are areas that don't have an actual View in them, such as the blank spaces beside Text views. When I click on those areas (the "background", see the image below), I can still select the item, but the dragging mechanism doesn't work;
When dropping a dragged an item on another, the area that accepts the drop is comprised only of the frames of the destination item's subviews. If I try to drop on the "background" area of the List item, it won't accept it.
Since I don't have enough reputation yet, please click this link to see the image.
Overall, it seems to me that whenever the item that accepts either drags or drops has subviews—being a simple Text or a more complex layout, such as the one I showed in the picture—the dragging mechanism disables selection whenever the mouse cursor hits inside one of those subviews, while only allowing drags and drops to occur by starting or ending inside their frames.
On the code below, the selection mechanism will work when clicking on the area to the right of the "Short" item, but the drag will only be triggered if it starts inside the Text view's frame.
struct ExampleList: View {
private let data = ["Short", "Average", "Loooooooooooong"]
#State private var selected = Set<String>()
var body: some View {
List(data, id: \.self, selection: $selected) { text in
Text(text) // Clicking outside the frame selects it...
// ...but clicking inside starts the drag mechanism.
.onDrag { NSItemProvider(object: text as NSString) }
}
}
}
Hope this helps. Cheers from Brazil!

Related

SwiftUI Text Editor Crash with Apple Pencil Long Press

Using Apple Pencil with SwiftUI text editor, when the user presses and holds in the middle of text already in the editor box a crash occurs. This is very reproducible even on this minimal example.
import SwiftUI
struct ContentView: View {
#State private var text: String = "The quick brown fox jumps over the lazy dog"
var body: some View {
VStack {
TextEditor(text: $text)
.background(Color.init(red: 242, green: 242, blue: 242))
.border(.black)
.frame(height: 200)
}
.padding()
}
}
When tapping and holding somewhere in the middle of the sentence, for example on the word "jumps" the app crashes with an exception reading: Thread 1: "NSTextContentStorage: Inconsistent element cache state. Elements for range {0, 43} are already cached while trying to insert"
The stack trace:
I have attempted to use the approach discussed in this post to make the underlying UITextView use TextKit 1, but could find no UITextView in the underlying UIView hierarchy. Any advice would be greatly appreciated.

Swiftui: How to Close one Tab in a TabView?

I'm learning SwiftUI and having trouble with closing Each Tab View Element.
My App shows photos from user's album by TabView with pageViewStyle one by one.
And What I want to make is user can click save button in each view, and when button is clicked, save that photo and close only that view while other photos are still displayed. So unless all photos are saved or discarded, if user clicks save button, TabView should automatically move to another one.
However, I don't know how to close only one Tab Element. I've tried to use dismiss() and dynamically changing vm.images element. Latter one actually works, but it displays awkward movement and it also requires quite messy code. How could I solve this issue?
Here is my code.
TabView {
ForEach(vm.images, id: \.self) { image in
TestView(image: image)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
struct TestView: View {
#ObservedObject var vm: TestviewModel
...
var body: some View {
VStack(spacing: 10) {
Image(...)
Spacer()
Button {
...
} label: {
Text("Save")
}
}
You need actually to remove saved image from the viewModel container, and UI will be updated automatically
literally
Button {
vm.images.removeAll { $0.id == image.id } // << here !!
} label: {
Text("Save")
}
You need to use the selection initializer of TabView in order to control what it displays. So replace TabView with:
TabView(selection: $selection)
Than add a new property: #State var selection: YourIdType = someDefaultValue, and in the Button action you set selection to whatever you want to display.
Also add .tag(TheIdTheViewWillUse) remember that whatever Id you use must be the same as your selection variable. I recommend you use Int for the simple use.

How to detect a tap on a link and tap coordinates at the same time in SwiftUI?

In my SwiftUI app, some parts of text need to be tappable. On taps, some custom actions should occur, not necessarily opening a web page. At the same time I need to detect tap coordinates. I was going to use a drag gesture handler for that.
I implemented tappable text as links using AttributedString.
The problem is that I cannot detect tap coordinates because handlers for tap or drag gestures are not invoked when tapping on a link.
Any ideas on how to detect a tap on a link and tap coordinates at the same time in SwiftUI?
(I wouldn't like to use a webview for that because my app is a reading app that has a lot of text-related functionality.)
Below is a code sample. I defined a custom URL scheme for my app to be able to implement custom handlers for links.
import SwiftUI
struct TappableTextView: View {
var body: some View {
VStack {
Text(makeAttributedString()).padding()
.gesture(
DragGesture(minimumDistance: 0)
.onEnded({ (value) in
print("Text has been tapped at \(value.location)")
})
)
Spacer()
}
.onOpenURL { url in
print("A link is tapped, url: \(url)")
}
}
func makeAttributedString() -> AttributedString {
var string = AttributedString("")
let s1 = AttributedString("This is a long paragraph. Somewhere in the paragraph is ")
var tappableText = AttributedString("tappable text")
tappableText.link = URL(string: "customurlscheme:somedata")
tappableText.foregroundColor = .green
let s2 = AttributedString(". This is the rest of the paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
string.append(s1)
string.append(tappableText)
string.append(s2)
return string
}
}
You need to use simultaneousGesture(_:including:). This is because you are clicking a link already so a normal gesture does not occur. Using simultaneousGesture means you both click the link and can grab the coordinates, at the same time.
Code:
Text(makeAttributedString()).padding()
.simultaneousGesture(
/* ... */
)

What mouse gesture to delete an item from a List on macOS in SwiftUI?

I don't understand how to delete or move a item on macOS. What action should I need to do with the mouse to trigger onDelete or onMove events?
#State var wishList = ["Item 1", "Item 2", "Item3"]
var body: some View {
List {
ForEach(wishList, id:\.self) { item in
Button(action: {
}) {
Text(item)
}
}
.onDelete { offsets in
}
.onMove { source, target in
}
}
}
Move:
Click and drag rows.
Delete:
Swipe with (two fingers on trackpad or one finger on magic mouse), like the way you scroll horizontally.
Note that you should NOT click and drag the row like the way you swipe in iOS simulator. Just a simple mac horizontal scroll is enough.
These gestures won't work if you have an ordinary two-button mouse. You will need to make affordances in your UI for people with simpler mice.

Is it possible to make a modal non-dismissible in SwiftUI?

I am creating an App where the login / register part is inside a modal, which is shown if the user is not logged in.
The problem is, that the user can dismiss the modal by swiping it down...
Is it possible to prevent this?
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) { // This needs to be non-dismissible
LoginRegister()
}
}
Second example:
I am using a modal to ask for information. The user should not be able to quit this process except by dismissing the modal with save button. The user has to input information before the button works. Unfortunately the modal can be dismissed by swiping it down.
Is it possible to prevent this?
iOS 15 and later:
Use .interactiveDismissDisabled(true) on the sheet, that's all.
Prev iOS 15:
You can try to do this by using a highPriorityGesture. Of course the blue Rectangle is only for demonstration but you would have to use a view which is covering the whole screen.
struct ModalViewNoClose : View {
#Environment(\.presentationMode) var presentationMode
let gesture = DragGesture()
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 300, height: 600)
.highPriorityGesture(gesture)
.overlay(
VStack{
Button("Close") {
self.presentationMode.value.dismiss()
}.accentColor(.white)
Text("Modal")
.highPriorityGesture(gesture)
TextField("as", text: .constant("sdf"))
.highPriorityGesture(gesture)
} .highPriorityGesture(gesture)
)
.border(Color.green)
}
}
This is a common problem and a "code smell"... well not really code but a "design pattern smell" anyway.
The problem is that you are making your login process part of the rest of the app.
Instead of presenting the LoginRegister over the App you should really be showing either App or LoginRegister.
i.e. you should have some state object like userLoggedIn: Bool or something and depending on that value you should show either App or LoginRegister.
Just don't have both in the view hierarchy at the same time. That way your user won't be able to dismiss the view.
If you dont mind using Introspect:
import Introspect
#available(iOS 13, *)
extension View {
/// A Boolean value indicating whether the view controller enforces a modal behavior.
///
/// The default value of this property is `false`. When you set it to `true`, UIKit ignores events
/// outside the view controller's bounds and prevents the interactive dismissal of the
/// view controller while it is onscreen.
public func isModalInPresentation(_ value: Bool) -> some View {
introspectViewController {
$0.isModalInPresentation = value
}
}
}
Usage:
.sheet {
VStack {
...
}.isModalInPresentation(true)
}
iOS 15+
Starting from iOS 15 you can use interactiveDismissDisabled.
You just need to attach it to the sheet:
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) {
LoginRegister()
.interactiveDismissDisabled(true)
}
}
Regarding your second example, you can pass a variable to control when the sheet is disabled:
.interactiveDismissDisabled(!isAllInformationProvided)
You can find more information in the documentation.
theoretically this may help you (I didn't tryed it)
private var isDisplayedBind: Binding<Bool>{ Binding(get: { true }, set: { _ = $0 }) }
and usage:
content
.sheet(isPresented: isDisplayedBind) { some sheet }