Why is an old view model responding to a notification? - swift

I’m creating a weightlifting calculator application (Swift 4) using MVVM and have been trying for 2 days to figure out why a view model that should have died is still responding to a UserDefaults.defaultsDidChange event notification.
I launch the app:
At launch, in the AppDelegate, I create a new lift event object and use it to initialize a new CalculatorLiftEventViewModelFromLiftEvent for the `CalculatorViewController':
I calculate a lift and save it
I tap the + button to create a new lift:
this causes a new, empty lift event object to be created
this new lift event object is used to initialize a new CalculatorLiftEventViewModelFromLiftEvent object
this new CalculatorLiftEventViewModelFromLiftEvent is then assigned to the CalculatorViewController's viewModel property, replacing the one created when the app launched
the values on the calculator screen are zeroed out, ready for a new lift event to be entered
I tap the Settings button to go to Settings where I change the Formula associated with the current lift event.
The new Formula is saved as the default and the UserDefaults.defaultsDidChange notification is fired
HERE’S THE PART I CAN’T FIGURE OUT: the original view model is still alive and it’s still listening for UserDefault notifications. When I close the Settings screen and go back to the Calculator view, the values from the prior lift event that had been cleared out now reappear.
Here’s what happens when the + (new) button on the Calculator screen is tapped:
#objc fileprivate func onNewButtonTapped(_ sender: UIBarButtonItem) {
let newLiftEvent = dataManager.createNewLiftEvent()
viewModel = CalculatorLiftEventViewModelFromLiftEvent(withLiftEvent: newLiftEvent, dataManager: dataManager)
setupView()
}
Here’s how the CalculatorLiftEventViewModelFromLiftEvent is initialized:
init(withLiftEvent liftEvent: LiftEventRepresentable, dataManager: CoreDataHelper) {
self.modelLiftEvent = liftEvent
self.liftName = Dynamic("\(modelLiftEvent.lift.liftName)")
self.weightLiftedTextField = Dynamic(modelLiftEvent.liftWeight.value)
self.repetitionsTextField = Dynamic("\(modelLiftEvent.repetitions)")
self.oneRepMaxTextField = Dynamic(modelLiftEvent.oneRepMax.value)
self.unitsTextField = Dynamic("\(UserDefaults.weightUnit())")
self.weightPercentages = Dynamic( [ : ] )
self.dataManager = dataManager
super.init()
subscribeToNotifications()
}
UPDATE: Here are the deinit and the addObservers in CalculatorLiftEventViewModelFromLiftEvent. Notice I'm not using block-based observations.
deinit {
print("I got to the deinit method")
unsubscribeFromNotifications()
}
func subscribeToNotifications() {
NotificationCenter.default.addObserver(self,
selector: #selector(liftNameDidChangeNotification(_:)),
name: NSNotification.Name(rawValue: LiftEventNotifications.LiftNameDidChangeNotification),
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(weightUnitDidChangeNotification(_:)),
name: NSNotification.Name(rawValue: LiftEventNotifications.WeightUnitDidChangeNotification),
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(roundingOptionDidChangeNotification(_:)),
name: NSNotification.Name(rawValue: UserDefaultsNotifications.roundingOptionDidChangeNotification),
object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(self.defaultsDidChange), name: UserDefaults.didChangeNotification,
object: nil)
}
--- END UPDATE
I pass the modelLiftEvent when segueing to the SettingsViewController:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let identifier = segue.identifier {
switch identifier {
case a:...
case b:...
case "SettingsSegue":
if let nav = segue.destination as? UINavigationController {
let destinationViewController = nav.topViewController as! SettingsViewController
destinationViewController.dismissalDelegate = self
let settingsViewModel = SettingsViewModelFromLiftEvent(withLiftEvent: self.viewModel.modelLiftEvent)
destinationViewController.settingsViewModel = settingsViewModel
destinationViewController.dataManager = dataManager
settingsViewModel.dataManager = dataManager
}
Finally, in CalculatorLiftEventViewModelFromLiftEvent, I’ve put a break point here because this is called when the view model hears the UserDefaults.defaultsDidChange notification. At this point, I have also verified that this CalculatorLiftEventViewModelFromLiftEvent is the old one, not the new one created when I tapped the + button:
#objc func defaultsDidChange(_ notification: Notification) {
let oneRepMax = modelLiftEvent.calculateOneRepMax()
guard oneRepMax.value != 0.0 else { return }
let weightPercentages = getWeightPercentages(weight: oneRepMax.value)
self.weightPercentages.value = weightPercentages
weightLiftedTextField.value = modelLiftEvent.liftWeight.value
repetitionsTextField.value = "\(modelLiftEvent.repetitions)"
oneRepMaxTextField.value = modelLiftEvent.oneRepMax.value
}
I've read through a bunch of documentation about the life cycle of objects but haven't found anything that helps. I expect that when the new CalculatorLiftEventViewModelFromLiftEvent is created and assigned to the `CalculatorViewController''s viewModel property, it would replace the reference to the old one and it would cease to exist. Evidently, that's not what's happening.
Does anyone have any idea why when I go from the Calculator view (step 3) that has no values (except for 0.0) to the Settings and then come back, the prior lift event values are displayed?

