Exclusive gestures in SwiftUI when gestures not in the same hierarchy - ios

Basically I would like this to work:
struct TestView: View {
var body: some View {
VStack {
Text("Should work simultaneously").onTapGesture {
print("Work simultaneously")
}
Text("Should work simultaneously").onTapGesture {
print("Work simultaneously")
}
Text("Should be exclusive").onTapGesture {
print("Work exclusively")
}
}
.simultaneousGesture(
TapGesture().onEnded {
print("Should work everywhere except for 3th child view")
}
)
}
}
The VStack has a gesture that should always be recognized, except when one their childs wants to handle the touches exclusively.
The pasted code is a minimal example.
In my real project, the 3th child view is a custom DatePicker that appears on some condition. I would like to dismiss the picker when one clicks outside, but if one clicks inside the picker it shouldn't be dismissed yet. For this reason I need handle the touches in the picker exclusively.

You can't block up the view hierarchy, only down. In order to have the simultaneous gesture on only part of the VStack, you will need to separate those views into their own composed view with the .simultaneousGesture() on that view. Presuming you don't want to change the look of your view, I would simply wrap the subviews you want the .simultaneousGesture() on in a Group{}. Something like this:
struct TestView: View {
var body: some View {
VStack {
Group {
Text("Should work simultaneously").onTapGesture {
print("Work simultaneously")
}
Text("Should work simultaneously").onTapGesture {
print("Work simultaneously")
}
}
.simultaneousGesture(
TapGesture().onEnded {
print("Should work everywhere except for 3th child view")
}
)
Text("Should be exclusive")
.onTapGesture {
print("Work exclusively")
}
}
}
}

Related

SwiftUI - How to handle drop event outside of my View

I'm implementing drag & drop in LazyVGrid. I'd like to call some function when drop happens outside of my View. How can I get the callback?
Here is my code:
var body: some View {
LazyVGrid(columns: ...) {
ForEach(...) { item in
createItem(...)
.onDrop(of: [.text], delegate: DropRelocateDelegate(didFinishDragging: {
draggingItem = nil
viewModel.didFinishDragging()
}))
}
}
}
As you can see I'm making some changes when drop happens. However this is only called when drop happens inside a grid. I'd like to make the exact same changes when drop happens outside of the View.
The most simple way would be to nest your LazyVGrid in a ZStack with a Rectangle(). The Rectangle() should fill the screen. You can then set the ZStack item alignment to top or whatever else you like.
struct SwiftUIView: View {
var body: some View {
ZStack(alignment: .top) {
Rectangle().foregroundColor(.clear)
LazyVGrid(columns: ...) {
ForEach(...) { item in
createItem(...)
.onDrop(of: [.text], delegate: DropRelocateDelegate(didFinishDragging: {
draggingItem = nil
viewModel.didFinishDragging()
}))
}
}
}
.onDrop(of: [.text], delegate: DropRelocateDelegate(didFinishDragging: {
draggingItem = nil
viewModel.didFinishDragging()
}))
}
}
Now in some cases, there may not be a need for putting onDrop() on the LazyVGrid items. Adding it to the ZStack should affect the entire screen. However, I included it just in case.

SwiftUI make ForEach List row properly clickable for edition in EditMode

