Learning Swift — Days 155 to 156

Hacking with Swift — Learning Project 37 Psychic Tester

Introduction

This is the second to last project in the book. There is so much to learn and I cannot say I could complete all of those challenges but hey, I will one day! I just feel like going on and learn new things!

This project will require an Apple Watch if I understood it correctly, which I don’t have, so I will see just how far I can go with it.

Brace yourself! Here we go!

Setting up

Create a new Xcode project, select watchOS at the top of the window, then iOS App with WatchKit App, name it “Psychic Tester”, deselect Include Notification Scene and save it somewhere sensible. In the Deployment Info, set it to be iPhone-only and only Landscape orientation.

Download the assets for this project and copy them into your assets catalogue and the Content directory to the project.

Laying out the cards: addChildViewController()

Go to Main.storyboard and set the simulator to be viewed in landscape orientation, then drag a UIView inside it and make it entered and 480 x 320 in size. Set its background colour in the Attributes Inspector to clear. While here uncheck the “Autoresize Subviews” option in the Drawing category. Using the Assistant Editor create a new outlet connection for the view and call it cardContainer.

Create a new Cocoa Touch Class called “CardViewController” that subclasses UIViewController.

Add four properties to this class:

  1. A weak reference to an implicitly unwrapped ViewController called delegate.
  2. A front implicitly unwrapped UIImageView.
  3. A back implicitly unwrapped UIImageView.
  4. A Boolean isCorrect set too false.

Add the following code to viewDidLoad():

  1. Set the view’s bound to a new CGRect with origin at 0, 0, width of 100 points and height of 140 points.
  2. Set the front and back properties to a new UIImageView(image:) passing them a UIImage(named:) “cardBack” (no, it’s not a typo).
  3. Add both of them as subviews to the view.
  4. Hide the front one and set the back’s alpha to 0.
  5. Animate the alpha of the back to 1 over 0.2 seconds.

Go to ViewController.swift and create a new empty array of CardViewControllers called allCards.

Create a new @objc method called loadCards():

  1. Initialise an array of 8 CGPoints with values (75, 185, 295, 405) for the x and either 85 or 235 for the y.
  2. Initialise 5 different UIImagess from the file named “card(shape)”.
  3. Create an array of images, one for each card, then shuffle it (why then Paul writes twice the first three cards???)… I mean, yes, to get to 8 but then don’t write it like that!
  4. For (index, position) in the enumerated version of the positions array, declare a new CardViewController(), set its delegate to self, add it as a child to the view controller (is this a new possible thing?!), then add its view as a subview of the cardContainer array, then call the didMove(toParent:) method on the card object.

A brief pause before moving on, or my head will explode: here is the addChild() method:

Summary

Adds the specified view controller as a child of the current view controller.

Declaration

Discussion

This method creates a parent-child relationship between the current view controller and the object in the childController parameter. This relationship is necessary when embedding the child view controller’s view into the current view controller’s content. If the new child view controller is already the child of a container view controller, it is removed from that container before being added.

This method is only intended to be called by an implementation of a custom container view controller. If you override this method, you must call super in your implementation.

Parameters

childController: the view controller to be added as a child.


We have already used it, but never looked at it from a closer point of view: here is the addSubview(_:) method:

Summary

Adds a view to the end of the receiver’s list of subviews.

Declaration

Discussion

This method establishes a strong reference to view and sets its next responder to the receiver, which is its new superview.

Views can have only one superview. If view already has a superview and that view is not the receiver, this method removes the previous superview before making the receiver its new superview.

Parameters

view: the view to be added. After being added, this view appears on top of any other subviews.


Finally, here is the didMove(toParent:) method:

Summary

Called after the view controller is added or removed from a container view controller.

Declaration

Discussion

Your view controller can override this method when it wants to react to being added to a container.

If you are implementing your own container view controller, it must call the didMove(toParent:) method of the child view controller after the transition to the new controller is complete or, if there is no transition, immediately after calling the addChild(_:) method.

The removeFromParent() method automatically calls the didMove(toParent:) method of the child view controller after it removes the child.

Parameters

parent: the parent view controller, or nil if there is no parent.


Good, we can move on:

  1. Set the center of the card view to the position index of the loop and the image of the front of the card to be the index element of the images array.
  2. If the image of the front of the card is the star, set its isCorrect property to true.
  3. Append the card to the allCards array?

