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 {
...
}
}
Related
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)
}
}
}
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()
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
I created a circularprogress view to be able to show a progress bar according to the steps data. But for some reason I can not reach to the step.count inside my stepView file.
This is my StepView
struct StepView: View {
private var healthStore: HealthStore?
#State private var presentClipboardView = true
#State private var steps: [Step] = [Step]()
init() {
healthStore = HealthStore()
}
private func updateUIFromStatistics(_ statisticsCollection: HKStatisticsCollection) {
let now = Date()
let startOfDay = Calendar.current.startOfDay(for: now)
statisticsCollection.enumerateStatistics(from: startOfDay, to: now) { (statistics, stop) in
let count = statistics.sumQuantity()?.doubleValue(for: .count())
let step = Step(count: Int(count ?? 0), date: statistics.startDate, wc: Double(count ?? 0 / 1000 ))
steps.append(step)
}
}
var body: some View {
VStack {
ForEach(steps, id: \.id) { step in
VStack {
HStack{
Text("WC")
Text("\(step.wc)")
}
HStack {
Text("\(step.count ?? 0)")
Text("Total Steps")
}
Text(step.date, style: .date)
.opacity(0.5)
CircularProgress(steps: step.count) //ERROR
Spacer()
}
}
.navigationBarBackButtonHidden(true)
}
.onAppear() {
if let healthStore = healthStore {
healthStore.requestAuthorization { (success) in
if success {
healthStore.calculateSteps { (statisticsCollection) in
if let statisticsCollection = statisticsCollection {
updateUIFromStatistics(statisticsCollection)
}
}
}
}
}
}
.onDisappear() {
self.presentClipboardView.toggle()
}
}
}
and this is my circularprogress view
struct CircularProgress: View {
var steps: Binding<Int>
var body: some View {
ZStack {
Color.progressBarColor
.edgesIgnoringSafeArea(.all)
VStack {
ZStack {
Label()
Outline(steps: steps)
}
}
}
}
}
struct Label: View {
var percentage: CGFloat = 20
var body : some View {
ZStack {
Text(String(format: "%.0f", percentage))
.font(Font.custom("SFCompactDisplay-Bold", size: 56))
}
}
}
struct Outline: View {
var steps: Binding<Int>
var percentage: CGFloat = 20
var colors : [Color] = [Color.trackProgressBarColor]
var body: some View {
ZStack {
Circle()
.fill(Color.clear)
.frame(width: 250, height: 250)
.overlay(
Circle()
.trim(from: 0, to: percentage * 0.01)
.stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round, lineJoin: .round))
.fill(AngularGradient(gradient: .init(colors: colors), center: .center, startAngle: .zero, endAngle: .init(degrees: 360)))
).animation(.spring(response: 2.0, dampingFraction: 1.0, blendDuration: 1.0))
}
}
}
I am getting this error at stepview WHILE CALLING CIRCULARPROGRESS inside the stepview. I guess I am trying to get the data in the wrong way.
I don't see necessity of binding here, so just replace corresponding places with simple Int:
struct CircularProgress: View {
var steps: Int
and
struct Outline: View {
var steps: Int
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")})