From b6ac6b8f42d4ee1afdae77a41861d43ad12a3ec9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 29 Nov 2021 10:00:59 -0500 Subject: [PATCH] 169 --- 0169-uikit-navigation-pt1/README.md | 5 + .../project.pbxproj | 564 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 98 +++ .../Assets.xcassets/Contents.json | 6 + .../SwiftUINavigation/ContentView.swift | 248 ++++++++ .../SwiftUINavigation/Info.plist | 17 + .../SwiftUINavigation/Inventory.swift | 201 +++++++ .../SwiftUINavigation/ItemRow.swift | 190 ++++++ .../SwiftUINavigation/ItemRowCellView.swift | 83 +++ .../SwiftUINavigation/ItemView.swift | 160 +++++ .../ItemViewController.swift | 215 +++++++ .../Preview Assets.xcassets/Contents.json | 6 + .../SwiftUINavigation/SwiftUIHelpers.swift | 264 ++++++++ .../SwiftUINavigationApp.swift | 29 + .../SwiftUINavigationTests.swift | 93 +++ README.md | 1 + 19 files changed, 2206 insertions(+) create mode 100644 0169-uikit-navigation-pt1/README.md create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.pbxproj create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/Contents.json create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ContentView.swift create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Info.plist create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Inventory.swift create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemRow.swift create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemRowCellView.swift create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemView.swift create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemViewController.swift create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/SwiftUIHelpers.swift create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/SwiftUINavigationApp.swift create mode 100644 0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigationTests/SwiftUINavigationTests.swift diff --git a/0169-uikit-navigation-pt1/README.md b/0169-uikit-navigation-pt1/README.md new file mode 100644 index 00000000..9154ed6c --- /dev/null +++ b/0169-uikit-navigation-pt1/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [UIKit Navigation: Part 1](https://www.pointfree.co/episodes/ep169-uikit-navigation-part-1) +> +> What does all the work we’ve done with navigation in SwiftUI have to say about UIKit? Turns out a lot! Without making a single change to the view models we can rewrite the entire view layer in UIKit, and the application will work exactly as it did before, deep-linking and all! diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.pbxproj b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.pbxproj new file mode 100644 index 00000000..de3ef1b7 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.pbxproj @@ -0,0 +1,564 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 2A3A7B8B26EF940C00A37A4D /* SwiftUINavigationApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3A7B8A26EF940C00A37A4D /* SwiftUINavigationApp.swift */; }; + 2A3A7B8D26EF940C00A37A4D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3A7B8C26EF940C00A37A4D /* ContentView.swift */; }; + 2A3A7B8F26EF940D00A37A4D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A3A7B8E26EF940D00A37A4D /* Assets.xcassets */; }; + 2A3A7B9226EF940D00A37A4D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A3A7B9126EF940D00A37A4D /* Preview Assets.xcassets */; }; + 2A3A7B9C26EF940D00A37A4D /* SwiftUINavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3A7B9B26EF940D00A37A4D /* SwiftUINavigationTests.swift */; }; + 2A3A7BB626EFA73100A37A4D /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 2A3A7BB526EFA73100A37A4D /* IdentifiedCollections */; }; + 2AF10F1C26F3B56B00796207 /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF10F1B26F3B56B00796207 /* ItemView.swift */; }; + 2AF10F1E26F3D4A000796207 /* ItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF10F1D26F3D4A000796207 /* ItemRow.swift */; }; + 4B3057AD26F3BD7900A5C737 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = 4B3057AC26F3BD7900A5C737 /* CasePaths */; }; + 4B781C62274C096B00779F2E /* ItemViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B781C61274C096B00779F2E /* ItemViewController.swift */; }; + 4B781C64274C191000779F2E /* ItemRowCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B781C63274C191000779F2E /* ItemRowCellView.swift */; }; + 4BCC55AC272C572F0032BF7A /* Parsing in Frameworks */ = {isa = PBXBuildFile; productRef = 4BCC55AB272C572F0032BF7A /* Parsing */; }; + 4BDDD1F426EF9CB00032CB71 /* Inventory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDDD1F326EF9CB00032CB71 /* Inventory.swift */; }; + 4BDDD1F626EFB0D00032CB71 /* SwiftUIHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BDDD1F526EFB0D00032CB71 /* SwiftUIHelpers.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2A3A7B9826EF940D00A37A4D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2A3A7B7F26EF940C00A37A4D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2A3A7B8626EF940C00A37A4D; + remoteInfo = SwiftUINavigation; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 2A3A7B8726EF940C00A37A4D /* SwiftUINavigation.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUINavigation.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A3A7B8A26EF940C00A37A4D /* SwiftUINavigationApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUINavigationApp.swift; sourceTree = ""; }; + 2A3A7B8C26EF940C00A37A4D /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 2A3A7B8E26EF940D00A37A4D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2A3A7B9126EF940D00A37A4D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 2A3A7B9726EF940D00A37A4D /* SwiftUINavigationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftUINavigationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A3A7B9B26EF940D00A37A4D /* SwiftUINavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUINavigationTests.swift; sourceTree = ""; }; + 2AF10F1B26F3B56B00796207 /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; + 2AF10F1D26F3D4A000796207 /* ItemRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRow.swift; sourceTree = ""; }; + 4B781C61274C096B00779F2E /* ItemViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewController.swift; sourceTree = ""; }; + 4B781C63274C191000779F2E /* ItemRowCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowCellView.swift; sourceTree = ""; }; + 4BCC55A9272C53D80032BF7A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 4BDDD1F326EF9CB00032CB71 /* Inventory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inventory.swift; sourceTree = ""; }; + 4BDDD1F526EFB0D00032CB71 /* SwiftUIHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIHelpers.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2A3A7B8426EF940C00A37A4D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4BCC55AC272C572F0032BF7A /* Parsing in Frameworks */, + 2A3A7BB626EFA73100A37A4D /* IdentifiedCollections in Frameworks */, + 4B3057AD26F3BD7900A5C737 /* CasePaths in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3A7B9426EF940D00A37A4D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2A3A7B7E26EF940C00A37A4D = { + isa = PBXGroup; + children = ( + 2A3A7B8926EF940C00A37A4D /* SwiftUINavigation */, + 2A3A7B9A26EF940D00A37A4D /* SwiftUINavigationTests */, + 2A3A7B8826EF940C00A37A4D /* Products */, + ); + sourceTree = ""; + }; + 2A3A7B8826EF940C00A37A4D /* Products */ = { + isa = PBXGroup; + children = ( + 2A3A7B8726EF940C00A37A4D /* SwiftUINavigation.app */, + 2A3A7B9726EF940D00A37A4D /* SwiftUINavigationTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 2A3A7B8926EF940C00A37A4D /* SwiftUINavigation */ = { + isa = PBXGroup; + children = ( + 4BCC55A9272C53D80032BF7A /* Info.plist */, + 2A3A7B8A26EF940C00A37A4D /* SwiftUINavigationApp.swift */, + 2A3A7B8C26EF940C00A37A4D /* ContentView.swift */, + 4BDDD1F326EF9CB00032CB71 /* Inventory.swift */, + 2AF10F1D26F3D4A000796207 /* ItemRow.swift */, + 4B781C63274C191000779F2E /* ItemRowCellView.swift */, + 4B781C61274C096B00779F2E /* ItemViewController.swift */, + 2AF10F1B26F3B56B00796207 /* ItemView.swift */, + 4BDDD1F526EFB0D00032CB71 /* SwiftUIHelpers.swift */, + 2A3A7B8E26EF940D00A37A4D /* Assets.xcassets */, + 2A3A7B9026EF940D00A37A4D /* Preview Content */, + ); + path = SwiftUINavigation; + sourceTree = ""; + }; + 2A3A7B9026EF940D00A37A4D /* Preview Content */ = { + isa = PBXGroup; + children = ( + 2A3A7B9126EF940D00A37A4D /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 2A3A7B9A26EF940D00A37A4D /* SwiftUINavigationTests */ = { + isa = PBXGroup; + children = ( + 2A3A7B9B26EF940D00A37A4D /* SwiftUINavigationTests.swift */, + ); + path = SwiftUINavigationTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2A3A7B8626EF940C00A37A4D /* SwiftUINavigation */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A3A7BAB26EF940E00A37A4D /* Build configuration list for PBXNativeTarget "SwiftUINavigation" */; + buildPhases = ( + 2A3A7B8326EF940C00A37A4D /* Sources */, + 2A3A7B8426EF940C00A37A4D /* Frameworks */, + 2A3A7B8526EF940C00A37A4D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SwiftUINavigation; + packageProductDependencies = ( + 2A3A7BB526EFA73100A37A4D /* IdentifiedCollections */, + 4B3057AC26F3BD7900A5C737 /* CasePaths */, + 4BCC55AB272C572F0032BF7A /* Parsing */, + ); + productName = SwiftUINavigation; + productReference = 2A3A7B8726EF940C00A37A4D /* SwiftUINavigation.app */; + productType = "com.apple.product-type.application"; + }; + 2A3A7B9626EF940D00A37A4D /* SwiftUINavigationTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A3A7BAE26EF940E00A37A4D /* Build configuration list for PBXNativeTarget "SwiftUINavigationTests" */; + buildPhases = ( + 2A3A7B9326EF940D00A37A4D /* Sources */, + 2A3A7B9426EF940D00A37A4D /* Frameworks */, + 2A3A7B9526EF940D00A37A4D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 2A3A7B9926EF940D00A37A4D /* PBXTargetDependency */, + ); + name = SwiftUINavigationTests; + productName = SwiftUINavigationTests; + productReference = 2A3A7B9726EF940D00A37A4D /* SwiftUINavigationTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2A3A7B7F26EF940C00A37A4D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1300; + LastUpgradeCheck = 1300; + TargetAttributes = { + 2A3A7B8626EF940C00A37A4D = { + CreatedOnToolsVersion = 13.0; + }; + 2A3A7B9626EF940D00A37A4D = { + CreatedOnToolsVersion = 13.0; + TestTargetID = 2A3A7B8626EF940C00A37A4D; + }; + }; + }; + buildConfigurationList = 2A3A7B8226EF940C00A37A4D /* Build configuration list for PBXProject "SwiftUINavigation" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2A3A7B7E26EF940C00A37A4D; + packageReferences = ( + 2A3A7BB426EFA73100A37A4D /* XCRemoteSwiftPackageReference "swift-identified-collections" */, + 4B3057AB26F3BD7900A5C737 /* XCRemoteSwiftPackageReference "swift-case-paths" */, + 4BCC55AA272C572F0032BF7A /* XCRemoteSwiftPackageReference "swift-parsing" */, + ); + productRefGroup = 2A3A7B8826EF940C00A37A4D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2A3A7B8626EF940C00A37A4D /* SwiftUINavigation */, + 2A3A7B9626EF940D00A37A4D /* SwiftUINavigationTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2A3A7B8526EF940C00A37A4D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A3A7B9226EF940D00A37A4D /* Preview Assets.xcassets in Resources */, + 2A3A7B8F26EF940D00A37A4D /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3A7B9526EF940D00A37A4D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2A3A7B8326EF940C00A37A4D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B781C62274C096B00779F2E /* ItemViewController.swift in Sources */, + 4BDDD1F426EF9CB00032CB71 /* Inventory.swift in Sources */, + 4B781C64274C191000779F2E /* ItemRowCellView.swift in Sources */, + 4BDDD1F626EFB0D00032CB71 /* SwiftUIHelpers.swift in Sources */, + 2A3A7B8D26EF940C00A37A4D /* ContentView.swift in Sources */, + 2AF10F1E26F3D4A000796207 /* ItemRow.swift in Sources */, + 2AF10F1C26F3B56B00796207 /* ItemView.swift in Sources */, + 2A3A7B8B26EF940C00A37A4D /* SwiftUINavigationApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A3A7B9326EF940D00A37A4D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A3A7B9C26EF940D00A37A4D /* SwiftUINavigationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2A3A7B9926EF940D00A37A4D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2A3A7B8626EF940C00A37A4D /* SwiftUINavigation */; + targetProxy = 2A3A7B9826EF940D00A37A4D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2A3A7BA926EF940E00A37A4D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 2A3A7BAA26EF940E00A37A4D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2A3A7BAC26EF940E00A37A4D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SwiftUINavigation/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SwiftUINavigation/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUINavigation; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2A3A7BAD26EF940E00A37A4D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"SwiftUINavigation/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SwiftUINavigation/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUINavigation; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 2A3A7BAF26EF940E00A37A4D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUINavigationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUINavigation.app/SwiftUINavigation"; + }; + name = Debug; + }; + 2A3A7BB026EF940E00A37A4D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUINavigationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUINavigation.app/SwiftUINavigation"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2A3A7B8226EF940C00A37A4D /* Build configuration list for PBXProject "SwiftUINavigation" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3A7BA926EF940E00A37A4D /* Debug */, + 2A3A7BAA26EF940E00A37A4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A3A7BAB26EF940E00A37A4D /* Build configuration list for PBXNativeTarget "SwiftUINavigation" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3A7BAC26EF940E00A37A4D /* Debug */, + 2A3A7BAD26EF940E00A37A4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2A3A7BAE26EF940E00A37A4D /* Build configuration list for PBXNativeTarget "SwiftUINavigationTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A3A7BAF26EF940E00A37A4D /* Debug */, + 2A3A7BB026EF940E00A37A4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 2A3A7BB426EFA73100A37A4D /* XCRemoteSwiftPackageReference "swift-identified-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-identified-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.2.0; + }; + }; + 4B3057AB26F3BD7900A5C737 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-case-paths.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.7.0; + }; + }; + 4BCC55AA272C572F0032BF7A /* XCRemoteSwiftPackageReference "swift-parsing" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-parsing.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 2A3A7BB526EFA73100A37A4D /* IdentifiedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = 2A3A7BB426EFA73100A37A4D /* XCRemoteSwiftPackageReference "swift-identified-collections" */; + productName = IdentifiedCollections; + }; + 4B3057AC26F3BD7900A5C737 /* CasePaths */ = { + isa = XCSwiftPackageProductDependency; + package = 4B3057AB26F3BD7900A5C737 /* XCRemoteSwiftPackageReference "swift-case-paths" */; + productName = CasePaths; + }; + 4BCC55AB272C572F0032BF7A /* Parsing */ = { + isa = XCSwiftPackageProductDependency; + package = 4BCC55AA272C572F0032BF7A /* XCRemoteSwiftPackageReference "swift-parsing" */; + productName = Parsing; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 2A3A7B7F26EF940C00A37A4D /* Project object */; +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AccentColor.colorset/Contents.json b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AppIcon.appiconset/Contents.json b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/Contents.json b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ContentView.swift b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ContentView.swift new file mode 100644 index 00000000..073a889a --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ContentView.swift @@ -0,0 +1,248 @@ +import Parsing +import SwiftUI + +struct DeepLinkRequest { + var pathComponents: ArraySlice + var queryItems: [String: ArraySlice] +} + +extension DeepLinkRequest { + init(url: URL) { + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + + self.init( + pathComponents: url.path.split(separator: "/")[...], + queryItems: queryItems.reduce(into: [:]) { dictionary, item in + dictionary[item.name, default: []].append(item.value?[...]) + } + ) + } +} + +struct PathComponent: Parser +where + ComponentParser: Parser, + ComponentParser.Input == Substring +{ + let component: ComponentParser + init(_ component: ComponentParser) { + self.component = component + } + + func parse(_ input: inout DeepLinkRequest) -> ComponentParser.Output? { + guard + var firstComponent = input.pathComponents.first, + let output = self.component.parse(&firstComponent), + firstComponent.isEmpty + else { return nil } + + input.pathComponents.removeFirst() + return output + } +} + +struct PathEnd: Parser { + func parse(_ input: inout DeepLinkRequest) -> Void? { + guard input.pathComponents.isEmpty + else { return nil } + return () + } +} + +struct QueryItem: Parser +where + ValueParser: Parser, + ValueParser.Input == Substring +{ + let name: String + let valueParser: ValueParser + + init(_ name: String, _ valueParser: ValueParser) { + self.name = name + self.valueParser = valueParser + } + + init(_ name: String) where ValueParser == Rest { + self.init(name, Rest()) + } + + func parse(_ input: inout DeepLinkRequest) -> ValueParser.Output? { + guard + let wrapped = input.queryItems[self.name]?.first, + var value = wrapped, + let output = self.valueParser.parse(&value), + value.isEmpty + else { return nil } + + input.queryItems[self.name]?.removeFirst() + return output + } +} + +enum AppRoute { + case one + case inventory(InventoryRoute?) + case three +} + +enum InventoryRoute { + case add(Item, ItemRoute? = nil) + case row(Item.ID, RowRoute) + + enum RowRoute { + case delete + case duplicate + case edit + } +} + +enum ItemRoute { + case colorPicker +} + +let item = QueryItem("name").orElse(Always("")) + .take(QueryItem("quantity", Int.parser()).orElse(Always(1))) + .map { name, quantity in + Item(name: String(name), status: .inStock(quantity: quantity)) + } + +let inventoryDeepLinker = PathEnd() + .map { AppRoute.inventory(nil) } + .orElse( + PathComponent("add") + .skip(PathEnd()) + .take(item) + .map { .inventory(.add($0)) } + ) + .orElse( + PathComponent("add") + .skip(PathComponent("colorPicker")) + .skip(PathEnd()) + .take(item) + .map { .inventory(.add($0, .colorPicker)) } + ) + .orElse( + PathComponent(UUID.parser()) + .skip(PathComponent("edit")) + .skip(PathEnd()) + .map { id in .inventory(.row(id, .edit)) } + ) + .orElse( + PathComponent(UUID.parser()) + .skip(PathComponent("delete")) + .skip(PathEnd()) + .map { id in .inventory(.row(id, .delete)) } + ) + +let deepLinker = PathComponent("one") + .skip(PathEnd()) + .map { AppRoute.one } + .orElse( + PathComponent("inventory") + .take(inventoryDeepLinker) + ) + .orElse( + PathComponent("three") + .skip(PathEnd()) + .map { .three } + ) + +enum Tab { + case one, inventory, three +} + +class AppViewModel: ObservableObject { + @Published var inventoryViewModel: InventoryViewModel + @Published var selectedTab: Tab + + init( + inventoryViewModel: InventoryViewModel = .init(), + selectedTab: Tab = .one + ) { + self.inventoryViewModel = inventoryViewModel + self.selectedTab = selectedTab + } + + func open(url: URL) { + var request = DeepLinkRequest(url: url) + if let route = deepLinker.parse(&request) { + switch route { + case .one: + self.selectedTab = .one + + case let .inventory(inventoryRoute): + self.selectedTab = .inventory + self.inventoryViewModel.navigate(to: inventoryRoute) + + case .three: + self.selectedTab = .three + } + } + } +} + +extension InventoryViewModel { + func navigate(to route: InventoryRoute?) { + switch route { + case let .add(item, .none): + self.route = .add(.init(item: item)) + + case let .add(item, .colorPicker): + self.route = .add(.init(item: item, route: .colorPicker)) + + case let .row(id, rowRoute): + guard let viewModel = self.inventory[id: id] + else { break } + viewModel.navigate(to: rowRoute) + + case .none: + self.route = nil + } + } +} + +extension ItemRowViewModel { + func navigate(to route: InventoryRoute.RowRoute) { + switch route { + case .delete: + self.route = .deleteAlert + case .duplicate: + self.route = .duplicate(.init(item: self.item)) + case .edit: + self.route = .edit(.init(item: self.item)) + } + } +} + +struct ContentView: View { + @ObservedObject var viewModel: AppViewModel + + var body: some View { + TabView(selection: self.$viewModel.selectedTab) { + Button("Go to 2nd tab") { + self.viewModel.selectedTab = .inventory + } + .tabItem { Text("One") } + .tag(Tab.one) + + NavigationView { + InventoryView(viewModel: self.viewModel.inventoryViewModel) + } + .tabItem { Text("Inventory") } + .tag(Tab.inventory) + + Text("Three") + .tabItem { Text("Three") } + .tag(Tab.three) + } + .onOpenURL { url in + self.viewModel.open(url: url) + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView(viewModel: .init(selectedTab: .inventory)) + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Info.plist b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Info.plist new file mode 100644 index 00000000..084492d5 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + nav + + + + + diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Inventory.swift b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Inventory.swift new file mode 100644 index 00000000..324a5eaa --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Inventory.swift @@ -0,0 +1,201 @@ +import CasePaths +import IdentifiedCollections +import SwiftUI + +struct Item: Equatable, Identifiable { + let id = UUID() + var name: String + var color: Color? + var status: Status + + enum Status: Equatable { + case inStock(quantity: Int) + case outOfStock(isOnBackOrder: Bool) + + var isInStock: Bool { + guard case .inStock = self else { return false } + return true + } + } + + struct Color: Equatable, Hashable { + var name: String + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + + static var defaults: [Self] = [ + .red, + .green, + .blue, + .black, + .yellow, + .white, + ] + + static let red = Self(name: "Red", red: 1) + static let green = Self(name: "Green", green: 1) + static let blue = Self(name: "Blue", blue: 1) + static let black = Self(name: "Black") + static let yellow = Self(name: "Yellow", red: 1, green: 1) + static let white = Self(name: "White", red: 1, green: 1, blue: 1) + + var swiftUIColor: SwiftUI.Color { + .init(red: self.red, green: self.green, blue: self.blue) + } + } +} + +class InventoryViewModel: ObservableObject { + @Published var inventory: IdentifiedArrayOf + @Published var route: Route? + + enum Route: Equatable { + case add(ItemViewModel) + case row(id: ItemRowViewModel.ID, route: ItemRowViewModel.Route) + + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.add(lhs), .add(rhs)): + return lhs === rhs + case let (.row(lhsId, lhsRoute), .row(rhsId, rhsRoute)): + return lhsId == rhsId && lhsRoute == rhsRoute + case (.add, .row), (.row, .add): + return false + } + } + } + + init( + inventory: IdentifiedArrayOf = [], + route: Route? = nil + ) { + self.inventory = [] + self.route = route + + for itemRowViewModel in inventory { + self.bind(itemRowViewModel: itemRowViewModel) + } + } + + private func bind(itemRowViewModel: ItemRowViewModel) { + print("bind id", itemRowViewModel.id) + + itemRowViewModel.onDelete = { [weak self, item = itemRowViewModel.item] in + withAnimation { + self?.delete(item: item) + } + } + itemRowViewModel.onDuplicate = { [weak self] item in + withAnimation { + self?.add(item: item) + } + } + itemRowViewModel.$route + .map { [id = itemRowViewModel.id] route in + route.map { Route.row(id: id, route: $0) } + } + .removeDuplicates() + .dropFirst() + .assign(to: &self.$route) + self.$route + .map { [id = itemRowViewModel.id] route in + guard + case let .row(id: routeRowId, route: route) = route, + routeRowId == id + else { return nil } + return route + } + .removeDuplicates() + .assign(to: &itemRowViewModel.$route) + self.inventory.append(itemRowViewModel) + } + + func delete(item: Item) { + withAnimation { + _ = self.inventory.remove(id: item.id) + } + } + + func add(item: Item) { + withAnimation { + self.bind(itemRowViewModel: .init(item: item)) + self.route = nil + } + } + + func addButtonTapped() { + self.route = .add( + .init( + item: .init(name: "", color: nil, status: .inStock(quantity: 1)) + ) + ) + + Task { @MainActor in + try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) + try (/Route.add).modify(&self.route) { + $0.item.name = "Bluetooth Keyboard" + } + } + } + + func cancelButtonTapped() { + self.route = nil + } +} + +struct InventoryView: View { + @ObservedObject var viewModel: InventoryViewModel + + var body: some View { + List { + ForEach( + self.viewModel.inventory, + content: ItemRowView.init(viewModel:) + ) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { self.viewModel.addButtonTapped() } + } + } + .navigationTitle("Inventory") + .sheet(item: self.$viewModel.route.case(/InventoryViewModel.Route.add)) { itemToAdd in + NavigationView { +// ItemView(viewModel: itemToAdd) + ToSwiftUI { + ItemViewController(viewModel: itemToAdd) + } + .navigationTitle("Add") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { self.viewModel.cancelButtonTapped() } + } + ToolbarItem(placement: .primaryAction) { + Button("Save") { self.viewModel.add(item: itemToAdd.item) } + } + } + } + } + } +} + +struct InventoryView_Previews: PreviewProvider { + static var previews: some View { + let keyboard = Item(name: "Keyboard", color: .blue, status: .inStock(quantity: 100)) + + NavigationView { + InventoryView( + viewModel: .init( + inventory: [ + .init(item: keyboard), + .init(item: Item(name: "Charger", color: .yellow, status: .inStock(quantity: 20))), + .init(item: Item(name: "Phone", color: .green, status: .outOfStock(isOnBackOrder: true))), + .init(item: Item(name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false))), + ], + route: nil + ) + ) + } + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemRow.swift b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemRow.swift new file mode 100644 index 00000000..6ef8aa5d --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemRow.swift @@ -0,0 +1,190 @@ +import CasePaths +import SwiftUI + +class ItemRowViewModel: Identifiable, ObservableObject { + @Published var item: Item + @Published var route: Route? + @Published var isSaving = false + + enum Route: Equatable { + case deleteAlert + case duplicate(ItemViewModel) + case edit(ItemViewModel) + + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.deleteAlert, .deleteAlert): + return true + case let (.duplicate(lhs), .duplicate(rhs)): + return lhs === rhs + case let (.edit(lhs), .edit(rhs)): + return lhs === rhs + case (.deleteAlert, _), (.duplicate, _), (.edit, _): + return false + } + } + } + + var onDelete: () -> Void = {} + var onDuplicate: (Item) -> Void = { _ in } + + var id: Item.ID { self.item.id } + + init( + item: Item + ) { + self.item = item + } + + func deleteButtonTapped() { + self.route = .deleteAlert + } + + func deleteConfirmationButtonTapped() { + self.onDelete() + self.route = nil + } + + func setEditNavigation(isActive: Bool) { + self.route = isActive ? .edit(.init(item: self.item)) : nil + } + + func edit(item: Item) { + self.isSaving = true + + Task { @MainActor in + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + + self.isSaving = false + self.item = item + self.route = nil + } + } + + func cancelButtonTapped() { + self.route = nil + } + + func duplicateButtonTapped() { + self.route = .duplicate(.init(item: self.item.duplicate())) + } + + func duplicate(item: Item) { + self.onDuplicate(item) + self.route = nil + } +} + +extension Item { + func duplicate() -> Self { + .init(name: self.name, color: self.color, status: self.status) + } +} + +struct ItemRowView: View { + @ObservedObject var viewModel: ItemRowViewModel + + var body: some View { + NavigationLink( + unwrap: self.$viewModel.route, + case: /ItemRowViewModel.Route.edit, + onNavigate: self.viewModel.setEditNavigation(isActive:), + destination: { $itemViewModel in +// ItemView(viewModel: itemViewModel) + ToSwiftUI { + ItemViewController(viewModel: itemViewModel) + } + .navigationBarTitle("Edit") + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.viewModel.cancelButtonTapped() + } + } + ToolbarItem(placement: .primaryAction) { + HStack { + if self.viewModel.isSaving { + ProgressView() + } + Button("Save") { + self.viewModel.edit(item: itemViewModel.item) + } + } + .disabled(self.viewModel.isSaving) + } + } + } + ) { + HStack { + VStack(alignment: .leading) { + Text(self.viewModel.item.name) + + switch self.viewModel.item.status { + case let .inStock(quantity): + Text("In stock: \(quantity)") + case let .outOfStock(isOnBackOrder): + Text("Out of stock" + (isOnBackOrder ? ": on back order" : "")) + } + } + + Spacer() + + if let color = self.viewModel.item.color { + Rectangle() + .frame(width: 30, height: 30) + .foregroundColor(color.swiftUIColor) + .border(Color.black, width: 1) + } + + Button(action: { self.viewModel.duplicateButtonTapped() }) { + Image(systemName: "square.fill.on.square.fill") + } + .padding(.leading) + + Button(action: { self.viewModel.deleteButtonTapped() }) { + Image(systemName: "trash.fill") + } + .padding(.leading) + } + .buttonStyle(.plain) + .foregroundColor(self.viewModel.item.status.isInStock ? nil : Color.gray) + .alert( + self.viewModel.item.name, + isPresented: self.$viewModel.route.isPresent(/ItemRowViewModel.Route.deleteAlert), + actions: { + Button("Delete", role: .destructive) { + self.viewModel.deleteConfirmationButtonTapped() + } + }, + message: { + Text("Are you sure you want to delete this item?") + } + ) + .popover( + item: self.$viewModel.route.case(/ItemRowViewModel.Route.duplicate) + ) { itemViewModel in + NavigationView { +// ItemView(viewModel: itemViewModel) + ToSwiftUI { + ItemViewController(viewModel: itemViewModel) + } + .navigationBarTitle("Duplicate") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.viewModel.cancelButtonTapped() + } + } + ToolbarItem(placement: .primaryAction) { + Button("Add") { + self.viewModel.duplicate(item: itemViewModel.item) + } + } + } + } + .frame(minWidth: 300, minHeight: 500) + } + } + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemRowCellView.swift b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemRowCellView.swift new file mode 100644 index 00000000..6a581ce8 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemRowCellView.swift @@ -0,0 +1,83 @@ +import Combine +import UIKit + +class ItemRowCellView: UICollectionViewListCell { + var cancellables: Set = [] + + override func prepareForReuse() { + super.prepareForReuse() + self.cancellables = [] + } + + func bind(viewModel: ItemRowViewModel, context: UIViewController) { + viewModel.$item + .map(\.name) + .removeDuplicates() + .sink { [unowned self] name in + var content = self.defaultContentConfiguration() + content.text = name + self.contentConfiguration = content + } + .store(in: &self.cancellables) + + viewModel.$route + .removeDuplicates() + .sink { [unowned self] route in + switch route { + case .none: + break + case .deleteAlert: + let alert = UIAlertController( + title: viewModel.item.name, + message: "Are you sure you want to delete this item?", + preferredStyle: .alert + ) + alert.addAction(.init(title: "Cancel", style: .cancel, handler: { _ in + viewModel.cancelButtonTapped() + })) + alert.addAction(.init(title: "Delete", style: .destructive, handler: { _ in + viewModel.deleteConfirmationButtonTapped() + })) + context.present(alert, animated: true) + + case let .duplicate(itemViewModel): + let vc = ItemViewController(viewModel: itemViewModel) + vc.title = "Duplicate" + vc.navigationItem.leftBarButtonItem = .init( + title: "Cancel", + primaryAction: .init { _ in + viewModel.cancelButtonTapped() + } + ) + vc.navigationItem.rightBarButtonItem = .init( + title: "Add", + primaryAction: .init { _ in + viewModel.duplicate(item: itemViewModel.item) + } + ) + let nav = UINavigationController(rootViewController: vc) + nav.modalPresentationStyle = .popover + nav.popoverPresentationController?.sourceView = self + context.present(nav, animated: true) + + case let .edit(itemViewModel): + let vc = ItemViewController(viewModel: itemViewModel) + vc.title = "Edit" + vc.navigationItem.leftBarButtonItem = .init( + title: "Cancel", + primaryAction: .init { _ in + viewModel.cancelButtonTapped() + } + ) + vc.navigationItem.rightBarButtonItem = .init( + title: "Save", + primaryAction: .init { _ in + viewModel.edit(item: itemViewModel.item) + } + ) + context.show(vc, sender: nil) + } + } + .store(in: &self.cancellables) + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemView.swift b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemView.swift new file mode 100644 index 00000000..12202f6d --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemView.swift @@ -0,0 +1,160 @@ +import CasePaths +import SwiftUI + +struct ColorPickerView: View { + @ObservedObject var viewModel: ItemViewModel + @Environment(\.dismiss) var dismiss + + var body: some View { + Form { + Button(action: { + self.viewModel.item.color = nil + self.dismiss() + }) { + HStack { + Text("None") + Spacer() + if self.viewModel.item.color == nil { + Image(systemName: "checkmark") + } + } + } + + Section(header: Text("Default colors")) { + ForEach(Item.Color.defaults, id: \.name) { color in + Button(action: { + self.viewModel.item.color = color + self.dismiss() + }) { + HStack { + Text(color.name) + Spacer() + if self.viewModel.item.color == color { + Image(systemName: "checkmark") + } + } + } + } + } + + if !self.viewModel.newColors.isEmpty { + Section(header: Text("New colors")) { + ForEach(self.viewModel.newColors, id: \.name) { color in + Button(action: { + self.viewModel.item.color = color + self.dismiss() + }) { + HStack { + Text(color.name) + Spacer() + if self.viewModel.item.color == color { + Image(systemName: "checkmark") + } + } + } + } + } + } + } + .task { + await self.viewModel.loadColors() + } + } +} + +class ItemViewModel: Identifiable, ObservableObject { + @Published var item: Item + @Published var nameIsDuplicate = false + @Published var newColors: [Item.Color] = [] + @Published var route: Route? + + var id: Item.ID { self.item.id } + + enum Route { + case colorPicker + } + + init(item: Item, route: Route? = nil) { + self.item = item + self.route = route + + Task { @MainActor in + for await item in self.$item.values { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 300) + self.nameIsDuplicate = item.name == "Keyboard" + } + } + } + + @MainActor + func loadColors() async { + try? await Task.sleep(nanoseconds: NSEC_PER_MSEC * 500) + self.newColors = [ + .init(name: "Pink", red: 1, green: 0.7, blue: 0.7), + ] + } + + func setColorPickerNavigation(isActive: Bool) { + self.route = isActive ? .colorPicker : nil + } +} + +struct ItemView: View { + @ObservedObject var viewModel: ItemViewModel + + var body: some View { + Form { + TextField("Name", text: self.$viewModel.item.name) + .background(self.viewModel.nameIsDuplicate ? Color.red.opacity(0.1) : Color.clear) + + NavigationLink( + unwrap: self.$viewModel.route, + case: /ItemViewModel.Route.colorPicker, + onNavigate: self.viewModel.setColorPickerNavigation(isActive:), + destination: { _ in ColorPickerView(viewModel: self.viewModel) } + ) { + HStack { + Text("Color") + Spacer() + if let color = self.viewModel.item.color { + Rectangle() + .frame(width: 30, height: 30) + .foregroundColor(color.swiftUIColor) + .border(Color.black, width: 1) + } + Text(self.viewModel.item.color?.name ?? "None") + .foregroundColor(.gray) + } + } + + IfCaseLet(self.$viewModel.item.status, pattern: /Item.Status.inStock) { $quantity in + Section(header: Text("In stock")) { + Stepper("Quantity: \(quantity)", value: $quantity) + Button("Mark as sold out") { + self.viewModel.item.status = .outOfStock(isOnBackOrder: false) + } + } + } + IfCaseLet(self.$viewModel.item.status, pattern: /Item.Status.outOfStock) { $isOnBackOrder in + Section(header: Text("Out of stock")) { + Toggle("Is on back order?", isOn: $isOnBackOrder) + Button("Is back in stock!") { + self.viewModel.item.status = .inStock(quantity: 1) + } + } + } + } + } +} + +struct ItemView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + ItemView( + viewModel: .init( + item: Item(name: "", color: nil, status: .inStock(quantity: 1)) + ) + ) + } + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemViewController.swift b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemViewController.swift new file mode 100644 index 00000000..4b941ce7 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/ItemViewController.swift @@ -0,0 +1,215 @@ +import CasePaths +import Combine +import SwiftUI +import UIKit + +class ItemViewController: UIViewController, UIPickerViewDelegate, UIPickerViewDataSource { + let viewModel: ItemViewModel + private var cancellables: Set = [] + + init(viewModel: ItemViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // MARK: View creation + + let nameTextField = UITextField() + nameTextField.placeholder = "Name" + nameTextField.borderStyle = .roundedRect + + let colorPicker = UIPickerView() + colorPicker.dataSource = self + colorPicker.delegate = self + + let quantityLabel = UILabel() + + let quantityStepper = UIStepper() + quantityStepper.maximumValue = .infinity + + let quantityStackView = UIStackView(arrangedSubviews: [ + quantityLabel, + quantityStepper + ]) + + let markAsSoldOutButton = UIButton(type: .system) + markAsSoldOutButton.setTitle("Mark as sold out", for: .normal) + + let inStockStackView = UIStackView(arrangedSubviews: [ + quantityStackView, + markAsSoldOutButton, + ]) + inStockStackView.axis = .vertical + + let isOnBackOrderLabel = UILabel() + isOnBackOrderLabel.text = "Is on back order?" + + let isOnBackOrderSwitch = UISwitch() + + let isOnBackOrderStackView = UIStackView(arrangedSubviews: [ + isOnBackOrderLabel, + isOnBackOrderSwitch, + ]) + + let isBackInStockButton = UIButton(type: .system) + isBackInStockButton.setTitle("Is back in stock!", for: .normal) + + let outOfStockStackView = UIStackView(arrangedSubviews: [ + isOnBackOrderStackView, + isBackInStockButton, + ]) + outOfStockStackView.axis = .vertical + + let stackView = UIStackView(arrangedSubviews: [ + nameTextField, + colorPicker, + inStockStackView, + outOfStockStackView, + ]) + stackView.axis = .vertical + stackView.spacing = UIStackView.spacingUseSystem + stackView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.view.readableContentGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: self.view.readableContentGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: self.view.readableContentGuide.trailingAnchor), + ]) + + // MARK: View model bindings + + self.viewModel.$item + .map(\.name) + .removeDuplicates() + .sink { nameTextField.text = $0 } + .store(in: &self.cancellables) + + self.viewModel.$item + .map(\.color) + .removeDuplicates() + .sink { color in + guard let row = Item.Color.all.firstIndex(of: color) + else { return } + colorPicker.selectRow(row, inComponent: 0, animated: false) + } + .store(in: &self.cancellables) + + self.viewModel.$item + .map(\.status) + .compactMap(/Item.Status.inStock) + .removeDuplicates() + .sink { quantity in + quantityLabel.text = "Quantity: \(quantity)" + quantityStepper.value = Double(quantity) + } + .store(in: &self.cancellables) + + self.viewModel.$item + .map { /Item.Status.inStock ~= $0.status } + .removeDuplicates() + .sink { isInStock in + inStockStackView.isHidden = !isInStock + } + .store(in: &self.cancellables) + + self.viewModel.$item + .map { /Item.Status.outOfStock ~= $0.status } + .removeDuplicates() + .sink { isOutOfStock in + outOfStockStackView.isHidden = !isOutOfStock + } + .store(in: &self.cancellables) + + self.viewModel.$item + .map(\.status) + .compactMap(/Item.Status.outOfStock) + .removeDuplicates() + .sink { isOnBackOrder in + isOnBackOrderSwitch.isOn = isOnBackOrder + } + .store(in: &self.cancellables) + + // MARK: UI actions + + quantityStepper.addAction(.init { [unowned self, unowned quantityStepper] _ in + self.viewModel.item.status = .inStock(quantity: Int(quantityStepper.value)) + }, for: .valueChanged) + + markAsSoldOutButton.addAction( + .init { [unowned self] _ in + self.viewModel.item.status = .outOfStock(isOnBackOrder: false) + }, + for: .touchUpInside + ) + + isBackInStockButton.addAction( + .init { [unowned self] _ in + self.viewModel.item.status = .inStock(quantity: 1) + }, + for: .touchUpInside + ) + + nameTextField.addAction( + .init { [unowned self, unowned nameTextField] _ in + self.viewModel.item.name = nameTextField.text ?? "" + }, + for: .editingChanged + ) + + isOnBackOrderSwitch.addAction( + .init { [unowned self, unowned isOnBackOrderSwitch] _ in + self.viewModel.item.status = .outOfStock(isOnBackOrder: isOnBackOrderSwitch.isOn) + }, + for: .valueChanged + ) + } + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + Item.Color.all.count + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + Item.Color.all[row]?.name ?? "None" + } + + func pickerView( + _ pickerView: UIPickerView, + didSelectRow row: Int, + inComponent component: Int + ) { + self.viewModel.item.color = Item.Color.all[row] + } +} + +extension Item.Color { + static let all: [Self?] = [nil] + Self.defaults +} + +struct ItemViewController_Previews: PreviewProvider { + static var previews: some View { + ToSwiftUI { + ItemViewController( + viewModel: ItemViewModel( + item: .init( + name: "Keyboard", + color: .blue, + status: .outOfStock(isOnBackOrder: true) + ), + route: nil + ) + ) + } + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Preview Content/Preview Assets.xcassets/Contents.json b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/SwiftUIHelpers.swift b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/SwiftUIHelpers.swift new file mode 100644 index 00000000..9a803bd2 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/SwiftUIHelpers.swift @@ -0,0 +1,264 @@ +import CasePaths +import SwiftUI + +extension Binding { + func isPresent() -> Binding + where Value == Wrapped? { + .init( + get: { self.wrappedValue != nil }, + set: { isPresented in + if !isPresented { + self.wrappedValue = nil + } + } + ) + } + + func isPresent(_ casePath: CasePath) -> Binding + where Value == Enum? { + Binding( + get: { + if let wrappedValue = self.wrappedValue, casePath.extract(from: wrappedValue) != nil { + return true + } else { + return false + } + }, + set: { isPresented in + if !isPresented { + self.wrappedValue = nil + } + } + ) + } + + func `case`(_ casePath: CasePath) -> Binding + where Value == Enum? { + Binding( + get: { + guard + let wrappedValue = self.wrappedValue, + let `case` = casePath.extract(from: wrappedValue) + else { return nil } + return `case` + }, + set: { `case` in + if let `case` = `case` { + self.wrappedValue = casePath.embed(`case`) + } else { + self.wrappedValue = nil + } + } + ) + } +} + +extension View { + func alert( + title: (Case) -> Text, + unwrap data: Binding, + case casePath: CasePath, + @ViewBuilder actions: @escaping (Case) -> A, + @ViewBuilder message: @escaping (Case) -> M + ) -> some View { + self.alert( + title: title, + presenting: data.case(casePath), + actions: actions, + message: message + ) + } + + func confirmationDialog( + title: (Case) -> Text, + unwrap data: Binding, + case casePath: CasePath, + @ViewBuilder actions: @escaping (Case) -> A, + @ViewBuilder message: @escaping (Case) -> M + ) -> some View { + self.confirmationDialog( + title: title, + presenting: data.case(casePath), + actions: actions, + message: message + ) + } + + func alert( + title: (T) -> Text, + presenting data: Binding, + @ViewBuilder actions: @escaping (T) -> A, + @ViewBuilder message: @escaping (T) -> M + ) -> some View { + self.alert( + data.wrappedValue.map(title) ?? Text(""), + isPresented: data.isPresent(), + presenting: data.wrappedValue, + actions: actions, + message: message + ) + } + + func confirmationDialog( + title: (T) -> Text, + titleVisibility: Visibility = .automatic, + presenting data: Binding, + @ViewBuilder actions: @escaping (T) -> A, + @ViewBuilder message: @escaping (T) -> M + ) -> some View { + self.confirmationDialog( + data.wrappedValue.map(title) ?? Text(""), + isPresented: data.isPresent(), + titleVisibility: titleVisibility, + presenting: data.wrappedValue, + actions: actions, + message: message + ) + } +} + +struct IfCaseLet: View where Content: View { + let binding: Binding + let casePath: CasePath + let content: (Binding) -> Content + + init( + _ binding: Binding, + pattern casePath: CasePath, + @ViewBuilder content: @escaping (Binding) -> Content + ) { + self.binding = binding + self.casePath = casePath + self.content = content + } + + var body: some View { + if let `case` = self.casePath.extract(from: self.binding.wrappedValue) { + self.content( + Binding( + get: { `case` }, + set: { binding.wrappedValue = self.casePath.embed($0) } + ) + ) + } + } +} + +extension Binding { + init?(unwrap binding: Binding) { + guard let wrappedValue = binding.wrappedValue + else { return nil } + + self.init( + get: { wrappedValue }, + set: { binding.wrappedValue = $0 } + ) + } +} + +extension View { + func sheet( + unwrap optionalValue: Binding, + case casePath: CasePath, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Case: Identifiable, Content: View { + self.sheet(unwrap: optionalValue.case(casePath), content: content) + } + + func sheet( + unwrap optionalValue: Binding, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Value: Identifiable, Content: View { + self.sheet( + item: optionalValue + ) { _ in + if let value = Binding(unwrap: optionalValue) { + content(value) + } + } + } + + func popover( + unwrap optionalValue: Binding, + case casePath: CasePath, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Case: Identifiable, Content: View { + self.popover(unwrap: optionalValue.case(casePath), content: content) + } + + func popover( + unwrap optionalValue: Binding, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Value: Identifiable, Content: View { + self.popover( + item: optionalValue + ) { _ in + if let value = Binding(unwrap: optionalValue) { + content(value) + } + } + } +} + +extension NavigationLink { + init( + unwrap optionalValue: Binding, + onNavigate: @escaping (Bool) -> Void, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + @ViewBuilder label: @escaping () -> Label + ) + where Destination == WrappedDestination? + { + self.init( + isActive: optionalValue.isPresent().didSet(onNavigate), + destination: { + if let value = Binding(unwrap: optionalValue) { + destination(value) + } + }, + label: label + ) + } +} + +extension Binding { + func didSet(_ callback: @escaping (Value) -> Void) -> Self { + .init( + get: { self.wrappedValue }, + set: { + self.wrappedValue = $0 + callback($0) + } + ) + } +} + +extension NavigationLink { + init( + unwrap optionalValue: Binding, + case casePath: CasePath, + onNavigate: @escaping (Bool) -> Void, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + @ViewBuilder label: @escaping () -> Label + ) + where Destination == WrappedDestination? + { + self.init( + unwrap: optionalValue.case(casePath), + onNavigate: onNavigate, + destination: destination, + label: label + ) + } +} + +struct ToSwiftUI: UIViewControllerRepresentable { + let viewController: () -> UIViewController + + func makeUIViewController(context: Context) -> UIViewController { + self.viewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/SwiftUINavigationApp.swift b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/SwiftUINavigationApp.swift new file mode 100644 index 00000000..64a7e8a9 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigation/SwiftUINavigationApp.swift @@ -0,0 +1,29 @@ +import SwiftUI + +@main +struct SwiftUINavigationApp: App { + var body: some Scene { + let keyboard = Item(name: "Keyboard", color: .blue, status: .inStock(quantity: 100)) + + var editedKeyboard = keyboard + editedKeyboard.name = "Bluetooth Keyboard" + editedKeyboard.status = .inStock(quantity: 1000) + + return WindowGroup { + ContentView( + viewModel: .init( + inventoryViewModel: .init( + inventory: [ + .init(item: keyboard), + .init(item: Item(name: "Charger", color: .yellow, status: .inStock(quantity: 20))), + .init(item: Item(name: "Phone", color: .green, status: .outOfStock(isOnBackOrder: true))), + .init(item: Item(name: "Headphones", color: .green, status: .outOfStock(isOnBackOrder: false))), + ], + route: nil + ), + selectedTab: .one + ) + ) + } + } +} diff --git a/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigationTests/SwiftUINavigationTests.swift b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigationTests/SwiftUINavigationTests.swift new file mode 100644 index 00000000..13dbbbb9 --- /dev/null +++ b/0169-uikit-navigation-pt1/SwiftUINavigation/SwiftUINavigationTests/SwiftUINavigationTests.swift @@ -0,0 +1,93 @@ +//import CasePaths +//import XCTest +//@testable import SwiftUINavigation +// +//class SwiftUINavigationTests: XCTestCase { +// func testAddItem() throws { +// let viewModel = InventoryViewModel() +// viewModel.addButtonTapped() +// +// let itemToAdd = try XCTUnwrap((/InventoryViewModel.Route.add).extract(from: XCTUnwrap(viewModel.route))) +// +// viewModel.add(item: itemToAdd) +// +// XCTAssertNil(viewModel.route) +// XCTAssertEqual(viewModel.inventory.count, 1) +// XCTAssertEqual(viewModel.inventory[0].item, itemToAdd) +// } +// +// func testDeleteItem() { +// let viewModel = InventoryViewModel( +// inventory: [ +// .init(item: .init(name: "Keyboard", color: .red, status: .inStock(quantity: 1))) +// ] +// ) +// +// viewModel.inventory[0].deleteButtonTapped() +// +// XCTAssertEqual(viewModel.inventory[0].route, .deleteAlert) +// XCTAssertEqual(viewModel.route, .row(id: viewModel.inventory[0].item.id, route: .deleteAlert)) +// +// viewModel.inventory[0].deleteConfirmationButtonTapped() +// +// XCTAssertEqual(viewModel.inventory.count, 0) +// XCTAssertEqual(viewModel.route, nil) +// } +// +// func testDuplicateItem() throws { +// let item = Item(name: "Keyboard", color: .red, status: .inStock(quantity: 1)) +// let viewModel = InventoryViewModel( +// inventory: [ +// .init(item: item) +// ] +// ) +// +// viewModel.inventory[0].duplicateButtonTapped() +// +//// XCTAssertEqual(viewModel.inventory[0].route, .duplicate(item)) +// XCTAssertNotNil( +// (/ItemRowViewModel.Route.duplicate) +// .extract(from: try XCTUnwrap(viewModel.inventory[0].route)) +// ) +// +// let dupe = item.duplicate() +// viewModel.inventory[0].duplicate(item: dupe) +// +// XCTAssertEqual(viewModel.inventory.count, 2) +// XCTAssertEqual(viewModel.inventory[0].item, item) +// XCTAssertEqual(viewModel.inventory[1].item, dupe) +// XCTAssertNil(viewModel.inventory[0].route) +// } +// +// func testEdit() async throws { +// let item = Item(name: "Keyboard", color: .red, status: .inStock(quantity: 1)) +// let viewModel = InventoryViewModel( +// inventory: [ +// .init(item: item) +// ] +// ) +// +// viewModel.inventory[0].setEditNavigation(isActive: true) +// +// XCTAssertNotNil( +// (/ItemRowViewModel.Route.edit) +// .extract(from: try XCTUnwrap(viewModel.inventory[0].route)) +// ) +// +// var editedItem = item +// editedItem.color = .blue +// viewModel.inventory[0].route = .edit(editedItem) +// +// viewModel.inventory[0].edit(item: editedItem) +// +// XCTAssertEqual(viewModel.inventory[0].isSaving, true) +// +// try await Task.sleep(nanoseconds: NSEC_PER_SEC + 100 * NSEC_PER_MSEC) +// +// XCTAssertNil(viewModel.inventory[0].route) +// XCTAssertNil(viewModel.route) +// XCTAssertEqual(viewModel.inventory[0].item, editedItem) +// +// XCTAssertEqual(viewModel.inventory[0].isSaving, false) +// } +//} diff --git a/README.md b/README.md index a04ebb3f..82d9cbe2 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,4 @@ This repository is the home of code written on episodes of [Point-Free](https:// 1. [SwiftUI Navigation: Links, Part 2](0166-navigation-pt7) 1. [SwiftUI Navigation: Links, Part 3](0167-navigation-pt8) 1. [SwiftUI Navigation: The Point](0168-navigation-pt9) +1. [UIKit Navigation: Part 1](0169-uikit-navigation-pt1)