Call this method in the viewDidLoad one. Now add this to the beginning of the loadCards() method (really? Why not doing this before?!)

  1. For each card in allCards: remove its view from its superview and remove it from the parent.
  2. Remove every element from the allCards array while keeping its capacity.

Animating a 3D flip effect using transition(with:)

In CardViewController.swift, at the end of viewDidLoad() declare a new UITapGestureRecogniser named tap with a target of self and an action of the yet unwritten selector cardTapped. Then, set the back of the card to be user interactive and add the gesture recogniser to it.

This method simply does one thing: transfer control and responsibility to the main view controller by calling the cardTapped(self) method of the delegate property.

In ViewController.swift create a new cardTapped(_:) method with a single parameter of type CardViewController.

  1. Check that the view is user interactive otherwise return from the method.
  2. Set the user-interactive capability of the view to false.
  3. Loop over each card in the allCards array then, if the card is equal to the parameter, call the wasTapped() method on it, then perform a selector on it called wasntTapped with no arguments after a delay of 1 second, otherwise simply call the wasntTapped() method on the card.
  4. After all this perform a new selector called loadCards (we should know this method by now), with no arguments after a 2 seconds delay.

The new thing here, apart from the two yet unwritten methods, is the perform method, which is thus described in the Documentation:

Summary

Invokes a method of the receiver on the current thread using the default mode after a delay.

Declaration

Discussion

This method sets up a timer to perform the aSelector message on the current thread’s run loop. The timer is configured to run in the default mode (NSDefaultRunLoopMode). When the timer fires, the thread attempts to dequeue the message from the run loop and perform the selector. It succeeds if the run loop is running and in the default mode; otherwise, the timer waits until the run loop is in the default mode.

If you want the message to be dequeued when the run loop is in a mode other than the default mode, use the perform(_:with:afterDelay:inModes:) method instead. If you are not sure whether the current thread is the main thread, you can use the performSelector(onMainThread:with:waitUntilDone:) or performSelector(onMainThread:with:waitUntilDone:modes:) method to guarantee that your selector executes on the main thread. To cancel a queued message, use the cancelPreviousPerformRequests(withTarget:) or cancelPreviousPerformRequests(withTarget:selector:object:) method.

Parameters

aSelector : a Selector that identifies the method to invoke. The method should not have a significant return value and should take a single argument of type id, or no arguments.

anArgument: the argument to pass to the method when it is invoked. Pass nil if the method does not take an argument.

delay: the minimum time before which the message is sent. Specifying a delay of 0 does not necessarily cause the selector to be performed immediately. The selector is still queued on the thread’s run loop and performed as soon as possible.

Enriched by our newfound knowledge we can now go to the beginning of the loadCards() method and set the view’s user interaction capabilities to be true.

Back in CardViewController.swift, implement the new @objc func wasntTapped():

  1. Animate the UIView with a duration of 0.7 seconds
  2. Set the transform property of the view (with self before?!) to be a Core Graphic affine transform to a scale of both x and y values of 0.00001 (that is 1/100000th?).
  3. Set the view’s alpha to 0, always with self before, not sure why…

Write the @objc func wasTapped() now:

  1. Call the .transition(with:duration:options:animations:) method on the UIView with view, 0.7, [.transitionFlipFromRight] as first arguments and invoke the closure as the last one.
  2. Inside the closure, capture an unowned self, then set the back of the card to be hidden and the front to be shown.

Adding a CAGradientLayer with IBDesignable and IBInspectable

Make a new Cocoa Touch Class subclassing UIView called GradientView. Make this class @IBDesignable and then:

  1. Create two new @IBInspectable variable properties of type UIColor initialised with .white and .black.
  2. override a class var called layerClass of type AnyClass, which is a computed property and should return CAGradientLayer.self. WHAT?! Did you understand a single word of that? I for sure didn’t and in the book it is not explained (I mean, it explains what this whole class does but not why this code is there).
  3. Override the layoutSubviews() method which sets the colours of the layer of the view (implicitly downcast as a CAGradientLayer) as an array of the .cgColor version of our topColor and bottomColor.

Here is what the Documentation has to say about the layerClass type property:

Type Property

layerClass