Description
I have this following code:
NavigationView {
List {
ForEach(someList) { someElement in
NavigationLink(destination: someView()) {
someRowDisplayView()
} //: NavigationLink
} //: ForEach
} //: List
} //: NavigationView
Basically, it displays a dynamic list of class objects previously filled by user input. I have an "addToList()" function that allows users to add some elements (let's say name, email, whatever) and once the user confirms it adds the element to this list. Then I have a view that displays the list through a ForEach and then makes a NavigationLink of each element as indicated in the code example above.
Once clicked on an element of this list, the user should be properly redirected to a destination view as the NavigationLink implies. All this is working fine.
What I want
Here's where I face an issue: I want the user to be able to edit the content of a row of this list without having to delete the row and re-add another one.
I decided to use the EditMode() feature of SwiftUI. So this is what I came up with:
#State private var editMode = EditMode.inactive
[...]
NavigationView {
List {
ForEach(someList) { someElement in
NavigationLink(destination: someView()) {
someRowDisplayView()
} //: NavigationLink
} //: ForEach
} //: List
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.environment(\.editMode, $editMode)
} //: NavigationView
The EditMode is properly triggered when I click on the Edit button. But I noticed that the list row is not clickable in edit mode, which is fine because I do not want it to follow the NavigationLink while in edit mode.
Though what I want is that the user is either redirected to a view that allows editing of the tapped row, or better, that an edition sheet is presented to the user.
What I have tried
Since I couldn't tap the row in edition mode, I have tried several tricks but none of them concluded as I wanted. Here are my tries.
.simultaneousGesture
I tried to add a .simultaneousGesture modified to my NavigationLink and toggle a #State variable in order to display the edition sheet.
#State private var isShowingEdit: Bool = false
[...]
.simultaneousGesture(TapGesture().onEnded{
isShowingEdit = true
})
.sheet(isPresented: $isShowingEdit) {
EditionView()
}
This simply does not work. It seems that this .simultaneousGesture somehow breaks the NavigationLink, the tap succeeds like once out of five times.
It doesn't work even by adding a condition on the edit mode, like:
if (editMode == .active) {
isShowingEdit = true
}
In non-edition mode the NavigationLink is still bugged. But I have noticed that once in edition mode, it kind of does what I wanted.
Modifier condition extension on View
After the previous failure my first thought was that I needed the tap gesture to be triggered only in edit mode, so that in non-edit mode the view doesn't even know that it has a tap gesture.
I decided to add an extension to the View structure in order to define conditions to modifiers (I found this code sample somewhere on Stackoverflow):
extension View {
#ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
Then in my main code, instead of the previously tried .simultaneousGesture modifier, I added this to NavigationLink:
.if(editMode == .active) { view in
view.onTapGesture {
isShowingEdit = true
}
}
And it worked, I was able to display this edition view once a user taps on a row while in edition mode and the normal non-edition mode still worked as it properly triggered the NavigationLink.
Remaining issues
But something bothered me, it didn't feel like a natural or native behavior. Once tapped, the row didn't show any feedback like it shows in non-edition mode when following the NavigationLink: it doesn't highlight even for a very short time.
The tap gesture modifier simply executes the code I have asked without animation, feedback or anything.
I would like the user to know and see which row was tapped and I would like it to highlight just as it does when when tapping on the NavigationLink: simply make it look like the row was actually "tapped". I hope it makes sense.
I would also like the code to be triggered when the user taps on the whole row and not only the parts where the text is visible. Currently, tapping on an empty field of the row does nothing. It has to have text on it.
An even better solution would be something that prevents me from applying such conditional modifiers and extensions to the View structure as I surely prefer a more natural and better method if this is possible.
I'm new to Swift so maybe there is a lot easier or better solution and I'm willing to follow the best practices.
How could I manage to accomplish what I want in this situation?
Thank you for your help.
Additional information
I am currently using the .onDelete and .onMove implementations on the List alongside with the SwiftUI edition mode and I want to keep using them.
I am developing the app for minimum iOS 14, using Swift language and SwiftUI framework.
If you need more code samples or better explanations please feel free to ask and I will edit my question.
Minimal working example
Asked by Yrb.
import SwiftUI
import PlaygroundSupport
extension View {
#ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
struct SomeList: Identifiable {
let id: UUID = UUID()
var name: String
}
let someList: [SomeList] = [
SomeList(name: "row1"),
SomeList(name: "row2"),
SomeList(name: "row3")
]
struct ContentView: View {
#State private var editMode = EditMode.inactive
#State private var isShowingEditionSheet: Bool = false
var body: some View {
NavigationView {
List {
ForEach(someList) { someElement in
NavigationLink(destination: EmptyView()) {
Text(someElement.name)
} //: NavigationLink
.if(editMode == .active) { view in
view.onTapGesture {
isShowingEditionSheet = true
}
}
} //: ForEach
.onDelete { (indexSet) in }
.onMove { (source, destination) in }
} //: List
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $isShowingEditionSheet) {
Text("Edition sheet")
}
} //: NavigationView
}
}
PlaygroundPage.current.setLiveView(ContentView())
As we discussed in the comments, your code does everything in your list except highlight the row when the disclosure chevron is tapped. To change the background color of a row, you use .listRowBackground(). To make just one row highlight, you need to make the row identifiable off of the id in your array in the ForEach. You then have an optional #State variable to hold the value of the row id, and set the row id in your .onTapGesture. Lastly, you use a DispatchQueue.main.asyncAfter() to reset the variable to nil after a certain time. This gives you a momentary highlight.
However, once you go this route, you have to manage the background highlighting for your NavigationLink as well. This brings its own complexity. You need to use the NavigationLink(destination:,isActive:,label:) initializer, create a binding with a setter and getter, and in the getter, run your highlight code as well.
struct ContentView: View {
#State private var editMode = EditMode.inactive
#State private var isShowingEditionSheet: Bool = false
#State private var isTapped = false
#State private var highlight: UUID?
var body: some View {
NavigationView {
List {
ForEach(someList) { someElement in
// You use the NavigationLink(destination:,isActive:,label:) initializer
// Then create your own binding for it
NavigationLink(destination: EmptyView(), isActive: Binding<Bool>(
get: { isTapped },
// in the set, you can run other code
set: {
isTapped = $0
// Set highlight to the row you just tapped
highlight = someElement.id
// Reset the row id to nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
highlight = nil
}
}
)) {
Text(someElement.name)
} //: NavigationLink
// identify your row
.id(someElement.id)
.if(editMode == .active) { view in
view.onTapGesture {
// Set highlight to the row you just tapped
highlight = someElement.id
// Reset the row id to nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
highlight = nil
}
isShowingEditionSheet = true
}
}
.listRowBackground(highlight == someElement.id ? Color(UIColor.systemGray5) : Color(UIColor.systemBackground))
} //: ForEach
.onDelete { (indexSet) in }
.onMove { (source, destination) in }
} //: List
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $isShowingEditionSheet) {
Text("Edition sheet")
}
} //: NavigationView
}
}

