How to run code when swiftui gesture recognizers begin - swiftui

SwiftUI MagnificationGesture and DragGesture have .onChanged and .onEnded APIs but nothing to check when the gesture started like in UIKit. Couple ways I thought to do it:
.$gestureStarted bool in onChanged and then set it back to false in .onEnded
use a gesture sequence with tap.
Am I missing some preferred way to do this? Seems like a pretty natural thing to want to check.

There is special #GestureState, which can be used for such purpose. So, here is possible approach
struct TestGestureBegin: View {
enum Progress {
case inactive
case started
case changed
}
#GestureState private var gestureState: Progress = .inactive // initial & reset value
var body: some View {
VStack {
Text("Drag over me!")
}
.frame(width: 200, height: 200)
.background(Color.yellow)
.gesture(DragGesture(minimumDistance: 0)
.updating($gestureState, body: { (value, state, transaction) in
switch state {
case .inactive:
state = .started
print("> started")
case .started:
state = .changed
print(">> just changed")
case .changed:
print(">>> changing")
}
})
.onEnded { value in
print("x ended")
}
)
}
}

Related

Swiftui longpress gesture onended and onchange not called on failure - is that right?

Encountering a problem in some hobby code in which a long-press appear to be misbehaving (that is, the previous action stopped happening ). I took the sample Apple code and started playing which revealed an anomaly (in my mind).
The anomaly is that if the gesture fails, then, whilst the underlying GestureState var is changed, neither the .onChange nor .onEnded modifiers are called. Similarly, .onEnded is ONLY invoked when the gesture succeeds. In a similar anomalous behavior, long-press, in isolation, appears to be detected and triggered, the instant the click happens. Which to me is contrary to the concept of a long-Press.
My sample code is below (modified Apple sample with print statements to track the sequence of events). If you click and hold the click until the duration expires then the associated actions occur as you expect, however, if you click, hold, and drag beyond the distance parameter or simply click and release, then the only place for detecting that failure is in the view that the gesture is attached to.
Does anyone know of a way of detecting a long-press failure?
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
LongPressGestureView()
}
.padding()
}
}
struct LongPressGestureView: View {
#GestureState var isDetectingLongPress = false
#State var completedLongPress = false
#State var scaleFactor = 1.0
var longPress: some Gesture {
LongPressGesture(minimumDuration: 4, maximumDistance: 100)
.updating($isDetectingLongPress) { currentState, gestureState,
transaction in
print("Gesture updating detected currentState: \(currentState)")
print("Gesture updating: saw state of completedLongPress: \(completedLongPress)")
gestureState = currentState
transaction.animation = Animation.easeIn(duration: 2.0)
}
.onChanged() { state in
print("Gesture onChange: saw state of isDetectingLongPress: \(state)")
print("Gesture onChange: saw state of completedLongPress: \(completedLongPress)")
}
.onEnded { finished in
print("Gesture onEnded detected completed: \(self.completedLongPress)")
self.completedLongPress = finished
}
}
var body: some View {
Circle()
.scale(scaleFactor)
.fill(self.isDetectingLongPress ?
Color.red :
(self.completedLongPress ? Color.green : Color.blue))
.frame(width: 100, height: 100, alignment: .center)
.gesture(longPress)
.onChange(of: self.isDetectingLongPress) {newState in
print("View: onChange isDetectingLongPress: \(newState)")
print("View: onChange isDetectingLongPress completedLongPress: \(completedLongPress)")
scaleFactor = newState ? 3.0 : 1.0
}
.onChange(of: self.completedLongPress) { state in
print("View: onChange completedLongPress isDetectingLongPress: \(isDetectingLongPress)")
print("View: onChange completedLongPress: \(completedLongPress)")
if (state) {
scaleFactor = 1.0
completedLongPress = false
}
}
.animation(.easeInOut(duration: 1.0), value: scaleFactor)
}
}

SwiftUI animation not working using animation(_:value:)

