Timer crashing screens in SwiftUI - swift

I have an app that is using a timer to scroll programmatically between views on the onboarding screen. From the onboarding screen, I can go to login then signup screen (or profile then my account screen). In both ways, when opening the login screen and clicking on signup, the signup screen appears but then disappears and takes me back to login (The same happens when I try to enter My Account screen from the profile screen). Check please the gif attached.
I've searched all around the web without finding any similar issue.
Note: I am using NavigationLink to navigate between screens, and I tried also to use both Timer, and Timer.TimerPublisher leading me to the same result, the screens will crash once the timer fires.
Note: When removing the timer (or the code inside onReceive) everything works fine. I also suspect that the issue could be related to menuIndex
struct OnboardingView: View {
//MARK: - PROPERTIES
#State var menuIndex = 0
#State var openLogin = false
let timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
//MARK: - BODY
var body: some View {
NavigationLink(destination: LoginView(), isActive: $openLogin) {}
VStack (spacing: 22) {
ProfileButton(openProfile: $openLogin)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.horizontal, 22)
OnboardingMenuItem(text: onboardingMenu[menuIndex].title, image: onboardingMenu[menuIndex].image)
.frame(height: 80)
.animation(.easeInOut, value: menuIndex)
.onReceive(onboardingVM.timer) { input in
if menuIndex < onboardingMenu.count - 1 {
menuIndex = menuIndex + 1
} else {
menuIndex = 0
}
}
...
}
}
}

