.repeatForever() does not work when value is updated in SwiftUI - ios

I'm trying to make an idle animation in SwiftUI that gets triggered if there's no touch in the screen for 3 seconds. I made a little animation that goes up and down (y offset 15) when there's no touch for 3 seconds and goes back to its original position when a touch occurs. But the thing is, when it goes to its original positon, autoreverses doesn't get triggered. Here's how it looks like:
Go Live button:
struct GoLiveButton: View {
#State private var animationOffset: CGFloat = 0
#Binding var isIdle: Bool
var body: some View {
ZStack {
Button(action: {} ) {
Text("Go Live")
.frame(width: 120, height: 40)
.background(Color.black)
.foregroundColor(.white)
.clipShape(Capsule())
.font(.system(size: 20))
.shadow(color: .black, radius: 4, x: 4, y: 4)
}
.offset(y: animationOffset)
.animation(.timingCurve(0.38, 0.07, 0.12, 0.93, duration: 2).repeatForever(autoreverses: true), value: isIdle)
.animation(.timingCurve(0.38, 0.07, 0.12, 0.93, duration: 2), value: !isIdle)
}
.onAppear {
self.isIdle = true
self.animationOffset = 15
}
.onChange(of: isIdle) { newValue in
if newValue {
self.animationOffset = 15
}
else {
self.animationOffset = 0
}
}
}
}
Here is the idle view:
struct StackOverflowView: View {
#State private var timer: Timer?
#State private var isIdle = false
var body: some View {
GeometryReader { geo in
GoLiveButton(isIdle: $isIdle)
}
.onTapGesture {
print("DEBUG: CustomTabView OnTapGesture Triggered")
self.isIdle = false
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
self.isIdle = true
}
}
.gesture(
DragGesture().onEnded { _ in
self.isIdle = false
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
self.isIdle = true
}
}
)
}
}

Here is an approach without AnimatableData:
I added a second timer that just triggers the animations by offset (0, -15, 0, -15 ...) every 2 seconds, repeating forever.
If isIdle changes to false, we just set offset to 0, and this will be animated too. We reset all timers. And again set the idle timer (3 secs) which when fires will start the animation timer (2 secs). voila.
(I also restructured the GoLiveButton a little bit so it holds all relevant states in itself, and the parent view only has to control isIdle)
struct GoLiveButton: View {
#Binding var isIdle: Bool
#State private var timer: Timer?
#State private var animationTimer: Timer?
#State private var animationOffset: CGFloat = 0
var body: some View {
ZStack {
Button(action: {} ) {
Text("Go Live")
.frame(width: 120, height: 40)
.background(Color.black)
.foregroundColor(.white)
.clipShape(Capsule())
.font(.system(size: 20))
.shadow(color: .black, radius: 4, x: 4, y: 4)
}
.offset(y: animationOffset)
.animation(.timingCurve(0.38, 0.07, 0.12, 0.93, duration: 2), value: animationOffset)
}
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
self.isIdle = true
animationTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
animationOffset = (animationOffset == 15) ? 0 : 15
}
}
}
.onChange(of: isIdle) { newValue in
if newValue == false {
// reset all
self.animationTimer?.invalidate()
self.timer?.invalidate()
animationOffset = 0
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
self.isIdle = true
animationTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
animationOffset = (animationOffset == 15) ? 0 : 15
}
}
}
}
}
}
struct ContentView: View {
#State private var isIdle = false
var body: some View {
ZStack {
// for background tap only
Color.gray.opacity(0.2)
.onTapGesture {
print("tap")
self.isIdle = false
}
GoLiveButton(isIdle: $isIdle)
}
}
}

Related

Pause the timer with a button

