├── Images
└── Demo-App.png
├── MVCS-Demo
├── Assets.xcassets
│ ├── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── ipad-icon-20x20@1x.png
│ │ ├── ipad-icon-20x20@2x.png
│ │ ├── ipad-icon-29x29@1x.png
│ │ ├── ipad-icon-29x29@2x.png
│ │ ├── ipad-icon-40x40@1x.png
│ │ ├── ipad-icon-40x40@2x.png
│ │ ├── ipad-icon-76x76@1x.png
│ │ ├── ipad-icon-76x76@2x.png
│ │ ├── iphone-icon-20x20@2x.png
│ │ ├── iphone-icon-20x20@3x.png
│ │ ├── iphone-icon-29x29@1x.png
│ │ ├── iphone-icon-29x29@2x.png
│ │ ├── iphone-icon-29x29@3x.png
│ │ ├── iphone-icon-40x40@2x.png
│ │ ├── iphone-icon-40x40@3x.png
│ │ ├── iphone-icon-57x57@1x.png
│ │ ├── iphone-icon-57x57@2x.png
│ │ ├── iphone-icon-60x60@2x.png
│ │ ├── iphone-icon-60x60@3x.png
│ │ ├── ipad-icon-83.5x83.5@2x.png
│ │ ├── ios-marketing-icon-1024x1024@1x.png
│ │ └── Contents.json
│ └── AccentColor.colorset
│ │ └── Contents.json
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── App
│ ├── App.MVCSDemo.swift
│ ├── App.Store.swift
│ └── ContentView.swift
├── Images
│ ├── RemoteImageView.swift
│ ├── RemoteImage.swift
│ └── ImagesController.swift
├── Design
│ ├── View+Style.swift
│ └── Color.Palette.swift
├── SwiftUI
│ └── ScrollFocusController.swift
└── Components
│ ├── CarouselView.swift
│ ├── RedPandaCardView.swift
│ └── FavoritesCarouselView.swift
├── MVCS-Demo.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
├── xcuserdata
│ └── mergesort.xcuserdatad
│ │ ├── xcschemes
│ │ └── xcschememanagement.plist
│ │ └── xcdebugger
│ │ └── Breakpoints_v2.xcbkptlist
└── project.pbxproj
├── README.md
├── .gitignore
└── LICENSE
/Images/Demo-App.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/Images/Demo-App.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/MVCS-Demo/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-20x20@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-20x20@1x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-20x20@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-20x20@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-29x29@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-29x29@1x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-29x29@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-29x29@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-40x40@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-40x40@1x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-40x40@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-40x40@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-76x76@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-76x76@1x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-76x76@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-76x76@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-20x20@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-20x20@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-20x20@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-20x20@3x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-29x29@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-29x29@1x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-29x29@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-29x29@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-29x29@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-29x29@3x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-40x40@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-40x40@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-40x40@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-40x40@3x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-57x57@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-57x57@1x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-57x57@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-57x57@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-60x60@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-60x60@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-60x60@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/iphone-icon-60x60@3x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-83.5x83.5@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ipad-icon-83.5x83.5@2x.png
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ios-marketing-icon-1024x1024@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mergesort/MVCS/HEAD/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/ios-marketing-icon-1024x1024@1x.png
--------------------------------------------------------------------------------
/MVCS-Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/MVCS-Demo/App/App.MVCSDemo.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct MVCSDemoApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | ContentView()
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This repo has been merged into [Boutique](https://github.com/mergesort/Boutique), please visit that repo's [Demo Project](https://github.com/mergesort/Boutique/tree/main/Demo) for the most up to date Model View Controller Store code.
2 |
--------------------------------------------------------------------------------
/MVCS-Demo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/MVCS-Demo/Images/RemoteImageView.swift:
--------------------------------------------------------------------------------
1 | import Boutique
2 | import SwiftUI
3 |
4 | /// A view that displays a `RemoteImage`.
5 | struct RemoteImageView: View {
6 |
7 | var image: RemoteImage
8 |
9 | var body: some View {
10 | let currentImage = UIImage(data: image.dataRepresentation) ?? UIImage()
11 |
12 | Image(uiImage: currentImage)
13 | .resizable()
14 | }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | MVC-Demo.xcodeproj/project.xcworkspace/xcuserdata/
2 | MVC-Demo.xcodeproj/xcuserdata/
3 | UMVC-Demo.xcodeproj/project.xcworkspace/xcuserdata/*
4 | UMVC-Demo.xcodeproj/xcuserdata/mergesort.xcuserdatad/*
5 | MVS-Demo.xcodeproj/project.xcworkspace/xcuserdata/*
6 | MVS-Demo.xcodeproj/xcuserdata/mergesort.xcuserdatad/*
7 | MVCS-Demo.xcodeproj/project.xcworkspace/xcuserdata/mergesort.xcuserdatad/*
8 | MVCS-Demo.xcodeproj/project.xcworkspace/xcuserdata/mergesort.xcuserdatad/*
9 |
--------------------------------------------------------------------------------
/MVCS-Demo/Design/View+Style.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 |
5 | func centerCroppedCardStyle() -> some View {
6 | self.scaledToFill()
7 | .clipped()
8 | .cornerRadius(8.0)
9 | }
10 |
11 | func primaryBorder() -> some View {
12 | self.overlay(
13 | RoundedRectangle(cornerRadius: 8.0)
14 | .stroke(Color.palette.primary, lineWidth: 4.0)
15 | )
16 | }
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/MVCS-Demo/SwiftUI/ScrollFocusController.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import SwiftUI
3 |
4 | /// A controller that allows a parent to subscribe to a child's tap events for the purpose of scrolling.
5 | final class ScrollFocusController: ObservableObject {
6 |
7 | private let currentValueSubject = CurrentValueSubject(nil)
8 |
9 | var publisher: AnyPublisher {
10 | return self.currentValueSubject.eraseToAnyPublisher()
11 | }
12 |
13 | func scrollTo(_ remoteImage: T) {
14 | self.currentValueSubject.value = remoteImage
15 | self.currentValueSubject.value = nil
16 | }
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/MVCS-Demo/App/App.Store.swift:
--------------------------------------------------------------------------------
1 | import Boutique
2 | import Foundation
3 |
4 | extension Store where Item == RemoteImage {
5 |
6 | /// The app's default images store`.
7 | ///
8 | /// Stores are low-cost and can be plugged into Controllers interchangeably, or even accessed independently.
9 | /// What does this mean? We decouple Controllers from Stores so if you want one global store for images
10 | /// you want cached and accessible throughout the app, you can have that. Or if you want to create many
11 | /// small or even temp stores, that's perfectly fine too, in fact that makes it great for testing.
12 | static let imagesStore = Store(
13 | storagePath: Store.documentsDirectory(appendingPath: "Images")
14 | )
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/MVCS-Demo/App/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ContentView: View {
4 |
5 | @StateObject private var carouselFocusController = ScrollFocusController()
6 |
7 | var body: some View {
8 | VStack(spacing: 0.0) {
9 | FavoritesCarouselView()
10 | .padding(.bottom, 8.0)
11 | .environmentObject(carouselFocusController)
12 |
13 | Divider()
14 |
15 | Spacer()
16 |
17 | RedPandaCardView()
18 | .environmentObject(carouselFocusController)
19 | }
20 | .padding(.horizontal, 16.0)
21 | .background(Color.palette.background)
22 | }
23 |
24 | }
25 |
26 | struct ContentView_Previews: PreviewProvider {
27 | static var previews: some View {
28 | ContentView()
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/MVCS-Demo.xcodeproj/xcuserdata/mergesort.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | MVC-Demo.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 0
11 |
12 | MVCS-Demo.xcscheme_^#shared#^_
13 |
14 | orderHint
15 | 0
16 |
17 | Red Pandapp.xcscheme_^#shared#^_
18 |
19 | orderHint
20 | 0
21 |
22 | UMVC-Demo.xcscheme_^#shared#^_
23 |
24 | orderHint
25 | 0
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/MVCS-Demo/Images/RemoteImage.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Bodega
3 |
4 | /// A type representing the API response of an image from the API we're interacting with.
5 | struct RemoteImage: Codable, Equatable, Identifiable {
6 | let createdAt: Date
7 | let url: URL
8 | let width: Float
9 | let height: Float
10 | let dataRepresentation: Data
11 |
12 | // We're using a `CacheKey` from Bodega (one of Boutique's dependencies)
13 | // because it's file-system safe, unlike `url.absoluteString`.
14 | //
15 | // In most cases using a plain string will be perfectly sufficient but URLs can be up to 4096 characters
16 | // and files on disk can only be 256 characters, so I recommend using a `CacheKey` when possible.
17 | // But it's worth emphasizing, using a String should be perfectly acceptable with pretty much any non-URL data type.
18 | var id: String {
19 | return CacheKey(url: self.url).value
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/MVCS-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "bodega",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/mergesort/Bodega",
7 | "state" : {
8 | "revision" : "29966bf638714c1fbe0c734e259b3d5dfda58d6b",
9 | "version" : "1.0.1"
10 | }
11 | },
12 | {
13 | "identity" : "boutique",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/mergesort/boutique",
16 | "state" : {
17 | "revision" : "bb6fa56ab625ba9dd1e5162cfdb31b8791f5b1fe",
18 | "version" : "1.0.2"
19 | }
20 | },
21 | {
22 | "identity" : "swift-collections",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/apple/swift-collections",
25 | "state" : {
26 | "revision" : "48254824bb4248676bf7ce56014ff57b142b77eb",
27 | "version" : "1.0.2"
28 | }
29 | }
30 | ],
31 | "version" : 2
32 | }
33 |
--------------------------------------------------------------------------------
/MVCS-Demo/Design/Color.Palette.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension Color {
4 | static let palette = Color.Palette()
5 | }
6 |
7 | extension Color {
8 |
9 | struct Palette {
10 | var primary: Color {
11 | Color(red: colorValue(195), green: colorValue(82), blue: colorValue(43))
12 | }
13 |
14 | var secondary: Color {
15 | Color(red: colorValue(227), green: colorValue(114), blue: colorValue(75))
16 | }
17 |
18 | var tertiary: Color {
19 | Color(red: colorValue(255), green: colorValue(162), blue: colorValue(123))
20 | }
21 |
22 | var background: Color {
23 | Color(red: colorValue(236), green: colorValue(240), blue: colorValue(241))
24 | }
25 | }
26 |
27 | }
28 |
29 | // I'm too lazy to build a real palette for this project so with this mediocre code.
30 | private extension Color {
31 |
32 | static func colorValue(_ fromRGBValue: Int) -> Double {
33 | return Double(fromRGBValue)/255.0
34 | }
35 |
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Joe Fabisevich
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MVCS-Demo/Components/CarouselView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A View for displaying content in a horizontally scrolling grid.
4 | struct CarouselView: View {
5 |
6 | var items: [Item]
7 | var contentView: (Item) -> ContentView
8 |
9 | @EnvironmentObject private var focusController: ScrollFocusController
10 | @State private var customPreferenceKey: String = ""
11 |
12 | var body: some View {
13 | ScrollView(.horizontal, showsIndicators: false) {
14 | ScrollViewReader { reader in
15 | HStack(alignment: .top, spacing: 16.0) {
16 | ForEach(items) { item in
17 | contentView(item)
18 | .tag(item.id)
19 | }
20 | }
21 | .onReceive(self.focusController.publisher, perform: { id in
22 | if let id = id {
23 | withAnimation {
24 | reader.scrollTo(id)
25 | }
26 | }
27 | })
28 | }
29 | }
30 | .listRowSeparator(.hidden)
31 | .listRowBackground(Color.clear)
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/MVCS-Demo/Images/ImagesController.swift:
--------------------------------------------------------------------------------
1 | import Boutique
2 | import SwiftUI
3 |
4 | // You may prefer to decouple a `Controller` from it's `Store`, and that's very easily doable.
5 | // Instead of a property in the `Controller` such as `@Stored(in: Store.imagesStore) var images`
6 | // you would instead create a `Controller` with a custom initializer takes in a `Store` like so.
7 | //
8 | // @Stored var images: [RemoteImage]
9 | //
10 | // init(store: Store) {
11 | // self._images = Stored(in: store)
12 | // }
13 | //
14 | // And whenever you instantiate a `Controller` you provide it a `Store`.
15 | // @StateObject private var imagesController = ImagesController(store: Store.imagesStore)
16 |
17 | /// A controller that allows you to fetch images remotely, and save or delete them from a `Store`.
18 | final class ImagesController: ObservableObject {
19 |
20 | /// The `Store` that we'll be using to save images.
21 | @Stored(in: .imagesStore) var images
22 |
23 | /// Fetches `RemoteImage` from the API, providing the user with a red panda if the request succeeds.
24 | /// - Returns: The `RemoteImage` requested.
25 | func fetchImage() async throws -> RemoteImage {
26 | // Hit the API that provides you a random image's metadata
27 | let imageURL = URL(string: "https://image.redpanda.club/random/json")!
28 | let randomImageRequest = URLRequest(url: imageURL)
29 | let (randomImageJSONData, _) = try await URLSession.shared.data(for: randomImageRequest)
30 |
31 | let imageResponse = try JSONDecoder().decode(RemoteImageResponse.self, from: randomImageJSONData)
32 |
33 | // Download the image at the URL we received from the API
34 | let imageRequest = URLRequest(url: imageResponse.url)
35 | let (imageData, _) = try await URLSession.shared.data(for: imageRequest)
36 |
37 | // Lazy error handling, sorry, please do it better in your app
38 | guard let pngData = UIImage(data: imageData)?.pngData() else { throw DownloadError.badData }
39 |
40 | return RemoteImage(createdAt: .now, url: imageResponse.url, width: imageResponse.width, height: imageResponse.height, dataRepresentation: pngData)
41 | }
42 |
43 | /// Saves an image to the `Store` in memory and on disk.
44 | /// - Parameter image: A `RemoteImage` to be saved.
45 | func saveImage(image: RemoteImage) async throws {
46 | try await self.$images.add(image)
47 | }
48 |
49 | /// Removes one image from the `Store` in memory and on disk.
50 | /// - Parameter image: A `RemoteImage` to be removed.
51 | func removeImage(image: RemoteImage) async throws {
52 | try await self.$images.remove(image)
53 | }
54 |
55 | /// Removes all of the images from the `Store` in memory and on disk.
56 | func clearAllImages() async throws {
57 | try await self.$images.removeAll()
58 | }
59 |
60 | }
61 |
62 | extension ImagesController {
63 |
64 | /// A few simple errors we can throw in case we receive bad data.
65 | enum DownloadError: Error {
66 | case badData
67 | case unexpectedStatusCode
68 | }
69 |
70 | }
71 |
72 | private extension ImagesController {
73 |
74 | /// A type representing the API response providing image metadata from the API we're interacting with.
75 | struct RemoteImageResponse: Codable {
76 | let width: Float
77 | let height: Float
78 | let key: String
79 | let url: URL
80 | }
81 |
82 | }
83 |
--------------------------------------------------------------------------------
/MVCS-Demo/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "ipad-icon-20x20@2x.png",
5 | "idiom" : "iphone",
6 | "scale" : "2x",
7 | "size" : "20x20"
8 | },
9 | {
10 | "filename" : "iphone-icon-20x20@3x.png",
11 | "idiom" : "iphone",
12 | "scale" : "3x",
13 | "size" : "20x20"
14 | },
15 | {
16 | "filename" : "ipad-icon-29x29@1x.png",
17 | "idiom" : "iphone",
18 | "scale" : "1x",
19 | "size" : "29x29"
20 | },
21 | {
22 | "filename" : "ipad-icon-29x29@2x.png",
23 | "idiom" : "iphone",
24 | "scale" : "2x",
25 | "size" : "29x29"
26 | },
27 | {
28 | "filename" : "iphone-icon-29x29@3x.png",
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "29x29"
32 | },
33 | {
34 | "filename" : "ipad-icon-40x40@2x.png",
35 | "idiom" : "iphone",
36 | "scale" : "2x",
37 | "size" : "40x40"
38 | },
39 | {
40 | "filename" : "iphone-icon-40x40@3x.png",
41 | "idiom" : "iphone",
42 | "scale" : "3x",
43 | "size" : "40x40"
44 | },
45 | {
46 | "filename" : "iphone-icon-57x57@1x.png",
47 | "idiom" : "iphone",
48 | "scale" : "1x",
49 | "size" : "57x57"
50 | },
51 | {
52 | "filename" : "iphone-icon-57x57@2x.png",
53 | "idiom" : "iphone",
54 | "scale" : "2x",
55 | "size" : "57x57"
56 | },
57 | {
58 | "filename" : "iphone-icon-60x60@2x.png",
59 | "idiom" : "iphone",
60 | "scale" : "2x",
61 | "size" : "60x60"
62 | },
63 | {
64 | "filename" : "iphone-icon-60x60@3x.png",
65 | "idiom" : "iphone",
66 | "scale" : "3x",
67 | "size" : "60x60"
68 | },
69 | {
70 | "filename" : "ipad-icon-20x20@1x.png",
71 | "idiom" : "ipad",
72 | "scale" : "1x",
73 | "size" : "20x20"
74 | },
75 | {
76 | "filename" : "iphone-icon-20x20@2x.png",
77 | "idiom" : "ipad",
78 | "scale" : "2x",
79 | "size" : "20x20"
80 | },
81 | {
82 | "filename" : "iphone-icon-29x29@1x.png",
83 | "idiom" : "ipad",
84 | "scale" : "1x",
85 | "size" : "29x29"
86 | },
87 | {
88 | "filename" : "iphone-icon-29x29@2x.png",
89 | "idiom" : "ipad",
90 | "scale" : "2x",
91 | "size" : "29x29"
92 | },
93 | {
94 | "filename" : "ipad-icon-40x40@1x.png",
95 | "idiom" : "ipad",
96 | "scale" : "1x",
97 | "size" : "40x40"
98 | },
99 | {
100 | "filename" : "iphone-icon-40x40@2x.png",
101 | "idiom" : "ipad",
102 | "scale" : "2x",
103 | "size" : "40x40"
104 | },
105 | {
106 | "filename" : "ipad-icon-76x76@1x.png",
107 | "idiom" : "ipad",
108 | "scale" : "1x",
109 | "size" : "76x76"
110 | },
111 | {
112 | "filename" : "ipad-icon-76x76@2x.png",
113 | "idiom" : "ipad",
114 | "scale" : "2x",
115 | "size" : "76x76"
116 | },
117 | {
118 | "filename" : "ipad-icon-83.5x83.5@2x.png",
119 | "idiom" : "ipad",
120 | "scale" : "2x",
121 | "size" : "83.5x83.5"
122 | },
123 | {
124 | "filename" : "ios-marketing-icon-1024x1024@1x.png",
125 | "idiom" : "ios-marketing",
126 | "scale" : "1x",
127 | "size" : "1024x1024"
128 | }
129 | ],
130 | "info" : {
131 | "author" : "xcode",
132 | "version" : 1
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/MVCS-Demo/Components/RedPandaCardView.swift:
--------------------------------------------------------------------------------
1 | import Boutique
2 | import SwiftUI
3 |
4 | /// A view that fetches a red panda image from the server and allows a user to favorite the red panda.
5 | struct RedPandaCardView: View {
6 |
7 | @EnvironmentObject private var focusController: ScrollFocusController
8 |
9 | @StateObject private var imagesController = ImagesController()
10 |
11 | @State private var currentImage: RemoteImage?
12 |
13 | @State private var requestInFlight = false
14 |
15 | var body: some View {
16 | VStack(spacing: 16.0) {
17 |
18 | Spacer()
19 | if let currentImage = currentImage {
20 | RemoteImageView(image: currentImage)
21 | .aspectRatio(CGFloat(currentImage.height / currentImage.width), contentMode: .fit)
22 | .primaryBorder()
23 | .overlay(content: {
24 | if self.currentImageIsSaved {
25 | Color.black.opacity(0.5)
26 | } else {
27 | Color.clear
28 | }
29 | })
30 | .cornerRadius(8.0)
31 | .onTapGesture(perform: {
32 | if self.currentImageIsSaved {
33 | focusController.scrollTo(self.currentImage!.id)
34 | }
35 | })
36 | } else {
37 | ProgressView()
38 | .frame(width: 300.0, height: 300.0)
39 | }
40 | Spacer()
41 |
42 | VStack(spacing: 0.0) {
43 | Button(action: {
44 | Task {
45 | try await self.setCurrentImage()
46 | }
47 | }, label: {
48 | Label("Fetch", systemImage: "arrow.clockwise.circle")
49 | .font(.title)
50 | .frame(maxWidth: .infinity)
51 | .frame(height: 52.0)
52 | .background(Color.palette.primary.overlay(self.requestInFlight ? Color.black.opacity(0.2) : Color.clear))
53 | .foregroundColor(.white)
54 | })
55 | .disabled(self.requestInFlight)
56 |
57 | Button(action: {
58 | Task {
59 | if self.currentImageIsSaved {
60 | focusController.scrollTo(self.currentImage!.id)
61 | } else {
62 | try await self.imagesController.saveImage(image: self.currentImage!)
63 | try await self.setCurrentImage()
64 | }
65 | }
66 | }, label: {
67 | let title = self.currentImageIsSaved ? "View Favorite" : "Favorite"
68 | let imageName = self.currentImageIsSaved ? "star.circle.fill" : "star.circle"
69 | Label(title, systemImage: imageName)
70 | .font(.title)
71 | .frame(maxWidth: .infinity)
72 | .frame(height: 52.0)
73 | .background(
74 | // I wouldn't use AnyView in a production app, but too lazy to disambiguate the required types here
75 | self.currentImageIsSaved ?
76 | AnyView(Color.palette.secondary) :
77 | AnyView(Color.palette.tertiary.overlay(self.requestInFlight ? Color.black.opacity(0.2) : Color.clear))
78 | )
79 | .foregroundColor(.white)
80 | })
81 | .disabled(self.requestInFlight)
82 | }
83 | .cornerRadius(8.0)
84 | }
85 | .padding(.vertical, 16.0)
86 | .task({
87 | do {
88 | try await self.setCurrentImage()
89 | } catch {
90 | print("Error fetching image", error)
91 | }
92 | })
93 | }
94 |
95 | }
96 |
97 | private extension RedPandaCardView {
98 |
99 | func setCurrentImage() async throws {
100 | self.requestInFlight = true
101 | defer {
102 | self.requestInFlight = false
103 | }
104 |
105 | self.currentImage = nil // Assigning nil shows the progress spinner
106 | self.currentImage = try await self.imagesController.fetchImage()
107 | }
108 |
109 | var currentImageIsSaved: Bool {
110 | if let image = self.currentImage {
111 | return self.imagesController.images.contains(where: { image.id == $0.id })
112 | } else {
113 | return false
114 | }
115 | }
116 |
117 | }
118 |
--------------------------------------------------------------------------------
/MVCS-Demo/Components/FavoritesCarouselView.swift:
--------------------------------------------------------------------------------
1 | import Boutique
2 | import SwiftUI
3 |
4 | // Below you'll see code that looks like this.
5 | // CarouselView(
6 | // items: self.images,
7 | // contentView: { image in
8 | // ...
9 | // }
10 | // )
11 | //
12 | // In place of `self.images` you may be tempted to directly use `imagesController.images`.
13 | // That will likely be fine for views rendering a small amount of data, especially if you
14 | // read the data directly (as opposed to performing operations such as map, filter, sort, etc).
15 | // But as the data grows this will create a performance bottleneck.
16 | //
17 | // The reason for this bottleneck is related to SwiftUI, and how SwiftUI renders Views.
18 | // The body of a SwiftUI is recomputed often, and you don't know when they're going to recompute.
19 | // It can happen any time, but because SwiftUI Views are simple structs rendering and recomputing
20 | // is not computationally expensive. What that means though is that imagesController.images will
21 | // be recalculated every render, and that can be computationally expensive,
22 | // especially if you apply operations like map, filter, sort, or have a large data set.
23 | //
24 | // But if you store the result of imagesController.images in a `@State` var, it will no longer
25 | // be recomputed on every render. That's because `@State` provides reference semantics
26 | // to the var, `self.images, that allow `self.images` to be persisted across View renders.
27 | // That turns an expensive operation into minimal cost since there are
28 | // no longer any computations occurring on re-render.
29 |
30 | // That's a long way of saying if you're working with a large data set or would like
31 | // to do more complex transforms on imagesController.images it's worth remembering
32 | // that you can subscribe to the @Published property your controller exposes.
33 | // This approach is probably worth using as your default, especially if you
34 | // don't know how large your array will be.
35 | //
36 | // Here `ImagesController`'s `images` is a @Published property which you can store and manipulate
37 | // in your own `@State` property by subscribing to self.imagesController.$images.$items like so.
38 | //
39 | // @State private var images: [RemoteImage] = []
40 | //
41 | // .onReceive(self.imagesController.$images.$items, perform: {
42 | // self.images = $0.filter({ $0.width > 500 && $0.height > 500 })
43 | // })
44 |
45 |
46 | /// A horizontally scrolling carousel that displays the red panda images a user has favorited.
47 | struct FavoritesCarouselView: View {
48 |
49 | @StateObject private var imagesController = ImagesController()
50 |
51 | @State private var animation: Animation? = nil
52 | @State private var images: [RemoteImage] = []
53 |
54 | var body: some View {
55 | VStack {
56 | HStack {
57 | Text("Favorites")
58 | .bold()
59 | .font(.largeTitle)
60 | .padding(.top)
61 |
62 | Spacer()
63 |
64 | Button(action: {
65 | Task {
66 | try await imagesController.clearAllImages()
67 | }
68 | }, label: {
69 | Image(systemName: "xmark.circle.fill")
70 | .opacity(imagesController.images.isEmpty ? 0.0 : 1.0)
71 | .font(.title)
72 | .foregroundColor(.red)
73 | })
74 | }
75 |
76 | if self.imagesController.images.isEmpty {
77 | VStack {
78 | Spacer()
79 |
80 | Text("Add some red pandas you love and they'll appear here!")
81 | .multilineTextAlignment(.center)
82 | .font(.title)
83 |
84 | Spacer()
85 | }
86 | } else {
87 | HStack {
88 | CarouselView(
89 | items: self.images,
90 | contentView: { image in
91 | ZStack(alignment: .topTrailing) {
92 | RemoteImageView(image: image)
93 | .primaryBorder()
94 | .centerCroppedCardStyle()
95 |
96 | Button(action: {
97 | Task {
98 | try await self.imagesController.removeImage(image: image)
99 | }
100 | }, label: {
101 | Image(systemName: "xmark.circle.fill")
102 | .font(.title2)
103 | .foregroundColor(.white)
104 | .shadow(color: .primary, radius: 4.0, x: 2.0, y: 2.0)
105 | })
106 | .padding(8.0)
107 | }
108 | }
109 | )
110 | .transition(.move(edge: .trailing))
111 | .animation(animation, value: self.imagesController.images)
112 | }
113 | .task({
114 | // Too lazy to figure out how to not trigger the janky
115 | // initial animation because it's mostly irrelevant to this demo.
116 | try! await Task.sleep(nanoseconds: 100_000_000)
117 | self.animation = .easeInOut(duration: 0.35)
118 | })
119 | }
120 | }
121 | .onReceive(self.imagesController.$images.$items, perform: {
122 | self.images = $0.sorted(by: { $0.createdAt > $1.createdAt})
123 | })
124 | .frame(height: 200.0)
125 | .background(Color.palette.background)
126 | }
127 |
128 | }
129 |
--------------------------------------------------------------------------------
/MVCS-Demo.xcodeproj/xcuserdata/mergesort.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
9 |
21 |
22 |
23 |
25 |
37 |
38 |
39 |
41 |
53 |
54 |
55 |
57 |
69 |
70 |
71 |
73 |
85 |
86 |
87 |
89 |
101 |
102 |
103 |
105 |
117 |
118 |
132 |
133 |
147 |
148 |
149 |
150 |
151 |
153 |
165 |
166 |
167 |
169 |
181 |
182 |
183 |
185 |
197 |
198 |
212 |
213 |
227 |
228 |
229 |
230 |
231 |
233 |
245 |
246 |
247 |
249 |
261 |
262 |
263 |
265 |
277 |
278 |
279 |
280 |
281 |
--------------------------------------------------------------------------------
/MVCS-Demo.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 55;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | B746F7ED2843FE48007FB106 /* Boutique in Frameworks */ = {isa = PBXBuildFile; productRef = B746F7EC2843FE48007FB106 /* Boutique */; };
11 | B7618A5C2852889400E3C6E6 /* ScrollFocusController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7618A5B2852889400E3C6E6 /* ScrollFocusController.swift */; };
12 | B7D5253D2852536D0018BBAB /* View+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5252A2852536D0018BBAB /* View+Style.swift */; };
13 | B7D5253E2852536D0018BBAB /* Color.Palette.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5252B2852536D0018BBAB /* Color.Palette.swift */; };
14 | B7D5253F2852536D0018BBAB /* App.Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5252D2852536D0018BBAB /* App.Store.swift */; };
15 | B7D525412852536D0018BBAB /* App.MVCSDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5252F2852536D0018BBAB /* App.MVCSDemo.swift */; };
16 | B7D525422852536D0018BBAB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D525302852536D0018BBAB /* ContentView.swift */; };
17 | B7D525432852536D0018BBAB /* RemoteImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D525322852536D0018BBAB /* RemoteImageView.swift */; };
18 | B7D525442852536D0018BBAB /* ImagesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D525332852536D0018BBAB /* ImagesController.swift */; };
19 | B7D525452852536D0018BBAB /* RemoteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D525342852536D0018BBAB /* RemoteImage.swift */; };
20 | B7D525472852536D0018BBAB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B7D525362852536D0018BBAB /* Assets.xcassets */; };
21 | B7D525482852536D0018BBAB /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B7D525382852536D0018BBAB /* Preview Assets.xcassets */; };
22 | B7D525492852536D0018BBAB /* FavoritesCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5253A2852536D0018BBAB /* FavoritesCarouselView.swift */; };
23 | B7D5254A2852536D0018BBAB /* RedPandaCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5253B2852536D0018BBAB /* RedPandaCardView.swift */; };
24 | B7D5254B2852536D0018BBAB /* CarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D5253C2852536D0018BBAB /* CarouselView.swift */; };
25 | /* End PBXBuildFile section */
26 |
27 | /* Begin PBXFileReference section */
28 | B7618A5B2852889400E3C6E6 /* ScrollFocusController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollFocusController.swift; sourceTree = ""; };
29 | B7D0A348283DEF9400FF06D3 /* MVCS Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "MVCS Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; };
30 | B7D5252A2852536D0018BBAB /* View+Style.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Style.swift"; sourceTree = ""; };
31 | B7D5252B2852536D0018BBAB /* Color.Palette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Color.Palette.swift; sourceTree = ""; };
32 | B7D5252D2852536D0018BBAB /* App.Store.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.Store.swift; sourceTree = ""; };
33 | B7D5252F2852536D0018BBAB /* App.MVCSDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.MVCSDemo.swift; sourceTree = ""; };
34 | B7D525302852536D0018BBAB /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
35 | B7D525322852536D0018BBAB /* RemoteImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteImageView.swift; sourceTree = ""; };
36 | B7D525332852536D0018BBAB /* ImagesController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagesController.swift; sourceTree = ""; };
37 | B7D525342852536D0018BBAB /* RemoteImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteImage.swift; sourceTree = ""; };
38 | B7D525362852536D0018BBAB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
39 | B7D525382852536D0018BBAB /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
40 | B7D5253A2852536D0018BBAB /* FavoritesCarouselView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoritesCarouselView.swift; sourceTree = ""; };
41 | B7D5253B2852536D0018BBAB /* RedPandaCardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedPandaCardView.swift; sourceTree = ""; };
42 | B7D5253C2852536D0018BBAB /* CarouselView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarouselView.swift; sourceTree = ""; };
43 | /* End PBXFileReference section */
44 |
45 | /* Begin PBXFrameworksBuildPhase section */
46 | B7D0A345283DEF9400FF06D3 /* Frameworks */ = {
47 | isa = PBXFrameworksBuildPhase;
48 | buildActionMask = 2147483647;
49 | files = (
50 | B746F7ED2843FE48007FB106 /* Boutique in Frameworks */,
51 | );
52 | runOnlyForDeploymentPostprocessing = 0;
53 | };
54 | /* End PBXFrameworksBuildPhase section */
55 |
56 | /* Begin PBXGroup section */
57 | B746F7EB2843FE47007FB106 /* Frameworks */ = {
58 | isa = PBXGroup;
59 | children = (
60 | );
61 | name = Frameworks;
62 | sourceTree = "";
63 | };
64 | B7618A5A2852889400E3C6E6 /* SwiftUI */ = {
65 | isa = PBXGroup;
66 | children = (
67 | B7618A5B2852889400E3C6E6 /* ScrollFocusController.swift */,
68 | );
69 | path = SwiftUI;
70 | sourceTree = "";
71 | };
72 | B7D0A33F283DEF9400FF06D3 = {
73 | isa = PBXGroup;
74 | children = (
75 | B7D525282852536D0018BBAB /* MVCS-Demo */,
76 | B7D0A349283DEF9400FF06D3 /* Products */,
77 | B746F7EB2843FE47007FB106 /* Frameworks */,
78 | );
79 | sourceTree = "";
80 | };
81 | B7D0A349283DEF9400FF06D3 /* Products */ = {
82 | isa = PBXGroup;
83 | children = (
84 | B7D0A348283DEF9400FF06D3 /* MVCS Demo.app */,
85 | );
86 | name = Products;
87 | sourceTree = "";
88 | };
89 | B7D525282852536D0018BBAB /* MVCS-Demo */ = {
90 | isa = PBXGroup;
91 | children = (
92 | B7D5252C2852536D0018BBAB /* App */,
93 | B7D525392852536D0018BBAB /* Components */,
94 | B7D525312852536D0018BBAB /* Images */,
95 | B7618A5A2852889400E3C6E6 /* SwiftUI */,
96 | B7D525292852536D0018BBAB /* Design */,
97 | B7D525362852536D0018BBAB /* Assets.xcassets */,
98 | B7D525372852536D0018BBAB /* Preview Content */,
99 | );
100 | path = "MVCS-Demo";
101 | sourceTree = "";
102 | };
103 | B7D525292852536D0018BBAB /* Design */ = {
104 | isa = PBXGroup;
105 | children = (
106 | B7D5252A2852536D0018BBAB /* View+Style.swift */,
107 | B7D5252B2852536D0018BBAB /* Color.Palette.swift */,
108 | );
109 | path = Design;
110 | sourceTree = "";
111 | };
112 | B7D5252C2852536D0018BBAB /* App */ = {
113 | isa = PBXGroup;
114 | children = (
115 | B7D5252D2852536D0018BBAB /* App.Store.swift */,
116 | B7D5252F2852536D0018BBAB /* App.MVCSDemo.swift */,
117 | B7D525302852536D0018BBAB /* ContentView.swift */,
118 | );
119 | path = App;
120 | sourceTree = "";
121 | };
122 | B7D525312852536D0018BBAB /* Images */ = {
123 | isa = PBXGroup;
124 | children = (
125 | B7D525322852536D0018BBAB /* RemoteImageView.swift */,
126 | B7D525332852536D0018BBAB /* ImagesController.swift */,
127 | B7D525342852536D0018BBAB /* RemoteImage.swift */,
128 | );
129 | path = Images;
130 | sourceTree = "";
131 | };
132 | B7D525372852536D0018BBAB /* Preview Content */ = {
133 | isa = PBXGroup;
134 | children = (
135 | B7D525382852536D0018BBAB /* Preview Assets.xcassets */,
136 | );
137 | path = "Preview Content";
138 | sourceTree = "";
139 | };
140 | B7D525392852536D0018BBAB /* Components */ = {
141 | isa = PBXGroup;
142 | children = (
143 | B7D5253A2852536D0018BBAB /* FavoritesCarouselView.swift */,
144 | B7D5253B2852536D0018BBAB /* RedPandaCardView.swift */,
145 | B7D5253C2852536D0018BBAB /* CarouselView.swift */,
146 | );
147 | path = Components;
148 | sourceTree = "";
149 | };
150 | /* End PBXGroup section */
151 |
152 | /* Begin PBXNativeTarget section */
153 | B7D0A347283DEF9400FF06D3 /* MVCS-Demo */ = {
154 | isa = PBXNativeTarget;
155 | buildConfigurationList = B7D0A356283DEF9600FF06D3 /* Build configuration list for PBXNativeTarget "MVCS-Demo" */;
156 | buildPhases = (
157 | B7D0A344283DEF9400FF06D3 /* Sources */,
158 | B7D0A345283DEF9400FF06D3 /* Frameworks */,
159 | B7D0A346283DEF9400FF06D3 /* Resources */,
160 | );
161 | buildRules = (
162 | );
163 | dependencies = (
164 | );
165 | name = "MVCS-Demo";
166 | packageProductDependencies = (
167 | B746F7EC2843FE48007FB106 /* Boutique */,
168 | );
169 | productName = "MVC-Demo";
170 | productReference = B7D0A348283DEF9400FF06D3 /* MVCS Demo.app */;
171 | productType = "com.apple.product-type.application";
172 | };
173 | /* End PBXNativeTarget section */
174 |
175 | /* Begin PBXProject section */
176 | B7D0A340283DEF9400FF06D3 /* Project object */ = {
177 | isa = PBXProject;
178 | attributes = {
179 | BuildIndependentTargetsInParallel = 1;
180 | LastSwiftUpdateCheck = 1340;
181 | LastUpgradeCheck = 1340;
182 | TargetAttributes = {
183 | B7D0A347283DEF9400FF06D3 = {
184 | CreatedOnToolsVersion = 13.4;
185 | };
186 | };
187 | };
188 | buildConfigurationList = B7D0A343283DEF9400FF06D3 /* Build configuration list for PBXProject "MVCS-Demo" */;
189 | compatibilityVersion = "Xcode 13.0";
190 | developmentRegion = en;
191 | hasScannedForEncodings = 0;
192 | knownRegions = (
193 | en,
194 | Base,
195 | );
196 | mainGroup = B7D0A33F283DEF9400FF06D3;
197 | packageReferences = (
198 | B746F7E82843FCDE007FB106 /* XCRemoteSwiftPackageReference "boutique" */,
199 | );
200 | productRefGroup = B7D0A349283DEF9400FF06D3 /* Products */;
201 | projectDirPath = "";
202 | projectRoot = "";
203 | targets = (
204 | B7D0A347283DEF9400FF06D3 /* MVCS-Demo */,
205 | );
206 | };
207 | /* End PBXProject section */
208 |
209 | /* Begin PBXResourcesBuildPhase section */
210 | B7D0A346283DEF9400FF06D3 /* Resources */ = {
211 | isa = PBXResourcesBuildPhase;
212 | buildActionMask = 2147483647;
213 | files = (
214 | B7D525482852536D0018BBAB /* Preview Assets.xcassets in Resources */,
215 | B7D525472852536D0018BBAB /* Assets.xcassets in Resources */,
216 | );
217 | runOnlyForDeploymentPostprocessing = 0;
218 | };
219 | /* End PBXResourcesBuildPhase section */
220 |
221 | /* Begin PBXSourcesBuildPhase section */
222 | B7D0A344283DEF9400FF06D3 /* Sources */ = {
223 | isa = PBXSourcesBuildPhase;
224 | buildActionMask = 2147483647;
225 | files = (
226 | B7D525452852536D0018BBAB /* RemoteImage.swift in Sources */,
227 | B7D525492852536D0018BBAB /* FavoritesCarouselView.swift in Sources */,
228 | B7D5253E2852536D0018BBAB /* Color.Palette.swift in Sources */,
229 | B7D5253F2852536D0018BBAB /* App.Store.swift in Sources */,
230 | B7D525422852536D0018BBAB /* ContentView.swift in Sources */,
231 | B7D525442852536D0018BBAB /* ImagesController.swift in Sources */,
232 | B7D5254B2852536D0018BBAB /* CarouselView.swift in Sources */,
233 | B7D525412852536D0018BBAB /* App.MVCSDemo.swift in Sources */,
234 | B7618A5C2852889400E3C6E6 /* ScrollFocusController.swift in Sources */,
235 | B7D5254A2852536D0018BBAB /* RedPandaCardView.swift in Sources */,
236 | B7D5253D2852536D0018BBAB /* View+Style.swift in Sources */,
237 | B7D525432852536D0018BBAB /* RemoteImageView.swift in Sources */,
238 | );
239 | runOnlyForDeploymentPostprocessing = 0;
240 | };
241 | /* End PBXSourcesBuildPhase section */
242 |
243 | /* Begin XCBuildConfiguration section */
244 | B7D0A354283DEF9600FF06D3 /* Debug */ = {
245 | isa = XCBuildConfiguration;
246 | buildSettings = {
247 | ALWAYS_SEARCH_USER_PATHS = NO;
248 | CLANG_ANALYZER_NONNULL = YES;
249 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
250 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
251 | CLANG_ENABLE_MODULES = YES;
252 | CLANG_ENABLE_OBJC_ARC = YES;
253 | CLANG_ENABLE_OBJC_WEAK = YES;
254 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
255 | CLANG_WARN_BOOL_CONVERSION = YES;
256 | CLANG_WARN_COMMA = YES;
257 | CLANG_WARN_CONSTANT_CONVERSION = YES;
258 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
259 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
260 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
261 | CLANG_WARN_EMPTY_BODY = YES;
262 | CLANG_WARN_ENUM_CONVERSION = YES;
263 | CLANG_WARN_INFINITE_RECURSION = YES;
264 | CLANG_WARN_INT_CONVERSION = YES;
265 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
266 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
267 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
268 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
269 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
270 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
271 | CLANG_WARN_STRICT_PROTOTYPES = YES;
272 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
273 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
274 | CLANG_WARN_UNREACHABLE_CODE = YES;
275 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
276 | COPY_PHASE_STRIP = NO;
277 | DEBUG_INFORMATION_FORMAT = dwarf;
278 | ENABLE_STRICT_OBJC_MSGSEND = YES;
279 | ENABLE_TESTABILITY = YES;
280 | GCC_C_LANGUAGE_STANDARD = gnu11;
281 | GCC_DYNAMIC_NO_PIC = NO;
282 | GCC_NO_COMMON_BLOCKS = YES;
283 | GCC_OPTIMIZATION_LEVEL = 0;
284 | GCC_PREPROCESSOR_DEFINITIONS = (
285 | "DEBUG=1",
286 | "$(inherited)",
287 | );
288 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
289 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
290 | GCC_WARN_UNDECLARED_SELECTOR = YES;
291 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
292 | GCC_WARN_UNUSED_FUNCTION = YES;
293 | GCC_WARN_UNUSED_VARIABLE = YES;
294 | IPHONEOS_DEPLOYMENT_TARGET = 15.5;
295 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
296 | MTL_FAST_MATH = YES;
297 | ONLY_ACTIVE_ARCH = YES;
298 | SDKROOT = iphoneos;
299 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
300 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
301 | };
302 | name = Debug;
303 | };
304 | B7D0A355283DEF9600FF06D3 /* Release */ = {
305 | isa = XCBuildConfiguration;
306 | buildSettings = {
307 | ALWAYS_SEARCH_USER_PATHS = NO;
308 | CLANG_ANALYZER_NONNULL = YES;
309 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
310 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
311 | CLANG_ENABLE_MODULES = YES;
312 | CLANG_ENABLE_OBJC_ARC = YES;
313 | CLANG_ENABLE_OBJC_WEAK = YES;
314 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
315 | CLANG_WARN_BOOL_CONVERSION = YES;
316 | CLANG_WARN_COMMA = YES;
317 | CLANG_WARN_CONSTANT_CONVERSION = YES;
318 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
319 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
320 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
321 | CLANG_WARN_EMPTY_BODY = YES;
322 | CLANG_WARN_ENUM_CONVERSION = YES;
323 | CLANG_WARN_INFINITE_RECURSION = YES;
324 | CLANG_WARN_INT_CONVERSION = YES;
325 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
326 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
327 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
328 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
329 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
330 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
331 | CLANG_WARN_STRICT_PROTOTYPES = YES;
332 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
333 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
334 | CLANG_WARN_UNREACHABLE_CODE = YES;
335 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
336 | COPY_PHASE_STRIP = NO;
337 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
338 | ENABLE_NS_ASSERTIONS = NO;
339 | ENABLE_STRICT_OBJC_MSGSEND = YES;
340 | GCC_C_LANGUAGE_STANDARD = gnu11;
341 | GCC_NO_COMMON_BLOCKS = YES;
342 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
343 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
344 | GCC_WARN_UNDECLARED_SELECTOR = YES;
345 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
346 | GCC_WARN_UNUSED_FUNCTION = YES;
347 | GCC_WARN_UNUSED_VARIABLE = YES;
348 | IPHONEOS_DEPLOYMENT_TARGET = 15.5;
349 | MTL_ENABLE_DEBUG_INFO = NO;
350 | MTL_FAST_MATH = YES;
351 | SDKROOT = iphoneos;
352 | SWIFT_COMPILATION_MODE = wholemodule;
353 | SWIFT_OPTIMIZATION_LEVEL = "-O";
354 | VALIDATE_PRODUCT = YES;
355 | };
356 | name = Release;
357 | };
358 | B7D0A357283DEF9600FF06D3 /* Debug */ = {
359 | isa = XCBuildConfiguration;
360 | buildSettings = {
361 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
362 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
363 | CODE_SIGN_STYLE = Automatic;
364 | CURRENT_PROJECT_VERSION = 1;
365 | DEVELOPMENT_ASSET_PATHS = "\"MVCS-Demo/Preview Content\"";
366 | DEVELOPMENT_TEAM = CHANGE_ME_TO_BUILD_ON_DEVICE;
367 | ENABLE_PREVIEWS = YES;
368 | GENERATE_INFOPLIST_FILE = YES;
369 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
370 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
371 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
372 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
373 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
374 | LD_RUNPATH_SEARCH_PATHS = (
375 | "$(inherited)",
376 | "@executable_path/Frameworks",
377 | );
378 | MARKETING_VERSION = 1.0;
379 | PRODUCT_BUNDLE_IDENTIFIER = com.redpandaclub.MVCSDemo;
380 | PRODUCT_NAME = "MVCS Demo";
381 | SWIFT_EMIT_LOC_STRINGS = YES;
382 | SWIFT_VERSION = 5.0;
383 | TARGETED_DEVICE_FAMILY = "1,2";
384 | };
385 | name = Debug;
386 | };
387 | B7D0A358283DEF9600FF06D3 /* Release */ = {
388 | isa = XCBuildConfiguration;
389 | buildSettings = {
390 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
391 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
392 | CODE_SIGN_STYLE = Automatic;
393 | CURRENT_PROJECT_VERSION = 1;
394 | DEVELOPMENT_ASSET_PATHS = "\"MVCS-Demo/Preview Content\"";
395 | DEVELOPMENT_TEAM = CHANGE_ME_TO_BUILD_ON_DEVICE;
396 | ENABLE_PREVIEWS = YES;
397 | GENERATE_INFOPLIST_FILE = YES;
398 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
399 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
400 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
401 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
402 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
403 | LD_RUNPATH_SEARCH_PATHS = (
404 | "$(inherited)",
405 | "@executable_path/Frameworks",
406 | );
407 | MARKETING_VERSION = 1.0;
408 | PRODUCT_BUNDLE_IDENTIFIER = com.redpandaclub.MVCSDemo;
409 | PRODUCT_NAME = "MVCS Demo";
410 | SWIFT_EMIT_LOC_STRINGS = YES;
411 | SWIFT_VERSION = 5.0;
412 | TARGETED_DEVICE_FAMILY = "1,2";
413 | };
414 | name = Release;
415 | };
416 | /* End XCBuildConfiguration section */
417 |
418 | /* Begin XCConfigurationList section */
419 | B7D0A343283DEF9400FF06D3 /* Build configuration list for PBXProject "MVCS-Demo" */ = {
420 | isa = XCConfigurationList;
421 | buildConfigurations = (
422 | B7D0A354283DEF9600FF06D3 /* Debug */,
423 | B7D0A355283DEF9600FF06D3 /* Release */,
424 | );
425 | defaultConfigurationIsVisible = 0;
426 | defaultConfigurationName = Release;
427 | };
428 | B7D0A356283DEF9600FF06D3 /* Build configuration list for PBXNativeTarget "MVCS-Demo" */ = {
429 | isa = XCConfigurationList;
430 | buildConfigurations = (
431 | B7D0A357283DEF9600FF06D3 /* Debug */,
432 | B7D0A358283DEF9600FF06D3 /* Release */,
433 | );
434 | defaultConfigurationIsVisible = 0;
435 | defaultConfigurationName = Release;
436 | };
437 | /* End XCConfigurationList section */
438 |
439 | /* Begin XCRemoteSwiftPackageReference section */
440 | B746F7E82843FCDE007FB106 /* XCRemoteSwiftPackageReference "boutique" */ = {
441 | isa = XCRemoteSwiftPackageReference;
442 | repositoryURL = "https://github.com/mergesort/boutique";
443 | requirement = {
444 | kind = upToNextMajorVersion;
445 | minimumVersion = 1.0.2;
446 | };
447 | };
448 | /* End XCRemoteSwiftPackageReference section */
449 |
450 | /* Begin XCSwiftPackageProductDependency section */
451 | B746F7EC2843FE48007FB106 /* Boutique */ = {
452 | isa = XCSwiftPackageProductDependency;
453 | package = B746F7E82843FCDE007FB106 /* XCRemoteSwiftPackageReference "boutique" */;
454 | productName = Boutique;
455 | };
456 | /* End XCSwiftPackageProductDependency section */
457 | };
458 | rootObject = B7D0A340283DEF9400FF06D3 /* Project object */;
459 | }
460 |
--------------------------------------------------------------------------------