I'm struggling to implement TapGesture and LongPressGesture simultaneously in a ScrollView. Everything works fine with .onTapGesture and .onLongPressGesture, but I want that the opacity of the button gets reduced when the user taps on it, like a normal Button().
However, a Button() doesn't have an option to do something on a long press for whatever reason. So I tried to use .gesture(LongPressGesture() ... ). This approach works and shows the tap indication. Unfortunately, that doesn't work with a ScrollView: you can't scroll it anymore!
So I did some research and I found out that there has to be a TapGesture before the LongPressGesture so ScrollView works properly. That's the case indeed but then my LongPressGesture doesn't work anymore.
Hope somebody has a solution...
struct ContentView: View {
var body: some View {
ScrollView(.horizontal){
HStack{
ForEach(0..<5){ _ in
Button()
}
}
}
}
}
struct Button: View{
#GestureState var isDetectingLongPress = false
#State var completedLongPress = false
var body: some View{
Circle()
.foregroundColor(.red)
.frame(width: 100, height: 100)
.opacity(self.isDetectingLongPress ? 0 : 1)
// That works, but there is no indication for the user that the UI recognized the gesture
// .onTapGesture {
// print("Tapped!")
// }
// .onLongPressGesture(minimumDuration: 0.5){
// print("Long pressed!")
// }
// The approach (*) shows the press indication, but the ScrollView is stuck because there is no TapGesture
// If I add a dummy TapGesture, the LongPressGesture won't work anymore but now the ScrollView works as expected
//.onTapGesture {}
// (*)
.gesture(LongPressGesture()
.updating(self.$isDetectingLongPress) { currentstate, gestureState,
transaction in
gestureState = currentstate
}
.onEnded { finished in
self.completedLongPress = finished
}
)
}
}
I've tried many combinations of trying onTapGesture + LongPressGesture + custom timings and animations and many work /almost/ but leave minor annoyances. This is what I found that works perfectly. Tested on iOS 13.6.
With this solution your scroll view still scrolls, you get the button depression animation, long pressing on the button works too.
struct MainView: View {
...
Scrollview {
RowView().highPriorityGesture(TapGesture()
.onEnded { _ in
// The function you would expect to call in a button tap here.
})
}
}
struct RowView: View {
#State var longPress = false
var body: some View {
Button(action: {
if (self.longPress) {
self.longPress.toggle()
} else {
// Normal button code here
}
}) {
// Buttons LaF here
}
// RowView code here.
.simultaneousGesture(LongPressGesture(minimumDuration: 0.5)
.onEnded { _ in
self.longPress = true
})
}
}
Related
I am attempting to add a LongPressGesture to a view that acts as a button which brings up a sheet. When it's a Button it works fine and the swipe works between views. But when I remove the button and add a LongPressGesture to the view, only the long press works and you can no longer swipe left and right on the TabView and only swipe above or below the view (example below).
I assume there's some precedence of gestures here, could anyone enlighten me on what the process is to have the TabView swiping gesture work with precedence over the LongPressGesture?
import SwiftUI
struct ContentView: View {
#State private var toggleSheet: Bool = false
#GestureState private var isDetectingLongPress: Bool = false
var body: some View {
TabView {
Text("1")
.font(Font.system(size: 300))
.gesture(
LongPressGesture().updating(self.$isDetectingLongPress) {
currentState, gestu[![enter image description here][1]][1]reState, transaction in
gestureState = currentState
}
.onEnded {
_ in
self.toggleSheet.toggle()
}
)
.sheet(isPresented: self.$toggleSheet) {
Rectangle()
}
Text("2")
.font(Font.system(size: 300))
.gesture(
LongPressGesture().updating(self.$isDetectingLongPress) {
currentState, gestureState, transaction in
gestureState = currentState
}
.onEnded {
_ in
self.toggleSheet.toggle()
}
)
.sheet(isPresented: self.$toggleSheet) {
Circle()
}
}
.tabViewStyle(PageTabViewStyle())
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Hard to see in the giphy, but when swiping my mouse is above or below the view.
I want to use these three gestures at the same time.
TapGesture = to able to scroll item
LongPressGesture = a menu pops up
DragGesture = to change view's offset
Here is my code; With this code, tapGesture and longPressGesture are working fine but some lags occurs on drag Gesture.
struct MyView: View {
#State private var viewOffset = CGSize.zero
var body: some View {
VStack{
Text("HEYOOOO")
.padding()
}
.offset(viewOffset)
.onTapGesture{}
.onLongPressGesture(minimumDuration: 0.5) {
//pops up a menu
//xxx = true
}
.gesture(
DragGesture(minimumDistance: 0.2)
.onChanged{ gesture in
if gesture.translation.width > 0 {
viewOffset.width = gesture.translation.width
}
}
.onEnded{ _ in
viewOffset = .zero
})
}
}
How can I prevent the lagging issue?
Thanks to all!
I'm trying to implement a ScrollView with elements which can be tapped and dragged. It should work the following way:
The ScrollView should work normally, so swiping up/down should not interfere with the gestures.
Tapping an entry should run some code. It would be great if there would be a "tap indicator", so the user knows that the tap has been registered (What makes this difficult is that the tap indicator should be triggered on touch down, not on touch up, and should be active until the finger gets released).
Long pressing an entry should activate a drag gesture, so items can be moved around.
The code below covers all of those requirements (except the tap indicator). However, I'm not sure why it works, to be specific, why I need to use .highPriorityGesture and for example can't sequence the Tap Gesture and the DragGesture with .sequenced(before: ...) (that will block the scrolling).
Also, I'd like to be notified on a touch down event (not touch up, see 2.). I tried to use LongPressGesture() instead of TapGesture(), but that blocks the ScrollView scrolling as well and doesn't even trigger the DragGesture afterwards.
Does somebody know how this can be achieved? Or is this the limit of SwiftUI? And if so, would it be possible to port UIKit stuff over to achieve this (I already tried that, too, but was unsuccessful, the content of the ScrollView should also be dynamic so porting over the whole ScrollView might be difficult)?
Thanks for helping me out!
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.highPriorityGesture(TapGesture().onEnded({print("tapped!")}))
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#GestureState var dragging = CGSize.zero
var body: some View {
Circle()
.frame(width: 100, height: 100)
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($dragging, body: {t, state, _ in
state = t.translation
}))
.offset(dragging)
}
}
I tried a few option and I think a combination of sequenced and simultaneously allows two gestures to run the same time. To achieve a onTouchDown I used a DragGesture with minimum distance of 0.
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#State private var offset = CGSize.zero
#State private var isDragging = false
#GestureState var isTapping = false
var body: some View {
// Gets triggered immediately because a drag of 0 distance starts already when touching down.
let tapGesture = DragGesture(minimumDistance: 0)
.updating($isTapping) {_, isTapping, _ in
isTapping = true
}
// minimumDistance here is mainly relevant to change to red before the drag
let dragGesture = DragGesture(minimumDistance: 0)
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation {
offset = .zero
isDragging = false
}
}
let pressGesture = LongPressGesture(minimumDuration: 1.0)
.onEnded { value in
withAnimation {
isDragging = true
}
}
// The dragGesture will wait until the pressGesture has triggered after minimumDuration 1.0 seconds.
let combined = pressGesture.sequenced(before: dragGesture)
// The new combined gesture is set to run together with the tapGesture.
let simultaneously = tapGesture.simultaneously(with: combined)
return Circle()
.overlay(isTapping ? Circle().stroke(Color.red, lineWidth: 5) : nil) //listening to the isTapping state
.frame(width: 100, height: 100)
.foregroundColor(isDragging ? Color.red : Color.black) // listening to the isDragging state.
.offset(offset)
.gesture(simultaneously)
}
}
For anyone interested here is a custom scroll view that will not be blocked by other gestures as mentioned in one of the comments. As this was not possible to be solved with the standard ScrollView.
OpenScrollView for SwiftUI on Github
Credit to
https://stackoverflow.com/a/59897987/12764795
http://developer.apple.com/documentation/swiftui/composing-swiftui-gestures
https://www.hackingwithswift.com/books/ios-swiftui/how-to-use-gestures-in-swiftui
I made a post about this yesterday and apologize for it not being clear or descriptive enough. Today I've made some more progress on the problem but still haven't found a solution.
In my program I have a main view called GameView(), a view called KeyboardView() and a view called ButtonView().
A ButtonView() is a simple button that displays a letter and when pressed tells the keyboardView it belongs to what letter it represents. When it's pressed it is also toggled off so that it cannot be pressed again. Here is the code.
struct ButtonView: View {
let impactFeedbackgenerator = UIImpactFeedbackGenerator(style: .medium)
var letter:String
var function:() -> Void
#State var pressed = false
var body: some View {
ZStack{
Button(action: {
if !self.pressed {
self.pressed = true
self.impactFeedbackgenerator.prepare()
self.impactFeedbackgenerator.impactOccurred()
self.function()
}
}, label: {
if pressed{
Text(letter)
.font(Font.custom("ComicNeue-Bold", size: 30))
.foregroundColor(.white)
.opacity(0.23)
} else if !pressed{
Text(letter)
.font(Font.custom("ComicNeue-Bold", size: 30))
.foregroundColor(.white)
}
})
}.padding(5)
}
}
A keyboard view is a collection of ButtonViews(), one for each button on the keyboard. It tells the GameView what button has been pressed if a button is pressed.
struct KeyboardView: View {
#Environment(\.parentFunction) var parentFunction
var topRow = ["Q","W","E","R","T","Y","U","I","O","P"]
var midRow = ["A","S","D","F","G","H","J","K","L"]
var botRow = ["Z","X","C","V","B","N","M"]
var body: some View {
VStack{
HStack(){
ForEach(0..<topRow.count, id: \.self){i in
ButtonView(letter: self.topRow[i], function: {self.makeGuess(self.topRow[i])})
}
}
HStack(){
ForEach(0..<midRow.count, id: \.self){i in
ButtonView(letter: self.midRow[i], function: {self.makeGuess(self.midRow[i])})
}
}
HStack(){
ForEach(0..<botRow.count, id: \.self){i in
ButtonView(letter: self.botRow[i], function: {self.makeGuess(self.botRow[i])})
}
}
}
}
func makeGuess(_ letter:String){
print("Keyboard: Guessed \(letter)")
self.parentFunction?(letter)
}
}
Finally a GameView() is where the keyboard belongs. It displays the keyboard along with the rest of the supposed game.
struct GameView: View {
#Environment(\.presentationMode) var presentation
#State var guessedLetters = [String]()
#State var myKeyboard:KeyboardView = KeyboardView()
var body: some View {
ZStack(){
Image("Background")
.resizable()
.edgesIgnoringSafeArea(.all)
.opacity(0.05)
VStack{
Button("New Game") {
self.newGame()
}.font(Font.custom("ComicNeue-Bold", size: 20))
.foregroundColor(.white)
.padding()
self.myKeyboard
.padding(.bottom, 20)
}
}.navigationBarTitle("")
.navigationBarBackButtonHidden(true)
.navigationBarHidden(true)
.environment(\.parentFunction, parentFunction)
}
func makeGuess(_ letter:String){
self.guessedLetters.append(letter)
}
func newGame(){
print("Started a new game.")
self.guessedLetters.removeAll()
self.myKeyboard = KeyboardView()
}
func parentFunction(_ letter:String) {
makeGuess(letter)
}
}
struct ParentFunctionKey: EnvironmentKey {
static let defaultValue: ((_ letter:String) -> Void)? = nil
}
extension EnvironmentValues {
var parentFunction: ((_ letter:String) -> Void)? {
get { self[ParentFunctionKey.self] }
set { self[ParentFunctionKey.self] = newValue }
}
}
The issue is that when I start a new game, the array is reset but not keyboardView(), the buttons that have been toggled off remain off, but since it's being replaced by a new keyboardView() shouldn't they go back to being toggled on?
I'll repeat what I said in an answer to your previous question - under most normal use cases you shouldn't instantiate views as variables, so if you find yourself doing that, you might be on the wrong track.
Whenever there's any state change, SwiftUI recomputes the body and reconstructs the view tree, and matches the child view states to the new tree.
When it detects that something has changed, it realizes that the new child view is truly new, so it resets its state, fires .onAppear and so forth. But when there's no change that it can detect, then it just keeps the same state for all the descendent views.
That's what you're observing.
Specifically, in your situation nothing structurally has changed - i.e. it's still:
GameView
--> KeyboardView
--> ButtonView
ButtonView
ButtonView
...
so, it keeps the state of ButtonViews as is.
You can signal to SwiftUI that the view has actually changed and that it should be updated by using an .id modifier (documentation isn't great, but you can find more info in blogs), where you need to supply any Hashable variable to it that's different than the current one, in order to reset it.
I'll use a new Bool variable as an example:
struct GameView {
#State var id: Bool = false // initial value doesn't matter
var body: some View {
VStack() {
KeyboardView()
.id(id) // use the id here
Button("new game") {
self.id.toggle() // changes the id
}
}
}
}
Every time the id changes, SwiftUI resets the state, so all the child views', like ButtonViews', states are reset.
The following code can make the running app unresponsive on iPhone (not iPad) or iPhone simulator. Xcode shows that the app consumes 100% CPU while allocating more and more memory.
struct SecondView: View {
#State private var keyboardHeight: CGFloat = 0
private let showPublisher = NotificationCenter.Publisher.init(
center: .default,
name: UIResponder.keyboardWillShowNotification
).map { (notification) -> CGFloat in
if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
return rect.size.height
} else {
return 0
}
}
var body: some View {
VStack(spacing: 20) {
if keyboardHeight == 0 {
Text("This is shown as long as there's no keyboard")
}
Text("This is the SecondView. Drag from the left edge to navigate back, but don't complete the gesture: crash results.")
}.onReceive(self.showPublisher) { (height) in
self.keyboardHeight = height
}
.navigationBarItems(trailing: Button("Dummy") { })
}
}
struct ContentView: View {
#State var textInput = ""
var body: some View {
NavigationView {
VStack(spacing: 20) {
TextField("1. Tap here to show keyboard", text: self.$textInput)
.textFieldStyle(RoundedBorderTextFieldStyle())
NavigationLink(destination: SecondView()) {
Text("2. Go to second screen")
}
Spacer()
}
}
}
}
To trigger the freeze:
Tap the textfield to make the keyboard appear
Tap the link to go to the next screen
Drag from the left side of the screen, but don't complete the gesture and instead release early
There are some workarounds:
Remove the navigation bar item (the Dummy button) in SecondView
Remove the use of the keyboardHeight variable in SecondView
Don't activate the keyboard in ContentView before navigating
However, I can't use the above workarounds in my app. Does anyone know what the root cause is?
I was able to deactivate the keyboard before navigating, with the following workaround:
NavigationLink(destination: SecondView(), tag: 2, selection: $navigationSelection) {
EmptyView()
}
Text("2. Go to second screen")
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
self.navigationSelection = 2
}