In SwiftUI, I've managed to make a Button animate right when the view is first drawn to the screen, using the animation(_:) modifier, that was deprecated in macOS 12.
I've tried to replace this with the new animation(_:value:) modifier, but this time nothing happens:
So this is not working:
struct ContentView: View {
#State var isOn = false
var body: some View {
Button("Press me") {
isOn.toggle()
}
.animation(.easeIn, value: isOn)
.frame(width: 300, height: 400)
}
}
But then this is working. Why?
struct ContentView: View {
var body: some View {
Button("Press me") {
}
.animation(.easeIn)
.frame(width: 300, height: 400)
}
}
The second example animates the button just as the view displays, while the first one does nothing
The difference between animation(_:) and animation(_:value:) is straightforward. The former is implicit, and the latter explicit. The implicit nature of animation(_:) meant that anytime ANYTHING changed, it would react. The other issue it had was trying to guess what you wanted to animate. As a result, this could be erratic and unexpected. There were some other issues, so Apple has simply deprecated it.
animation(_:value:) is an explicit animation. It will only trigger when the value you give it changes. This means you can't just stick it on a view and expect the view to animate when it appears. You need to change the value in an .onAppear() or use some value that naturally changes when a view appears to trigger the animation. You also need to have some modifier specifically react to the changed value.
struct ContentView: View {
#State var isOn = false
//The better route is to have a separate variable to control the animations
// This prevents unpleasant side-effects.
#State private var animate = false
var body: some View {
VStack {
Text("I don't change.")
.padding()
Button("Press me, I do change") {
isOn.toggle()
animate = false
// Because .opacity is animated, we need to switch it
// back so the button shows.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
animate = true
}
}
// In this case I chose to animate .opacity
.opacity(animate ? 1 : 0)
.animation(.easeIn, value: animate)
.frame(width: 300, height: 400)
// If you want the button to animate when the view appears, you need to change the value
.onAppear { animate = true }
}
}
}
Follow up question: animating based on a property of an object is working on the view itself, but when I'm passing that view its data through a ForEach in the parent view, an animation modifier on that object in the parent view is not working. It won't even compile. The objects happen to be NSManagedObjects but I'm wondering if that's not the issue, it's that the modifier works directly on the child view but not on the passed version in the parent view. Any insight would be greatly appreciated
// child view
struct TileView: View {
#ObservedObject var tile: Tile
var body: some View {
Rectangle()
.fill(tile.fillColor)
.cornerRadius(7)
.overlay(
Text(tile.word)
.bold()
.font(.title3)
.foregroundColor(tile.fillColor == .myWhite ? .darkBlue : .myWhite)
)
// .animation(.easeInOut(duration: 0.75), value: tile.arrayPos)
// this modifier worked here
}
}
struct GridView: View {
#ObservedObject var game: Game
let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 4)
var body: some View {
GeometryReader { geo in
LazyVGrid(columns: columns) {
ForEach(game.tilesArray, id: \.self) { tile in
Button(action: {
tile.toggleSelectedStatus()
moveTiles() <- this changes their array position (arrayPos), and
the change in position should be animated
}) {
TileView(tile: tile)
.frame(height: geo.size.height * 0.23)
}
.disabled(tile.status == .solved || tile.status == .locked)
.animation(.easeInOut(duration: 0.75), value: arrayPos)
.zIndex(tile.status == .locked ? 1 : 0)
}
}
}
}
}

onChange not firing when attached to a Picker