I am making a timer but am unsure of how to pause it when the button is triggered.
I have attempted to make a boolean #State but I am yet unsure how to pause the timer when the button is triggered. Please review my code below...
struct TestView: View {
#State var isTimeStarted = false
#State var to: CGFloat = 0
#State var timeDuration = 60
#State var time = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
#State var isPaused = false
#State private var count = 0
var body: some View {
ZStack {
VStack {
ZStack {
Circle()
.trim(from: 0, to: self.to)
.stroke(LinearGradient(gradient: Gradient(colors: [Color.white, Color.white.opacity(0.2)]), startPoint: .topLeading, endPoint: .bottomTrailing), style: StrokeStyle(lineWidth: 15.6, lineCap: .round))
.shadow(radius: 8)
.rotationEffect(.degrees(90))
.rotation3DEffect(Angle(degrees: 180), axis: (x: 1, y: 0, z: 110))
.frame(width: 70, height: 70)
.animation(.easeOut)
.padding()
.padding(.leading, 10)
Text("\(self.timeDuration, specifier: formatTime())")
.font(.system(size: 17.5, design: .rounded))
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.leading, 10)
}
Button {
isPaused = true
} label: {
ZStack {
BlurView2(style: .systemThinMaterialDark)
.frame(width: 145, height: 45)
.background(Color.yellow)
.cornerRadius(30)
.padding(.horizontal)
Image(systemName: "pause")
.font(.title2)
.shadow(radius: 10)
.foregroundColor(.yellow)
}
}
}
}
.preferredColorScheme(.dark)
.onAppear {
self.timeDuration = 60
withAnimation(.default) {
self.to = 60
}
self.isTimeStarted = true
}
.onReceive(self.time, perform: { _ in
if self.timeDuration != 0 {
self.timeDuration -= 1
withAnimation(.default) {
self.to = CGFloat(self.timeDuration)/60
}
} else {
self.timeDuration = 60
self.to = 60
}
})
}
func formatTime() -> String {
let minutes = Int(timeDuration) / 60 % 60
let seconds = Int(timeDuration) % 60
return String(format: "%02i:%02i", minutes,seconds)
}
}
struct BlurView2: UIViewRepresentable {
var style: UIBlurEffect.Style
func makeUIView(context: Context) -> UIVisualEffectView {
let view = UIVisualEffectView(effect: UIBlurEffect(style: style))
return view
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
}
}
Thi should be pretty simple. Keep your timer firing but return in your function if it should pause.
Add this to the start of your .onReceive function:
if isPaused{
return
}
and change your Button to:
isPaused.toggle()

SwiftUI: Longpress Gesture Hold for only 1 Second

