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:
- A weak reference to an implicitly unwrapped
ViewController
calleddelegate
. - A
front
implicitly unwrappedUIImageView
. - A
back
implicitly unwrappedUIImageView
. - A Boolean
isCorrect
set too false.
Add the following code to viewDidLoad()
:
- Set the view’s bound to a new
CGRect
with origin at0, 0
, width of 100 points and height of 140 points. - Set the
front
andback
properties to a newUIImageView(image:)
passing them aUIImage(named:)
“cardBack” (no, it’s not a typo). - Add both of them as subviews to the view.
- Hide the front one and set the back’s alpha to
0
. - Animate the alpha of the back to 1 over 0.2 seconds.
Go to ViewController.swift and create a new empty array of CardViewController
s called allCards
.
Create a new @objc
method called loadCards()
:
- Initialise an array of 8
CGPoint
s with values (75, 185, 295, 405) for thex
and either 85 or 235 for they
. - Initialise 5 different
UIImages
s from the file named “card(shape)”. - 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!
- For
(index, position)
in the enumerated version of the positions array, declare a newCardViewController()
, set its delegate toself
, add it as a child to the view controller (is this a new possible thing?!), then add its view as a subview of thecardContainer
array, then call thedidMove(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 theaddChild(_:)
method.The
removeFromParent()
method automatically calls thedidMove(toParent:)
method of the child view controller after it removes the child.Parameters
parent
: the parent view controller, ornil
if there is no parent.
Good, we can move on:
- 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 theindex
element of theimages
array. - If the image of the front of the card is the star, set its
isCorrect
property totrue
. - 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?!)
- For each card in
allCards
: remove its view from its superview and remove it from the parent. - 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
.
- Check that the view is user interactive otherwise return from the method.
- Set the user-interactive capability of the view to false.
- Loop over each card in the
allCards
array then, if the card is equal to the parameter, call thewasTapped()
method on it, then perform a selector on it calledwasntTapped
with no arguments after a delay of 1 second, otherwise simply call thewasntTapped()
method on the card. - 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 theperformSelector(onMainThread:with:waitUntilDone:)
orperformSelector(onMainThread:with:waitUntilDone:modes:)
method to guarantee that your selector executes on the main thread. To cancel a queued message, use thecancelPreviousPerformRequests(withTarget:)
orcancelPreviousPerformRequests(withTarget:selector:object:)
method.Parameters
aSelector
: aSelector
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. Passnil
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()
:
- Animate the UIView with a duration of 0.7 seconds
- 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?). - Set the view’s alpha to 0, always with
self
before, not sure why…
Write the @objc func wasTapped()
now:
- Call the
.transition(with:duration:options:animations:)
method on the UIView withview
,0.7
,[.transitionFlipFromRight]
as first arguments and invoke the closure as the last one. - 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:
- Create two new
@IBInspectable
variable properties of typeUIColor
initialised with.white
and.black
. override
aclass var
calledlayerClass
of typeAnyClass
, which is a computed property and should returnCAGradientLayer.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).- Override the
layoutSubviews()
method which sets the colours of thelayer
of the view (implicitly downcast as aCAGradientLayer
) as an array of the.cgColor
version of ourtopColor
andbottomColor
.
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 theCATiledLayer
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 thelayoutIfNeeded()
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:
- Declare a new
CAEmitterLayer()
. - Set its
.emitterPosition
property to be aCGPoint
withx
equal to half the width of the frame of the view andy
equal to-50
. - Set its
.emitterShape
to be.line
, its size to be equal to aCGSize
of the width of the view’s frame times1
point and its.renderMode
to be.additive
(not sure what source-additing compositing means). - Declare a new
CAEmitterCell()
- 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. - Set its color to be a
UIColor
of full white with 0.1 alpha in its.cgColor
version, itsalphaSpeed
to -0.025 (why if we are not setting an alpha-range?). - Set its content to be an
UIImage
named “particle” which, if found, must be converted to a.cgImage
. - Set the particle emitter’s
.emitterCells
property to be an array containing the cell. - 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()
:
- 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.
- 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. - Perform the
wiggle
selector without arguments after 8 seconds. - 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 AVAudioPlayer
and create a new playMusic()
method:
- 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 newAVAudioPlayer
and store it in theaudioPlayer
constant. - If all this succeeds, set the
music
property to be the audio player, the number of loops to-1
(i.e. infinite) and call theplay()
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:
- Call the
super.
version of this method so that it inherits what it needs to. - Check that there is a touch and that it is the first one, then store it in a constant.
- Store the location of that touch in the card container in a constant.
- 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 theforceTouchCapability
property of the view’s trait collection is equal to.available
. - 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. - 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 thesessionDidBecomeInactive(_:)
andsessionDidDeactivate(_:)
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()
:
- Fetch the current absolute time and store it into a constant.
- 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.
- 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. - 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:
- 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.
- 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
- 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
andalwaysWin
. Then I created a new property calledgameMode
of typeGameMode
with a default value of.normal
. - Towards the end of the
loadCards()
method, after thecard.view.center = position
line, I wrote a check so that if thegameMode
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!
- First I tried to create two different game modes with an enumeration at the top of the view controller, with two cases
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!