I'd like to be able to change the state of a variable once an animation is complete and then redraw using the new value.
For example, in the code below, I'd like the Text to rotate a different amount each time the animation cycle completes.
struct RotateView: View {
#State var angle: CGFloat = 0
var body: some View {
Text("Rotate")
.rotationEffect(Angle.degrees(angle))
.onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever()) {
self.angle = CGFloat.random(in: -360 ... 360)
}
}
}
}
struct RotateView_Previews: PreviewProvider {
static var previews: some View {
RotateView()
}
}
I understand that within the withAnimation body closure that I'm only setting the state to animate between and this is only executed once - thus, a random amount is calculated and the text rotates that amount forever.
I've looked at this excellent post that adds an onAnimationCompleted but it seems like overkill for my problem. In addition, while it sort of worked, the callback was called many times after each animation completed and randomly set the variable many times - causing the Text to rotate very quickly until the animation kicked off again?
if you can stick to the defined duration of 2 secs, you can combine the .onAppear animation with additional animations kicked off by a timer that fires every two seconds.
let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
#State var angle: CGFloat = 0
var body: some View {
Text("Rotate")
.rotationEffect(Angle.degrees(angle))
.onAppear {
withAnimation(.easeInOut(duration: 2)) {
self.angle = CGFloat.random(in: -360 ... 360)
}
}
.onReceive(timer) { time in
withAnimation(.easeInOut(duration: 2)) {
self.angle = CGFloat.random(in: -360 ... 360)
}
}
}
Related
I have some buttons inside a stack with an animated offset. For some reason, with the animated offset buttons, they are not clickable. The buttons seem to be clickable for a second when offset is about 250 or so and then become non-clickable at offsets below that value again...Any help is much appreciated!
struct ContentView: View {
#State var offset: CGFloat = -300
var body: some View {
HStack {
Button(action: {
print("clickable")
}, label: {
Text("Click me")
})
Button(action: {
print("clickable2")
}, label: {
Text("Click me2")
})
Button(action: {
print("clickable3")
}, label: {
Text("Click me3")
})
}.offset(x: offset)
.onAppear(perform: {
withAnimation(.linear(duration: 10).repeatForever()) {
offset = 300
}
})
}
}
How Offsetting works?
First of all, this is an expected behavior. Because when you use offset, SwiftUI shifts the displayed contents. To be brief, that means, SwiftUI shifts the View itself
Since onTapGesture only recognizes the touches on the view that also explains why you can click to an offsetted View
How Animation Works?
In your code, you're offsetting your View First, then you're applying your animation. When you use withAnimation, SwiftUI recomputes the view's body with provided animation, but keep in mind that it does not change anything that is applied to the View beforehand.
Notice how Click Me becomes clickable when entering the red rectangle. That happens because the red rectangle indicates the final offset amount of the Click Me button. (so it is just a placeholder)
So the View itself, and the offset has to match because as you offset your View first, SwiftUI needs your view there to trigger the tap gestures.
Possible solution
Now that we understand the problem, we can solve it. So, the problem happens because we are offsetting our view first, then applying animation.
So if that does not help, one possible solution could be to change the offset in periods (for example, I used 0.1 seconds per period) with an animation, because that would result in SwiftUI repositioning the view every time we change the offset, so our weird bug should not occur.
Code:
struct ContentView: View {
#State private var increment : CGFloat = 1
#State private var offset : CGFloat = 0
var body: some View {
ZStack {
Button("Click Me") {
print("Click")
}
.fontWeight(.black)
}
.tappableOffsetAnimation(offset: $offset, animation: .linear, duration: 5, finalOffsetAmount: 300)
}
}
struct TappableAnimationModifier : ViewModifier {
#Binding var offset : CGFloat
var duration : Double
var finalOffsetAmount : Double
var animation : Animation
var timerPublishInSeconds : TimeInterval = 0.1
let timer : Publishers.Autoconnect<Timer.TimerPublisher>
var autoreverses : Bool = false
#State private var decreasing = false
public init(offset: Binding<CGFloat>, duration: Double, finalOffsetAmount: Double, animation: Animation, autoreverses: Bool) {
self._offset = offset
self.duration = duration
self.finalOffsetAmount = finalOffsetAmount
self.animation = animation
self.autoreverses = autoreverses
self.timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
}
public init(offset: Binding<CGFloat>, duration: Double, finalOffsetAmount: Double, animation: Animation, timerPublishInSeconds: TimeInterval) {
self._offset = offset
self.duration = duration
self.finalOffsetAmount = finalOffsetAmount
self.animation = animation
self.timerPublishInSeconds = timerPublishInSeconds
self.timer = Timer.publish(every: timerPublishInSeconds, on: .main, in: .common).autoconnect()
}
public init(offset: Binding<CGFloat>, duration: Double, finalOffsetAmount: Double, animation: Animation) {
self._offset = offset
self.duration = duration
self.finalOffsetAmount = finalOffsetAmount
self.animation = animation
self.timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
}
func body(content: Content) -> some View {
content
.animation(animation, value: offset)
.offset(x: offset)
.onReceive(timer) { input in
/*
* a simple math here, we're dividing duration by 0.1 because our timer gets triggered
* in every 0.1 seconds, so dividing this result will always produce the
* proper value to finish offset animation in `x` seconds
* example: 300 / (5 / 0.1) = 300 / 50 = 6 increment per 0.1 second
*/
if (offset >= finalOffsetAmount) {
// you could implement autoReverses by not canceling the timer here
// and substracting finalOffsetAmount / (duration / 0.1) until it reaches zero
// then you can again start incrementing it.
if autoreverses {
self.decreasing = true
}
else {
timer.upstream.connect().cancel()
return
}
}
if offset <= 0 {
self.decreasing = false
}
if decreasing {
offset -= finalOffsetAmount / (duration / timerPublishInSeconds)
}
else {
offset += finalOffsetAmount / (duration / timerPublishInSeconds)
}
}
}
}
extension View {
func tappableOffsetAnimation(offset: Binding<CGFloat>, animation: Animation, duration: Double, finalOffsetAmount: Double) -> some View {
modifier(TappableAnimationModifier(offset: offset, duration: duration, finalOffsetAmount: finalOffsetAmount, animation: animation))
}
}
edit: I added a customizable timestamp as well as auto reverses.
Here's how it looks like:
Your view is running, go catch it out x)
Here I have this question when I try to give a View an initial position, then user can use drag gesture to change the location of the View to anywhere. Although I already solved the issue by only using .position(x:y:) on a View, at the beginning I was thinking using .position(x:y:) to give initial position and .offset(offset:) to make the View move with gesture, simultaneously. Now, I really just want to know in more detail, what exactly happens when I use both of them the same time (the code below), so I can explain what happens in the View below.
What I cannot explain in the View below is that: when I simply drag gesture on the VStack box, it works as expected and the VStack moves with finger gesture, however, once the gesture ends and try to start a new drag gesture on the VStack, the VStack box goes back to the original position suddenly (like jumping to the original position when the code is loaded), then start moving with the gesture. Note that the gesture is moving as regular gesture, but the VStack already jumped to a different position so it starts moving from a different position. And this causes that the finger tip is no long on top of the VStack box, but off for some distance, although the VStack moves with the same trajectory as drag gesture does.
My question is: why the .position(x:y:) modifier seems only take effect at the very beginning of each new drag gesture detected, but during the drag gesture action on it seems .offset(offset:) dominates the main movement and the VStack stops at where it was dragged to. But once new drag gesture is on, the VStack jumps suddenly to the original position. I just could not wrap my head around how this behavior happens through timeline. Can somebody provide some insights?
Note that I already solved the issue to achieve what I need, right now it's just to understand what is exactly going on when .position(x:y:) and .offset(offset:) are used the same time, so please avoid some advice like. not use them simultaneously, thank you. The code bellow suppose to be runnable after copy and paste, if not pardon me for making mistake as I delete few lines to make it cleaner to reproduce the issue.
import SwiftUI
struct ContentView: View {
var body: some View {
ButtonsViewOffset()
}
}
struct ButtonsViewOffset: View {
let location: CGPoint = CGPoint(x: 50, y: 50)
#State private var offset = CGSize.zero
#State private var color = Color.purple
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
self.offset = value.translation
print("offset onChange: \(offset)")
}
.onEnded{ _ in
if self.color == Color.purple{
self.color = Color.blue
}
else{
self.color = Color.purple
}
}
}
var body: some View {
VStack {
Text("Watch 3-1")
Text("x: \(self.location.x), y: \(self.location.y)")
}
.background(Color.gray)
.foregroundColor(self.color)
.offset(self.offset)
.position(x: self.location.x, y: self.location.y)
.gesture(dragGesture)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
}
}
}
Your issue has nothing to do with the use of position and offset. They actually both work simultaneously. Position sets the absolute position of the view, where as offset moves it relative to the absolute position. Therefore, you will notice that your view starts at position (50, 50) on the screen, and then you can drag it all around. Once you let go, it stops wherever it was. So far, so good. You then want to move it around again, and it pops back to the original position. The reason it does that is the way you set up location as a let constant. It needs to be state.
The problem stems from the fact that you are adding, without realizing it, the values of offset to position. When you finish your drag, offset retains the last values. However, when you start your next drag, those values start at (0,0) again, therefore the offset is reset to (0,0) and the view moves back to the original position. The key is that you need to use just the position or update the the offset in .onEnded. Don't use both. Here you have a set position, and are not saving the offset. How you handle it depends upon the purpose for which you are moving the view.
First, just use .position():
struct OffsetAndPositionView: View {
#State private var position = CGPoint(x: 50, y: 50)
#State private var color = Color.purple
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
position = value.location
print("position onChange: \(position)")
}
.onEnded{ value in
if color == Color.purple{
color = Color.blue
}
else{
color = Color.purple
}
}
}
var body: some View {
Rectangle()
.fill(color)
.frame(width: 30, height: 30)
.position(position)
.gesture(dragGesture)
}
}
Second, just use .offset():
struct ButtonsViewOffset: View {
#State private var savedOffset = CGSize.zero
#State private var dragValue = CGSize.zero
#State private var color = Color.purple
var offset: CGSize {
savedOffset + dragValue
}
var dragGesture: some Gesture {
DragGesture()
.onChanged{ value in
dragValue = value.translation
print("dragValue onChange: \(dragValue)")
}
.onEnded{ value in
savedOffset = savedOffset + value.translation
dragValue = CGSize.zero
if color == Color.purple{
color = Color.blue
}
else{
color = Color.purple
}
}
}
var body: some View {
Rectangle()
.fill(color)
.frame(width: 30, height: 30)
.offset(offset)
.gesture(dragGesture)
}
}
// Convenience operator overload
func + (lhs: CGSize, rhs: CGSize) -> CGSize {
return CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}
I have a slider for my music app that updates its knob position every second using a Timer publisher. But I don't want it to pause animating after each second instead animate smoothly and continuously like the slider/scrubber from WhatsApp Media View or Apple Music. Here is the code that I tried:
This is what WhatsApp does
This is what my app does
//My View Model
class AudioPlaybackManager: NSObject, ObservableObject {
var timer = Timer.publish(every: 1.0, tolerance: 0.5, on: .main, in: .common).autoconnect()
#Published var sliderValue = 0.0
}
//My View
struct Scrubber: View {
#EnvironmentObject var audioPlaybackManager: AudioPlaybackManager
var body: some View {
Slider(value: $audioPlaybackManager.sliderValue, in: 0...audioPlaybackManager.duration) { _ in
audioPlaybackManager.audioPlayer?.currentTime = audioPlaybackManager.sliderValue
}
.onReceive(audioPlaybackManager.timer) { _ in
//Right here, I want the slider to move/animate continuously instead of animate for each second and pause for a while.
withAnimation(.easeInOut) {
if audioPlaybackManager.isPlaying {
audioPlaybackManager.sliderValue = audioPlaybackManager.audioPlayer?.currentTime ?? 0.0
}
}
}
}
}
I think you're seeing the behavior you are because you're using the .easeInOut Animation, which does not apply the animation at a constant speed, but instead starts slowly, speeds up and ends slowly - easing in and out. I think if you change to use .linear instead, it will fix your behavior because the animation will be applied evenly over time instead.
I'm trying to mimic ScrollView behaviour in my own custom component.
The thing I'm having trouble with is scroll animation. As far I understand I can use predictedEndTranslation object to get the predicted position of the drag gesture. But I don't know which easing function I should use to mimic default ScrollView easing.
Here is my code
struct ViewContent: View {
#State var offsetY: CGFloat = 0
var body: some View {
View {
}
.offset(y: offsetY)
.gesture(
DragGesture()
.onChange { value in
offsetY = value.translation.height
}
.onEnded { value in
let nextScrollPosition = value.predictedEndTranslation.height
withAnimation(???) { // What easing to use?
offsetY = nextScrollPosition
}
}
)
}
}
I would use .spring() as this preset animation will get you the closes to the generic scrollview animation.
withAnimation(.spring()) { // User .spring()
offsetY = nextScrollPosition
}
I'd like to run the code in the longPressGesture every 0.5 seconds while the button is being held down. Any ideas on how to implement this?
import SwiftUI
struct ViewName: View {
var body: some View {
VStack {
Button(action: { } ) {
Image(systemName: "chevron.left")
.onTapGesture {
//Run code for tap gesture here
}
.onLongPressGesture (minimumDuration: 0.5) {
//Run this code every 0.5 seconds
}
}
}
}
You can do this by using timer. Make the timer starts when the user long pressed the image, and if the timer reaches 0, you can add two actions: 1. resetting the timer back to 0.5 seconds and 2.code you want to run every 0.5 seconds
struct ContentView: View {
#State var timeRemaining = 0.5
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
#State var userIsPressing = false //detecting whether user is long pressing the screen
var body: some View {
VStack {
Image(systemName: "chevron.left").onReceive(self.timer) { _ in
if self.userIsPressing == true {
if self.timeRemaining > 0 {
self.timeRemaining -= 0.5
}
//resetting the timer every 0.5 secdonds and executing code whenever //timer reaches 0
if self.timeRemaining == 0 {
print("execute this code")
self.timeRemaining = 0.5
}
}
}.gesture(LongPressGesture(minimumDuration: 0.5)
.onChanged() { _ in
//when longpressGesture started
self.userIsPressing = true
}
.onEnded() { _ in
//when longpressGesture ended
self.userIsPressing = false
}
)
}
}
}
Oh boy, I'm not really an expert but I've had a similar problem (detecting pressing and releasing) recently and the solution I've found is less than elegant. I'd love if someone show a more elegant solution but here's my monstrosity:
import SwiftUI
import Combine
struct ContentView: View {
#State private var ticker = Ticker()
#State private var isPressed: Bool = false
#State private var timePassed: TimeInterval?
var body: some View {
Button(action: {
// Action when tapped
NSLog("Tapped!")
}) {
Text(self.isPressed ? "Pressed for: \(String(format: "%0.1f", timePassed ?? 0))" : "Press and hold")
.padding()
.background(Capsule().fill(Color.yellow))
}
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: { (value) in
self.isPressed = value
if value == true {
self.timePassed = 0
self.ticker.start(interval: 0.5)
}
}, perform: {})
.onReceive(ticker.objectWillChange) { (_) in
// Stop timer and reset the start date if the button in not pressed
guard self.isPressed else {
self.ticker.stop()
return
}
// Your code here:
self.timePassed = self.ticker.timeIntervalSinceStarted
}
}
}
/// Helper "ticker" that will publish regular "objectWillChange" messages
class Ticker: ObservableObject {
var startedAt: Date = Date()
var timeIntervalSinceStarted: TimeInterval {
return Date().timeIntervalSince(startedAt)
}
private var timer: Timer?
func start(interval: TimeInterval) {
stop()
startedAt = Date()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
self.objectWillChange.send()
}
}
func stop() {
timer?.invalidate()
}
deinit {
timer?.invalidate()
}
}
This requires an explanation:
onTapGesture() is not necessary here because that's what Button does by default, so just putting the code you need to run in the action block should be sufficient;
There is a limited number of gestures available in SwiftUI and, as far as I know, the only way to make new gestures is to combine existing ones;
There is no gesture that would continuously execute some code as long as the button is pressed, but LongPressGesture might be the closest to it. However, this gesture is recognized (and ends) when the allotted time expires but you want to detect the touch as long as it lasts, hence the minimumDuration: .infinity parameter;
LongPressGesture would also end when the touch has moved away a long enough distance, however, that's not how the Button works – you can wander away and return back and, as long as you've lifted the touch on top of the button view, the gesture will be recognized as a button press. We should replicate this behavior in our long press as well, hence maximumDistance: .infinity;
With these parameters, the LongPressGesture will never be recognized, but there is a press parameter that now allows us to be notified when presses start and end;
Some sort of a timer could be used to execute a code block every so often; I've copied this "ticker" ObservableObject from somewhere. It has to be an ObservableObject because, that way, we can subscribe to it's updates within the View;
Now, when the button in pressed, we start the ticker;
When ticker ticks, we capture that with the onReceive() subscriber and that allows us to do something on every tick.
Something like that; again, I'd love someone to show me a better way :)
Good luck with your project!
–Baglan
I simply cleaned up #Baglan 's "monstrosity" a bit this morning.
import Foundation
import SwiftUI
struct LongPressButton: View {
#ObservedObject var timer = PressTimer()
enum PressState {
case inactive
case pressing
case finished
}
#State private var pressState = PressState.inactive
var duration: Double = 2.0
var body: some View {
button
.onLongPressGesture(minimumDuration: duration, maximumDistance: 50, pressing: { (value) in
if value == true {
/// Press has started
self.pressState = .pressing
print("start")
self.timer.start(duration)
} else {
/// Press has cancelled
self.pressState = .inactive
print("stop")
self.timer.stop()
}
}, perform: {
/// Press has completed successfully
self.pressState = .finished
print("done")
})
}
var button: some View {
pressState == .pressing ? Text("Pressing - \(String(format: "%.0f", timer.percent))%")
: Text("Start")
}
}
class PressTimer: ObservableObject {
#Published var percent: CGFloat = 0
private var count: CGFloat = 0
private let frameRateHz: CGFloat = 60
private var durationSeconds: CGFloat = 2
var timer: Timer?
func start(_ duration: Double = 2.0) {
self.durationSeconds = CGFloat(duration)
let timerInterval: CGFloat = 1 / frameRateHz
timer = Timer.scheduledTimer(withTimeInterval: Double(timerInterval), repeats: true, block: { _ in
self.count += timerInterval
self.percent = self.count / self.durationSeconds * 100
})
}
func stop() {
self.count = 0
self.percent = 0
self.timer?.invalidate()
self.timer = nil
}
}