Returns the class used to create the layer for instances of this class.

Declaration

Return Value

The class used to create the view’s Core Animation layer.

Discussion

This method returns the CALayer class object by default. Subclasses can override this method and return a different layer class as needed. For example, if your view uses tiling to display a large scrollable area, you might want to override this property and return the CATiledLayer class, as shown in Listing 1.

// Listing 1 
// Returning a tiled layer
override class var layerClass : AnyClass {
   return CATiledLayer.self}

This method is called only once early in the creation of the view in order to create the corresponding layer object.

Good, I feel better and I wonder why this was not explained to us, but I always make too many question, ain’t I? The layoutSubviews() method, instead, shows this entry in the Documentation:

Summary

Lays out subviews.

Declaration

Discussion

The default implementation of this method does nothing on iOS 5.1 and earlier. Otherwise, the default implementation uses any constraints you have set to determine the size and position of any subviews.

Subclasses can override this method as needed to perform more precise layout of their subviews. You should override this method only if the autoresizing and constraint-based behaviors of the subviews do not offer the behavior you want. You can use your implementation to set the frame rectangles of your subviews directly.

You should not call this method directly. If you want to force a layout update, call the setNeedsLayout() method instead to do so prior to the next drawing update. If you want to update the layout of your views immediately, call the layoutIfNeeded() method.

Good, we can proceed!

Go to Main.storyboard, drag a new UIView to the canvas, make it stretch from edge to edge of the device and pin it, before sending it to the back via Editor > Arrange > Send to back.

Paul was not clear here about whether we had to stretch the view from edge to edge of the screen or of the safe area but, regardless, we should select the main view of the view controller and uncheck Safe Area Relative Margins and Safe Area Layout Guide in the size inspector. I am not sure this is completely up to date but well … let’s move on.

Now select the last view we added and change its class to be Gradient View in the Identity Inspector. This will add a new line called Designables which will change between “updating” and “up to date” according to what you are doing currently. In the Attributes Inspector we should now see a new category at the top called “Gradient View” with two editable properties: Top Color and Bottom Color.

Set the Top Color to “Dark Grey Color” and the Bottom Color to “Black Color” … uh?! Then set the alpha of the view to 0.9.

Create an outlet for this gradient view. Once more, Paul doesn’t say where to put this but I assumed (and Xcode did too!) that this had to go inside the ViewController.swift file… Let’s hope this is the right one. The image Paul shows is not the same as the one we get as we followed his instructions to have Dark Grey and Black while his image is still white and black gradient … What do I have to do to have a tutorial that is correct?

Anyway, go to ViewController.swift and, in viewDidLoad() set the view’s background color to be red and animate the UIVIew over 20 seconds with no delay and the options to allow user interaction, autoreverse and repeat so that the background color of the view becomes blue.

The result is for sure pleasing but I wonder if the fact that we changed to dark grey and black influenced this: it is all so dark …

Creating a particle system using CAEmitterLayer

Write a createParticles() method inside ViewController.swift:

  1. Declare a new CAEmitterLayer().
  2. Set its .emitterPosition property to be a CGPoint with x equal to half the width of the frame of the view and y equal to -50.
  3. Set its .emitterShape to be .line, its size to be equal to a CGSize of the width of the view’s frame times 1 point and its .renderMode to be .additive (not sure what source-additing compositing means).
  4. Declare a new CAEmitterCell()
  5. Set its birthrate to 2, its lifetime to 5, its velocity to 100, its velocity range to 50, its emission longitude to .pi (it should be on a range of 180°), its spin range to 5 (cannot evaluate this without proper explanation), its scale to 0.5, its scale range to 0.25.
  6. Set its color to be a UIColor of full white with 0.1 alpha in its .cgColor version, its alphaSpeed to -0.025 (why if we are not setting an alpha-range?).
  7. Set its content to be an UIImage named “particle” which, if found, must be converted to a .cgImage.
  8. Set the particle emitter’s .emitterCells property to be an array containing the cell.
  9. Add the particle emitter as a sublayer to the layer of the gradient view.

Call this method in viewDidLoad(), just before loadCards().

Wiggling cards and background music with AVAudioPlayer.

