Solutions for Stanford CS193p 2020 course assignments. Use different commits to browse repo files of certain Lectures and Assignments.
Projects are written in Xcode Version 12.0 beta 2 and 12.3 later.
Type | Quantity | Completion |
---|---|---|
Lectures | 14 / 14 | 100% |
Assignment I | 6 / 6 | 100% |
Extra credits | 1 / 1 | +100% |
Assignment II | 9 / 9 | 100% |
Extra credits | 1 / 2 | +50% |
Assignment III | 19 / 19 | 100% |
Extra credits | 4 / 11 | +36% |
Assignment IV | 10 / 10 | 100% |
Extra credits | 1 / 1 | +100% |
Assignment V | 2 / 2 | 100% |
Assignment VI | 14 / 14 | 100% |
Extra credits | 2 / 2 | +100% |
Project is made with Xcode Version 12.0 beta 2.
Task 1. The code accordingly to the Lecture 8.
Task 2 - 5. Support the selection of the emojis.
- Use a Set to collect selected emojis.
- Write a
toggleMatching()
method as an extension to the Set that allows adding (selection) and removing (deselection) emojis from the Set. Call this method.onTapGesture
for each emoji.- Customize an appearance of selected emojis with the ternany operator (? :).
- Add
.exclusively(before:)
to allow two variants of tap gesture: double tap for zoom and one tap to deselect all emojis.
// 1
@State private var selectedEmojis: Set<EmojiArt.Emoji> = []
// 2 Set+Identifiable.swift
extension Set where Element: Identifiable {
mutating func toggleMatching(_ element: Element) {
if contains(matching: element) {
remove(element)
} else {
insert(element)
}
}
}
// View
ZStack {
Color.white
ForEach(document.emojis) { emoji in
Text(emoji.text)
// 2
.onTapGesture {
selectedEmojis.toggleMatching(emoji)
}
// 3
.background(
Circle()
.stroke(Color.red)
.opacity(isSelected(emoji) ? 1 : 0)
)
}
}
// 4
.gesture(tapBgToDeselectOrZoom(in: geometry.size))
---
private func tapBgToDeselectOrZoom(in size: CGSize) -> some Gesture {
TapGesture(count: 1)
.exclusively(before: doubleTapToZoom(in: size))
.onEnded { _ in
withAnimation(.linear(duration: 0.2)) {
selectedEmojis.removeAll()
}
}
}
// 3
private func isSelected(_ emoji: EmojiArt.Emoji) -> Bool {
selectedEmojis.contains(matching: emoji)
}
Task 6 - 7. Dragging emojis
Create
@State
and@GestureState
variables and a function for the Drag Gesture. Add a.gesture()
modifier with this gesture to an emoji.A background image will be dragged if there is no selection of emojis.
@State private var steadyStateDragEmojisOffset: CGSize = .zero
@GestureState private var gestureDragEmojisOffset: CGSize = .zero
private func dragEmojisGesture(for emoji: EmojiArt.Emoji) -> some Gesture {
DragGesture()
.updating($gestureDragEmojisOffset) { latestDragGestureValue, gestureDragEmojisOffset, transition in
gestureDragEmojisOffset = latestDragGestureValue.translation / zoomScale
}
.onEnded { finalDragGestureValue in
let distanceDragged = finalDragGestureValue.translation / zoomScale
for emoji in selectedEmojis {
withAnimation {
document.moveEmoji(emoji, by: distanceDragged)
}
}
}
}
---
ForEach(document.emojis) { emoji in //...
.gesture(dragEmojisGesture(for: emoji))
}
Task 8 - 9. Pinching emojis
Improve a
zoomScale
variable in the way that if there is a selection of emojis during pinching a size of the bg image will stay the same.Add a method to calculate zoomScale for each emoji.
Modify
zoomGesture()
to change a scale of the selected emojis only, or a scale of the whole picture if there is no selection.
// 1
private var zoomScale: CGFloat {
steadyStateZoomScale * (selectedEmojis.isEmpty ? gestureZoomScale : 1)
}
// 2
private func zoomScale(for emoji: EmojiArt.Emoji) -> CGFloat {
if isSelected(emoji) {
return steadyStateZoomScale * gestureZoomScale
} else {
return zoomScale
}
}
.font(animatableWithSize: emoji.fontSize * zoomScale(for: emoji))
// 3
private func zoomGesture() -> some Gesture { //...
.onEnded { finalGestureScale in
if selectedEmojis.isEmpty {
steadyStateZoomScale *= finalGestureScale
} else {
selectedEmojis.forEach { emoji in
document.scaleEmoji(emoji, by: finalGestureScale)
}
}
}
}
Task 10. Deleting emojis
Add to the ViewModel
removeEmoji()
function.Implement this function in the View.
Variant A. Deleting with the Long Tap Gesture.
Variant B. Selected emojis can be deleted with the tap on the Bin 🗑.
// 1 ViewModel
func removeEmoji(_ emoji: EmojiArt.Emoji) {
if let index = emojiArt.emojis.firstIndex(matching: emoji) {
emojiArt.emojis.remove(at: index)
}
}
// 2. Variant A
private func longPressToRemove(_ emoji: EmojiArt.Emoji) -> some Gesture {
LongPressGesture(minimumDuration: 1)
.onEnded { _ in
document.removeEmoji(emoji)
}
}
---
ForEach(document.emojis) { emoji in //...
.gesture(longPressToRemove(emoji))
}
// 2. Variant B
Button(action: {
selectedEmojis.forEach { emoji in
document.removeEmoji(emoji)
selectedEmojis.remove(emoji)
}
}) {
Image(systemName: "trash.fill")
.font(.headline)
.foregroundColor(selectedEmojis.isEmpty ? .gray : .blue)
}
Extra credit 1. Dragging unselected emojis
Add condition in the
.onEnded { }
modifier of thedragEmojisGesture(for:)
function. If an emoji isn't a part of the selection it will be moved anyway by the dragged distance.
private func dragEmojisGesture(for emoji: EmojiArt.Emoji) -> some Gesture {
// Extra credit
let isEmojiPartOfSelection = isSelected(emoji)
return DragGesture()
.updating($gestureDragEmojisOffset) { latestDragGestureValue, gestureDragEmojisOffset, transition in
gestureDragEmojisOffset = latestDragGestureValue.translation / zoomScale
}
.onEnded { finalDragGestureValue in
let distanceDragged = finalDragGestureValue.translation / zoomScale
// Extra credit `if-else`
if isEmojiPartOfSelection {
for emoji in selectedEmojis {
withAnimation {
document.moveEmoji(emoji, by: distanceDragged)
}
}
} else {
document.moveEmoji(emoji, by: distanceDragged)
}
}
}
Task 1 - 19. Make a game of Set
Since the 1st task sounds like "Implement a game of solo Set" and other 18 tasks also cannot be called unexacting, I'm not providing here detailed answers for each task. However you can find some comments and print output in the Set Game project I've made.
I used a new feature of SwiftUI
LazyVGrid
instead of the Grid provided by Professor.This project is made with Xcode Version 12.0 beta 2.
Task 1. The code from the 1st & 2nd Lectures
Task 2. Unpredictable order
Swift provides an easy solution to randomize Array. In the ViewModel I added a shuffled() method to randomize emojis to play with and in the Model a shuffle() method to initialize a new game with shuffled cards
let emojis = theme.emojis.shuffled()
cards.shuffle()
Task 3. Width and height in a certain proportion
Applied to the ZStack of the View, because of the better solution it was commented later
.aspectRatio(2/3, contentMode: .fit)
Task 4. Randomize number of pairs of cards
In the 2nd Assignment it is changed for an another solution
let numberOfPairs = Int.random(in: 2...5)
Task 5. Adjust size of the font
After lecture #4 a Grid is applied to the project
.font(EmojiMemoryGame.numberOfPairs > 4 ? .callout : .largeTitle)
Extra Credit 1.
Solution for that was displayed earlier
let emojis = theme.emojis.shuffled()
Task 1. The code from the 3rd & 4th Lectures.
Task 2 - 5. Architect the concept of a 'theme' into the game
'static' properties belong to the type; a new theme can be added as an another 'static' property or by using
append(newTheme)
to the variable 'theme'. I prefer 'static' because I can see all themes in one place
struct Theme {
var name: String
var emojis: [String]
var cardsNumber: Int?
var color: Color
static let cats = Theme(name: "Cats", emojis: ["😺", "😸", "😹", "😻", "🙀", "😿", "😾", "😼"], color: .yellow)
... // other themes in the project //
static var themes = [cats, techno, zodiac, animals, vegetables, flowers]
}
Initialize a theme property in ViewModel
class EmojiMemoryGame: ObservableObject {
...
var theme: Theme
init() {
let theme = Theme.themes.randomElement()!
self.theme = theme
model = EmojiMemoryGame.createMemoryGame(with: theme)
}
...
}
Task 6. Add 'New Game' button
Add a Reset button in ViewModel and then use it functionality in the View
// ViewModel
func resetGame() {
theme = Theme.themes.randomElement()!
model = EmojiMemoryGame.createMemoryGame(with: theme)
}
// View
Button(action: {
self.viewModel.resetGame()
}) {
Text("New Game")
}
Task 7. Display a theme in UI
Text(viewModel.theme.name)
Task 8. Giving and penalizing points
The logic should be implemented in the Model. To the 'Card' struct we need to add a new boolean property 'alreadyBeenSeen' that will help us to penalize when cards haven't been matched during the 1st try.
In the
choose(card:)
method replace the 'indexOfTheOneAndOnlyFaceUpCard' index which was created in the 4th lecture with the array 'faceupCardIndeces'. The new element will contain up to 2 indeces and flip cards face down when they don't match.Additional comments in the project.
mutating func choose(card: Card) {
let faceupCardIndeces = cards.indices.filter { cards[$0].isFaceUp }
\\\ third tap; when two cards are opened, update there Seen status and flip them; restart
if faceupCardIndeces.count > 1 {
for index in cards.indices {
if cards[index].isFaceUp {
cards[index].alreadyBeenSeen = true
cards[index].isFaceUp = false
}
}
}
/// when the 1st and then the 2nd card has chosen which hasn't been matched yet
if let chosenIndex = cards.firstIndex(matching: card), !cards[chosenIndex].isFaceUp, !cards[chosenIndex].isMatched {
/// 1st tap: choose card - > flip the 1st card;
/// 2nd tap: choose another card (start over func) -> flip the second card but in faceUpIndeces will be only one index still
cards[chosenIndex].isFaceUp = true
if faceupCardIndeces.count == 1 {
if cards[faceupCardIndeces[0]].content == cards[chosenIndex].content {
cards[chosenIndex].isMatched = true
cards[faceupCardIndeces[0]].isMatched = true
/// add two points for match even if cards had been seen
score += 2
} else {
/// penalizing 1 point for each card that has been seen but hasn't been matched yet
if cards[chosenIndex].alreadyBeenSeen {
score -= 1
}
if cards[faceupCardIndeces[0]].alreadyBeenSeen {
score -= 1
}
}
}
}
}
Task 9. Display the score in UI
Add score as a variable in ViewModel and then display in the VIew
var score: Int { model.score }
// ViewModel
Text("\(viewModel.score)")
// View
Extra Credit 1. Support a gradient as the 'color' for a theme
In the View (because it's a visual improvement) I added a computed property to make a gradient from the theme's color. 'CardView' requires now two values:
card
andthemeColor: LinearGradient
, so that color can be used to design appereance of cards
var themeColor: LinearGradient {
return LinearGradient(gradient:
Gradient(colors: [viewModel.theme.color,
viewModel.theme.color.opacity(0.4)]),
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
Task 1. Pre-defined number of cards for each theme
Remove an option of a random number of pairs. In the
ViewModel
inside thecreateMemoryGame(with:)
method replace use a theme property.
let numberOfPairs = theme.cardsNumber
In
GameTheme.swift
file make Non-optional number of cards. Add a value of this property to each theme.
var cardsNumber: Int
static let cats = Theme(name: "Cats", emojis: ["😺", "😸", "😹", "😻", "🙀", "😿", "😾", "😼"], cardsNumber: 8, color: .red)
Task 2. JSON representation of the theme
Add extensions: UIColor+RGBA and Data+utf8.
Change a type of the
color
property of theTheme
structure toUIColor.RGB
.A theme should be created as the following:
static let animals = Theme(name: "Animals", emojis: ["🐶", "🐨", "🦁", "🐼", "🦊", "🐻", "🐰"], cardsNumber: 7, color: .init(red: 200/255, green: 81/255, blue: 81/255, alpha: 1))
- Add a
var json: Data?
toTheme
structure. Use it in theViewModel
to print the data as json.
print("json = \(theme.json?.utf8 ?? "nil")")
- Make sure a theme color returns a Color view in the
View
:
var themeColor { return Color(viewModel.theme.color) }
Task 1-2. Show a list of saved themes when the app launches
- Create a ViewModel for themes that loads saved themes if they were edited by user or default version. Add a method to save data.
class Themes: ObservableObject {
@Published private(set) var savedThemes: [Theme]
static let saveKey = "SavedThemesForEmojiMemoryGame"
init() {
if let data = UserDefaults.standard.data(forKey: Self.saveKey) {
if let decoded = try? JSONDecoder().decode([Theme].self, from: data) {
self.savedThemes = decoded
return
}
}
self.savedThemes = [Theme.cats, Theme.techno, Theme.zodiac, Theme.animals, Theme.vegetables, Theme.flowers]
}
private func save() {
if let encoded = try? JSONEncoder().encode(savedThemes) {
UserDefaults.standard.set(encoded, forKey: Self.saveKey)
}
}
}
- Add a
id
property to the Theme structure, and conform it to the Identifiable protocol
struct Theme: Codable, Identifiable {
var id = UUID()
...
}
- Create a View that presents a List of themes
struct ThemesListView: View {
@ObservedObject var viewModelThemes: Themes
var body: some View {
NavigationView {
VStack {
List(viewModelThemes.savedThemes) { theme in
Text(theme.name)
}
}
}.navigationBarTitle("Memorize")
}
}
- Change a
contentView
constant to contain the created ThemesListView with the data from Themes ViewModel
// class SceneDelegate, `scene()` method
let themes = Themes()
let contentView = ThemesListView(viewModelThemes: themes)
Task 3. Display information for each theme
Display a color with
Color(theme.color)
. A method that returns String can be used to show a descriptive text (emojis and number of played pairs):Text(showListOfEmojis(theme: theme))
. The method:
private func showListOfEmojis(theme: Theme) -> String {
let pairsStr = theme.cardsNumber == theme.emojis.count ? "" : "\(theme.cardsNumber) pairs from "
return pairsStr + String(theme.emojis.joined())
}
Task 4-6. Rows of the themes list navigate to start the game
- Wrape each row of the List view with the NavigationLink:
NavigationLink(destination: EmojiMemoryGameView(viewModel: EmojiMemoryGame(theme: theme))) {
...
}
- Update initialization of the
EmojiMemoryGame
ViewModel, it should accept the chosen theme as a parameter to create a game.
init(theme: Theme) {
self.theme = theme
model = EmojiMemoryGame.createMemoryGame(with: theme)
}
Task 7. Adding a new theme
- Add a method to the created ViewModel that adds a new theme into the array. Which can used also for editing theme afterwards.
// class Themes - ViewModel
func addTheme(_ theme: Theme) {
if let index = savedThemes.firstIndex(matching: theme) {
savedThemes[index] = theme
} else {
savedThemes.append(theme)
}
save()
}
Create a new view
ThemeEditorView
to show the Adding/Editing options for the theme, that view takes to parameterstheme: Binding<Theme?>, updateChanges: @escaping (Theme) -> Void)
. The method will allow to save changes and update the model.Show
ThemeEditorView
throughsheet()
.
.sheet(isPresented: $showingUpdateThemeView) {
ThemeEditorView(theme: $selectedTheme) { updatedTheme in
viewModelThemes.addTheme(updatedTheme)
editMode = .inactive
}
}
If the "Add" button is pressed, the property
selectedTheme: Theme?
is equalnil
.
private func addNewTheme() {
selectedTheme = nil
showingUpdateThemeView = true
}
Task 8-9. Edit mode. Deleting themes
- Add to the ViewModel method to remove the chosen theme
func removeTheme(_ theme: Theme) {
if let index = savedThemes.firstIndex(matching: theme) {
savedThemes.remove(at: index)
save()
}
}
- Surround a "theme row" with
ForEach
view, so.onDelete
modifier can catchindexSet
for the row that needed to be removed. AddonTapGesture
modifier to allow edit a specific theme.
List {
ForEach(viewModelThemes.savedThemes) { theme in
NavigationLink(destination: EmojiMemoryGameView(viewModel: EmojiMemoryGame(theme: theme))) {
HStack {
ZStack {
Rectangle()
.frame(width: 25, height: 25)
.foregroundColor(Color(theme.color))
Image(systemName: "pencil")
.foregroundColor(.white)
.opacity(editMode.isEditing ? 1 : 0)
.onTapGesture {
selectedTheme = theme
showingUpdateThemeView = true
}
}.frame(width: 25, height: 25)
VStack(alignment: .leading, spacing: 5) {
Text(theme.name)
.font(.headline)
Text(showListOfEmojis(theme: theme))
.font(.caption)
.lineLimit(1)
}
}.padding(5)
}
}
.onDelete { indexSet in
indexSet.map { viewModelThemes.savedThemes[$0] }.forEach { theme in
viewModelThemes.removeTheme(theme)
}
}
}
Task 10-13. Editing theme with ThemeEditorView
Edit the name of the theme
TextField("Name theme", text: $themeToAdd.name)
Add emoji to the theme
TextField("Emoji", text: $emojiToAdd)
Button("Add") {
for character in emojiToAdd {
if !themeToAdd.emojis.contains(String(character)) {
themeToAdd.emojis.append(String(character))
}
}
emojiToAdd = ""
updateCardsNumber()
}.disabled(emojiToAdd.isEmpty)
- Update a number of cards in the theme
if themeToAdd.emojis.count > 1 {
Section(header: Text("Number of cards to play")) {
Stepper(value: $themeToAdd.cardsNumber, in: 2...themeToAdd.emojis.count) {
Text("\(themeToAdd.cardsNumber) pairs")
}
}
}
...
private func updateCardsNumber() {
themeToAdd.cardsNumber = themeToAdd.emojis.count
}
- Closure in the View allows to update the ViewModel
Extra credit 1. Support choosing a theme's color
Add property colors for the
ThemeEditorView
private var colors: [UIColor] = [.red, .blue, .green, .purple, .yellow, .magenta, .orange, .cyan, .brown]
Assign a chosen color for the theme on the Tap gesture
Section(header: Text("Theme color")) {
Grid(colors, id: \.self) { color in
ZStack {
Circle().fill(Color(color)).frame(width: 35, height: 35)
Image(systemName: "checkmark.circle")
.foregroundColor(.white)
.opacity(UIColor(themeToAdd.color) == color ? 1 : 0)
}.onTapGesture {
themeToAdd.color = UIColor.getRGB(color)
}
}.frame(minHeight: 20, idealHeight: 80, maxHeight: 100)
}
Extra credit 2. Keep track of the removed emojis
Add 2nd view for removed emojis, able tap gesture action
Section(header: Text("Emojis to play"), footer: Text("Tap emoji to exclude")) {
Grid(themeToAdd.emojis, id: \.self) { emoji in
Text(emoji)
.frame(width: 30, height: 30)
.onTapGesture {
withAnimation {
removeEmoji(emoji)
}
}
}.frame(minHeight: 20, idealHeight: 100, maxHeight: 300)
}
if !themeToAdd.removedEmojis.isEmpty {
Section(header: Text("Removed emojis"), footer: Text("Tap emoji to include in the game")) {
Grid(themeToAdd.removedEmojis, id: \.self) { emoji in
Text(emoji)
.frame(width: 30, height: 30)
.onTapGesture {
withAnimation {
returnEmoji(emoji)
}
}
}.frame(minHeight: 20, idealHeight: 100, maxHeight: 300)
}
}
Supporting methods
private func removeEmoji(_ emoji: String) {
if let index = themeToAdd.emojis.firstIndex(of: emoji) {
themeToAdd.removedEmojis.append(themeToAdd.emojis.remove(at: index))
updateCardsNumber()
}
}
...
private func returnEmoji(_ emoji: String) {
if let index = themeToAdd.removedEmojis.firstIndex(of: emoji) {
themeToAdd.emojis.append(themeToAdd.removedEmojis.remove(at: index))
updateCardsNumber()
}
}
❗️ Update in the
ThemeEditorView
after the 11th Lecture: Initialization of State property with a wrapper value of theBinding<Theme>
init(theme: Binding<Theme?>, updateChanges: @escaping (Theme) -> Void) {
_theme = theme
self.updateChanges = updateChanges
_themeToAdd = State(wrappedValue: theme.wrappedValue ?? Theme(name: "Unknown", emojis: [], removedEmojis: [], cardsNumber: 0, color: UIColor.getRGB(.red)))
}
- If Model can be made with different types, add Generics to it.
- ViewModel establishes a specific type of data.
- Static function can be used to create a specific ViewModel from Model; that function can be used to reset the game.
- Use 'Drawing Constraints' to avoid setting values directly in a code.
- Make variable of the model 'private' in ViewModel and send to the View an another copy of it. Mark 'private' all properties and functions that shouldn't be accessible and 'private(set)' - that shouldn't be changeable nowhere else.
- Custom ViewModifiers, Shapes can help make code much reusable.
- Use .updating($gestureStateVar) to change the value of @GestureState for Non-discrete gestures.
- Modifier
.exclusively(before: doubleTapToZoom(in: size))
allows to combine gestures. - A value of the State var can be assign with initialized data. 1st variant to use
.onAppear
, which is called after initialization. Or 2nd variant is to assign _value with the wrappedValue:
init(document: EmojiArtDocument) {
self.document = document
_chosenPalette = State(wrappedValue: document.defaultPalette)
}
- Environment Object is usually used for sharing ViewModel data between screens
UIPasteboard.general.url
has a URL? data that a user copied somewhere, and it can be used as a value for properties, can be pasted.- Reodering views can be achieved with
.zIndex(-1)
for background. State
property can be initialized withBinding.wrapperValue
:
_themeToAdd = State(wrappedValue: theme.wrappedValue
- Tags of the
Picker
has to have the same type withselection
value;nil
value can be presented as.tag(String?.none)
.Picker
can have additional items (and tags) that are not included inselection
array.
Presented code is my attempt to solve required tasks of the Stanford course. I'd appreciate any features and improvement you have to share. Feel free to reach me out 😊
A playing card back image used in the Set Game is made by macrovector. These guys have nice solutions for Stanford assignments: Antonio J Rossi, Ruban.