How to get a view to update automatically after period of inactivity? - ios

I am trying to add a timeout feature to my SwiftUI app. The view should be updated when timeout is reached. I have found code on a different thread, which works for the timeout part, but I cannot get the view to update.
I am using a static property in the UIApplication extension to toggle the timeout flag. Looks like the view is not notified when this static property changes. What is the correct way to do this?
Clarification added:
#workingdog has proposed an answer below. This does not quite work, because in the actual app, there is not just one view, but multiple views that the user can navigate between. So, I am looking for a global timer that gets reset by any touch action whatever the current view is.
In the sample code, the global timer works, but the view does not take notice when the static var UIApplication.timeout is changed to true.
How can I get the view to update? Is there something more appropriate for this purpose than a static var? Or maybe the timer should not be in the UIApplication extension to begin with?
Here is my code:
import SwiftUI
#main
struct TimeoutApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear(perform: UIApplication.shared.addTapGestureRecognizer)
}
}
}
extension UIApplication {
private static var timerToDetectInactivity: Timer?
static var timeout = false
func addTapGestureRecognizer() {
guard let window = windows.first else { return }
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapped))
tapGesture.requiresExclusiveTouchType = false
tapGesture.cancelsTouchesInView = false
tapGesture.delegate = self
window.addGestureRecognizer(tapGesture)
}
private func resetTimer() {
let showScreenSaverInSeconds: TimeInterval = 5
if let timerToDetectInactivity = UIApplication.timerToDetectInactivity {
timerToDetectInactivity.invalidate()
}
UIApplication.timerToDetectInactivity = Timer.scheduledTimer(
timeInterval: showScreenSaverInSeconds,
target: self,
selector: #selector(timeout),
userInfo: nil,
repeats: false
)
}
#objc func timeout() {
print("Timeout")
Self.timeout = true
}
#objc func tapped(_ sender: UITapGestureRecognizer) {
if !Self.timeout {
print("Tapped")
self.resetTimer()
}
}
}
extension UIApplication: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Text(UIApplication.timeout ? "TimeOut" : "Hello World!")
.padding()
Button("Print to Console") {
print(UIApplication.timeout ? "Timeout reached, why is view not updated?" : "Hello World!")
}
}
}
}

to update the view when timeout is reached, you could do something like this:
import SwiftUI
#main
struct TimeoutApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#State var thingToUpdate = "tap the screen to rest the timer"
#State var timeRemaining = 5
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack (spacing: 30) {
Text("\(thingToUpdate)")
Text("\(timeRemaining)")
.onReceive(timer) { _ in
if timeRemaining > 0 {
timeRemaining -= 1
} else {
thingToUpdate = "refreshed, tap the screen to rest the timer"
}
}
}
.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity,
minHeight: 0, idealHeight: .infinity, maxHeight: .infinity,
alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
thingToUpdate = "tap the screen to rest the timer"
timeRemaining = 5
}
}
}

You can do this like this - >
Create a subclass of "UIApplication" (use separate files like
MYApplication.swift).
Use the below code in the file.
Now method "idle_timer_exceeded" will get called once the user stops
touching the screen.
Use notification to update the UI
import UIKit
import Foundation
private let g_secs = 5.0 // Set desired time
class MYApplication: UIApplication
{
var idle_timer : dispatch_cancelable_closure?
override init()
{
super.init()
reset_idle_timer()
}
override func sendEvent( event: UIEvent )
{
super.sendEvent( event )
if let all_touches = event.allTouches() {
if ( all_touches.count > 0 ) {
let phase = (all_touches.anyObject() as UITouch).phase
if phase == UITouchPhase.Began {
reset_idle_timer()
}
}
}
}
private func reset_idle_timer()
{
cancel_delay( idle_timer )
idle_timer = delay( g_secs ) { self.idle_timer_exceeded() }
}
func idle_timer_exceeded()
{
// You can broadcast notification here and use an observer to trigger the UI update function
reset_idle_timer()
}
}
You can view the source for this post here.