Using the Long press gestures on SwiftUI only keep the long press hold gesture for 1 second then automatically releases the long press. I would like for the user to press up to 1 minute or more. Is this possible and how can it be done.
Check out my code below, which currently only supports a 1-second duration long-press gesture.
struct IgnitionDriveView: View {
#GestureState private var drivingGestureState = false
#GestureState private var reverseGestureState = false
#State private var showDriveAlert = true
#State private var showOutOfGasAlert = false
#State var distanceCovered: Float = 1.0
var body: some View {
let circleShape = Circle()
let driveGesture = LongPressGesture(minimumDuration: 1)
.updating($drivingGestureState) { (currentState, gestureState, transaction) in
gestureState = currentState
}.onChanged { _ in
if distanceCovered < 1000 {
self.distanceCovered += 10
} else {
showOutOfGasAlert = true
}
}
let reverseGesture = LongPressGesture(minimumDuration: 1)
.updating($reverseGestureState) { (currentState, gestureState, transaction) in
gestureState = currentState
}.onChanged { _ in
if distanceCovered > 0 {
self.distanceCovered -= 10
}
}
VStack(alignment: .leading) {
Text("Distance Covered in Km: \(distanceCovered)")
.font(.headline)
ProgressView(value: distanceCovered > 0 ? distanceCovered : 0, total: 1000)
.frame(height: 40)
HStack {
ZStack {
circleShape.strokeBorder(style: StrokeStyle(lineWidth: 2))
circleShape
.fill(drivingGestureState ? .white : .red)
.frame(width: 100, height: 100, alignment: .center)
Text("D")
.bold()
.padding()
.foregroundColor(.green)
.font(.title)
}.foregroundColor(.green)
.gesture(driveGesture)
Spacer()
ZStack {
circleShape.strokeBorder(style: StrokeStyle(lineWidth: 2))
circleShape
.fill(reverseGestureState ? .white : .red)
.frame(width: 100, height: 100, alignment: .center)
Text("R")
.bold()
.padding()
.foregroundColor(.red)
.font(.title)
}.foregroundColor(.green)
.gesture(reverseGesture)
}.padding()
}.alert("Press D to Drive and R to Reverse", isPresented: $showDriveAlert) {
Button("Okay") { showDriveAlert = false }
}.alert("You ran out of Gas, Reverse to Gas Station", isPresented: $showOutOfGasAlert) {
Button("Sucks, but fine!") { showOutOfGasAlert = false }
}
.padding()
}
}
here is a very basic approach that you can build on, based on the code in:
https://adampaxton.com/make-a-press-and-hold-fast-forward-button-in-swiftui/
struct IgnitionDriveView: View {
#State private var timer: Timer?
#State var isLongPressD = false
#State var isLongPressR = false
#State private var showDriveAlert = true
#State private var showOutOfGasAlert = false
#State var distanceCovered: Float = 0.0
private func circleShape(isPressed: Binding<Bool>) -> some View {
Button(action: {
if isPressed.wrappedValue {
isPressed.wrappedValue.toggle()
timer?.invalidate()
}
}) {
ZStack {
Circle().strokeBorder(style: StrokeStyle(lineWidth: 2))
Circle().fill(isPressed.wrappedValue ? .white : .red)
}.frame(width: 100, height: 100, alignment: .center)
}
}
var body: some View {
VStack(alignment: .leading) {
Text("Distance Covered in Km: \(distanceCovered)").font(.headline)
ProgressView(value: distanceCovered > 0 ? distanceCovered : 0, total: 1000).frame(height: 40)
HStack {
ZStack {
circleShape(isPressed: $isLongPressD)
.simultaneousGesture(LongPressGesture(minimumDuration: 0.2).onEnded { _ in
isLongPressD = true
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { _ in
if distanceCovered < 1000 {
distanceCovered += 10
} else {
showOutOfGasAlert = true
}
})
})
Text("D").bold().padding().foregroundColor(.green).font(.title)
}.foregroundColor(.green)
Spacer()
ZStack {
circleShape(isPressed: $isLongPressR)
.simultaneousGesture(LongPressGesture(minimumDuration: 0.2).onEnded { _ in
isLongPressR = true
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { _ in
if distanceCovered > 0 {
distanceCovered -= 10
}
})
})
Text("R").bold().padding().foregroundColor(.blue).font(.title)
}.foregroundColor(.green)
}.padding()
}.alert("Press D to Drive and R to Reverse", isPresented: $showDriveAlert) {
Button("Okay") { showDriveAlert = false }
}.alert("You ran out of Gas, Reverse to Gas Station", isPresented: $showOutOfGasAlert) {
Button("Sucks, but fine!") { showOutOfGasAlert = false }
}
.padding()
}
}
The LongPressGesture is updating after the minimum time no matter if the user lifts its finger or not. Take a look here on how to register to the onEnded even which I guess is what you want to wait for. i.e when the user takes his/hers finger off screen - https://developer.apple.com/documentation/swiftui/longpressgesture

SwiftUI progress circle with interval timer