Can't get removal of view to animate

I have a VStack where I have a title above some other views. The title is shown/hidden based on the value of an #Published variable from an environment object. Ideally, when the title should be hidden, I want it to transition by fading out and moving the views below it in the VStack up. However, what actually happens is it just disappears immediately and moves the rest of the views up without animation. How can I fix this?
struct MyView: View {
#EnvironmentObject var modelController: MyModelController
var body: some View {
VStack {
title()
//..other views here
}
}
#ViewBuilder
func title() -> some View {
if let currentPage = modelController.currentPage,
currentPage >= 6 {
EmptyView()
} else {
Text("Create Event")
}
}
}
You'll need to use the .transition() modifier to tell the system that you want to animate when the view appears or disappears. Additionally, I don't think you need to return EmptyView when you want to hide the title.
#ViewBuilder
func title() -> some View {
if modelController.currentPage ?? 0 < 6 {
Text("Create Event")
.transition(.opacity)
}
}
I've used the opacity transition but it's a very customizable modifier, and you can pass an asymmetric transition that does different animations on insert and removal. I would suggest googling it or looking at the documentation to learn more about it.
Your code snapshot is not testable, but try the following
VStack {
title()
//..other views here
}
.animation(.default, value: modelController.currentPage) // << here !!

I want to avoid conflict with `Gesture` of `ScrollView`