I've fixed the problem of the prior liftEvent being displayed after clearing the calculator, changing the default formula, and coming back to the calculator screen.
On CalculatorViewController, when the + button is tapped, instead of creating a new viewModel and assigning it to the viewModel property, I'm asking my AppDelegate to create both a new CalculatorViewController and CalculatorLiftEventViewModelFromLiftEvent by using the launchCalculatorViewController method which does this when the app launches.
The original code in CalculatorViewController:
#objc fileprivate func onNewButtonTapped(_ sender: UIBarButtonItem) {
let newLiftEvent = dataManager.createNewLiftEvent()
viewModel = CalculatorLiftEventViewModelFromLiftEvent(withLiftEvent: newLiftEvent, dataManager: dataManager)
self.percentagesTableView.reloadData()
setupView()
}
Now the new code in CalculatorViewController:
#objc fileprivate func onNewButtonTapped(_ sender: UIBarButtonItem) {
(UIApplication.shared.delegate as? AppDelegate)?.launchCalculatorViewController()
}
and in AppDelegate:
func launchCalculatorViewController() {
self.window = UIWindow(frame: UIScreen.main.bounds)
let mainStoryboard: UIStoryboard = UIStoryboard(name: "Main", bundle: nil)
if let initialViewController: CalculatorViewController = mainStoryboard.instantiateInitialViewController() as? CalculatorViewController {
self.window?.rootViewController = initialViewController
let liftEvent = dataManager.createNewLiftEvent()
let viewModel = CalculatorLiftEventViewModelFromLiftEvent(withLiftEvent: liftEvent, dataManager: dataManager)
initialViewController.viewModel = viewModel
initialViewController.dataManager = dataManager
self.window?.makeKeyAndVisible()
}
}
Unfortunately, I determined that CalculatorLiftEventViewModelFromLiftEvent objects are never being deallocated which tells me I've got a strong reference cycle that won't let go:
That will have to be another SO question.

Related

Swift Multiplayer Calls Present Game Multiple Times