I'm trying to create a component where I have multiple time intervals that control a circle progress indicator. When an interval completes, I want to immediately start the next interval and kick off the next progress animation.
So far, the only way I can do it is to have a 1 second delay from one interval ending to the next one starting.
Is anyone able to point me in the direction of how I might be able to achieve this? I'm pretty new to Swift and SwiftUI so maybe I have taken completely the wrong approach here and there is a better way to do this?
struct CircleProgress: View {
let timer = Timer.publish(every: 1.0, on: .current, in: .common)
let timeDurations = [1.0, 2.0, 3.0]
#State var timeDuration = 0.0
#State var durationIndex = 0
#State var progress = 0.0
#State var isFinished = false
var body: some View {
VStack {
ZStack {
Circle()
.stroke(lineWidth: 32)
.opacity(0.25)
.rotationEffect(.degrees(90))
.padding()
VStack {
Text("Progress: \(progress)")
if self.isFinished {
Text("Finished")
}
}
Circle()
.trim(from: 0.0, to: CGFloat(min(progress, 1.0)))
.stroke(style: StrokeStyle(lineWidth: 32, lineCap: .round, lineJoin: .round))
.foregroundColor(getStrokeColor())
.rotationEffect(.degrees(-90))
.padding()
.onReceive(timer) { _ in
if progress < 1 {
withAnimation(Animation.linear(duration: 1.0)) {
incrementProgress()
}
} else {
progress = 0
incrementDurationIndex()
}
}
}.padding()
Button(action: { startTimer() }) { Text("Start") }
Spacer()
}
}
func startTimer() {
timeDuration = timeDurations[durationIndex]
var _ = timer.connect()
}
func incrementDurationIndex() {
durationIndex += 1
if durationIndex < timeDurations.count {
timeDuration = timeDurations[durationIndex]
} else {
setFinished()
cancelTimer()
}
}
func stopTimer() {
timer.connect().cancel()
}
func setFinished() {
isFinished = true
}
func incrementProgress() {
progress += 1 / timeDuration
}
func cancelTimer() {
timer.connect().cancel()
}
private func getStrokeColor() -> Color {
...
}
}

SwiftUI Circle line width change stops trim animation

I want to animate the trim attribute of a circle. However, if the animation is running and I change the line width, the animation finishes immediately (and unexpectedly).
Does somebody have an idea what I am doing wrong? I'm sure it's a very simple thing.
Thanks for answering!
import SwiftUI
struct ContentView: View {
#State var progress: Double = 0.01
#State var lineWidth: CGFloat = 20
var body: some View {
VStack{
CircleView(progress: progress, lineWidth: lineWidth)
.foregroundColor(.orange)
.onAppear{
withAnimation(.linear(duration: 20)){
progress = 1
}
}
.padding()
Button(action: {
withAnimation{
lineWidth = 40
}
}, label: {Text("Change Line Width")})
}
}
}
struct CircleView: View {
var progress: Double
var lineWidth: CGFloat
var body: some View {
Circle()
.trim(from: 0, to: CGFloat(progress))
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
.padding()
}
}
I'm not sure you can directly do what you hope to do. The stroking of the line uses the lineWidth, so I don't believe you can animate it with a separate time interval.
What you can do is change the lineWidth over time so that while the circle animation is running and redrawing the circle, it will use the new values.
With that in mind, I created the function changeValueOverTime(value:newValue:duration) to do this:
struct ContentView: View {
#State var progress: Double = 0.01
#State var lineWidth: CGFloat = 20
var body: some View {
VStack{
CircleView(progress: progress, lineWidth: $lineWidth)
.foregroundColor(.orange)
.onAppear{
withAnimation(.linear(duration: 20)){
progress = 1
}
}
.padding()
Button(action: {
changeValueOverTime(value: $lineWidth, newValue: 40, duration: 0.5)
}, label: {Text("Change Line Width")})
}
}
func changeValueOverTime(value: Binding<CGFloat>, newValue: CGFloat, duration: Double) {
let timeIncrements = 0.02
let steps = Int(duration / timeIncrements)
var count = 0
let increment = (newValue - value.wrappedValue) / CGFloat(steps)
Timer.scheduledTimer(withTimeInterval: timeIncrements, repeats: true) { timer in
value.wrappedValue += increment
count += 1
if count == steps {
timer.invalidate()
}
}
}
}
Just remove second animation and all will work. Tested with Xcode 12 / iOS 14
Button(action: {
lineWidth = 40 // << no animation here !!
}, label: {Text("Change Line Width")})