I want to avoid conflict with Gesture of ScrollView to meet a customer's complex animation requirements.
The reason I not want to conflict it is that when I simply gave Gesture as shown below, onEnded was not called.
In the first place, I feel that onChanged is called by accident, and I don't know if this way of writing is safe or not.
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("\(i)")
}
}
}
.gesture(
gestureA
)
}
var gestureA: some Gesture {
DragGesture().onChanged { value in
print("A onChanged:\(value)")
}
.onEnded { value in
print("A onEnded:\(value)")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I wish I could use simultaneously(with:), but I don't know how to write with ScrollView.
If it's UIScrollView, I've found a way to remove the gesture recognizer as shown in the following with removeTarget:
https://stackoverflow.com/a/18337648/1979953
Please let me know if there is any way to avoid conflicts.
Edit: I tried the following after seeing https://developer.apple.com/forums/thread/655465, but still onEnded was not called.
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("\(i)")
}
}
}
.simultaneousGesture(
gestureA
)

SwiftUI add subview dynamically but the animation doesn't work

I would like to create a view in SwiftUI that add a subview dynamically and with animation.
struct ContentView : View {
#State private var isButtonVisible = false
var body: some View {
VStack {
Toggle(isOn: $isButtonVisible.animation()) {
Text("add view button")
}
if isButtonVisible {
AnyView(DetailView())
.transition(.move(edge: .trailing))
.animation(Animation.linear(duration: 2))
}else{
AnyView(Text("test"))
}
}
}
}
The above code works fine with the animation . however when i move the view selection part into a function, the animation is not working anymore (since i want to add different views dynamically, therefore, I put the logic in a function.)
struct ContentView : View {
#State private var isButtonVisible = false
var body: some View {
VStack {
Toggle(isOn: $isButtonVisible.animation()) {
Text("add view button")
}
subView().transition(.move(edge: .trailing))
.animation(Animation.linear(duration: 2))
}
func subView() -> some View {
if isButtonVisible {
return AnyView(DetailView())
}else{
return AnyView(Text("test"))
}
}
}
it looks totally the same to me, however, i don't understand why they have different result. Could somebody explain me why? and any better solutions? thanks alot!
Here's your code, modified so that it works:
struct ContentView : View {
#State private var isButtonVisible = false
var body: some View {
VStack {
Toggle(isOn: $isButtonVisible.animation()) {
Text("add view button")
}
subView()
.transition(.move(edge: .trailing))
.animation(Animation.linear(duration: 2))
}
}
func subView() -> some View {
Group {
if isButtonVisible {
DetailView()
} else {
Text("test")
}
}
}
}
Note two things:
Your two examples above are different, which is why you get different results. The first applies a transition and animation to a DetailView, then type-erases it with AnyView. The second type-erases a DetailView with AnyView, then applies a transition and animation.
Rather that using AnyView and type-erasure, I prefer to encapsulate the conditional logic inside of a Group view. Then the type you return is Group, which will animate properly.
If you wanted different animations on the two possibilities for your subview, you can now apply them directly to DetailView() or Text("test").
Update
The Group method will only work with if, elseif, and else statements. If you want to use a switch, you will have to wrap each branch in AnyView(). However, this breaks transitions/animations. Using switch and setting custom animations is currently not possible.
I was able to get it to work with a switch statement by wrapping the function that returns an AnyView in a VStack. I also had to give the AnyView an .id so SwiftUI can know when it changes. This is on Xcode 11.3 and iOS 13.3
struct EnumView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
view(for: viewModel.viewState)
.id(viewModel.viewState)
.transition(.opacity)
}
}
func view(for viewState: ViewModel.ViewState) -> AnyView {
switch viewState {
case .loading:
return AnyView(LoadingStateView(viewModel: self.viewModel))
case .dataLoaded:
return AnyView(LoadedStateView(viewModel: self.viewModel))
case let .error(error):
return AnyView(ErrorView(error: error, viewModel: viewModel))
}
}
}
Also for my example in the ViewModel I need to wrap the viewState changes in a withAnimation block
withAnimation {
self.viewState = .loading
}
In iOS 14 they added the possibility to use if let and switch statements in function builders. Maybe it helps for your issues:
https://www.hackingwithswift.com/articles/221/whats-new-in-swiftui-for-ios-14 (at the article's bottom)

Resources