I have a 3-part picker, and I'm trying to make the values of one Picker to be based on the value of another. Specifically adding/removing the s on the end of "Days","Weeks",etc. I have read a similar post (here) on this type of situation, but the proposed Apple solution for IOS 14+ deployments is not working. Given that the other question focuses primarily on pre-14 solutions, I thought starting a new question would be more helpful.
Can anyone shed any light on why the .onChange is never getting called? I set a breakpoint there, and it is never called when the middle wheels value change between 1 and any other value as it should.
The unconventional init is just so I could encapsulate this code removed from a larger project.
Also, I have the .id for the 3rd picker commented out in the code below, but can un-comment if the only problem remaining is for the 3rd picker to update on the change.
import SwiftUI
enum EveryType:String, Codable, CaseIterable, Identifiable {
case every="Every"
case onceIn="Once in"
var id: EveryType {self}
var description:String {
get {
return self.rawValue
}
}
}
enum EveryInterval:String, Codable, CaseIterable, Identifiable {
case days = "Day"
case weeks = "Week"
case months = "Month"
case years = "Year"
var id: EveryInterval {self}
var description:String {
get {
return self.rawValue
}
}
}
struct EventItem {
var everyType:EveryType = .onceIn
var everyInterval:EveryInterval = .days
var everyNumber:Int = Int.random(in:1...3)
}
struct ContentView: View {
init(eventItem:Binding<EventItem> = .constant(EventItem())) {
_eventItem = eventItem
}
#Binding var eventItem:EventItem
#State var intervalId:UUID = UUID()
var body: some View {
GeometryReader { geometry in
HStack {
Picker("", selection: self.$eventItem.everyType) {
ForEach(EveryType.allCases)
{ type in Text(type.description)
}
}
.pickerStyle(WheelPickerStyle())
.frame(width: geometry.size.width * 0.3, height:100)
.compositingGroup()
.padding(0)
.clipped()
Picker("", selection: self.$eventItem.everyNumber
) {
ForEach(1..<180, id: \.self) { number in
Text(String(number)).tag(number)
}
}
//The purpase of the == 1 below is to only fire if the
// everyNumber values changes between being a 1 and
// any other value.
.onChange(of: self.eventItem.everyNumber == 1) { _ in
intervalId = UUID() //Why won't this ever happen?
}
.pickerStyle(WheelPickerStyle())
.frame(width: geometry.size.width * 0.25, height:100)
.compositingGroup()
.padding(0)
.clipped()
Picker("", selection: self.$eventItem.everyInterval) {
ForEach(EveryInterval.allCases) { interval in
Text("\(interval.description)\(self.eventItem.everyNumber == 1 ? "" : "s")")
}
}
.pickerStyle(WheelPickerStyle())
.frame(width: geometry.size.width * 0.4, height:100)
.compositingGroup()
.clipped()
//.id(self.intervalId)
}
}
.frame(height:100)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(eventItem: .constant(EventItem()))
}
}
For Picker, its item data type must conform Identifiable and we must pass a property of item into "tag" modifier as "id" to let Picker trigger selection and return that property in Binding variable with selection.
For example :
Picker(selection: $selected, label: Text("")){
ForEach(data){item in //data's item type must conform Identifiable
HStack{
//item view
}
.tag(item.property)
}
}
.onChange(of: selected, perform: { value in
//handle value of selected here (selected = item.property when user change selection)
})
//omg! I spent whole one day to find out this
Try the following
.onChange(of: self.eventItem.everyNumber) { newValue in
if newValue == 1 {
intervalId = UUID()
}
}
but it might also depend on how do you use this view, because with .constant binding nothing will change ever.
The answer by Thang Dang, above, turned out to be very helpful to me. I did not know how to conform my tag to Identifiable, but changed my tags from tag(1) to a string, as in the SwiftUI code below. The tag with a mere number in it caused nothing to happen when the Picker was set to Icosahedron (my breakpoint on setShape was never triggered), but the other three caused the correct shape to be passed in to setShape.
// set the current Shape
func setShape(value: String) {
print(value)
}
#State var shapeSelected = "Cube"
VStack {
Picker(selection: $shapeSelected, label: Text("$\(shapeSelected)")) {
Text("Cube").tag("Cube")
Text("Simplex").tag("Simplex")
Text("Pentagon (3D)").tag("Pentagon")
Text("Icosahedron").tag(1)
}.onChange(of: shapeSelected, perform: { tag in
setShape(value: "\(tag)")
})
}

Is there an opposite to .onAppear modifier in SwfitUI?