Here is my code for a "global" timer using the "Environment". Although it works for me, it seems to be a lot of
work just to do something simple. This leads me to believe there must be
a better way to do this. Anyhow, there maybe some ideas you can recycle here.
import SwiftUI
#main
struct TestApp: App {
#StateObject var globalTimer = MyGlobalTimer()
var body: some Scene {
WindowGroup {
ContentView().environment(\.globalTimerKey, globalTimer)
}
}
}
struct MyGlobalTimerKey: EnvironmentKey {
static let defaultValue = MyGlobalTimer()
}
extension EnvironmentValues {
var globalTimerKey: MyGlobalTimer {
get { return self[MyGlobalTimerKey] }
set { self[MyGlobalTimerKey] = newValue }
}
}
class MyGlobalTimer: ObservableObject {
#Published var timeRemaining: Int = 5
var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
func reset(_ newValue: Int = 5) {
timeRemaining = newValue
}
}
struct ContentView: View {
#Environment(\.globalTimerKey) var globalTimer
#State var refresh = true
var body: some View {
GlobalTimerView { // <-- this puts the content into a tapable view
// the content
VStack {
Text(String(globalTimer.timeRemaining))
.accentColor(refresh ? .black :.black) // <-- trick to refresh the view
.onReceive(globalTimer.timer) { _ in
if globalTimer.timeRemaining > 0 {
globalTimer.timeRemaining -= 1
refresh.toggle() // <-- trick to refresh the view
print("----> time remaining: \(globalTimer.timeRemaining)")
} else {
// do something at every time step after the countdown
print("----> do something ")
}
}
}
}
}
}
struct GlobalTimerView<Content: View>: View {
#Environment(\.globalTimerKey) var globalTimer
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ZStack {
content
Color(white: 1.0, opacity: 0.001)
.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity,
minHeight: 0, idealHeight: .infinity, maxHeight: .infinity,
alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
globalTimer.reset()
}
}.onAppear {
globalTimer.reset()
}
}
}

Related

SwiftUI - Showing and hiding a custom alert

