{"id":15874882,"url":"https://github.com/rudifa/ios-memorize","last_synced_at":"2025-10-11T21:36:55.383Z","repository":{"id":74523931,"uuid":"404845275","full_name":"rudifa/iOS-Memorize","owner":"rudifa","description":"Notes and code from the Stanford University CS193p Spring 2021 SwiftUI course given by the Professor Paul Hegarty ","archived":false,"fork":false,"pushed_at":"2021-09-10T20:43:41.000Z","size":44,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-02-07T15:14:34.871Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/rudifa.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null}},"created_at":"2021-09-09T19:25:24.000Z","updated_at":"2024-01-19T10:44:53.000Z","dependencies_parsed_at":"2023-02-23T21:15:40.140Z","dependency_job_id":null,"html_url":"https://github.com/rudifa/iOS-Memorize","commit_stats":null,"previous_names":["rudifa/ios-memorize"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rudifa%2FiOS-Memorize","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rudifa%2FiOS-Memorize/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rudifa%2FiOS-Memorize/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rudifa%2FiOS-Memorize/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rudifa","download_url":"https://codeload.github.com/rudifa/iOS-Memorize/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":246725522,"owners_count":20823652,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-06T01:41:44.745Z","updated_at":"2025-10-11T21:36:50.338Z","avatar_url":"https://github.com/rudifa.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Learning SwiftUI\n#### from Professor Paul Hegarty, Stanford University CS193p Spring 2021 course\n\n#### *[Lesson 1: Getting started with SwiftUI](https://www.youtube.com/watch?v=bqu6BquVi2M)*\n\nIntroduces the Xcode and creates an initial SwiftUI application\n\n\n```\nimport SwiftUI\n@main\nstruct MemorizeApp: App {\n    var body: some Scene {\n        WindowGroup {\n            ContentView()\n        }\n    }\n}\n```\n\nHighlights a hiearchy of [`View`](https://docs.google.com/document/d/1BaqqU2GpQ1I8GkcWZbuTvmBFB5qRDIYlkclxkcjUuKc/edit) instances consisting of containers like `ZStack` and final views like `Text`.\n\nA container view takes an argument, an `@ViewBuilder` function that returns `some View`. This function must list one or more views, and can have local variables and if-then constructs, to control the layout of the contained views. \n\nAdds modifier views like `.padding` and `.foregroundColor`.\n\n```\nstruct ContentView: View {\n    var body: some View {\n        ZStack {\n            RoundedRectangle(cornerRadius: 25.0)\n                .stroke()\n            Text(\"Hello, world!\")\n        }\n        .padding(.all)\n        .foregroundColor(.blue)\n    }\n}\n\n```\n\n#### *[Lesson 2: Learning more about SwiftUI](https://www.youtube.com/watch?v=3lahkdHEhW8)*\n\nFactors out code into a subview, `struct CardView: View`.\n\nIntroduces a local variable, `@State var isFaceUp: Bool` and a gesture modifier `.onTapGesture { isFaceUp.toggle() }`.\n\nPuts to the use more container (or combiner) views: `VStack`, `HStack``LazyVGrid`, `ForEach`, `ScrollView`, and uses final views like `Button` and `Spacer`.\n\nShows how to make the card size and shape adapt to different screen sizes and orientations with `GridItem(.adaptive(minimum: 65))` and `.aspectRatio(2 / 3, contentMode: .fit)`.\n\nCode at the end of Lesson 2:\n\n```\nstruct ContentView: View {\n    var emojis = [\"🚗\", \"🚕\", \"🚙\", \"🚌\", \"🏎\", \"🚓\", \"🚑\", \"🚒\", \"🚐\", \"🛻\", \"🚚\", \"🚛\", \"🚜\", \"🦽\", \"🚲\", \"🛵\", \"🏍\", \"🛺\", \"🚞\", \"🚝\", \"🚜\", \"🚞\", \"✈️\"]\n    @State var emojiCount = 6\n    var body: some View {\n        VStack {\n            ScrollView {\n                LazyVGrid(columns: [GridItem(.adaptive(minimum: 65))]) {\n                    ForEach(emojis[0 ..\u003c emojiCount], id: \\.self) { emoji in\n                        CardView(content: emoji).aspectRatio(2 / 3, contentMode: .fit)\n                    }\n                }\n            }\n            .foregroundColor(.red)\n            HStack {\n                add\n                Spacer()\n                remove\n            }\n            .font(.largeTitle)\n            .padding([.top, .leading, .trailing])\n        }\n        .padding(.all)\n    }\n\n    var add: some View {\n        Button {\n            if emojiCount \u003e 1 {\n                emojiCount -= 1\n            }\n        } label: {\n            Image(systemName: \"minus.circle\")\n        }\n    }\n\n    var remove: some View {\n        Button {\n            if emojiCount \u003c emojis.count {\n                emojiCount += 1\n            }\n        } label: {\n            Image(systemName: \"plus.circle\")\n        }\n    }\n}\n\n```\n```\n\nstruct CardView: View {\n    var content: String\n    @State var isFaceUp: Bool = true\n    let shape = RoundedRectangle(cornerRadius: 25.0)\n    var body: some View {\n        ZStack {\n            if isFaceUp {\n                shape.fill().foregroundColor(.white)\n                shape.strokeBorder(lineWidth: 3)\n                Text(content).font(.largeTitle)\n            } else {\n                shape.fill()\n            }\n        }\n        .onTapGesture {\n            isFaceUp.toggle()\n        }\n    }\n}\n```\n\n\n#### *[Lesson 3: MVVM and the Swift type system](https://www.youtube.com/watch?v=--qKOhdgJAs)*\n\nExplains the MVVM architecture in general terms\n\n![MVVM](https://cs193p.sites.stanford.edu/sites/g/files/sbiybj16636/files/styles/card_1900x950/public/media/image/l3_still_small_0.png?h=32a8b475\u0026itok=vFDFi6rz)\n\nView depends on ViewModel and it\n\n* subscribes to notifications from ViewModel\n* reads the Model data to be displayed, as interpreted by ViewModel\n* calls 'intent' functions on ViewModel to modify the Model data\n\nViewModel depends on Model and it\n\n* reads the Model data\n* modifies the Model data\n\nThe demo project introduces the Model `struct MemoryGame` and the ViewModel `class EmojiMemoryGame`.\n\n#### *Lesson 4: Memorize Game Logic*\n\nAt the end of this lesson the Model, ViewModel and the View look like this:\n\n```\nimport Foundation\n\n// MARK: - Model\n\nstruct MemoryGame\u003cCardContent\u003e where CardContent: Equatable {\n    private(set) var cards: [Card]\n\n    private var indexOfOneAndOnlyFaceUpCard: Int?\n\n    // the game logic\n    mutating func choose(_ card: Card) {\n        if let index = cards.firstIndex(where: { $0.id == card.id }),\n           !cards[index].isFaceUp,\n           !cards[index].isMatched\n        {\n            if let potentialMatchIndex = indexOfOneAndOnlyFaceUpCard {\n                if cards[index].content == cards[potentialMatchIndex].content {\n                    cards[index].isMatched = true\n                    cards[potentialMatchIndex].isMatched = true\n                }\n                indexOfOneAndOnlyFaceUpCard = nil\n            } else {\n                for i in cards.indices {\n                    cards[i].isFaceUp = false\n                }\n                indexOfOneAndOnlyFaceUpCard = index\n            }\n            cards[index].isFaceUp.toggle()\n        }\n    }\n\n    struct Card: Identifiable {\n        var isFaceUp: Bool = false\n        var isMatched: Bool = false\n        var content: CardContent\n        var id: Int\n    }\n\n    init(numberOfPairsOfCards: Int, createCardContent: (Int) -\u003e CardContent) {\n        cards = [Card]()\n        for pairIndex in 0 ..\u003c numberOfPairsOfCards {\n            let content = createCardContent(pairIndex)\n            cards.append(Card(content: content, id: pairIndex * 2))\n            cards.append(Card(content: content, id: pairIndex * 2 + 1))\n        }\n    }\n}\n\n```\n\nThe Model is generic in `CardContent`.\n\n\n```\nimport SwiftUI\n\n// MARK: - ViewModel\n\nclass EmojiMemoryGame: ObservableObject {\n    static let emojis = [\"🚗\", \"🚕\", \"🚙\", \"🚌\", \"🏎\", \"🚓\", \"🚑\", \"🚒\", \"🚐\", \"🛻\", \"🚚\", \"🚛\", \"🚜\", \"🦽\", \"🚲\", \"🛵\", \"🏍\", \"🛺\", \"🚞\", \"🚝\", \"🚜\", \"🚞\", \"✈️\"]\n\n    static func createMemoryGameModel() -\u003e MemoryGame\u003cString\u003e {\n        MemoryGame\u003cString\u003e(numberOfPairsOfCards: 6) { pairIndex in\n            emojis[pairIndex]\n        }\n    }\n\n    @Published private(set) var model = createMemoryGameModel()\n\n    var cards: [MemoryGame\u003cString\u003e.Card] {\n        return model.cards\n    }\n\n    // MARK: - Intent\n\n    func choose(_ card: MemoryGame\u003cString\u003e.Card) {\n        // objectWillChange.send() // implicit in Published\n        model.choose(card)\n    }\n}\n```\n\nThe ViewModel defines a specific `CardContent`, a `String` containining an emoji character. A possible alternative could be an `Image`.\n\nThe ViewModel is declared as `ObservableObject` and its data member `var model` is `@Published`, meaning that it notifies subscribers of any change in the model.\n\n```\nimport SwiftUI\n\nstruct ContentView: View {\n    @ObservedObject var viewModel: EmojiMemoryGame\n\n    var body: some View {\n        ScrollView {\n            LazyVGrid(columns: [GridItem(.adaptive(minimum: 65))]) {\n                ForEach(viewModel.cards) { card in\n                    CardView(card: card).aspectRatio(2 / 3, contentMode: .fit)\n                        .onTapGesture {\n                            viewModel.choose(card)\n                        }\n                }\n            }\n        }\n        .foregroundColor(.red)\n        .padding(.all)\n    }\n}\n\nstruct CardView: View {\n    let card: MemoryGame\u003cString\u003e.Card\n    var body: some View {\n    ...\n    }\n}\n```\n\nThe View subscribes to the notifications of Model changes by declaring `@ObservedObject var viewModel: EmojiMemoryGame`. \n\nConsequently, the View will rebuild the view hierarchy on the screen whenever the Model state changes.\n\nOn user event, `.onTapGesture { viewModel.choose(card) }` causes the ViewModel to update the Model state.\n\n\n```\nimport SwiftUI\n\n@main\nstruct MemorizeApp: App {\n    var game = EmojiMemoryGame()\n    var body: some Scene {\n        WindowGroup {\n            ContentView(viewModel: game)\n        }\n    }\n}\n```\n\nThe App binds the elements together, by creating the instance `var game = EmojiMemoryGame()` and passing it to the main view in `ContentView(viewModel: game)`.\n\nThus, the View owns (and depends on) ViewModel, and ViewModel owns (and depends on) Model, as suggested in the above presentation of MVVM.\n\n\n\n#### *[Lesson 5: Properties Layout @ViewBuilder](https://www.youtube.com/watch?v=--qKOhdgJAs)*\n\nMore about `@State` variables:\n\n* used to store temporary state local to the view\n* view rebuilds its body when the variables change\n\nThe demo code gets cleaned up and simplified using Swift functional programming and other features.\n\nHow is the space on-screen apportioned to the Views?\n\n1. Container Views offer space to the contained Views\n2. Views choose the size they want\n3. Container Views position the contained Views\n4. Container Views choose their own size based on (2)\n\n`HStack` and `VStack` offer the space to the least flexible subviews first.\n\nExamples:\n\n* Image: very inflexible (fixed size)\n* Text: more flexible\n* RoundedRectangle: very flexible (uses all of the space offered)\n\n`Text(\"xyz\").layoutPriority(100)` will override the default priority/flexibility which is 0.\n\n`HStack` and `VStack` have the `alignment` parameter: `.center`, `.leading`, ...\n\n`LazyHStack` and `LazyVStack` do not build views that are not on the screen, so they are used inside `ScrollView`.\n\n`LazyHGrid` and `LazyVGrid` ... tbd\n\n`List`, `Form` and `OutlineGroup` ... tbd\n\n`HStack`, `VStack` and `ZStack` become fully flexible if they contain at least one fully flexible subview.\n\nModifiers `.background `and `.overlay` add another `View` behind or in front of the `View` they are applied to.\n\n`GeometryReader` supplies to its subviews a `GeometryProxy` object that contains the `.size` which can be used by the subviews to adapt their size properties (e.g. `fontSize`) to the size offered. This is shown in the demo.\n\nWrapper `@ViewBuilder` can be applied to a function or a computed variable that returns `some View`. Consequently, the function body behaves like the var body of a `View`, namely it can contain a list of `View`s and possibly local variables and if-let constructs.\n\nThis opens the way to writing custom container views and modifiers.\n\n#### *[Lesson 6: Protocols Shapes](https://www.youtube.com/watch?v=Og9gXZpbKWo)*\n\nThe lecturer explains in detail the features of Swift Generics and Protocols, and hints at how these are used in SwiftUI.\n\nIn the demo, he creates a custom View combiner `AspectVGrid` that will display all game cards in a grid, adapting the card size to the available screen space.\n\n`AspectVGrid` is used like this:\n\n```\nstruct EmojiMemoryGameView: View {\n    @ObservedObject var game: EmojiMemoryGame\n\n    var body: some View {\n        AspectVGrid(items: game.cards, aspectRatio: 2 / 3) { card in\n            if card.isMatched \u0026\u0026 !card.isFaceUp {\n                Rectangle().opacity(0)\n            } else {\n                CardView(card)\n                    .padding(2)\n                    .onTapGesture {\n                        game.choose(card)\n                    }\n            }\n        }\n        .foregroundColor(.red)\n        .padding(.all)\n    }\n}\n\n```\n\nImportant notes on `AspectVGrid`:\n\n- it is generic in `\u003cItem, ItemView\u003e` \n- the generic result `ItemView` must be a `View`\n- the generic input `Item` must be `Identifiable`\n- it has an eplicit `init(...)` with 3 parameters\n- the parameter `content` is a closure that is stored in a var, so it must be annotated as `@escaping`\n- the parameter `content` is also anotated as `@ViewBuilder`, so its body can accept several `View`s, variables and if-else constructs\n\n```\nstruct AspectVGrid\u003cItem, ItemView\u003e: View where ItemView: View, Item: Identifiable {\n    var items: [Item]\n    var aspectRatio: CGFloat\n    var content: (Item) -\u003e ItemView\n\n    init(items: [Item], aspectRatio: CGFloat, @ViewBuilder content: @escaping (Item) -\u003e ItemView) {\n        self.items = items\n        self.aspectRatio = aspectRatio\n        self.content = content\n    }\n\n    var body: some View {\n        GeometryReader { geometry in\n            VStack {\n                let width = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)\n                LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {\n                    ForEach(items) { item in\n                        content(item).aspectRatio(aspectRatio, contentMode: .fit)\n                    }\n                }\n                Spacer(minLength: 0)\n            }\n        }\n    }\n\n    private func adaptiveGridItem(width: CGFloat) -\u003e GridItem {\n        var gridItem = GridItem(.adaptive(minimum: width))\n        gridItem.spacing = 0\n        return gridItem\n    }\n\n    private func widthThatFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -\u003e CGFloat {\n        var columnCount = 1\n        var rowCount = itemCount\n        repeat {\n            let itemWidth = size.width / CGFloat(columnCount)\n            let itemHeight = itemWidth / itemAspectRatio\n            if CGFloat(rowCount) * itemHeight \u003c size.height {\n                break\n            }\n            columnCount += 1\n            rowCount = (itemCount + columnCount - 1) / columnCount\n        } while columnCount \u003c itemCount\n        if columnCount \u003e itemCount {\n            columnCount = itemCount\n        }\n        return floor(size.width / CGFloat(columnCount))\n    }\n}\n\n```\n\nIn the second demo a custom pie chart shape, `Pie`, is created. \n\n`Shape` is a protocol that inherits from `View`. One of its features is the function `.fill(...)`, already seen in the demo. Interestingly, its argument is not a simple color, but a `ShapeStyle` which can also be  an image or a gradient:\n\n`func fill\u003cS\u003e(_ whatToFillWith: S9 -\u003e some View where S: ShapeStyle`\n\n\nThe finished `Pie` shape looks like this:\n\n```\nstruct Pie: Shape {\n    var startAngle: Angle\n    var endAngle: Angle\n    var clockwise: Bool = false\n\n    func path(in rect: CGRect) -\u003e Path {\n        let center = CGPoint(x: rect.midX, y: rect.midY)\n        let radius = min(rect.width, rect.height) / 2\n        let start = CGPoint(\n            x: center.x + radius * CGFloat(cos(startAngle.radians)),\n            y: center.y + radius * CGFloat(sin(startAngle.radians))\n        )\n        var p = Path()\n        p.move(to: center)\n        p.addLine(to: start)\n        p.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: !clockwise)\n        p.addLine(to: center)\n        return p\n    }\n}\n```\n\nNotes:\n\n- the view conforming to the `Shape` protocol must implement `func path(in rect: CGRect) -\u003e Path`\n- the implementation uses the `Path` functions to draw the desired shape in the supplied `rect`.\n\n\nAt the end of the Lesson 6 demo a `Pie` is inserted into the `CardView`'s `ZStack`:\n\n\n```\nstruct CardView: View {\n...\n    var body: some View {\n        GeometryReader { geometry in\n            ZStack {\n                let shape = RoundedRectangle(cornerRadius: Const.cornerRadius)\n                if card.isFaceUp {\n                    shape.fill().foregroundColor(.white)\n                    shape.strokeBorder(lineWidth: Const.lineWidth)\n                    Pie(startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 110-90)).padding(5).opacity(0.4)\n                    Text(card.content).font(font(of: geometry.size))\n                } else if card.isMatched {\n                    shape.opacity(0)\n                } else {\n                    shape.fill()\n                }\n            }\n        }\n    }\n...\n}\n```\n\n\n#### *[Lesson 7: ViewModifier Animation](https://www.youtube.com/watch?v=PoeaUMGAx6c)*\n\nAnimations in SwiftUI are always done in view modifiers or in shapes.\n\nThe SwiftUI `View` has a function, [`.modifier(_:)`](https://developer.apple.com/documentation/swiftui/view/modifier(_:)) which takes anything conforming to the `protocol ViewModifier`.\n\n```\nprotocol ViewModifier {\n    typealias Content\n    func body(content: Content) -\u003e some View {\n        return ... // some view that \n        almost certainly contains the View content\n```\n\nAs an example, the Lesson 7 Demo (final version) defines\n\n```\nstruct Cardify: ViewModifier {\n    var isFaceUp: Bool\n\n    func body(content: Content) -\u003e some View {\n        ZStack {\n            let shape = RoundedRectangle(cornerRadius: Const.cornerRadius)\n            if isFaceUp {\n                shape.fill().foregroundColor(.white)\n                shape.strokeBorder(lineWidth: Const.lineWidth)\n            } else {\n                shape.fill()\n            }\n            content\n                .opacity(isFaceUp ? 1 : 0)\n        }\n    }\n\n    private enum Const {\n        static let cornerRadius: CGFloat = 5\n        static let lineWidth: CGFloat = 3\n    }\n}\n\nextension View {\n    func cardify(isFaceUp: Bool) -\u003e some View {\n        self.modifier(Cardify(isFaceUp: isFaceUp))\n    }\n}\n```\n\nand uses it in `struct CardView`\n\n```\nvar body: some View {\n    GeometryReader { geometry in\n        ZStack {\n            Pie(startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 110 - 90)).padding(5).opacity(0.4)\n            Text(card.content).font(font(of: geometry.size))\n        }\n        .cardify(isFaceUp: card.isFaceUp)\n    }\n}\n```\n\nThere are three ways to animate\n\n* implicit: `.animation(Animation)` view modifier\n* explicit: `withAnimation(.linear(duration: 2)) {...}`\n* xxx\n\nDemo of implicit animation (rotates `card.content` when `card.isMatched `changes)\n\n```\nstruct CardView: View {\n...\n\tText(card.content)\n\t    .rotationEffect(Angle.degrees(card.isMatched ? 360 : 0))\n\t    .animation(.easeInOut(duration: 2))\n\t    .font(Font.system(size: Const.fontSize))\n\t    .scaleEffect(scale(thatFits: geometry.size))\n...\n}\n\nprivate func scale(thatFits size: CGSize) -\u003e CGFloat {\n    min(size.width, size.height) / (Const.fontSize / Const.fontScale)\n}\n\n```\n\n#### See also\n\n[MVVM in iOS with SwiftUI (Detailed Example + Pitfalls)](https://matteomanferdini.com/mvvm-pattern-ios-swift/)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frudifa%2Fios-memorize","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frudifa%2Fios-memorize","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frudifa%2Fios-memorize/lists"}