COMP 1601 2021W Assignment 2 Solutions 1a. The difference between picviewer-1 and picviewer-2 regarding how angles were stored is that picviewer-1 stores angles as an Angle struct while picviewer-2 stores angles as a string (encoding a number in degrees). 1b. picviewer-1 stores the angle of rotation as an Angle object, while picviewer-2 stores it as a String. We can revert the code to what was there in picviewer-1 by changing all references to angleS to angle. The hard part is the code with the TextField in lines 37-41. According to the Apple documentation for TextField, in order to accept input for a non-String, we need to supply a Formatter class to translate back and forth between a String and the Angle object. There is no standard Formatter to do this, so we'd have to create our own. Creating a custom Formatter is possible, but the code is tricky as it is actually interfacing with NSFormatter, an Objective C class. 2a. currentAngle() returns an Angle struct representing the current angle of rotation based on the numeric value in the string angleS. If angleS cannot be converted to a number (Double), it defaults to a zero degree angle. 2b. currentAngle() is called every time the ActiveImage view needs to be redrawn, which happens every time one of the local state variables or bindings to state variables change. These variables change when the user interacts with the app in some way, via gesture or typed input. Specificially, currentAngle() is called on line 64 to determine the rotation of the image. 2c. Replace line 64 with the following: .rotationEffect(Angle(degrees: Double(self.angleS) ?? 0)) This code creates an Angle struct and passes it to the rotationEffect method. The Angle is initialized with the number in AngleS for the number of degrees. If AngleS cannot be converted to a Double, 0 degrees is used. 3. Replace line 88 (the body of the .onChanged handler of the rotation gesture) with the following: let d = round(angle.degrees / 45) * 45 self.angleS = String(format: "%.0f", d) We only have to change this one part of the program because this is the part that handles storing rotation gesture inputs. With this change the angle text field still allows for arbitrary angle inputs. 4. First, declare a dictionary at the top of ContentView: var animals = [ "Kittens": "kittens", "Sad Dog": "sadDog", "Tab w/ Attitude": "tabAttitude", "Sleeping Shift": "sleepyShift"] Then, change the body of the Menu (lines 24-31) with the following: let animalList = [String] (animals.keys) ForEach(animalList, id: \.self) {a in Button(a, action: { theImage = animals[a]! resetState() }) } This code makes an array out of the keys of the animals dictionary, and then uses a SwiftUI ForEach to generate the buttons. We use a ForEach because it returns a dynamically-generated view of the contents of the given RandomAccessCollection (such as an array). 5. To add the double tap gesture, we just add a handler between lines 66 and 67 (just before the onTapGesture that is already there): .onTapGesture(count: 2, perform: nextImage) Note this needs to be placed before we add the single tap gesture handler, otherwise it will never be called. We then define a nextImage function inside ActiveImage as follows: func nextImage() { let imageList = [String] (animals.values) if let i = imageList.firstIndex(of: self.theImage) { self.theImage = imageList[(i + 1) % imageList.count] } else { print("Couldn't look up \(self.theImage)") } } This function first gets a list of images by creating an array of values of the animals dictionary. We then find the index of the current image and add one to it to get the next image (looking up the name of the new image in the imageList array). We do this modulo the number of images (imageList.count) as we want to wrap around. (Note that firstIndex is relatively slow, since it has to loop through all of the elements of the imageList in the worst case to find a match. We could speed this up by using a dictionary that mapped image tags to their array indices, but for a small array like this the performance difference would be hard to measure.) 6. First, note that we need the magnification and position variables to be strings so they can be edited. So, remove the old magnification CGFloat variable (line 12) and add there three new state variables: @State private var magS = "1" @State private var pX = "0" @State private var pY = "0" Then change line 17, make it reset the value of magS rather than magnification. magS = "1" And update the ActiveImage view inclusion to pass on these new state variables and not pass on magnification (lines 33-34) ActiveImage(theImage: $theImage, moved: $moved, magS: $magS, angleS: $angleS, pX: $pX, pY: $pY) Update the state variables at the top of ActiveImage with the following: @State private var currentAmount: CGFloat = 0 @Binding var theImage: String @Binding var moved: Bool @Binding var magS: String @Binding var angleS: String @Binding var pX: String @Binding var pY: String Note that finalAmount is gone and magS, pX, and pY have been added. Change .scaleEffect on line 63 to use magS. We can have it default to 1 by using the ?? operator: .scaleEffect(CGFloat(Double(magS) ?? 1) + currentAmount) To use pX and pY for position, change lines 61 and 62 to be: .position(moved ? CGPoint(x: Double(pX) ?? 0, y: Double(pY) ?? 0): CGPoint(x: g.size.width / 2, y: g.size.height / 2)) Change the first line of tapReset() (line 80) to reset magS, not finalAmount: self.magS = "1" To fix the magnifying gesture, replace the body of the onEnded handler (lines 97-98) with the following: let mag = (Double(magS) ?? 1) + Double(self.currentAmount) self.magS = String(format: "%.2f", mag) self.currentAmount = 0 To fix the dragging so it updates pX and pY, change the body of onChanged (lines 105-106) and onEnded (109-110) to the following: self.moved = true pX = String(format: "%.0f", s.location.x) pY = String(format: "%.0f", s.location.y) At this point everything should run as before. But now change the main view to show and allow editing of the values. Add the following between lines 41 and 42: Text("M:") TextField("", text: $magS) Text("X:") TextField("", text: $pX, onCommit: { moved = true }) Text("Y:") TextField("", text: $pY, onCommit: { moved = true }) Note this code is doing basically what the code for displaying degrees, except the only special handling we have is to set moved to true when the X and Y values are committed. At this point you have a full solution. However, when you start the app, tap on the image or select a new image from the menu, it does not properly display the coordinates. Below we show how to show the right coordinates on single tap events. This approach is a bit advanced, feel free to ignore. We can display the proper coordinates for image on tap by changing the tap handler to the following: func tapReset(_ g: GeometryProxy) -> () -> Void { return { self.magS = "1" self.moved = false self.angleS = "0" self.pX = String(Int(g.size.width / 2)) self.pY = String(Int(g.size.height / 2)) } } Note this returns a function that then actually is responsible for setting the coordinates. We need this because the geometry is out of scope. We then register the tap handler as follows: .onTapGesture(count: 1, perform: tapReset(g)) If we don't want to go through the whole making a function that returns a function, we could instead just do this: .onTapGesture(count: 1, perform: { self.magS = "1" self.moved = false self.angleS = "0" self.pX = String(Int(g.size.width / 2)) self.pY = String(Int(g.size.height / 2)) }) Getting the menu reset events is even more complicated because the geometry of the ActiveImage view isn't available in ContentView, and there are lots of restrictions on how information can be changed. To do this properly you'd probably need to reorganize how state is maintained, say by using @EnvironmentObject. We likely won't cover these in this course.