You can only have one detail NavigationLink, if you want more you have to set isDetailLink(false), this is why you are seeing the glitch. Alternatively if you don't require the split view behaviour you can use .navigationViewStyle(.stack). Note in iOS 16 this is replaced with NavigationStackView.
FYI a timer needs to be #State or it will start from zero every time that View is init, e.g. on a state change in the parent View causing its body to be invoked. Even better is to group the related properties into their own struct, e.g.
struct OnboardingViewConfig {
var menuIndex = 0
var openLogin = false
let timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
// mutating func exampleMethod(){}
}
struct OnboardingView: View {
#State var config = OnboardingViewConfig()
...

Related

SwiftUI - detecting Long Press while keeping TabView swipable

I'm trying to detect a Long Press gesture on TabView that's swipable.
The issue is that it disables TabView's swipable behavior at the moment.
Applying the gesture on individual VStacks didn't work either - the long press doesn't get detected if I tap on the background.
Here's a simplified version of my code - it can be copy-pasted into Swift Playground:
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var currentSlideIndex: Int = 0
#GestureState var isPaused: Bool = false
var body: some View {
let tap = LongPressGesture(minimumDuration: 0.5,
maximumDistance: 10)
.updating($isPaused) { value, state, transaction in
state = value
}
Text(isPaused ? "Paused" : "Not Paused")
TabView(selection: $currentSlideIndex) {
VStack {
Text("Slide 1")
Button(action: { print("Slide 1 Button Tapped")}, label: {
Text("Button 1")
})
}
VStack {
Text("Slide 2")
Button(action: { print("Slide 2 Button Tapped")}, label: {
Text("Button 2")
})
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(width: 400, height: 700, alignment: .bottom)
.simultaneousGesture(tap)
.onChange(of: isPaused, perform: { value in
print("isPaused: \(isPaused)")
})
}
}
PlaygroundPage.current.setLiveView(ContentView())
The overall idea is that this TabView will be rotating the slides automatically but holding a finger on any of them should pause the rotation (similar to Instagram stories). I removed that logic for simplicity.
Update: using DragGesture didn't work either.
The issue here is with the precedence of Animations in SwiftUI. Because TabView is a struct we are unable to change, its animation detection precedence cannot really be changed. The solution to this, however clunky, is to write our own custom tab view that has the expected behavior.
I apologize for how much code is here, but the behavior you described is surprisingly complex. In essence, we have a TimeLineView that is sending automatic updates to our view, telling it to change the pages, as you would see on Instagram. TimeLineView is a new feature, so if you want this to work old school, you could replace it with a Timer and its onReceive method, but I'm using this for brevity.
In the pages themselves, we are listening for this update, but only actually changing the page to the next one if there is room to do so and we are not long pressing the view. We use the .updating modifier on the LongPressGesture to know exactly when our finger is still on the screen or not. This LongPressGesture is combined in a SimultaneousGesture with a DragGesture, so that the drag can also be activated. In the drag gesture, we wait for the user's mouse/finger to traverse a certain percentage of the screen before animating the change in pages.
When sliding backwards, we initiate an async request to set the animation direction back to sliding forwards once the animation completes, so that updates received from the TimeLineView still animate in the correct direction, no matter which way we just swiped. Using custom gestures here has the added benefit that if you choose to do so, you can implement some fancy geometry effects to more closely emulate Instagram's animations. At the same time, our CustomPageView is still fully interactable, which means I can still click on button1 and see it's onTapGesture print message!
One caveat of passing in Views to a struct as a generic as I am doing in CustomTabView is that all of the views must be of the same type, which is part of the reason the pages are now reusable structs in their own right. If you have any questions about what you can / can't do with this methodology, let me know, but I've just run this in Playground same as you and it works exactly as described.
import SwiftUI
import PlaygroundSupport
// Custom Tab View to handle all the expected behaviors
struct CustomTabView<Page: View>: View {
#Binding var pageIndex: Int
var pages: [Page]
/// Primary initializer for a Custom Tab View
/// - Parameters:
/// - pageIndex: The index controlling which page we are viewing
/// - pages: The views to display on each Page
init(_ pageIndex: Binding<Int>, pages: [() -> Page]) {
self._pageIndex = pageIndex
self.pages = pages.map { $0() }
}
struct currentPage<Page: View>: View {
#Binding var pageIndex: Int
#GestureState private var isPressingDown: Bool = false
#State private var forwards: Bool = true
private let animationDuration = 0.5
var pages: [Page]
var date: Date
/// - Parameters:
/// - pageIndex: The index controlling which page we are viewing
/// - pages: The views to display on each Page
/// - date: The current date
init(_ pageIndex: Binding<Int>, pages: [Page], date: Date) {
self._pageIndex = pageIndex
self.pages = pages
self.date = date
}
var body: some View {
// Ensure that the Page fills the screen
GeometryReader { bounds in
ZStack {
// You can obviously change this to whatever you like, but it's here right now because SwiftUI will not look for gestures on a clear background, and the CustomPageView I implemented is extremely bare
Color.red
// Space the Page horizontally to keep it centered
HStack {
Spacer()
pages[pageIndex]
Spacer()
}
}
// Frame this ZStack with the GeometryReader's bounds to include the full width in gesturable bounds
.frame(width: bounds.size.width, height: bounds.size.height)
// Identify this page by its index so SwiftUI knows our views are not identical
.id("page\(pageIndex)")
// Specify the transition type
.transition(getTransition())
.gesture(
// Either of these Gestures are allowed
SimultaneousGesture(
// Case 1, we perform a Long Press
LongPressGesture(minimumDuration: 0.1, maximumDistance: .infinity)
// Sequence this Gesture before an infinitely long press that will never trigger
.sequenced(before: LongPressGesture(minimumDuration: .infinity))
// Update the isPressingDown value
.updating($isPressingDown) { value, state, _ in
switch value {
// This means the first Gesture completed
case .second(true, nil):
// Update the GestureState
state = true
// We don't need to handle any other case
default: break
}
},
// Case 2, we perform a Drag Gesture
DragGesture(minimumDistance: 10)
.onChanged { onDragChange($0, bounds.size) }
)
)
}
// If the user releases their finger, set the slide animation direction back to forwards
.onChange(of: isPressingDown) { newValue in
if !newValue { forwards = true }
}
// When we receive a signal from the TimeLineView
.onChange(of: date) { _ in
// If the animation is not pause and there are still pages left to show
if !isPressingDown && pageIndex < pages.count - 1{
// This should always say sliding forwards, because this will only be triggered automatically
print("changing pages by sliding \(forwards ? "forwards" : "backwards")")
// Animate the change in pages
withAnimation(.easeIn(duration: animationDuration)) {
pageIndex += 1
}
}
}
}
/// Called when the Drag Gesture occurs
private func onDragChange(_ drag: DragGesture.Value, _ frame: CGSize) {
// If we've dragged across at least 15% of the screen, change the Page Index
if abs(drag.translation.width) / frame.width > 0.15 {
// If we're moving forwards and there is room
if drag.translation.width < 0 && pageIndex < pages.count - 1 {
forwards = true
withAnimation(.easeInOut(duration: animationDuration)) {
pageIndex += 1
}
}
// If we're moving backwards and there is room
else if drag.translation.width > 0 && pageIndex > 0 {
forwards = false
withAnimation(.easeInOut(duration: animationDuration)) {
pageIndex -= 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
forwards = true
}
}
}
}
// Tell the view which direction to slide
private func getTransition() -> AnyTransition {
// If we are swiping left / moving forwards
if forwards {
return .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
}
// If we are swiping right / moving backwards
else {
return .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
}
}
}
var body: some View {
ZStack {
// Create a TimeLine that updates every five seconds automatically
TimelineView(.periodic(from: Date(), by: 5)) { timeLine in
// Create a current page struct, as we cant react to timeLine.date changes in this view
currentPage($pageIndex, pages: pages, date: timeLine.date)
}
}
}
}
// This is the view that becomes the Page in our Custom Tab View, you can make it whatever you want as long as it is reusable
struct CustomPageView: View {
var title: String
var buttonTitle: String
var buttonAction: () -> ()
var body: some View {
VStack {
Text("\(title)")
Button(action: { buttonAction() }, label: { Text("\(buttonTitle)") })
}
}
}
struct ContentView: View {
#State var currentSlideIndex: Int = 0
#GestureState var isPaused: Bool = false
var body: some View {
CustomTabView($currentSlideIndex, pages: [
{
CustomPageView(title: "slide 1", buttonTitle: "button 1", buttonAction: { print("slide 1 button tapped") })
},
{
CustomPageView(title: "slide 2", buttonTitle: "button 2", buttonAction: { print("slide 2 button tapped") })
}]
)
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(width: 400, height: 700, alignment: .bottom)
}
}
PlaygroundPage.current.setLiveView(ContentView())
I found the best and cleanest solution to this is just to add a clear view on top of your tabView when the slide show is active and put the gesture recognizer on that.
I haven't shown the implementation of the start stop timer which depends on your design.
private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
#State var slideshowPlaying = false
#State var selection = 0
var body: some View {
ZStack {
TabView(selection: $selection) {
ForEach(modelArray.indices, id: \.self) { index in
SomeView()
.tag(index)
}
}
.tabViewStyle(PageTabViewStyle())
.background(Color(.systemGroupedBackground))
.onReceive(self.timer) { _ in
if selection < modelArray.count + 1 {
selection += 1
} else {
selection = 0
}
}
if slideshowPlaying {
Color.clear
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onChanged { _ in
slideshowPlaying = false
})
}
}
}

How to stop animation in the current state in SwiftUI

Hello i wrote a simple code where i created an animation with a Circle which changes its position. I want the animation to stop when i tap on that Circle, but it doesn't stop, no matter that i set the animation to nil. Maybe my approach is wrong in some way. I will be happy if someone helps.
struct ContentView: View {
#State private var enabled = true
#State private var offset = 0.0
var body: some View {
Circle()
.frame(width: 100, height: 100)
.offset(y: CGFloat(self.offset))
.onAppear {
withAnimation(self.enabled ? .easeIn(duration: 5) : nil) {
self.offset = Double(-UIScreen.main.bounds.size.height + 300)
}
}
.onTapGesture {
self.enabled = false
}
}
}
As #Schottky stated above, the offset value has already been set, SwiftUI is just animating the changes.
The problem is that when you tap the circle, onAppear is not called again which means enabled is not checked at all and even if it was it wouldn’t stop the offset from animating.
To solve this, we can introduce a Timer and do some small calculations, iOS comes with a built-in Timer class that lets us run code on a regular basis. So the code would look like this:
struct ContentView: View {
#State private var offset = 0.0
// 1
let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
#State var animationDuration: Double = 5.0
var body: some View {
Circle()
.frame(width: 100, height: 100)
.offset(y: CGFloat(self.offset))
.onTapGesture {
// 2
timer.upstream.connect().cancel()
}
.onReceive(timer) { _ in
// 3
animationDuration -= 0.1
if animationDuration <= 0.0 {
timer.upstream.connect().cancel()
} else {
withAnimation(.easeIn) {
self.offset += Double(-UIScreen.main.bounds.size.height + 300)/50
}
}
}
}
}
This creates a timer publisher which asks the timer to fire every 0.1 seconds. It says the timer should run on the main thread, It should run on the common run loop, which is the one you’ll want to use most of the time. (Run loops lets iOS handle running code while the user is actively doing something, such as scrolling in a list or tapping a button.)
When you tap the circle, the timer is automatically cancelled.
We need to catch the announcements (publications) by hand using a new modifier called onReceive(). This accepts a publisher as its first parameter (in this came it's our timer) and a function to run as its second, and it will make sure that function is called whenever the publisher sends its change notification (in this case, every 0.1 seconds).
After every 0.1 seconds, we reduce the duration of our animation, when the duration is over (duration = 0.0), we stop the timer otherwise we keep decreasing the offset.
Check out triggering-events-repeatedly-using-a-timer to learn more about Timer
After going through many things, I found out something that works for me. At the least for the time being and till I have time to figure out a better way.
struct WiggleAnimation<Content: View>: View {
var content: Content
#Binding var animate: Bool
#State private var wave = true
var body: some View {
ZStack {
content
if animate {
Image(systemName: "minus.circle.fill")
.foregroundColor(Color(.systemGray))
.offset(x: -25, y: -25)
}
}
.id(animate) //THIS IS THE MAGIC
.onChange(of: animate) { newValue in
if newValue {
let baseAnimation = Animation.linear(duration: 0.15)
withAnimation(baseAnimation.repeatForever(autoreverses: true)) {
wave.toggle()
}
}
}
.rotationEffect(.degrees(animate ? (wave ? 2.5 : -2.5) : 0.0),
anchor: .center)
}
init(animate: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content) {
self.content = content()
self._animate = animate
}
}
Use
#State private var editMode = false
WiggleAnimation(animate: $editMode) {
VStack {
Image(systemName: image)
.resizable()
.frame(width: UIScreen.screenWidth * 0.1,
height: UIScreen.screenWidth * 0.1)
.padding()
.foregroundColor(.white)
.background(.gray)
Text(text)
.multilineTextAlignment(.center)
.font(KMFont.tiny)
.foregroundColor(.black)
}
}
What do you have to change?
make an offset as #Binding, provide from outside
How does it work?
.id(animate) modifier here does not refresh the view but just replaces it with a new one, and since you have offset as binding property, replaced view will have the correct offset.
Again this might not be the best solution but it works for my case.
What you need to understand is that only something that has already happened is being animated. It just takes longer to show. So in other words, when you set your offset to some value, it is going to animate towards that value, no matter what. What you, therefore, need to do is to set the offset to zero when tapping on the circle. The animation system is smart enough to deal with the 'conflicting' animations and cancel the previous one.
Also, a quick tip: You can do var offset: CGFloat = 0.0 so you don't have to cast it in the onAppear modifier

Refreshing UserDefaults Variable not working

I have a navigation view that uses User defaults, when I am on a different page I have a button that adds += 1 to the userDefualts variable. When I go back there, it doesn't refresh unless I swipe the app. That is super weird, what I want to happen is it automatically refreshes when I enter that page.
Nav View that I need refreshed:
struct Analytics: View {
#ObservedObject var viewModel = VariableViewModel()
HStack {
// Problems compleated
Text(" Amount of problems compleated:")
.font(.system(size: 20))
.foregroundColor(.black)
Spacer()
Text(String(viewModel.problemsCompleated))
.font(.system(size: 20))
.foregroundColor(.yellow)
.bold()
.offset(x: -5, y: 0)
}.offset(x: 0, y: 5)
}
Nav View were the variables sit and are being changed:
class VariableViewModel: ObservableObject {
// analytics
#Published var problemsCompleated = UserDefaults.standard.integer(forKey: "problemsCompleated")
problemsCompleated += 1
UserDefaults.standard.set(problemsCompleated, forKey: "problemsCompleated")
}
With your current code, if you're using different instances of VariableViewModel, you won't see it refresh because it only ever pulls from UserDefaults on instantiation. So, when you go 'back' to the other page, it has no way of knowing that variable has been updated.
If you were just within your view, I'd recommend using #AppStorage, since it does all the lifting of checking and updating UserDefaults for you. In the ObservableObject, it's slightly more complicated, because #AppStorage won't trigger an update like #Published. But, it looks like by manually calling objectWillChange when the #AppStorage is set, we can get the best of both worlds:
class VariableViewModel: ObservableObject {
#AppStorage("problemsCompleted") var problemsCompleted = 0 {
didSet {
self.objectWillChange.send()
}
}
func increment() {
problemsCompleted += 1
}
}

How to animate the removal of a view created with a ForEach loop getting its data from an ObservableObject in SwiftUI

The app has the following setup:
My main view creates a tag cloud using a SwiftUI ForEach loop.
The ForEach gets its data from the #Published array of an ObservableObject called TagModel. Using a Timer, every three seconds the ObservableObject adds a new tag to the array.
By adding a tag the ForEach gets triggered again and creates another TagView. Once more than three tags have been added to the array, the ObservableObject removes the first (oldest) tag from the array and the ForEach removes that particular TagView.
With the following problem:
The creation of the TagViews works perfect. It also animates the way it's supposed to with the animations and .onAppear modifiers of the TagView. However when the oldest tag is removed it does not animate its removal. The code in .onDisappear executes but the TagView is removed immediately.
I tried the following to solve the issue:
I tried to have the whole appearing and disappearing animations of TagView run inside the .onAppear by using animations that repeat and then autoreverse.
It sort of works but this way there are two issues.
First, if the animation is timed too short, once the animation finishes and the TagView is removed, will show up for a short moment without any modifiers applied, then it will be removed.
Second, if I set the animation duration longer the TagView will be removed before the animation has finished.
In order for this to work I'd need to time the removal and the duration of the animation very precisely, which would make the TagView very dependent on the Timer and this doesn't seem to be a good solution.
Another solution I tried was finding something similar to self.presentationMode.wrappedValue.dismiss() using the #Environment(\.presentationMode) variable and somehow have the TagView remove itself after the .onAppear animation has finished. But this only works if the view has been created in an navigation stack and I couldn't find any other way to have a view destroy itself. Also I assume that would again cause issue as soon as TagModel updates its array.
I read several other S.O. solution that pointed towards the enumeration of the data in the ForEach loop. But I'm creating each TagView as its own object, I'd assume this should not be the issue and I'm not sure how I'd have to implement this if this is part of the issue.
Here is the simplified code of my app that can be run in an iOS single view SwiftUI project.
import SwiftUI
struct ContentView: View {
private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
#ObservedObject var tagModel = TagModel()
var body: some View {
ZStack {
ForEach(tagModel.tags, id: \.self) { label in
TagView(label: label)
}
.onReceive(timer) { _ in
self.tagModel.addNextTag()
if tagModel.tags.count > 3 {
self.tagModel.removeOldestTag()
}
}
}
}
}
class TagModel: ObservableObject {
#Published var tags = [String]()
func addNextTag() {
tags.append(String( Date().timeIntervalSince1970 ))
}
func removeOldestTag() {
tags.remove(at: 0)
}
}
struct TagView: View {
#State private var show: Bool = false
#State private var position: CGPoint = CGPoint(x: Int.random(in: 50..<250), y: Int.random(in: 10..<25))
#State private var offsetY: CGFloat = .zero
let label: String
var body: some View {
let text = Text(label)
.opacity(show ? 1.0 : 0.0)
.scaleEffect(show ? 1.0 : 0.0)
.animation(Animation.easeInOut(duration: 6))
.position(position)
.offset(y: offsetY)
.animation(Animation.easeInOut(duration: 6))
.onAppear() {
show = true
offsetY = 100
}
.onDisappear() {
show = false
offsetY = 0
}
return text
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
It is not clear which effect do you try to achieve, but on remove you should animate not view internals, but view itself, ie. in parent, because view remove there and as-a-whole.
Something like (just direction where to experiment):
var body: some View {
ZStack {
ForEach(tagModel.tags, id: \.self) { label in
TagView(label: label)
.transition(.move(edge: .leading)) // << here !! (maybe asymmetric needed)
}
.onReceive(timer) { _ in
self.tagModel.addNextTag()
if tagModel.tags.count > 3 {
self.tagModel.removeOldestTag()
}
}
}
.animation(Animation.easeInOut(duration: 1)) // << here !! (parent animates subview removing)

Timer onReceive not working inside NavigationView

I have the following timer:
struct ContentView: View {
#State var timeRemaining = 10
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
NavigationView{
VStack {
if(self.timeRemaining > 0) {
Text("\(timeRemaining)")
.onReceive(timer) { _ in
if self.timeRemaining > 0 {
self.timeRemaining -= 1
}
}
} else {
Text("Time is up!")
}
}
}
}
}
If I remove the NavigationView view, the timer updates and works, but like that it doesn't, what's going on here and how can I update it while in the NavigationView? Or is there a better practice?
Thanks
It is better to attach observers to non-conditional views, like
var body: some View {
NavigationView{
VStack {
if(self.timeRemaining > 0) {
Text("\(timeRemaining)")
} else {
Text("Time is up!")
}
}
.onReceive(timer) { _ in // << to VStack
if self.timeRemaining > 0 {
self.timeRemaining -= 1
}
}
}
}
Update: some thoughts added (of course SwiftUI internals are known only for Apple).
.onReceive must be attached to persistently present view in NavigationView, the reason is most probably in conditional ViewBuilder and internal of NavigationView wrapper around UIKit UINavigationControler.
if you remove the condition and have a single Text view with onReceive inside the VStack inside the NavigationView, nothing is ever received
If after condition removed it is attached to Text("\(timeRemaining)") then all works (tested with Xcode 11.4), because there is state dependency in body.
If it is attached to constant Text then there is nothing changed in body, ie. dependent on changed state - timeRemaining, so SwiftUI rendering engine interprets body as static.
İ had same problem but not one-to-one. I Solved like this : my ContentView has .onApper {binancemanager.fetch()} —-> i call here but i moved it to
#main
struct MyApp:{WindowsGroup.Vstack.Content.