├── 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 | --------------------------------------------------------------------------------