I am writing several Swift multiplayer games based on the Ray Wenderlich tutorial for Nine Knights. (https://www.raywenderlich.com/7544-game-center-for-ios-building-a-turn-based-game)
I use pretty much the same GameCenterHelper file except that I change to a segue instead of present scene since I am using UIKit instead of Sprite Kit with the following important pieces:
present match maker:
func presentMatchmaker() {
guard GKLocalPlayer.local.isAuthenticated else {return}
let request = GKMatchRequest()
request.minPlayers = 2
request.maxPlayers = 2
request.inviteMessage = "Would you like to play?"
let vc = GKTurnBasedMatchmakerViewController(matchRequest: request)
vc.turnBasedMatchmakerDelegate = self
currentMatchmakerVC = vc
print(vc)
viewController?.present(vc, animated: true)
}
the player listener function:
extension GameCenterHelper: GKLocalPlayerListener {
func player(_ player: GKPlayer, receivedTurnEventFor match: GKTurnBasedMatch, didBecomeActive: Bool) {
if let vc = currentMatchmakerVC {
currentMatchmakerVC = nil
vc.dismiss(animated: true)
}
guard didBecomeActive else {return}
NotificationCenter.default.post(name: .presentGame, object: match)
}
}
The following extension for Notification Center:
extension Notification.Name {
static let presentGame = Notification.Name(rawValue: "presentGame")
static let authenticationChanged = Notification.Name(rawValue: "authenticationChanged")
}
In the viewdidload of the menu I call the following:
override func viewDidLoad() {
super.viewDidLoad()
createTitleLabel()
createGameImage()
createButtons()
GameCenterHelper.helper.viewController = self
GameCenterHelper.helper.currentMatch = nil
NotificationCenter.default.addObserver(self, selector: #selector(authenticationChanged(_:)), name: .authenticationChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(presentGame(_:)), name: .presentGame, object: nil)
}
and tapping the multi device buttons calls the following:
#objc func startMultiDeviceGame() {
multiPlayer = true
GameCenterHelper.helper.presentMatchmaker()
}
and the notifications call the following:
#objc func presentGame(_ notification: Notification) {
// 1
print("present game")
guard let match = notification.object as? GKTurnBasedMatch else {return}
loadAndDisplay(match: match)
}
// MARK: - Helpers
private func loadAndDisplay(match: GKTurnBasedMatch) {
match.loadMatchData { [self] data, error in
if let data = data {
do {
gameModel = try JSONDecoder().decode(GameModel.self, from: data)
} catch {gameModel = GameModel()}
} else {gameModel = GameModel()}
GameCenterHelper.helper.currentMatch = match
print("load and display")
performSegue(withIdentifier: "gameSegue", sender: nil)
}
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
print("prepare to segue")
if let vc = segue.destination as? GameVC {vc.gameModel = gameModel}
}
Which is hopefully easy to follow.
The game starts and the menu scene adds the observer for present game
The player taps multi device, which presents the matchmaker
The player selects their game from the match maker, which I think activates the player listener function
This posts to the Notification Center for present game
The notification center observer calls present game, which calls load and display, with a little help from prepare segue
My issue is that the first time I do this it works perfectly, and per the framework from that tutorial that I can't figure out how to change (an issue for a different question I think) after a player takes their turn they are returned to the menu. The second time they enter present matchmaker and select a game the present game function is called twice, and the third time they take their turn without shutting down the app it is called 3 times, etc. (I have the print statements in both the present game and load and display functions and they are called back to back the 2nd time through and back to back to back the 3rd time etc. even though they are only called once the first time a game is selected from the matchmaker)
Console messages
present matchmaker true
<GKTurnBasedMatchmakerViewController: 0x104810000>
present game
present game
present game
load and display
prepare to segue
load and display
prepare to segue
load and display
prepare to segue
2021-03-20 22:32:26.838680-0600 STAX[4997:435032] [Presentation] Attempt to present <STAX.GameVC: 0x103894c00> on <Game.MenuVC: 0x103814800> (from < Game.MenuVC: 0x103814800>) whose view is not in the window hierarchy.
(419.60100000000006, 39.0)
2021-03-20 22:32:26.877943-0600 STAX[4997:435032] [Presentation] Attempt to present <STAX.GameVC: 0x103898e00> on < Game.MenuVC: 0x10501c800> (from < Game.MenuVC: 0x10501c800>) whose view is not in the window hierarchy.
I had thought that this was due to me not removing the Notification Center observers, but I tried the following in the view did load for the menu screen (right before I added the .presentGame observer):
NotificationCenter.default.removeObserver(self, name: .presentGame, object: nil)
and that didn't fix the issue, so I tried the following (in place of the above):
NotificationCenter.default.removeObserver(self)
and that didn't work so I tried them each, one at a time in the view did disappear of the game view controller (which I didn't think would work since self refers to the menu vc, but I was getting desperate) and that didn't work either.
I started thinking that maybe I'm not adding multiple observers that are calling present game more than once, since the following didn't work at all the second time (I'm just using a global variable to keep track of the first run through that adds the observers and then not adding them the second time):
if addObservers {
NotificationCenter.default.addObserver(self, selector: #selector(authenticationChanged(_:)), name: .authenticationChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(presentGame(_:)), name: .presentGame, object: nil)
addObservers = false
}
since it is trying to add a view that is not in the view hierarchy. (Although the background music for that screen starts playing, the menu remains and the game board is not shown...)
I wasn't sure if I'm removing the Notification Center observers incorrectly or if they aren't really the source of the problem so I decided to ask for help :)
Thank you!
I figured it out. I was trying to remove the Notifications from a deallocated instance of the view controller per the below link (The bottom most answer):
How to avoid adding multiple NSNotification observer?
The correct way to remove the notifications is in the view will disappear function like this:
override func viewWillDisappear(_ animated: Bool) {
NotificationCenter.default.removeObserver(self, name: Notification.Name.presentGame, object: nil)
NotificationCenter.default.removeObserver(self, name: Notification.Name.authenticationChanged, object: nil)
}
After implementing that I stopped making multiple calls to the notification center.

Userdefaults Boolean for button

I am fairly new to Swift programming. Using Userdefaults I was trying to customize user behaviour. Below image is of my initial controller. I require to save userdefaults so that App remembers the user selection of button, (i.e. A or B). Can you assist to provide me a function that I use in viewDidLoad and it remembers the button selection and segues to its respective ViewController.
My code to perfrom segue if Button A or B is selected is
let parent = self.storyboard?.instantiateViewController(withIdentifier: "DashboardVC") as! DashboardVC
self.navigationController?.pushViewController(parent!, animated: true)
Yet it doesnt segue. It keeps loading my initial viewcontroller.
do like
set the tag for each button and create the common method for handle the function , for e.g
#IBAction func handle_Action(_ sender: UIButton) {
defaultName.set(sender.tag, forKey: "yourKeyName")
}
and in your class
class ViewController: UIViewController {
let defaultName = UserDefaults.standard
// finally access the integer in your Viewload
override func viewDidLoad() {
super.viewDidLoad()
let getVal = defaultName.integer(forKey: "yourKeyName") as Int
if getVal == 1{ //called by A
}else if getVal == 2{
//called by B
}else{ // not interactwithButton action }
}

Update label.text from another class

I have a view controller with a label ("titre") and a button.
class PresentationViewController: UIViewController, UIScrollViewDelegate,SKProductsRequestDelegate, SKRequestDelegate {
#IBOutlet weak var titre: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
let defaults = UserDefaults.standard
if !defaults.bool(forKey: "XXXXXX.\(GlobalVariables.sharedinstance.circuitselectionne!)"){
titre.text = "2,90€"
}
else {
titre.text = "Démarrer la visite ! "
}
When this button is triggered, a function in another class is launched.
In this function, we define the boolean of the variable checked in the initial viewController.
let defaults = UserDefaults.standard
defaults.set(true,forKey:"XXXXXX.\(nomtelechargementàajouter)")
But when it's done, titre.text is not updated in my Viewcontroller.
So how could I refresh the value of titre.text in the ViewController?
Another point is this function could be called in other ViewController.
Any idea?
You can use Notifications and observers to control your code behavior between your data and your controllers.
On the end of your function that define the boolean variable that you check in the viewDidload of your viewController post a notification like this :
NotificationCenter.default.post(name: NSNotification.Name(rawValue: "VariableDefined"), object: nil)
With this you create a notification called VariableDefined (you can name it as you want). object parameter allows you to send data or whatever parameter you want with this notification.
Then, you have to set up a listener to listen to this notification. Who is interested on your boolean variable ? Your viewController.
Put a listener of this notification on your view controller like this :
let name = Notification.Name(rawValue: "VariableDefined")
NotificationCenter.default.addObserver(
self, selector: #selector(checkVariable),
name: name, object: nil)
With this you are defining a listener to the notification called VariableDefined and handle it with the method checkVariable.
Finally, on this handler you can update your label title :
func checkVariable() {
let defaults = UserDefaults.standard
if !defaults.bool(forKey: "XXXXXX.\
(GlobalVariables.sharedinstance.circuitselectionne!)"){
titre.text = "2,90€"
} else {
titre.text = "Démarrer la visite ! "
}
}
Hope it will help you. That's a way to communicate between models and view controllers.

My cells are duplicating themselves

I am new to swift and I am trying to make this note app. I have split view controller that goes in my first view controller and that view controller connects to a table view controller. Everything works perfectly is just that when I launch the app I have all the notes like I want but when I try to go back to my first view controller and come back to my table view controller, all the notes are duplicated every single time I do it. I tried everything I can try, is there anyone who can help me
my MasterViewController is
import UIKit
class MasterViewController: UITableViewController {
var detailViewController: DetailViewController? = nil
override func viewDidLoad()
{
super.viewDidLoad()
Note.loadNotes() // The problem is here, I think
noteTable = self.tableView
// Do any additional setup after loading the view, typically from a nib.
let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(insertNewObject(_:)))
navigationItem.rightBarButtonItem = addButton
if let split = splitViewController
{
let controllers = split.viewControllers
detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController
}
}
My loadNotes function is
class func loadNotes()
{
let defaults:UserDefaults = UserDefaults.standard
let saveData: [NSDictionary]? = defaults.object(forKey: kAllNotes) as? [NSDictionary]
if let data:[NSDictionary] = saveData
{
for i:Int in 0 ..< data.count
{
let n:Note = Note()
n.setValuesForKeys(data[i] as! [String : Any])
allNotes.append(n)
}
}
}
Your loadNotes method keeps appending. The first line of loadNotes should be:
allNotes = [Note]()
Then it starts with an empty array and fills it up.
And why is loadNotes a static method? That's a bad design. Make Notes a normal class and make loadNotes an instance method.
On an unrelated note (no pun intended), do not use UserDefaults to store app data. Only use it to store little bits of information.

Passing data between viewControllers in swift

I was wondering if their were ways to send data to another view as soon as one view loads. I'm trying to make an app that stores something like a highScore but the only way to pass the data from the gameOver view to the mainMenu is if they click main menu and using the prepareForSegue method. Is their a way I could send it in like the viewDidLoad function?
Thanks!
According you use an UINavigationController, GameOverViewController
On your main menu add this code :
override func prepareForSegue(segue: UIStoryboardSegue!, sender: AnyObject!) {
let navigationController = segue.destinationViewController as UINavigationController
let gameOverVC = navigationController.topViewController as GameOverViewController
gameOverVC.highScore = 26
}
If you don't use an UINavigationController :
override func prepareForSegue(segue: UIStoryboardSegue!, sender: AnyObject!) {
let gameOverVC = segue.destinationViewController as GameOverViewController
gameOverVC.highScore = 26
}
Then, add your highScore var : var highScore: NSString! like this :
class GameOverViewController: UIViewController {
var highScore: NSString!
override func viewDidLoad() {
println(self.highScore)
}
}
It should works.
If not, please add more code in your post.
EDIT :
By this way, you will just pass data forward, not backward. To pass backward use Delegation, Singleton, NSNotificationCenter or NSUserDefaults
To pass data backward side, i.e. from second view to first, there are option like Delegation Pattern and NSNotificationCenter.
Suppose we using NSNotificationCenter.
In your case, We need to Send highScore to MainMenu from GameOver Class.
Register your notification.(in viewDidLoad of MainMenu Class)
NSNotificationCenter.defaultCenter().addObserver(self, selector: "updateHighScoreFromGameOver:", name: "notificationUpdateHighScoreFromGameOver", object: nil)
Create method to handle posted notification. (in MainMenu Class)
#objc func updateHighScoreFromGameOver(notification: NSNotification){
NSNumber highScore = notification.object;
}
Post Notification When your game is Over(next line after displaying game over message).
NSNotificationCenter.defaultCenter().postNotificationName("notificationUpdateHighScoreFromGameOver", object: hightScore_ofTypeNSNumber)
Remove observer once you are done. (in viewWillAppear of MainMenu Class)
NSNotificationCenter.defaultCenter().removeObserver(self name: "notificationUpdateHighScoreFromGameOver", object: nil);
//alternatively you can remove all observers on this object:
NSNotificationCenter.defaultCenter().removeObserver(self);
Note : If you will not remove previous added observer and add it again, the method will be called two times. If you add same observer third time, the method will be called three times, and so on.