#Binding value change not being recognised in parent view

I have a connected a #Binding variable called showCodeMessage between my parent view LiveView and its child view NewTimer.
struct LiveView: View {
#ObservedObject var countdown: CountDown
#State var showCodeMessage: Bool = false {
didSet(val){
print("changed: \(val)") // doesn't print
}
}
var body: some View {
ZStack {
NewTimer(countdown: self.countdown, showCodeMessage: $showCodeMessage)
if self.showCodeMessage { // doesn't show
MessageWithButton()
.frame(minWidth: 0, maxWidth: .infinity, alignment: .bottom)
.padding(.top, 10)
.padding(20)
.opacity(self.messageOpacity2)
}
}
}
}
struct NewTimer: View {
#ObservedObject var countdown: CountDown
#Binding var showCodeMessage: Bool
#State var codeSent = false
#State var inBackground = false
var body: some View {
VStack{
Text("Timer")
.onAppear{
self.countdown.secondsLeft = 600
self.countdown.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true){ _ in
self.countdown.secondsLeft -= 1
// At the 9:00 minute mark, show code message
if self.countdown.secondsLeft <= 595 && !showCodeMessage && self.inBackground == false {
print("Show code message: \(self.countdown.secondsLeft)")
self.showCodeMessage = true
}
}
}
}
}
}
However, when I perform self.showCodeMessage = true - the change doesn't register in my parent view. It doesn't even detect that a change has been made.
Any idea why?
EDIT: Added other classes/structs below
class CountDown: ObservableObject {
init(secondsLeft: Int){
self.secondsLeft = secondsLeft
}
#Published var secondsLeft: Int
var timer: Timer?
func start(){
// Reset epoch
UserDefaults.standard.set(0, forKey: "epoch")
}
func stop(){
self.timer?.invalidate()
}
func performAtEnd(action: #escaping () -> Void){
self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true){ _ in
self.secondsLeft -= 1
print("performAtEnd() \(self.secondsLeft)")
if (self.secondsLeft == 0){
print("action() \(self.secondsLeft)")
action()
self.timer?.invalidate()
}
}
}
}
struct MessageWithButton: View {
let text: String
let color: Color
let hasButton: Bool = false
#Binding var showCode: Bool
var body: some View {
HStack{
Text("\(self.text)")
.frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
.font(.custom("AvenirNext-Bold", size: 16))
.foregroundColor(.white)
.padding(11)
MessageButton(showCode: $showCode).padding(5)
}
.background(
RoundedRectangle(cornerRadius: 5)
.foregroundColor(self.color)
)
}
}
struct MessageButton : View {
#Binding var showCode: Bool
// #AppStorage("codeResult") var codeResult = UserDefaults.standard.string(forKey: "codeResult") ?? ""
#AppStorage("codeResult") var codeResult = UserDefaults.standard.string(forKey: "codeResult") ?? ""
var body: some View {
Button(action: {self.clickedCode()}){
Text("\(buttonText())")
.font(.custom("AvenirNext-Bold", size: 18))
.fontWeight(.bold)
.foregroundColor(Color.white)
.padding(10)
}
.background(Color("LightBlack"))
.cornerRadius(5)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(Color.black, lineWidth: 1)
)
.padding(.trailing, 5)
}
func buttonText() -> String {
if self.showCode { return "HIDE" }
return "ENTER"
}
func clickedCode(){
// Remove 'incorrect' from codeResult so Code input can show again
self.codeResult = ""
print("clickedcode()")
self.showCode.toggle()
}
}

Resources