I am creating a custom alert reusable modifier which will have different number of buttons depending upon the style I need. Anyways in my sample code below, I have removed as much extra code as I can to keep it small and simple.
With the code below, I am able to show the alert, but clicking on "Click Me" button does not dismiss the alert. I feel something is wrong with the way I am setting "isPresented" binding variable, but not able to figure out what. Am new to SwiftUI.
Custom Alert Modifier:
struct CustomAlertView: ViewModifier {
var isPresented: Binding<Bool>
init(isPresented: Binding<Bool>) {
self.isPresented = isPresented
}
func body(content: Content) -> some View {
content.overlay(alertContent())
}
#ViewBuilder
private func alertContent() -> some View {
GeometryReader { geometry in
if self.isPresented {
// Do something
// Setting isPresented to false is not doing anything when button is clicked.
Button(action: { self.isPresented.wrappedValue = false }) {
Text("Click Me")
}
}
}
}
}
func customAlert(isPresented: Binding<Bool>) -> some View {
return modifier(CustomAlertView(isPresented: isPresented))
}
View and view model code:
struct MyView: View {
#ObservedObject var viewModel: CustomViewModel = CustomViewModel()
var body: some View {
VStack {
switch viewModel.state {
case .idle:
Color.clear.onAppear(perform: { viewModel.doSomething() })
case .showNewView:
// Navigate
}
}.onAppear() {
viewModel.state = .idle
}.customAlert(isPresented: .constant(viewModel.showAlert))
}
}
#MainActor
class CustomViewModel: ObservableObject {
enum State {
case idle
case showNewView
}
#Published var state = State.idle
#Published var showAlert = false
func doSomething() {
if failure {
self.showAlert = true
} else {
self.state = .showNewView
}
}
}
What am I doing wrong or why am I not able to dismiss the alert?
Thanks for looking!
First of all, you want #Binding var isPresented: Bool inside the CustomAlertView, and you assign it in init as self._isPresented = isPresented:
struct CustomAlertView: ViewModifier {
#Binding var isPresented: Bool // <-- like this
init(isPresented: Binding<Bool>) {
self._isPresented = isPresented // <-- and like this
}
//...
And second, you definitely don't want to set isPresented to a constant in .customAlert(isPresented: .constant(viewModel.showAlert)). Instead, you need to pass the binding variable:
VStack {
// ...
}
.customAlert(isPresented: $viewModel.showAlert) // <-- bind it here

Running code when SwiftUI Toggle Changes Value

The general structure of my code is that I have a UIKit app in which I am trying to embed a swiftui view. So I have a file called SettingsViewController which is as follows:
class SettingsViewController: UIViewController {
...
var items: [(SettingsView.Setting)] = ...
var actionsForItems: [() -> Void = []
#State var isOn: Bool = false
override func viewDidLoad() {
super.viewDidLoad()
actionsForItems = ...
...
addSettingCell(isOn: isOn)
let childView = UIHostingController(rootView: SettingsView(settings: items, actionsForSettings: actionsForItems))
addChild(childView)
childView.view.frame = container.bounds
container.addSubview(childView.view)
childView.didMove(toParent: self)
}
...
func addCell(isOn: Bool) {
items.insert((settingName, $isOn as AnyObject) as SettingsView.Setting)
actionsForItems.insert({
self.onSwitchValueChanged(isOn: isOn) //defined later
})
}
}
which creates a view called Settings View which is structured as follows:
struct SettingsView: View {
typealias Setting = (key: String, value: AnyObject?)
var settings: [Setting]
var actions: [() -> Void]
var body: some View {
List(settings.indices) { index in
...
SettingsWithSwitchView(setting: settings[index], action: actions[index], isOn: setting.value as Binding<Bool>)
}
Spacer()
}
}
and SettingsWithSwitchView is as follows:
struct SettingsWithSwitchView: View {
var setting: SettingsView.Setting
var action: () -> Void
#Binding var isOn: Bool {
willSet {
print("willSet: newValue =\(newValue) oldValue =\(isOn)")
}
didSet {
print("didSet: oldValue=\(oldValue) newValue=\(isOn)")
action()
}
}
var body: some View {
HStack {
Text(setting.key)
.foregroundColor(Color("GrayText"))
.font(Font.custom("OpenSans", size: 15))
Spacer()
Toggle(isOn: $isOn) {}
}
}
}
I read in another post here on Stack Overflow that calling didSet on the isOn property should be the way to accomplish this, but I need to call onSwitchValueChanged when the Toggle value is updated, but my current setup does not work. I would really appreciate some help to figure out how to do this. I can update with some other information if necessary.
The thing that ended up working for me was creating a ViewModel which was also an ObservableObject and then setting the action for the toggle inside of .onTapGesture

Avoiding Keyboard in SwifUI with TextEditor

I'm attempting to recreate a simple version of the iOS notes app. Mind you, I'm a complete Swift novice. My current issue is that I want my view to move up as the keyboard appears. I've implemented some code that does do this, but it has some nasty bugs. It first moves the view up WAY too high, then when you begin typing, the view is where it should be. Here are some photos for representation, as well as my code:
Before the keyboard appears
When the keyboard first appears
Once you begin typing
Code:
class KeyboardResponder: ObservableObject {
#Published var currentHeight: CGFloat = 0
var _center: NotificationCenter
#objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
withAnimation {
currentHeight = keyboardSize.height
print("KEYBOARDSIZE.HEIGHT IN OBSERVER: \(keyboardSize.height)")
}
}
print("KEYBOARD HEIGHT IN OBSERVER: \(currentHeight)")
}
#objc func keyBoardWillHide(notification: Notification) {
withAnimation {
currentHeight = 0
}
}
init(center: NotificationCenter = .default) {
_center = center
_center.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
_center.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
}
First, there is a KeyboardResponder class, listening for keyboard appearances and disappearances (with a published variable for its height).
VStack {
TextEditor(text: $content).padding(.all).foregroundColor(fontColors[self.color]).font(fontStyles[self.font])
HStack {
Spacer()
Button(action: {
self.show = false
}) {
Text("Cancel").foregroundColor(.gray).font(.headline)
}
Spacer()
Button(action: {
self.showPanel = true
}) {
Image(systemName: "textformat").font(.headline).foregroundColor(.white).padding(.all)
}.background(Color.green).clipShape(Circle())
Spacer()
Button(action: {
self.show.toggle()
self.saveData()
}) {
Text("Save").foregroundColor(Color(UIColor.systemBlue)).font(.headline)
}
Spacer()
}
}.padding(.bottom, keyboardResponder.currentHeight)
This is the view, with the editor shown in the photos. At the top of this view I have #ObservedObject var keyboardResponder = KeyboardResponder(). I've tried both .padding(.bottom, keyboardResponder.currentHeight) as well as .offset(y: -keyboardResponder.currentHeight). Anyone know what's going on?
Solution Found:
I finally found a solution that works! I got this code from
https://augmentedcode.io/2020/03/29/revealing-content-behind-keyboard-in-swiftui/
fileprivate final class KeyboardObserver: ObservableObject {
struct Info {
let curve: UIView.AnimationCurve
let duration: TimeInterval
let endFrame: CGRect
}
private var observers = [NSObjectProtocol]()
init() {
let handler: (Notification) -> Void = { [weak self] notification in
self?.keyboardInfo = Info(notification: notification)
}
let names: [Notification.Name] = [
UIResponder.keyboardWillShowNotification,
UIResponder.keyboardWillHideNotification,
UIResponder.keyboardWillChangeFrameNotification
]
observers = names.map({ name in
NotificationCenter.default.addObserver(forName: name,
object: nil,
queue: .main,
using: handler)
})
}
#Published var keyboardInfo = Info(curve: .linear, duration: 0, endFrame: .zero)
}
fileprivate extension KeyboardObserver.Info {
init(notification: Notification) {
guard let userInfo = notification.userInfo else { fatalError() }
curve = {
let rawValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! Int
return UIView.AnimationCurve(rawValue: rawValue)!
}()
duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! TimeInterval
endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
}
}
struct KeyboardVisibility: ViewModifier {
#ObservedObject fileprivate var keyboardObserver = KeyboardObserver()
func body(content: Content) -> some View {
GeometryReader { geometry in
withAnimation() {
content.padding(.bottom, max(0, self.keyboardObserver.keyboardInfo.endFrame.height - geometry.safeAreaInsets.bottom))
.animation(Animation(keyboardInfo: self.keyboardObserver.keyboardInfo))
}
}
}
}
fileprivate extension Animation {
init(keyboardInfo: KeyboardObserver.Info) {
switch keyboardInfo.curve {
case .easeInOut:
self = .easeInOut(duration: keyboardInfo.duration)
case .easeIn:
self = .easeIn(duration: keyboardInfo.duration)
case .easeOut:
self = .easeOut(duration: keyboardInfo.duration)
case .linear:
self = .linear(duration: keyboardInfo.duration)
#unknown default:
self = .easeInOut(duration: keyboardInfo.duration)
}
}
}
extension View {
func keyboardVisibility() -> some View {
return modifier(KeyboardVisibility())
}
}
Then just add the modifier to the view you want to move up with the keyboard like so .keyboardVisibility()
add this line in the VStack where you add bottom padding, cause that vStack take padding from the safe area( after keyboard appear the key board area is also belongs to safe area)
.edgesIgnoringSafeArea(.bottom)