I created a custom horizontal scroll, which uses animation to scroll.
When the view is presented, it uses animation to present the views.
So when I invoke the view and the onAppear is initiated, it changes the offset with animation, But this behavior is unwanted. I don't want to animate my view when they are created.
I tried to create #State variable
#State private var myBool = false
and create a condition:
.animation(self.myBool ? Animation.spring : .none)
and then inside .onAppear
.onAppear(
...
some code that changing state variables
...
self.myBool = true
)
but it didn't work, it still uses the animation even when myBool is false
How can I allow animation only AFTER the body is created?
I mean, is there a modifier .onFinish?
This is an example of the code:
struct scroll: View {
#State var offset: 0
var body: some View {
GeometryReader { geo in
HStack {
ForEach(0..<5) { i in
Rectangle()
}
}
.gesture(DragGesture()
.onChange ({ (value) in
self.offset = value.translation.width
}
.onEnded ({ _ in
self.offset = 50
}
)
.offset(x: self.offset)
.animation(.spring)
.onAppear (
self.offset = geo.size.width
)
}
}
}
As I see now you just need to set initial value directly
struct scroll: View {
var body: some View {
GeometryReader { geo in
innerScroll(offset: geo.size.width) // << here !!
}
}
struct innerScroll: View { // inner subview
#State var offset: CGFloat
var body: some View {
HStack {
ForEach(0..<5) { i in
Rectangle()
}
}
.gesture(DragGesture()
.onChanged { (value) in
self.offset = value.translation.width
}
.onEnded { _ in
self.offset = 50
}
)
.offset(x: self.offset)
.animation(.spring())
}
}
}
Maybe I am misunderstanding your question, but if you don't want any animation onAppear, why don't you initialize your offset with 150?
Instead you start with 0 and set it to 150 onAppear which seems to be the reason for the animation because the variable changes onAppear.

How to create a SwiftUI button that changes opacity and triggers button action when touched?

I wanna create a button with SwiftUI that fires the moment my finger touches it (like UIKit's touch down instead of touch up inside). I also want the opacity of the button to become 0.7 when my finger is pressing the button. And I want the opacity of the button to change back to 1 ONLY when my finger is no longer touching the button.
I've tried 2 different types of button styles to create such a button but both of them failed:
struct ContentView: View {
var body: some View {
Button(action: {
print("action triggered")
}){
Text("Button").padding()
}
.buttonStyle(SomeButtonStyle())
}
}
struct SomeButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.background(Color.green)
.opacity(configuration.isPressed ? 0.7 : 1)
.onLongPressGesture(
minimumDuration: 0,
perform: configuration.trigger//Value of type 'SomeButtonStyle.Configuration' (aka 'ButtonStyleConfiguration') has no member 'trigger'
)
}
}
struct SomePrimativeButtonStyle: PrimitiveButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(Color.green)
.opacity(configuration.isPressed ? 0.7 : 1)//Value of type 'SomePrimativeButtonStyle.Configuration' (aka 'PrimitiveButtonStyleConfiguration') has no member 'isPressed'
.onLongPressGesture(
minimumDuration: 0,
perform: configuration.trigger
)
}
}
Apparently none of the button styles above worked because ButtonStyle and PrimitiveButtonStyle don't share the same methods and properties so I can't use both the isPressed property (which belongs to ButtonStyle) AND the trigger method (which belongs to PrimitiveButtonStyle) in the same button style.
How should I configure my button style to make this work?
Ok, I understand that author wants to see solution only with Button, so I dig a little more. And found something interesting at Swift UI Lab. The idea is the same as in my first answer: use #GestureState and create LongPressGesture which .updating($...) this state. But in PrimitiveButtonStyle you don't need to compose a few gestures together. So, I simplified code a little and tested it at simulator. And I think now it just what author need:
struct ComposingGestures: View {
var body: some View {
Button(action: {
print("action triggered")
}){
Text("Button")
.padding()
}
.buttonStyle(MyPrimitiveButtonStyle())
}
}
struct MyPrimitiveButtonStyle: PrimitiveButtonStyle {
func makeBody(configuration: PrimitiveButtonStyle.Configuration) -> some View {
MyButton(configuration: configuration)
}
struct MyButton: View {
#GestureState private var pressed = false
let configuration: PrimitiveButtonStyle.Configuration
let color: Color = .green
#State private var didTriggered = false
var body: some View {
// you can set minimumDuration to Double.greatestFiniteMagnitude if you think that
// user can hold button for such a long time
let longPress = LongPressGesture(minimumDuration: 300, maximumDistance: 300.0)
.updating($pressed) { value, state, _ in
state = value
self.configuration.trigger()
}
return configuration.label
.background(Color.green)
.opacity(pressed ? 0.5 : 1.0)
.gesture(longPress)
}
}
}
I didn't work with ButtonStyle, but tried to solve it with Composing SwiftUI Gestures. I compose TapGesture and LongPressGesture and playing with #GestureState to control .opacity of "button" (which is just Text). The result is just as you asked:
struct ComposingGestures: View {
enum TapAndLongPress {
case inactive
case pressing
var isPressing: Bool {
return self == .pressing
}
}
#GestureState var gestureState = TapAndLongPress.inactive
#State private var didPress = false
var body: some View {
let tapAndLongPressGesture = LongPressGesture(minimumDuration: 2) // if minimumDuration <= 1 gesture state returns to inactive in 1 second
.sequenced(before: TapGesture())
.updating($gestureState) { value, state, transaction in
switch value {
case .first(true), .second(true, nil):
self.didPress = true // simulation of firing action
state = .pressing
default:
state = .pressing
}
}
return VStack {
Text("action was fired!")
.opacity(didPress ? 1 : 0)
Text("Hello world!")
.gesture(tapAndLongPressGesture)
.background(Color.green)
.opacity(gestureState.isPressing ? 0.7 : 1)
}
}
}
P.S. I played only with #State var didPress to show, how to fire action. Maybe it's better to fire it only in the first case, like this:
// ...
.updating($gestureState) { value, state, transaction in
switch value {
case .first(true):
self.didPress = true // simulation of firing action
state = .pressing
case .second(true, nil):
state = .pressing
default:
state = .pressing
}
UPDATE
tried code at simulator and fixed two mistakes:
// ...
let tapAndLongPressGesture = LongPressGesture(minimumDuration: 300, maximumDistance: 300) // now button don't return opacity to 1 even if you move your finger
// ...
case .first(true), .second(true, nil):
DispatchQueue.main.async { // now there are no purple mistakes
self.didPress = true // simulation of firing action
}
state = .pressing
// ...