Go to CardViewController.swift and create a new @objc func wiggle():

  1. If a random number between 0 and 3 inclusive is equal to 1 (that is 25% of the times), animate the UIView with a duration of 1/5 of a second, no delay and the option to allow user interaction.
  2. In the animation block, set the transform property of the back of the card to scale both dimensions by 1%, and in the completion block set it back to the .identity affine transform.
  3. Perform the wiggle selector without arguments after 8 seconds.
  4. In the other 75% of possibilities perform the same selector, just after 2 seconds.

Call this method in a perform(aSelector:with:afterDelay:)at the end of viewDidLoad.

In ViewController.swift import AVFoundation, create a new implicitly unwrapped music of type AVAudioPlayerand create a new playMusic() method:

  1. If you can conditionally bind the URL of the resource named “PhantomFromSpace.mp3” contained in the main application bundle to a constant musicURL, optionally try to conditionally bind the contents of that URL in a new AVAudioPlayer and store it in the audioPlayer constant.
  2. If all this succeeds, set the music property to be the audio player, the number of loops to -1 (i.e. infinite) and call the play() method on it.

Call the playMusic() method at the end of viewDidLoad().

How to measure touch strength using 3D Touch

Override and implement the touchesMoved(_:with:) method:

  1. Call the super. version of this method so that it inherits what it needs to.
  2. Check that there is a touch and that it is the first one, then store it in a constant.
  3. Store the location of that touch in the card container in a constant.
  4. Loop over each card in the allCards array then, if the frame of the card’s view contains that location, check if the view is able to receive 3D Touch by checking if the forceTouchCapability property of the view’s trait collection is equal to .available.
  5. If so, check if the force of the touch is equal to the maximum possible force, in which case, change the image of the front of the card to a UIImage named “cardStar”, that is, our winning card.
  6. Finally set the .isCorrect property of the card to true.

Communicating between iOS and watchOS: WCSession

In ViewController.swift import WatchConnectivity.

In viewDidLoad(), at the end of the method, check is the Watch Connectivity Session is supported and, if so, create a new default WCSession, set the session’s delegate to self and activate it. Don’t forget to make the view controller conform to the WCSessionDelegate protocol, described down here:

Summary

A delegate protocol that defines methods for receiving messages sent by a WCSession object.

Declaration

Discussion

Session objects are used to communicate between a WatchKit extension and the companion iOS app on a paired and active iPhone. When configuring your session object, you must specify a delegate object that implements this protocol. The session calls your delegate methods to deliver incoming data from the counterpart app and to manage session-related changes.

Most methods of this protocol are optional. You implement the methods you need to respond to the data transfer operations that your apps support. However, apps must implement the session(_:activationDidCompleteWith:error:) method, supporting asynchronous activation. On iOS, you must also implement the sessionDidBecomeInactive(_:) and sessionDidDeactivate(_:) methods, supporting multiple Apple Watches.

The WCSession object calls the methods of its delegate serially, so your method implementations do not need to be reentrant. Immediate messages can be sent only while both the WatchKit extension and iOS app are running. By contrast, context updates and file transfers can be initiated at any time and delivered in the background to the other device. All transfers are delivered in the order in which they were sent.

Note

The methods of this protocol are called on a background thread of your app, so any code you write should be written with that fact in mind. In particular, if your method implementations initiate modifications to your app’s interface, make sure to redirect those modifications to your app’s main thread.

Add, to the end of the class, the three methods that are needed to conform to this protocol, even if they can stay empty by now: session(_:activationDidCompleteWith:error:), sessionDidBecomeInactive(_:) and sessionDidDeactivate(_:).

Write a new sendWatchMessage():

  1. Fetch the current absolute time and store it into a constant.
  2. If the time of the last message plus 0.5 is greater than the current time, return from the method. I kind of understand what this line is doing but I do not get the logic of how it is implemented. This will make sense only if this method is called thousand of times per second, which it probably is, as far as I know.
  3. If the default WCSession is reachable, create a [String: String] dictionary with “Message” as key and “Hello” as value (or whatever you like, really), then send it without a reply or completion handler.
  4. Update the lastMessage property with the current time.

Inside the touchesMoved() method, at the end of the contains condition, call the sendWatchMessage() method if the card is correct.

Designing a simple watchOS app to receive data

Open Interface.storyboard in the “Psychic Tester WatchKit App” folder. Drag a label and a button into the storyboard.