When I use withAnimation in the Model, no animation happens, why?

here is my ViewModel
import SwiftUI
class SetGameVM: ObservableObject {
#Published var model: SetGame = SetGame()
var cards: Array<SetGame.Card> {
model.cards
}
func selectCard(card: SetGame.Card) {
model.selectCard(card: card)
}
func dealCards() {
model.dealMoreCards()
}
func reset() {
model = SetGame()
}
}
and Model
import SwiftUI
struct SetGame {
var cards: Array<Card>
mutating func selectCard(card: Card) -> Bool {
...
withAnimation {
dealMoreCards()
}
...
}
}
If I use withAnimation in ViewModle or View then animation happens as expected, else if I use withAnimation in the Model which wraps some changes, View still reflect those changes, but no animation happens.
Do it in view model
func selectCard(card: SetGame.Card) {
withAnimation { // << here !!
model.selectCard(card: card)
}
}

Completely move to other view and don't allow to go back in SwiftUI

I'm developing a simple iOS app with SwiftUI with two views: a LogInView() and a HomeView().
What I want is really simple: when the user clicks on the Login Button on LogInView() I want the app to hide the LogInView() and show the HomeView(), full screen, not like a modal and without allowing the user to go back.
This could be easily done with Storyboards in Swift and UIKit, is there any way to do this with SwiftUI?
Any help is appreciated.
Thanks in advance.
My code:
LogInView:
struct LogInView: View {
var body: some View {
VStack {
Text("Welcome to Mamoot!")
.font(.largeTitle)
.fontWeight(.heavy)
Text("We are glad to have you here.")
Text("Please log in with your Mastodon or Twitter account to continue.")
.multilineTextAlignment(.center)
.lineLimit(4)
.padding()
Spacer()
FloatingTextField(title: "Username", placeholder: "Username", width: 300, type: "Username")
FloatingTextField(title: "Password", placeholder: "Password", width: 300, type: "password")
.padding(.top, -50)
Spacer()
ZStack {
Button(action: { /* go to HomeView() */ }) {
Text("Log in")
.foregroundColor(Color.white)
.bold()
.shadow(color: .red, radius: 10)
}
.padding(.leading, 140)
.padding(.trailing, 140)
.padding(.top, 15)
.padding(.bottom, 15)
.background(Color.red)
.cornerRadius(10)
}
.padding(.bottom)
}
}
}
HomeView:
struct HomeView: View {
var body: some View {
Text("Home Page")
}
}
Update: I got some time to update the answer, and add a transition. Note that I changed Group by VStack, otherwise the transition does not work.
You can alter the duration in the withAnimationcall (button closure).
I also moved some modifiers in your button, so the whole thing is "tappable". Otherwise, only tapping on the text of the button would trigger the action.
You can use an ObservedObject, an EnvironmentObject or a Binding. Here's an example with ObservedObject:
import SwiftUI
class Model: ObservableObject {
#Published var loggedIn = false
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
if model.loggedIn {
HomeView().transition(.opacity)
} else {
LogInView(model: model).transition(.opacity)
}
}
}
}
struct HomeView: View {
var body: some View {
Text("Home Page")
}
}
struct LogInView: View {
#ObservedObject var model: Model
var body: some View {
VStack {
Text("Welcome to Mamoot!")
.font(.largeTitle)
.fontWeight(.heavy)
Text("We are glad to have you here.")
Text("Please log in with your Mastodon or Twitter account to continue.")
.multilineTextAlignment(.center)
.lineLimit(4)
.padding()
Spacer()
// FloatingTextField(title: "Username", placeholder: "Username", width: 300, type: "Username")
// FloatingTextField(title: "Password", placeholder: "Password", width: 300, type: "password")
// .padding(.top, -50)
Spacer()
ZStack {
Button(action: {
withAnimation(.easeInOut(duration: 1.0)) {
self.model.loggedIn = true
}
}) {
Text("Log in")
.foregroundColor(Color.white)
.bold()
.shadow(color: .red, radius: 10)
// moved modifiers here, so the whole button is tappable
.padding(.leading, 140)
.padding(.trailing, 140)
.padding(.top, 15)
.padding(.bottom, 15)
.background(Color.red)
.cornerRadius(10)
}
}
.padding(.bottom)
}
}
}
The answer by #kontiki is probably the most SwiftUI-y, but I will present a different solution, probably not as good! But maybe more flexible/scalable.
You can swap rootView of UIHostingController:
SceneDelegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
fileprivate lazy var appCoordinator: AppCoordinator = {
let rootViewController: UIHostingController<AnyView> = .init(rootView: EmptyView().eraseToAny())
window?.rootViewController = rootViewController
let navigationHandler: (AnyScreen, TransitionAnimation) -> Void = { [unowned rootViewController, window] (newRootScreen: AnyScreen, transitionAnimation: TransitionAnimation) in
UIView.transition(
with: window!,
duration: 0.5,
options: transitionAnimation.asUIKitTransitionAnimation,
animations: { rootViewController.rootView = newRootScreen },
completion: nil
)
}
return AppCoordinator(
dependencies: (
securePersistence: KeyValueStore(KeychainSwift()),
preferences: .default
),
navigator: navigationHandler
)
}()
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
self.window = .fromScene(scene)
appCoordinator.start()
}
}
enum TransitionAnimation {
case flipFromLeft
case flipFromRight
}
private extension TransitionAnimation {
var asUIKitTransitionAnimation: UIView.AnimationOptions {
switch self {
case .flipFromLeft: return UIView.AnimationOptions.transitionFlipFromLeft
case .flipFromRight: return UIView.AnimationOptions.transitionFlipFromRight
}
}
}
AppCoordinator
And here is the AppCoordinator:
final class AppCoordinator {
private let preferences: Preferences
private let securePersistence: SecurePersistence
private let navigationHandler: (AnyScreen, TransitionAnimation) -> Void
init(
dependencies: (securePersistence: SecurePersistence, preferences: Preferences),
navigator navigationHandler: #escaping (AnyScreen, TransitionAnimation) -> Void
) {
self.preferences = dependencies.preferences
self.securePersistence = dependencies.securePersistence
self.navigationHandler = navigationHandler
}
}
// MARK: Internal
internal extension AppCoordinator {
func start() {
navigate(to: initialDestination)
}
}
// MARK: Destination
private extension AppCoordinator {
enum Destination {
case welcome, getStarted, main
}
func navigate(to destination: Destination, transitionAnimation: TransitionAnimation = .flipFromLeft) {
let screen = screenForDestination(destination)
navigationHandler(screen, transitionAnimation)
}
func screenForDestination(_ destination: Destination) -> AnyScreen {
switch destination {
case .welcome: return AnyScreen(welcome)
case .getStarted: return AnyScreen(getStarted)
case .main: return AnyScreen(main)
}
}
var initialDestination: Destination {
guard preferences.hasAgreedToTermsAndPolicy else {
return .welcome
}
guard securePersistence.isAccountSetup else {
return .getStarted
}
return .main
}
}
// MARK: - Screens
private extension AppCoordinator {
var welcome: some Screen {
WelcomeScreen()
.environmentObject(
WelcomeViewModel(
preferences: preferences,
termsHaveBeenAccepted: { [unowned self] in self.start() }
)
)
}
var getStarted: some Screen {
GetStartedScreen()
.environmentObject(
GetStartedViewModel(
preferences: preferences,
securePersistence: securePersistence,
walletCreated: { [unowned self] in self.navigate(to: .main) }
)
)
}
var main: some Screen {
return MainScreen().environmentObject(
MainViewModel(
preferences: preferences,
securePersistence: securePersistence,
walletDeleted: { [unowned self] in
self.navigate(to: .getStarted, transitionAnimation: .flipFromRight)
}
)
)
}
}

Resources