Select the Label, then in the Attributes Inspector, set its Lines property to be 0, align its text to center and give it a text of “Please read the instructions on your phone before continuing”.

Select the Button and give it a text of “I’m Ready”.

Select both Label and Button and, in the Alignment section of the Attributes Inspector set both properties to “Center”.

Invoke the Assistant Editor so that it opens the InterfaceController.swift file. Create two outlet connections, a welcomeText Interface Label and a hideButton Interface Button. Finally create an action called hideWelcomeText from the button.

Back to the Standard Editor open InterfaceController.swift.

Import the WatchConnectivity framework. Inside the willActivate() method, check if the WCSession is available and, if so, create a new default session, make self its delegate and activate it.

Conform to the WCSessionDelegate protocol and add an empty session(_:activationDidCompleteWith:error:) method so that the error gets silenced.

Inside the hideWelcomeText() action call the .setHidden(true) method on both the label and the button.

Implement the session(_:didReceiveMessage:) method by calling the .play(.click) method on the WKInterfaceDevice() object.

Wrap up

I do not have an Apple Watch so I cannot check that this works but I trust Paul with this (and I plan to buy one in the Fall if I can afford it).

The proposed challenges here are:

  1. Implement the sessionWatchStateDidChange() method in ViewController.swift to detect when the Watch goes to sleep — if we can make our phone play a brief but innocuous sound, it would alert us to wake our watch.
    • I implemented this but didn’t find any royalty free and free to download sound to test this (fine, I didn’t search that hard but I also don’t have all this time right now). I have put the code for the sound playing inside an if !session.isReachable as I guess this is what will trigger it. By now I have no way to check it so I have just commented out that code. I will see in the future.
  2. Add a hidden button to the Watch user interface that enables “always win mode” — i.e., every card that gets tapped will be the star.
    • First I tried to create two different game modes with an enumeration at the top of the view controller, with two cases normal and alwaysWin. Then I created a new property called gameMode of type GameMode with a default value of .normal.
    • Towards the end of the loadCards() method, after the card.view.center = position line, I wrote a check so that if the gameMode is normal it will call the already written code, otherwise it will put the star. Now, how to make things switch?
    • I created a new button at the bottom of the interface of the Apple Watch and cancelled the text. If I check “hidden” it will go away from the interface but I guess this is what will happen also with the others… The issue is that checking “hidden” redrew the interface so that the button, even if invisible, would not be clickable.
    • To recognise it I entered a “Win mode” text inside it. I then created an outlet and an action for it and added the code to hide this button as well into the hideWelcomeText method.
    • Now, how to send a message back to the iPhone? Setting the game mode as a static var doesn’t help because we are on a different device … From the sample code I read I could not find anything interesting or helpful (as usual I would say). It seems everything outside of a tutorial is a well kept secret in coding! I tried this in the InterfaceController.swift file:
    • In ViewController.swift, instead, at the end, I implemented this:
    • Could anyone check if this is even remotely correct? Thank you so much!

Paul wrote an entire book on making watchOS app, which you can check out here.


Please don’t forget to drop a hello and a thank you to Paul for all his great work (you can find him on Twitter) and be sure to visit the 100 Days Of Swift initiative page. We are learning so much thanks to him and he deserves to know of our gratitude.

He has written about 20 great books on Swift, all of which you can check about here (affiliate links so please use them if you want to support both his work .


If you like what I’m doing here please consider liking this article and sharing it with some of your peers. If you are feeling like being really awesome, please consider making a small donation to support my studies and my writing (please appreciate that I am not using advertisement on my articles).

If you are interested in my music engraving and my publications don’t forget visit my Facebook page and the pages where I publish my scores (Gumroad, SheetMusicPlus, ScoreExchange and on Apple Books).

You can also support me by buying Paul Hudson’s books from this Affiliate Link.

Anyways, thank you so much for reading!

Till the next one!

Published by Michele Galvagno

Professional Musical Scores Designer and Engraver Graduated Classical Musician (cello) and Teacher Tech Enthusiast and Apprentice iOS / macOS Developer Grafico di Partiture Musicali Professionista Musicista classico diplomato (violoncello) ed insegnante Appassionato di tecnologia ed apprendista Sviluppatore iOS / macOS

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: