From c935f6d24576a51bd4bfd10762882f6ec0969fe2 Mon Sep 17 00:00:00 2001 From: Daniel Eggert Date: Thu, 3 Oct 2019 12:36:41 +0200 Subject: [PATCH 1/3] Add Resin. Move to package manager file layout. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently “Incremental” provides “incremental programming helpers” for updating and observing state changes. As we’ve used this, we have written wrappers and helpers to integrate this into an iOS app. We’ve named this wrapper library “Resin”. This provides a way to manage application state. More detailed info is in the (updated) readme.md file. Co-authored-by: Sommer Panage Co-authored-by: Teddy Wyly Co-authored-by: Julien Grimault Co-authored-by: Robb Böhnke Co-authored-by: Daniel Eggert --- .gitignore | 16 + .../project.pbxproj | 340 ------------------ Package.swift | 22 ++ .../Incremental}/Helpers.swift | 0 .../Incremental}/Incremental+NSObject.swift | 0 .../Incremental}/Incremental.swift | 0 .../Incremental}/IncrementalArray.swift | 12 +- .../Incremental}/IncrementalStdLib.swift | 0 .../Incremental}/Info.plist | 0 Sources/Resin/Action.swift | 16 + Sources/Resin/ActionContext.swift | 18 + Sources/Resin/ActionHandler.swift | 36 ++ Sources/Resin/ActionTarget.swift | 17 + Sources/Resin/AnyReducer+Optics.swift | 22 ++ Sources/Resin/ArbitraryState.swift | 25 ++ Sources/Resin/DefaultInit.swift | 5 + Sources/Resin/Disposable+Composition.swift | 12 + Sources/Resin/Equatable+IdentityKeyPath.swift | 13 + Sources/Resin/I+ViewModel.swift | 25 ++ Sources/Resin/IdentifiableByType.swift | 34 ++ .../Resin/ImplicitPresentationAction.swift | 104 ++++++ Sources/Resin/Incremental_UIKit.h | 7 + Sources/Resin/Info.plist | 24 ++ Sources/Resin/Lifecycle.swift | 78 ++++ Sources/Resin/Middleware.swift | 78 ++++ Sources/Resin/MiddlewareFactory.swift | 27 ++ .../Resin/NSObjectProtocol+Lifecycle.swift | 22 ++ Sources/Resin/Parcel.swift | 43 +++ Sources/Resin/PresentationAction.swift | 171 +++++++++ Sources/Resin/PresentationIdentifier.swift | 35 ++ Sources/Resin/PresentationRoute.swift | 110 ++++++ Sources/Resin/PresentationRouter.swift | 75 ++++ Sources/Resin/Reducer.swift | 47 +++ Sources/Resin/Store+Parcel.swift | 17 + Sources/Resin/Store.swift | 134 +++++++ Sources/Resin/UIBarButtonItem+Redux.swift | 59 +++ Sources/Resin/UIControl+Redux.swift | 49 +++ Sources/Resin/UIGestureRecognizer+Redux.swift | 30 ++ .../Resin/UINavigationController+Redux.swift | 92 +++++ Sources/Resin/UIView+Redux.swift | 46 +++ .../Resin/UIViewController+Lifecycle.swift | 162 +++++++++ .../UIViewController+ModalPresentation.swift | 161 +++++++++ Sources/Resin/UIViewController+Redux.swift | 40 +++ ...Controller+RequiresModalPresentation.swift | 26 ++ Sources/Resin/ViewControllerCache.swift | 45 +++ Sources/Resin/ViewModel.swift | 38 ++ readme.md | 51 +++ 47 files changed, 2038 insertions(+), 346 deletions(-) create mode 100644 .gitignore delete mode 100644 IncrementalSimplified.xcodeproj/project.pbxproj create mode 100644 Package.swift rename {Incremental => Sources/Incremental}/Helpers.swift (100%) rename {Incremental => Sources/Incremental}/Incremental+NSObject.swift (100%) rename {Incremental => Sources/Incremental}/Incremental.swift (100%) rename {Incremental => Sources/Incremental}/IncrementalArray.swift (98%) rename {Incremental => Sources/Incremental}/IncrementalStdLib.swift (100%) rename {Incremental => Sources/Incremental}/Info.plist (100%) create mode 100644 Sources/Resin/Action.swift create mode 100644 Sources/Resin/ActionContext.swift create mode 100644 Sources/Resin/ActionHandler.swift create mode 100644 Sources/Resin/ActionTarget.swift create mode 100644 Sources/Resin/AnyReducer+Optics.swift create mode 100644 Sources/Resin/ArbitraryState.swift create mode 100644 Sources/Resin/DefaultInit.swift create mode 100644 Sources/Resin/Disposable+Composition.swift create mode 100644 Sources/Resin/Equatable+IdentityKeyPath.swift create mode 100644 Sources/Resin/I+ViewModel.swift create mode 100644 Sources/Resin/IdentifiableByType.swift create mode 100644 Sources/Resin/ImplicitPresentationAction.swift create mode 100644 Sources/Resin/Incremental_UIKit.h create mode 100644 Sources/Resin/Info.plist create mode 100644 Sources/Resin/Lifecycle.swift create mode 100644 Sources/Resin/Middleware.swift create mode 100644 Sources/Resin/MiddlewareFactory.swift create mode 100644 Sources/Resin/NSObjectProtocol+Lifecycle.swift create mode 100644 Sources/Resin/Parcel.swift create mode 100644 Sources/Resin/PresentationAction.swift create mode 100644 Sources/Resin/PresentationIdentifier.swift create mode 100644 Sources/Resin/PresentationRoute.swift create mode 100644 Sources/Resin/PresentationRouter.swift create mode 100644 Sources/Resin/Reducer.swift create mode 100644 Sources/Resin/Store+Parcel.swift create mode 100644 Sources/Resin/Store.swift create mode 100644 Sources/Resin/UIBarButtonItem+Redux.swift create mode 100644 Sources/Resin/UIControl+Redux.swift create mode 100644 Sources/Resin/UIGestureRecognizer+Redux.swift create mode 100644 Sources/Resin/UINavigationController+Redux.swift create mode 100644 Sources/Resin/UIView+Redux.swift create mode 100644 Sources/Resin/UIViewController+Lifecycle.swift create mode 100644 Sources/Resin/UIViewController+ModalPresentation.swift create mode 100644 Sources/Resin/UIViewController+Redux.swift create mode 100644 Sources/Resin/UIViewController+RequiresModalPresentation.swift create mode 100644 Sources/Resin/ViewControllerCache.swift create mode 100644 Sources/Resin/ViewModel.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b835f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.build/ +.swiftpm/ +*.xcodeproj + +.hg +.svn +CVS + +.DS_Store +Desktop.ini +Thumbs.db +ehthumbs.db +*~ +.#* +.*.swp +.*.swo diff --git a/IncrementalSimplified.xcodeproj/project.pbxproj b/IncrementalSimplified.xcodeproj/project.pbxproj deleted file mode 100644 index 27957d5..0000000 --- a/IncrementalSimplified.xcodeproj/project.pbxproj +++ /dev/null @@ -1,340 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 48; - objects = { - -/* Begin PBXBuildFile section */ - 830932632099A86C00E1BD2A /* IncrementalArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8309325F2099A86B00E1BD2A /* IncrementalArray.swift */; }; - 830932642099A86C00E1BD2A /* IncrementalStdLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830932602099A86B00E1BD2A /* IncrementalStdLib.swift */; }; - 830932662099A86C00E1BD2A /* Incremental+NSObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830932622099A86C00E1BD2A /* Incremental+NSObject.swift */; }; - 830932682099A89700E1BD2A /* Incremental.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830932672099A89700E1BD2A /* Incremental.swift */; }; - 8309326A2099A8C500E1BD2A /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830932692099A8C500E1BD2A /* Helpers.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 8309325F2099A86B00E1BD2A /* IncrementalArray.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IncrementalArray.swift; sourceTree = ""; }; - 830932602099A86B00E1BD2A /* IncrementalStdLib.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IncrementalStdLib.swift; sourceTree = ""; }; - 830932622099A86C00E1BD2A /* Incremental+NSObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Incremental+NSObject.swift"; sourceTree = ""; }; - 830932672099A89700E1BD2A /* Incremental.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Incremental.swift; sourceTree = ""; }; - 830932692099A8C500E1BD2A /* Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; - 83DC8D381F697DA5004861BF /* Incremental.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Incremental.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 83DC8D3B1F697DA5004861BF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 83DC8D421F697E27004861BF /* Samples.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Samples.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 83DC8D341F697DA5004861BF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 83DC8D391F697DA5004861BF /* Incremental */ = { - isa = PBXGroup; - children = ( - 83DC8D3B1F697DA5004861BF /* Info.plist */, - 830932692099A8C500E1BD2A /* Helpers.swift */, - 830932672099A89700E1BD2A /* Incremental.swift */, - 830932622099A86C00E1BD2A /* Incremental+NSObject.swift */, - 8309325F2099A86B00E1BD2A /* IncrementalArray.swift */, - 830932602099A86B00E1BD2A /* IncrementalStdLib.swift */, - ); - path = Incremental; - sourceTree = ""; - }; - 83E47DFD1F56A04F00CF7E8B = { - isa = PBXGroup; - children = ( - 83DC8D421F697E27004861BF /* Samples.playground */, - 83DC8D391F697DA5004861BF /* Incremental */, - 83E47E071F56A04F00CF7E8B /* Products */, - ); - sourceTree = ""; - }; - 83E47E071F56A04F00CF7E8B /* Products */ = { - isa = PBXGroup; - children = ( - 83DC8D381F697DA5004861BF /* Incremental.framework */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXHeadersBuildPhase section */ - 83DC8D351F697DA5004861BF /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXHeadersBuildPhase section */ - -/* Begin PBXNativeTarget section */ - 83DC8D371F697DA5004861BF /* Incremental */ = { - isa = PBXNativeTarget; - buildConfigurationList = 83DC8D3F1F697DA5004861BF /* Build configuration list for PBXNativeTarget "Incremental" */; - buildPhases = ( - 83DC8D331F697DA5004861BF /* Sources */, - 83DC8D341F697DA5004861BF /* Frameworks */, - 83DC8D351F697DA5004861BF /* Headers */, - 83DC8D361F697DA5004861BF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Incremental; - productName = Incremental; - productReference = 83DC8D381F697DA5004861BF /* Incremental.framework */; - productType = "com.apple.product-type.framework"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 83E47DFE1F56A04F00CF7E8B /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 0900; - LastUpgradeCheck = 0900; - ORGANIZATIONNAME = objc.io; - TargetAttributes = { - 83DC8D371F697DA5004861BF = { - CreatedOnToolsVersion = 9.0; - LastSwiftMigration = 0900; - }; - }; - }; - buildConfigurationList = 83E47E011F56A04F00CF7E8B /* Build configuration list for PBXProject "IncrementalSimplified" */; - compatibilityVersion = "Xcode 8.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - ); - mainGroup = 83E47DFD1F56A04F00CF7E8B; - productRefGroup = 83E47E071F56A04F00CF7E8B /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 83DC8D371F697DA5004861BF /* Incremental */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 83DC8D361F697DA5004861BF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 83DC8D331F697DA5004861BF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 830932632099A86C00E1BD2A /* IncrementalArray.swift in Sources */, - 830932642099A86C00E1BD2A /* IncrementalStdLib.swift in Sources */, - 830932682099A89700E1BD2A /* Incremental.swift in Sources */, - 8309326A2099A8C500E1BD2A /* Helpers.swift in Sources */, - 830932662099A86C00E1BD2A /* Incremental+NSObject.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 83DC8D3D1F697DA5004861BF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = ""; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = ""; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - FRAMEWORK_VERSION = A; - INFOPLIST_FILE = Incremental/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.objcio.Incremental; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 4.0; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; - 83DC8D3E1F697DA5004861BF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = ""; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = ""; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - FRAMEWORK_VERSION = A; - INFOPLIST_FILE = Incremental/Info.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = io.objcio.Incremental; - PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; - SKIP_INSTALL = YES; - SWIFT_VERSION = 4.0; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; - 83E47E0B1F56A04F00CF7E8B /* 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++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = 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_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - 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; - CODE_SIGN_IDENTITY = "Mac Developer"; - 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; - MACOSX_DEPLOYMENT_TARGET = 10.12; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 83E47E0C1F56A04F00CF7E8B /* 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++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = 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_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - 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; - CODE_SIGN_IDENTITY = "Mac Developer"; - 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; - MACOSX_DEPLOYMENT_TARGET = 10.12; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 83DC8D3F1F697DA5004861BF /* Build configuration list for PBXNativeTarget "Incremental" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 83DC8D3D1F697DA5004861BF /* Debug */, - 83DC8D3E1F697DA5004861BF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 83E47E011F56A04F00CF7E8B /* Build configuration list for PBXProject "IncrementalSimplified" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 83E47E0B1F56A04F00CF7E8B /* Debug */, - 83E47E0C1F56A04F00CF7E8B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 83E47DFE1F56A04F00CF7E8B /* Project object */; -} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..03765a0 --- /dev/null +++ b/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Incremental", + platforms: [.iOS(.v12)], + products: [ + .library(name: "Incremental", targets: ["Incremental"]), + .library(name: "Resin", targets: ["Resin"]), + ], + dependencies: [], + targets: [ + .target( + name: "Incremental", + dependencies: []), + .target( + name: "Resin", + dependencies: ["Incremental"]) + ] +) diff --git a/Incremental/Helpers.swift b/Sources/Incremental/Helpers.swift similarity index 100% rename from Incremental/Helpers.swift rename to Sources/Incremental/Helpers.swift diff --git a/Incremental/Incremental+NSObject.swift b/Sources/Incremental/Incremental+NSObject.swift similarity index 100% rename from Incremental/Incremental+NSObject.swift rename to Sources/Incremental/Incremental+NSObject.swift diff --git a/Incremental/Incremental.swift b/Sources/Incremental/Incremental.swift similarity index 100% rename from Incremental/Incremental.swift rename to Sources/Incremental/Incremental.swift diff --git a/Incremental/IncrementalArray.swift b/Sources/Incremental/IncrementalArray.swift similarity index 98% rename from Incremental/IncrementalArray.swift rename to Sources/Incremental/IncrementalArray.swift index d60b856..cc30be5 100644 --- a/Incremental/IncrementalArray.swift +++ b/Sources/Incremental/IncrementalArray.swift @@ -191,7 +191,7 @@ extension Array where Element: Equatable { for (element, oldIndex) in zip(self, self.indices).reversed() { // TODO calling index(of:) in each iteration is inefficient. we could use binary search here, // or provide an even more efficient implementation for elements that are hashable - let newIndex = newSorted.index(of: element)! + let newIndex = newSorted.firstIndex(of: element)! if oldIndex != newIndex { result.append(.remove(at: oldIndex)) inserts.append((element, newIndex)) @@ -236,7 +236,7 @@ extension ArrayWithHistory { // mutation. we could either track this with a phan // todo I'm not sure if this is a good idea either... should only be used when mutating. public func index(of: A) -> Int? { - return unsafeLatestSnapshot.index(of: of) + return unsafeLatestSnapshot.firstIndex(of: of) } // todo not sure if this is the best idea @@ -374,17 +374,17 @@ extension ArrayWithHistory { switch change { case let .insert(element, _): // TODO this is inefficient, since we're sorting the original array each time to look up the index of the new element - let newIndex = newLatest.sorted(by: currentSortOrder).index(of: element)! + let newIndex = newLatest.sorted(by: currentSortOrder).firstIndex(of: element)! appendOnly(.insert(element, at: newIndex), to: changesOut) case let .remove(at: index): let element = latest[index] // TODO this is inefficient, since we're sorting the original array each time to look up the index of the new element - let newIndex = latest.sorted(by: currentSortOrder).index(of: element)! + let newIndex = latest.sorted(by: currentSortOrder).firstIndex(of: element)! appendOnly(.remove(at: newIndex), to: changesOut) case let .replace(with: element, at: index): let previousElement = latest[index] - let previousElementSortedIndex = latest.sorted(by: currentSortOrder).index(of: previousElement)! - let newIndex = newLatest.sorted(by: currentSortOrder).index(of: element)! + let previousElementSortedIndex = latest.sorted(by: currentSortOrder).firstIndex(of: previousElement)! + let newIndex = newLatest.sorted(by: currentSortOrder).firstIndex(of: element)! if previousElementSortedIndex == newIndex { appendOnly(.replace(with: element, at: newIndex), to: changesOut) } else { diff --git a/Incremental/IncrementalStdLib.swift b/Sources/Incremental/IncrementalStdLib.swift similarity index 100% rename from Incremental/IncrementalStdLib.swift rename to Sources/Incremental/IncrementalStdLib.swift diff --git a/Incremental/Info.plist b/Sources/Incremental/Info.plist similarity index 100% rename from Incremental/Info.plist rename to Sources/Incremental/Info.plist diff --git a/Sources/Resin/Action.swift b/Sources/Resin/Action.swift new file mode 100644 index 0000000..1378873 --- /dev/null +++ b/Sources/Resin/Action.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Value types conforming to the `Action` protocol are used to perform changes +/// to the `Store`. +/// +/// In order to make use of an `Action`, you need to either write a `Middleware` +/// or a `Reducer` and register them with the `Store` you send this `Action` to. +public protocol Action { } + +/// Actions that need to be executed and perform changes in the store immediately +/// (in the same runloop as they are dispatched) must conform to this protocol. +/// +/// This should *only* be used to change state in response to system events, +/// such as a `UIApplicationWillEnterForegroundNotification` or a +/// `UINavigationController` delegate callback +public protocol SystemResponseAction: Action { } diff --git a/Sources/Resin/ActionContext.swift b/Sources/Resin/ActionContext.swift new file mode 100644 index 0000000..499b460 --- /dev/null +++ b/Sources/Resin/ActionContext.swift @@ -0,0 +1,18 @@ +import UIKit + +public struct ActionContext { + public var file: String + + public var line: Int + + public var view: UIView? + + public var viewController: UIViewController? + + public init(file: String, line: Int, view: UIView? = nil, viewController: UIViewController? = nil) { + self.file = file + self.line = line + self.view = view + self.viewController = viewController + } +} diff --git a/Sources/Resin/ActionHandler.swift b/Sources/Resin/ActionHandler.swift new file mode 100644 index 0000000..3d6b0b5 --- /dev/null +++ b/Sources/Resin/ActionHandler.swift @@ -0,0 +1,36 @@ +import Foundation + +public protocol ActionHandler: class { + /// Handles an action. + /// + /// - Important: You should call `dispatch` instead, this method only exists + /// since we can't pass default arguments to `file` and `line`. + func handle(_ action: Action, context: ActionContext) +} + +public extension ActionHandler { + func dispatch(_ action: Action, file: String = #file, line: Int = #line) { + dispatch(action, context: ActionContext(file: file, line: line)) + } + + /// Dispatches an action. + func dispatch(_ action: Action, context: ActionContext) { + DispatchQueue.main.async { + self.handle(action, context: context) + } + } + + func dispatchWithSynchronousExecution(_ action: SystemResponseAction, file: String = #file, line: Int = #line) { + dispatchWithSynchronousExecution(action, context: ActionContext(file: file, line: line)) + } + + /// Dispatches a `SystemResponseAction` with the intent of being executed + /// in the same run loop in which it was called. + /// + /// Must be called on the main thread. + func dispatchWithSynchronousExecution(_ action: SystemResponseAction, context: ActionContext) { + dispatchPrecondition(condition: .onQueue(.main)) + + handle(action, context: context) + } +} diff --git a/Sources/Resin/ActionTarget.swift b/Sources/Resin/ActionTarget.swift new file mode 100644 index 0000000..f6db7bf --- /dev/null +++ b/Sources/Resin/ActionTarget.swift @@ -0,0 +1,17 @@ +import Foundation + +final class ActionTarget: NSObject { + let closure: (AnyObject) -> Void + + unowned let owner: AnyObject + + init(owner: AnyObject, closure: @escaping (AnyObject) -> Void) { + self.closure = closure + self.owner = owner + } + + @objc + func handle() { + closure(owner) + } +} diff --git a/Sources/Resin/AnyReducer+Optics.swift b/Sources/Resin/AnyReducer+Optics.swift new file mode 100644 index 0000000..c6a3d0b --- /dev/null +++ b/Sources/Resin/AnyReducer+Optics.swift @@ -0,0 +1,22 @@ +import Foundation + +extension AnyReducer { + /// These methods create a new Reducer for a state that contains the + /// state of the receiver. + /// + /// This allows us to write a `Reducer` focused on the most specific state + /// they need to know about and defer the integration into the `Superstate`. + /// + /// - Parameters: + /// - by: A `WritableKeyPath` that maps from the superstate to the state + /// of the receiver. + public func unfocused(by keyPath: WritableKeyPath) -> AnyReducer { + return AnyReducer(actionIdentifier: actionIdentifier) { [perform] action, superstate in + var property = superstate[keyPath: keyPath] + + perform(action, &property) + + superstate[keyPath: keyPath] = property + } + } +} diff --git a/Sources/Resin/ArbitraryState.swift b/Sources/Resin/ArbitraryState.swift new file mode 100644 index 0000000..6c97888 --- /dev/null +++ b/Sources/Resin/ArbitraryState.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A placeholder state that can be derived from any `Equatable`. +/// +/// Used for `PresentationRoute`s that don't require an explicit state. +public struct ArbitraryState: Equatable, DefaultInit { + public init() { } + + public static func arbitraryKeyPath() -> WritableKeyPath { + return \S.arbitraryState + } + + public static func == (lhs: ArbitraryState, rhs: ArbitraryState) -> Bool { + return true + } +} + +private extension Equatable { + var arbitraryState: ArbitraryState { + get { + return ArbitraryState() + } + set {} + } +} diff --git a/Sources/Resin/DefaultInit.swift b/Sources/Resin/DefaultInit.swift new file mode 100644 index 0000000..d97fd82 --- /dev/null +++ b/Sources/Resin/DefaultInit.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol DefaultInit { + init() +} diff --git a/Sources/Resin/Disposable+Composition.swift b/Sources/Resin/Disposable+Composition.swift new file mode 100644 index 0000000..630a0c9 --- /dev/null +++ b/Sources/Resin/Disposable+Composition.swift @@ -0,0 +1,12 @@ +import Incremental + +extension Disposable: ExpressibleByArrayLiteral { + public convenience init(arrayLiteral elements: Disposable...) { + var disposables = elements + + self.init { + disposables = [] + _ = disposables + } + } +} diff --git a/Sources/Resin/Equatable+IdentityKeyPath.swift b/Sources/Resin/Equatable+IdentityKeyPath.swift new file mode 100644 index 0000000..3c276ab --- /dev/null +++ b/Sources/Resin/Equatable+IdentityKeyPath.swift @@ -0,0 +1,13 @@ +internal extension Equatable { + /// Used to form an identity key path. + /// + /// - SeeAlso: https://forums.swift.org/t/some-small-keypath-extensions-identity-and-tuple-components/13729 + var keyPathSelf: Self { + get { + return self + } + set { + self = newValue + } + } +} diff --git a/Sources/Resin/I+ViewModel.swift b/Sources/Resin/I+ViewModel.swift new file mode 100644 index 0000000..797c1b4 --- /dev/null +++ b/Sources/Resin/I+ViewModel.swift @@ -0,0 +1,25 @@ +import UIKit +import Incremental + +extension I { + /// Helper to map a state `I` to an `I`. + /// + /// The `transform` generates an optional result, in which case the result + /// will be generated using the `ViewModel.fallback(previous:current:)` + /// method. + /// + /// By default this will return a `ViewModel` with `isStale` set whenever + /// the transform returns `nil`. If there was a previous value, that one + /// will be returned (as stale), or if no value ever existed, `.empty` will + /// be returned as stale. + /// + /// A `ViewModel` can override the `ViewModel.fallback(previous:current:)` + /// method to provide different behaviour. + public func mapWithFallback(_ transform: @escaping (A) -> B?) -> I { + return map(eq: ==, transform).withFallback() + } + + public func withFallback() -> I where A == V?, V: ViewModel { + return self.reduce(V.empty, V.fallback) + } +} diff --git a/Sources/Resin/IdentifiableByType.swift b/Sources/Resin/IdentifiableByType.swift new file mode 100644 index 0000000..a332b10 --- /dev/null +++ b/Sources/Resin/IdentifiableByType.swift @@ -0,0 +1,34 @@ +/// A value-type that can be used as a key when mapping types to values in a +/// dictionary. +internal struct TypeIdentifier: Hashable, RawRepresentable, CustomStringConvertible { + public let rawValue: ObjectIdentifier + #if DEBUG + public let name: String + #endif + + public init(value: Any) { + self.init(type: type(of: value)) + } + + public init(type: Any.Type) { + self.rawValue = ObjectIdentifier(Swift.type(of: type)) + #if DEBUG + self.name = Mirror(reflecting: type).description + #endif + } + + public init?(rawValue: ObjectIdentifier) { + self.rawValue = rawValue + #if DEBUG + self.name = rawValue.debugDescription + #endif + } + + public var description: String { + #if DEBUG + return "TypeIdentifier(\(name)-\(rawValue.debugDescription))" + #else + return "TypeIdentifier(\(rawValue.debugDescription))" + #endif + } +} diff --git a/Sources/Resin/ImplicitPresentationAction.swift b/Sources/Resin/ImplicitPresentationAction.swift new file mode 100644 index 0000000..a2b9284 --- /dev/null +++ b/Sources/Resin/ImplicitPresentationAction.swift @@ -0,0 +1,104 @@ +import Incremental + +public struct ImplicitPresentationAction: Action { + enum Presentation { + case push(AnyPresentationIdentifier) + case pop + case popSpecific(AnyPresentationIdentifier) + + @available(*, deprecated, message: "This will be removed in the future.") + case replaceTop(AnyPresentationIdentifier) + } + + var presentation: Presentation + + public static func pop(_ identifier: N? = nil) -> ImplicitPresentationAction { + return pop(identifier?.typeErased) + } + + public static func pop(_ identifier: AnyPresentationIdentifier? = nil) -> ImplicitPresentationAction { + return ImplicitPresentationAction(presentation: identifier.map(Presentation.popSpecific) ?? .pop) + } + + public static func push(_ identifier: N) -> ImplicitPresentationAction { + return push(identifier.typeErased) + } + + public static func push(_ identifier: AnyPresentationIdentifier) -> ImplicitPresentationAction { + return ImplicitPresentationAction(presentation: .push(identifier)) + } + + @available(*, deprecated, message: "This will be removed in the future.") + public static func replaceTop(_ identifier: N) -> ImplicitPresentationAction { + return replaceTop(identifier.typeErased) + } + + @available(*, deprecated, message: "This will be removed in the future.") + public static func replaceTop(_ identifier: AnyPresentationIdentifier) -> ImplicitPresentationAction { + return ImplicitPresentationAction(presentation: .replaceTop(identifier)) + } +} + +internal final class ImplicitPresentationMiddleware: Middleware { + override func transform(action: ImplicitPresentationAction, context: ActionContext, state: ArbitraryState) -> Action? { + guard let viewController = context.viewController else { + fatalError("Action \(action) was not dispatched from a view controller context in \(context.file):\(context.line).") + } + + // TODO: We should inject the presentation router here through whatever + // dependency mechanism we come up with. + let presentationRouter = viewController.presentationRouter! + + let requiresModalPresentation: Bool + + switch action.presentation { + case .pop: + requiresModalPresentation = type(of: viewController).requiresModalPresentation + case .popSpecific(let identifier): + requiresModalPresentation = presentationRouter.requiresModalPresentation(identifier: identifier) + case .push(let identifier): + requiresModalPresentation = presentationRouter.requiresModalPresentation(identifier: identifier) + case .replaceTop(let identifier): + requiresModalPresentation = presentationRouter.requiresModalPresentation(identifier: identifier) + } + + /// TODO: This mechanism shouldn't use the navigation controller but + /// instead use something like + // `targetViewController(forAction:sender:)`. + let presentationKeyPath = requiresModalPresentation ? nil : viewController.navigationController?.navigationIdentifiersKeyPath + + guard let targetKeyPath = presentationKeyPath ?? viewController.resolvedPresentationKeyPath else { + return nil + } + + let presentationAction: PresentationAction + + switch action.presentation { + case .pop: + presentationAction = PresentationAction(popFrom: targetKeyPath) + case .popSpecific(let identifier): + presentationAction = PresentationAction(popFrom: targetKeyPath, identifier: identifier) + case .push(let identifier): + presentationAction = PresentationAction(push: identifier, onto: targetKeyPath) + case .replaceTop(let identifier): + presentationAction = PresentationAction(replaceTopWith: identifier, onto: targetKeyPath) + } + + dispatch(presentationAction, context: context) + + return nil + } +} + +public extension RootStore { + /// TODO: We should probably just install this by default. + func addImplicitNavigationMiddleware() { + let keyPath = ArbitraryState.arbitraryKeyPath() as WritableKeyPath + + let factory: MiddlewareFactory = ImplicitPresentationMiddleware.factory + .lift { $0 as Any } + .unfocused(by: keyPath) + + add(middleware: factory) + } +} diff --git a/Sources/Resin/Incremental_UIKit.h b/Sources/Resin/Incremental_UIKit.h new file mode 100644 index 0000000..40e42f0 --- /dev/null +++ b/Sources/Resin/Incremental_UIKit.h @@ -0,0 +1,7 @@ +#import + +//! Project version number for Incremental_UIKit. +FOUNDATION_EXPORT double Incremental_UIKitVersionNumber; + +//! Project version string for Incremental_UIKit. +FOUNDATION_EXPORT const unsigned char Incremental_UIKitVersionString[]; diff --git a/Sources/Resin/Info.plist b/Sources/Resin/Info.plist new file mode 100644 index 0000000..1007fd9 --- /dev/null +++ b/Sources/Resin/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/Sources/Resin/Lifecycle.swift b/Sources/Resin/Lifecycle.swift new file mode 100644 index 0000000..24fb6eb --- /dev/null +++ b/Sources/Resin/Lifecycle.swift @@ -0,0 +1,78 @@ +import Foundation +import Incremental + +/// A `Lifecycle` can be used to manage bindings on an object that should be +/// limited to certain parts of its life-cycle. +/// +/// For example, most view bindings only need to be active while a view +/// controller is visible. A `Lifecycle` can be used to automatically create +/// the necessary bindings when the view appears and to dispose of them when the +/// view disappears. +/// +/// It is parameterized by `OwnerType`, which is the type of object the +/// lifecycle belongs to. +public final class Lifecycle { + internal var underlying: AnyLifecycle + + /// Initializes the Lifecycle with a type-erased `AnyLifecycle`. + /// + /// You need to make sure that the owner of the underlying `AnyLifecycle` + /// is type-compatible with `OwnerType`. + public init(underlying: AnyLifecycle) { + self.underlying = underlying + } + + /// Adds a binding to the Lifecycle. + /// + /// The binding will be executed whenever the Lifecycle is running or + /// immediately if that is already the case. + public func `do`(closure: @escaping (OwnerType) -> Disposable) { + underlying.add { owner in + [closure(owner as! OwnerType)] + } + } +} + +/// A type-erased Lifecycle. +public final class AnyLifecycle { + internal var closures: [(AnyObject) -> [Disposable]] = [] + + internal var disposables: [Disposable] = [] + + internal var isRunning: Bool = false + + internal unowned let owner: AnyObject + + /// Initializes the `AnyLifecycle` with an object it will keep an unowned + /// reference to. + public init(owner: AnyObject) { + self.owner = owner + } + + internal func add(closure: @escaping (AnyObject) -> [Disposable]) { + closures.append(closure) + + if isRunning { + disposables.append(contentsOf: closure(owner)) + } + } + + /// Starts the lifecycle, creating the bindings if necessary. + public func start() { + guard !isRunning else { return } + + disposables = closures.flatMap { $0(owner) } + + isRunning = true + } + + /// Stops the lifecycle, disposing of all `Disposable`s that were created + /// as necessary. + public func stop() { + guard isRunning else { return } + + disposables = [] + + isRunning = false + } +} diff --git a/Sources/Resin/Middleware.swift b/Sources/Resin/Middleware.swift new file mode 100644 index 0000000..053f5d5 --- /dev/null +++ b/Sources/Resin/Middleware.swift @@ -0,0 +1,78 @@ +import Foundation +import Incremental + +open class AnyMiddleware { + open weak var actionHandler: ActionHandler? + + open func transform(action: Action, context: ActionContext, state: State) -> Action? { + return action + } + + public init() {} + + public func dispatch(_ action: Action, file: String = #file, line: Int = #line) { + let context = ActionContext(file: file, line: line) + + dispatch(action, context: context) + } + + public func dispatch(_ action: Action, context: ActionContext) { + actionHandler!.dispatch(action, context: context) + } +} + +open class Middleware: AnyMiddleware { + public let environment: Environment + + open class var factory: MiddlewareFactory { + return MiddlewareFactory { state, environment in + return self.init(state: state, environment: environment) + } + } + + required public init(state: I, environment: Environment) { + self.environment = environment + } + + open func transform(action: Action, context: ActionContext, state: State) -> Resin.Action? { + return action + } + + override open func transform(action: Resin.Action, context: ActionContext, state: State) -> Resin.Action? { + if let action = action as? Action { + return transform(action: action, context: context, state: state) + } else { + return action + } + } +} + +private final class ProxyMiddleware: AnyMiddleware { + override var actionHandler: ActionHandler? { + get { + return proxied.actionHandler + } + set { + proxied.actionHandler = newValue + } + } + + let keyPath: KeyPath + + let proxied: AnyMiddleware + + init(keyPath: KeyPath, proxied: AnyMiddleware) { + self.keyPath = keyPath + self.proxied = proxied + } + + override public func transform(action: Resin.Action, context: ActionContext, state: Superstate) -> Resin.Action? { + return proxied.transform(action: action, context: context, state: state[keyPath: keyPath]) + } +} + +extension AnyMiddleware { + public func unfocused(by keyPath: KeyPath) -> AnyMiddleware { + return ProxyMiddleware(keyPath: keyPath, proxied: self) + } +} diff --git a/Sources/Resin/MiddlewareFactory.swift b/Sources/Resin/MiddlewareFactory.swift new file mode 100644 index 0000000..fe4f2c7 --- /dev/null +++ b/Sources/Resin/MiddlewareFactory.swift @@ -0,0 +1,27 @@ +import Incremental + +public class MiddlewareFactory { + private let closure: (I, Environment) -> AnyMiddleware + + public init(closure: @escaping (I, Environment) -> AnyMiddleware) { + self.closure = closure + } + + public func make(state: I, environment: Environment) -> AnyMiddleware { + return closure(state, environment) + } + + public func unfocused(by keyPath: KeyPath) -> MiddlewareFactory { + return MiddlewareFactory { state, environment in + let substate = state[keyPath] + + return self.make(state: substate, environment: environment).unfocused(by: keyPath) + } + } + + public func lift(proof: @escaping (E) -> Environment) -> MiddlewareFactory { + return MiddlewareFactory { state, environment in + return self.make(state: state, environment: proof(environment)) + } + } +} diff --git a/Sources/Resin/NSObjectProtocol+Lifecycle.swift b/Sources/Resin/NSObjectProtocol+Lifecycle.swift new file mode 100644 index 0000000..cf086ab --- /dev/null +++ b/Sources/Resin/NSObjectProtocol+Lifecycle.swift @@ -0,0 +1,22 @@ +import Foundation + +private var deallocationLifecycleKey = "deallocationLifecycleKey" + +extension NSObjectProtocol { + public var untilDeallocated: Lifecycle { + return Lifecycle(underlying: underlyingDeallocationLifecycle) + } + + internal var underlyingDeallocationLifecycle: AnyLifecycle { + if let lifecycle = objc_getAssociatedObject(self, &deallocationLifecycleKey) as? AnyLifecycle { + return lifecycle + } + + let lifecycle = AnyLifecycle(owner: self) + lifecycle.start() + + objc_setAssociatedObject(self, &deallocationLifecycleKey, lifecycle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + return lifecycle + } +} diff --git a/Sources/Resin/Parcel.swift b/Sources/Resin/Parcel.swift new file mode 100644 index 0000000..4d12b30 --- /dev/null +++ b/Sources/Resin/Parcel.swift @@ -0,0 +1,43 @@ +import Foundation + +public struct Parcel { + @available(*, deprecated, message: "Use middleware instead") + public var legacyMiddleware: [AnyMiddleware] = [] + + public var middleware: [MiddlewareFactory] = [] + + public var presentationRoutes: [AnyPresentationRoute] = [] + + public var reducers: [AnyReducer] = [] + + public init() {} +} + +extension Parcel { + public func unfocused(by keyPath: WritableKeyPath) -> Parcel { + var result = Parcel() + result.legacyMiddleware = legacyMiddleware.map { $0.unfocused(by: keyPath) } + result.middleware = middleware.map { $0.unfocused(by: keyPath) } + result.presentationRoutes = presentationRoutes.map { $0.unfocused(by: keyPath) } + result.reducers = reducers.map { $0.unfocused(by: keyPath) } + + return result + } + + public func lift(proof: @escaping (E) -> Environment) -> Parcel { + var result = Parcel() + result.legacyMiddleware = legacyMiddleware + result.middleware = middleware.map { $0.lift(proof: proof) } + result.presentationRoutes = presentationRoutes + result.reducers = reducers + + return result + } +} + +extension Parcel where State == ArbitraryState { + /// Unfocuses a `Parcel` to a `Parcel` for any `S`. + public func arbitrarilyUnfocused(to superstate: Superstate.Type) -> Parcel { + return unfocused(by: ArbitraryState.arbitraryKeyPath()) + } +} diff --git a/Sources/Resin/PresentationAction.swift b/Sources/Resin/PresentationAction.swift new file mode 100644 index 0000000..4bf1534 --- /dev/null +++ b/Sources/Resin/PresentationAction.swift @@ -0,0 +1,171 @@ +import Foundation + +public struct PresentationAction: Action { + public enum Presentation: Equatable { + case force([AnyPresentationIdentifier]) + case pop + case popSpecific(AnyPresentationIdentifier) + case popToTargetCount(Int) + case push(AnyPresentationIdentifier) + + @available(*, deprecated, message: "This will be removed in the future.") + case replace(AnyPresentationIdentifier) // Replaces top PresentationIdentifier + } + + public var presentation: Presentation + + public var target: AnyKeyPath + + public init(push identifier: N, onto keyPath: WritableKeyPath) { + self.init(push: identifier.typeErased, onto: keyPath as AnyKeyPath) + } + + public init(push identifier: AnyPresentationIdentifier, onto keyPath: WritableKeyPath) { + self.init(push: identifier, onto: keyPath as AnyKeyPath) + } + + public init(push identifier: AnyPresentationIdentifier, onto keyPath: AnyKeyPath) { + precondition(type(of: keyPath).valueType is [AnyPresentationIdentifier].Type) + + presentation = .push(identifier) + target = keyPath + } + + public init(popFrom keyPath: WritableKeyPath, identifier: AnyPresentationIdentifier? = nil) { + self.init(popFrom: keyPath as AnyKeyPath) + } + + public init(popFrom keyPath: AnyKeyPath, identifier: AnyPresentationIdentifier? = nil) { + presentation = identifier.map(Presentation.popSpecific) ?? .pop + target = keyPath + } + + public init(popToTargetCount count: Int, from keyPath: WritableKeyPath) { + presentation = .popToTargetCount(count) + target = keyPath + } + + public init(force identifiers: [AnyPresentationIdentifier], onto keyPath: WritableKeyPath) { + presentation = .force(identifiers) + target = keyPath + } + + @available(*, deprecated, message: "This will be removed in the future.") + public init(replaceTopWith identifier: N, onto keyPath: WritableKeyPath) { + self.init(replaceTopWith: identifier.typeErased, onto: keyPath as AnyKeyPath) + } + + @available(*, deprecated, message: "This will be removed in the future.") + public init(replaceTopWith identifier: AnyPresentationIdentifier, onto keyPath: WritableKeyPath) { + self.init(replaceTopWith: identifier, onto: keyPath as AnyKeyPath) + } + + @available(*, deprecated, message: "This will be removed in the future.") + public init(replaceTopWith identifier: AnyPresentationIdentifier, onto keyPath: AnyKeyPath) { + precondition(type(of: keyPath).valueType is [AnyPresentationIdentifier].Type) + + presentation = .replace(identifier) + target = keyPath + } + + public init(force identifiers: [N], onto keyPath: WritableKeyPath) { + self.init(force: identifiers.map { $0.typeErased }, onto: keyPath) + } +} + +internal struct PresentationReducer: Reducer { + // swiftlint:disable:next cyclomatic_complexity + public func reduce(action: PresentationAction, state: inout State) { + guard let keyPath = action.target as? WritableKeyPath else { + print("Target key path %@ is not compatible with %@", "\(action.target)", "\(State.self)") + return + } + + var stack: [AnyPresentationIdentifier] { + get { + return state[keyPath: keyPath] + } + set { + state[keyPath: keyPath] = newValue + } + } + + switch action.presentation { + case .force(let identifiers): + stack = identifiers + case .pop: + guard !stack.isEmpty else { + print("Couldn't pop from %@", "\(action.target)") + return + } + + stack.removeLast() + case .popSpecific(let identifier): + if stack.last == identifier { + stack.removeLast() + } + case .popToTargetCount(let count): + guard stack.count >= count else { + print("Couldn't pop to target count from %@", "\(action.target)") + return + } + + stack.removeLast(stack.count - count) + case .push(let identifier): + stack.append(identifier) + case .replace(let identifier): + guard !stack.isEmpty else { + print("No identifiers to replace from %@", "\(action.target)") + return + } + + stack[stack.count - 1] = identifier + } + } +} + +/// Used exclusively to catch state up with UIKit. +/// +/// For example, when a back button is pressed in UINavigationController +/// this can be called from navigationController:didShow:animated: to sync state. +public struct SynchronousPresentationAction: SystemResponseAction { + public enum Presentation { + case popToTargetCount(Int) + } + + public var presentation: Presentation + + public var target: AnyKeyPath + + public init(popToTargetCount count: Int, from keyPath: AnyKeyPath) { + presentation = .popToTargetCount(count) + target = keyPath + } +} + +internal struct SynchronousPresentationReducer: Reducer { + public func reduce(action: SynchronousPresentationAction, state: inout State) { + switch action.presentation { + case .popToTargetCount(let count): + guard let keyPath = action.target as? WritableKeyPath else { + print("Target key path %@ is not compatible with %@", "\(action.target)", "\(State.self)") + return + } + + guard state[keyPath: keyPath].count >= count else { + print("Couldn't pop to target count from %@", "\(action.target)") + return + } + + state[keyPath: keyPath].removeLast(state[keyPath: keyPath].count - count) + } + } +} + +public extension RootStore { + /// TODO: We should probably just install these by default. + func addPresentationReducers() { + add(reducer: PresentationReducer().typeErased) + add(reducer: SynchronousPresentationReducer().typeErased) + } +} diff --git a/Sources/Resin/PresentationIdentifier.swift b/Sources/Resin/PresentationIdentifier.swift new file mode 100644 index 0000000..384e13c --- /dev/null +++ b/Sources/Resin/PresentationIdentifier.swift @@ -0,0 +1,35 @@ +import UIKit + +/// A `PresentationIdentifier` identifies a target that can be navigated to, +/// e.g. using a `UINavigationController`. +public protocol PresentationIdentifier: Equatable {} + +public extension PresentationIdentifier { + var typeErased: AnyPresentationIdentifier { + return AnyPresentationIdentifier(presentationIdentifier: self) + } +} + +/// A type-erased presentation identifier. +public struct AnyPresentationIdentifier: Equatable { + private let equal: (Any) -> Bool + + internal let underlying: Any + + internal let underlyingTypeIdentifier: TypeIdentifier + + internal init(presentationIdentifier: N) { + equal = { rhs in + guard let other = rhs as? N else { return false } + + return presentationIdentifier == other + } + + underlying = presentationIdentifier + underlyingTypeIdentifier = TypeIdentifier(value: presentationIdentifier) + } + + public static func == (lhs: AnyPresentationIdentifier, rhs: AnyPresentationIdentifier) -> Bool { + return lhs.equal(rhs.underlying) + } +} diff --git a/Sources/Resin/PresentationRoute.swift b/Sources/Resin/PresentationRoute.swift new file mode 100644 index 0000000..b0b4951 --- /dev/null +++ b/Sources/Resin/PresentationRoute.swift @@ -0,0 +1,110 @@ +import Foundation +import Incremental +import UIKit + +/// A `PresentationRoute` is used to create a `UIViewController` for a given +/// `PresentationIdentifier` without having to know the context in which it is +/// created. +public struct PresentationRoute { + private let isModal: (Identifier) -> Bool + + private let make: (Identifier, Store) -> ViewController + + public init(isModal: @escaping (Identifier) -> Bool = { _ in ViewController.requiresModalPresentation }, make: @escaping (Identifier, Store) -> ViewController) { + self.isModal = isModal + self.make = make + } + + public func makeViewController(identifier: Identifier, store: Store) -> ViewController { + return make(identifier, store) + } + + public func requiresModalPresentation(identifier: Identifier) -> Bool { + return isModal(identifier) + } +} + +public extension PresentationRoute { + var typeErased: AnyPresentationRoute { + return AnyPresentationRoute(isModal: isModal, make: make) + } +} + +extension PresentationRoute { + public func unfocused(by keyPath: WritableKeyPath) -> PresentationRoute { + return PresentationRoute { identifier, store in + self.makeViewController(identifier: identifier, store: store.focused(by: keyPath)) + } + } +} + +extension PresentationRoute where State == ArbitraryState { + public init(isModal: @escaping (Identifier) -> Bool = { _ in ViewController.requiresModalPresentation }, make: @escaping (Identifier) -> ViewController) { + self.make = { identifier, _ in + make(identifier) + } + self.isModal = isModal + } + + public func makeViewController(identifier: Identifier) -> ViewController { + let store = RootStore(state: ArbitraryState(), environment: Void()) + + return make(identifier, store) + } +} + +public struct AnyPresentationRoute { + private let isModal: (AnyPresentationIdentifier) -> Bool + + private var make: (AnyPresentationIdentifier, Store) -> UIViewController + + internal var underlyingTypeIdentifier: TypeIdentifier + + internal init(isModal: @escaping (N) -> Bool = { _ in V.requiresModalPresentation }, make: @escaping (N, Store) -> V) { + self.init(isModal: { isModal($0.underlying as! N) }, underlyingTypeIdentifier: TypeIdentifier(type: N.self), make: { make($0.underlying as! N, $1) }) + } + + private init(isModal: @escaping (AnyPresentationIdentifier) -> Bool, underlyingTypeIdentifier: TypeIdentifier, make: @escaping (AnyPresentationIdentifier, Store) -> UIViewController) { + self.isModal = isModal + self.make = make + self.underlyingTypeIdentifier = underlyingTypeIdentifier + } + + public func makeViewController(identifier: AnyPresentationIdentifier, store: Store) -> UIViewController { + precondition(underlyingTypeIdentifier == identifier.underlyingTypeIdentifier) + + return make(identifier, store) + } + + public func requiresModalPresentation(identifier: AnyPresentationIdentifier) -> Bool { + precondition(underlyingTypeIdentifier == identifier.underlyingTypeIdentifier) + + return isModal(identifier) + } +} + +extension AnyPresentationRoute { + public func unfocused(by keyPath: WritableKeyPath) -> AnyPresentationRoute { + return AnyPresentationRoute(isModal: isModal, underlyingTypeIdentifier: underlyingTypeIdentifier) { identifier, store in + return self.make(identifier, store.focused(by: keyPath)) + } + } +} + +extension AnyPresentationRoute where State == ArbitraryState { + public func makeViewController(identifier: AnyPresentationIdentifier) -> UIViewController { + precondition(underlyingTypeIdentifier == identifier.underlyingTypeIdentifier) + + let store = RootStore(state: ArbitraryState(), environment: Void()) + + return make(identifier, store) + } + + public func arbitrarilyUnfocused(to superstate: Superstate.Type) -> AnyPresentationRoute { + let make = self.make + + return AnyPresentationRoute(isModal: isModal, underlyingTypeIdentifier: underlyingTypeIdentifier) { identifier, store in + return make(identifier, store.focused(by: ArbitraryState.arbitraryKeyPath())) + } + } +} diff --git a/Sources/Resin/PresentationRouter.swift b/Sources/Resin/PresentationRouter.swift new file mode 100644 index 0000000..c1ace73 --- /dev/null +++ b/Sources/Resin/PresentationRouter.swift @@ -0,0 +1,75 @@ +import Incremental +import UIKit + +public final class PresentationRouter { + private var routes: [TypeIdentifier: AnyPresentationRoute] = [:] + + private let store: Store + + public init(store: Store) { + self.store = store + } + + public func register(route: AnyPresentationRoute) { + routes[route.underlyingTypeIdentifier] = route + } + + public func register(routes: [AnyPresentationRoute]) { + routes.forEach { register(route: $0) } + } + + public func register(route: PresentationRoute) { + register(route: route.typeErased) + } + + public func makeViewController(identifier: AnyPresentationIdentifier) -> UIViewController { + guard let route = routes[identifier.underlyingTypeIdentifier] else { + fatalError("No route registered for \(identifier)") + } + + return route.makeViewController(identifier: identifier, store: store) + } + + public func requiresModalPresentation(identifier: AnyPresentationIdentifier) -> Bool { + guard let route = routes[identifier.underlyingTypeIdentifier] else { + fatalError("No route registered for \(identifier)") + } + + return route.requiresModalPresentation(identifier: identifier) + } +} + +extension PresentationRouter { + public func register(route: AnyPresentationRoute) { + register(route: route.arbitrarilyUnfocused(to: State.self)) + } + + public func register(routes: [AnyPresentationRoute]) { + register(routes: routes.map { $0.arbitrarilyUnfocused(to: State.self) }) + } +} + +extension PresentationRouter { + public var typeErased: AnyPresentationRouter { + return AnyPresentationRouter(presentationRouter: self) + } +} + +public final class AnyPresentationRouter { + private let isModal: (AnyPresentationIdentifier) -> Bool + + private let make: (AnyPresentationIdentifier) -> UIViewController + + internal init(presentationRouter: PresentationRouter) { + isModal = presentationRouter.requiresModalPresentation + make = presentationRouter.makeViewController + } + + public func makeViewController(identifier: AnyPresentationIdentifier) -> UIViewController { + return make(identifier) + } + + public func requiresModalPresentation(identifier: AnyPresentationIdentifier) -> Bool { + return isModal(identifier) + } +} diff --git a/Sources/Resin/Reducer.swift b/Sources/Resin/Reducer.swift new file mode 100644 index 0000000..0ead981 --- /dev/null +++ b/Sources/Resin/Reducer.swift @@ -0,0 +1,47 @@ +import Foundation + +/// A `Reducer` modifies a `State` based on an `Action` it receives using a pure +/// function. +public protocol Reducer { + associatedtype Action: Resin.Action + + associatedtype State: Equatable + + func reduce(action: Action, state: inout State) +} + +public extension Reducer { + var typeErased: AnyReducer { + return AnyReducer(reducer: self.reduce) + } +} + +/// An `AnyReducer` erases the `Action` type of a `Reducer`. +public struct AnyReducer { + internal let actionIdentifier: TypeIdentifier + + internal let perform: (Any, inout State) -> Void + + internal init(actionIdentifier: TypeIdentifier, perform: @escaping (Any, inout State) -> Void) { + self.actionIdentifier = actionIdentifier + self.perform = perform + } + + /// Initializes a new Reducer. + /// + /// - Parameters: + /// - reducer: A pure function that modifies a state based on an `Action`. + public init(reducer: @escaping (A, inout State) -> Void) { + actionIdentifier = TypeIdentifier(type: A.self) + + perform = { action, state in + if let actual = action as? A { + reducer(actual, &state) + } + } + } + + public func reduce(action: Any, state: inout State) { + perform(action, &state) + } +} diff --git a/Sources/Resin/Store+Parcel.swift b/Sources/Resin/Store+Parcel.swift new file mode 100644 index 0000000..37ed45c --- /dev/null +++ b/Sources/Resin/Store+Parcel.swift @@ -0,0 +1,17 @@ +import Foundation + +extension RootStore { + public func integrate(parcel: Parcel) { + for middleware in parcel.legacyMiddleware { + add(middleware: middleware) + } + + for factory in parcel.middleware { + add(middleware: factory) + } + + for reducer in parcel.reducers { + add(reducer: reducer) + } + } +} diff --git a/Sources/Resin/Store.swift b/Sources/Resin/Store.swift new file mode 100644 index 0000000..50ce59b --- /dev/null +++ b/Sources/Resin/Store.swift @@ -0,0 +1,134 @@ +import Dispatch +import Incremental + +private protocol AnyStore: ActionHandler { + var keyPath: AnyKeyPath { get } +} + +/// A `Store` offers a way to subscribe to a `State` and handles updates to it +/// using `Reducer`s. +public class Store: AnyStore { + public let state: I + + public let keyPath: AnyKeyPath + + private let parent: AnyStore? + + fileprivate init(state: I, parent: AnyStore!, keyPath: AnyKeyPath) { + self.state = state + self.parent = parent + self.keyPath = keyPath + } + + /// Handles an Action. + /// + /// - Parameters: + /// - action: The action to handle, if no `Reducer` is set up for this + /// `Action` type, an error will be logged. + public func handle(_ action: Action, context: ActionContext) { + parent!.handle(action, context: context) + } + + /// Creates a new Store that focuses on a part of the receiver's State. + /// + /// This method allows us to pass up Stores focused on the most specific + /// state that other components need to care about. + /// + /// The resulting store forwards all actions to the receiver. + /// + /// - Parameters: + /// - by: A `KeyPath` that maps from the state of the receiver to a more + /// specific substate. + public func focused(by keyPath: WritableKeyPath) -> Store { + let combined = self.keyPath.appending(path: keyPath)! + + return Store(state: state[keyPath], parent: self, keyPath: combined) + } +} + +/// A `Store` can be in direct control of the middleware, reducers and state +/// or be a proxy of a different store. +/// +/// This allows us to give separate components a different view on the same +/// data. +public final class RootStore: Store { + var middleware: [AnyMiddleware] = [] + + var reducers: [TypeIdentifier: AnyReducer] = [:] + + private let stateInput: Input + + private var isCurrentlyExecutingAction = false + + public let environment: Environment + + private var underlyingState: State { + didSet { + stateInput.write(underlyingState) + } + } + + /// Initializes a store. + /// + /// - Parameters: + /// - state: The initial state of the store. All further modifications to + /// this state will be made by handling actions. + public init(state: State, environment: Environment) { + self.environment = environment + self.stateInput = Input(state) + self.underlyingState = state + + super.init(state: stateInput.i, parent: nil, keyPath: \State.keyPathSelf) + } + + public func add(middleware: AnyMiddleware) { + middleware.actionHandler = self + + self.middleware.append(middleware) + } + + public func add(middleware factory: MiddlewareFactory) { + let middleware = factory.make(state: self.state, environment: environment) + + add(middleware: middleware) + } + + public func add(reducer: AnyReducer) { + reducers[reducer.actionIdentifier] = reducer + } + + public override func handle(_ action: Action, context: ActionContext) { + guard !isCurrentlyExecutingAction else { + print("Action caused another Action to dispatch before completing") + return + } + + isCurrentlyExecutingAction = true + defer { + isCurrentlyExecutingAction = false + } + + print("Handling action %@", String(describing: type(of: action))) + + // Run our Action through the middleware, if at any step action gets + // caught, abort. + let transformedAction: Action? = middleware.reduce(action as Action?) { action, middle in + guard let action = action else { return nil } + + return middle.transform(action: action, context: context, state: underlyingState) + } + + guard let action = transformedAction else { return } + + /// If an action makes it past the Middleware, it needs to be handled on + /// the main queue. + dispatchPrecondition(condition: .onQueue(.main)) + + guard let reducer = reducers[TypeIdentifier(value: action)] else { + print("Action dispatched in %@:%i was not handled.", context.file, context.line) + return + } + + reducer.reduce(action: action, state: &underlyingState) + } +} diff --git a/Sources/Resin/UIBarButtonItem+Redux.swift b/Sources/Resin/UIBarButtonItem+Redux.swift new file mode 100644 index 0000000..21114b0 --- /dev/null +++ b/Sources/Resin/UIBarButtonItem+Redux.swift @@ -0,0 +1,59 @@ +import ObjectiveC +import UIKit + +private var actionTargetKey = "actionTargetKey" + +extension UIBarButtonItem { + private var actionTarget: ActionTarget? { + get { + return objc_getAssociatedObject(self, &actionTargetKey) as? ActionTarget + } + set { + objc_setAssociatedObject(self, &actionTargetKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public convenience init(image: UIImage?, style: UIBarButtonItem.Style, source: UIViewController, action: Action) { + self.init(image: image, style: style, source: source, dispatch: { _ in action }) + } + + public convenience init(image: UIImage?, style: UIBarButtonItem.Style, source: UIViewController, dispatch: @escaping (UIBarButtonItem) -> Action) { + self.init(image: image, style: style, target: nil, action: nil) + + self.dispatch(from: source, do: dispatch) + } + + public convenience init(title: String?, style: UIBarButtonItem.Style, source: UIViewController, action: Action) { + self.init(title: title, style: style, source: source, dispatch: { _ in action }) + } + + public convenience init(title: String?, style: UIBarButtonItem.Style, source: UIViewController, dispatch: @escaping (UIBarButtonItem) -> Action) { + self.init(title: title, style: style, target: nil, action: nil) + + self.dispatch(from: source, do: dispatch) + } + + public convenience init(barButtonSystemItem: UIBarButtonItem.SystemItem, source: UIViewController, action: Action) { + self.init(barButtonSystemItem: barButtonSystemItem, source: source, dispatch: { _ in action }) + } + + public convenience init(barButtonSystemItem: UIBarButtonItem.SystemItem, source: UIViewController, dispatch: @escaping (UIBarButtonItem) -> Action) { + self.init(barButtonSystemItem: barButtonSystemItem, target: nil, action: nil) + + self.dispatch(from: source, do: dispatch) + } + + public func `do`(closure: @escaping (UIBarButtonItem) -> Void) { + action = #selector(ActionTarget.handle) + actionTarget = ActionTarget(owner: self) { closure($0 as! UIBarButtonItem) } + target = actionTarget + } + + public func dispatch(from source: UIViewController, do closure: @escaping (UIBarButtonItem) -> Action) { + `do` { [unowned source = source] item in + let action = closure(item) + + source.dispatch(action) + } + } +} diff --git a/Sources/Resin/UIControl+Redux.swift b/Sources/Resin/UIControl+Redux.swift new file mode 100644 index 0000000..9d2a7da --- /dev/null +++ b/Sources/Resin/UIControl+Redux.swift @@ -0,0 +1,49 @@ +import ObjectiveC +import UIKit + +private var associatedObjectKey = "resin_UIControlActionTargetKey" + +public protocol UIControlProtocol: AnyObject { + func addTarget(_ target: Any?, action: Selector, for controlEvents: UIControl.Event) +} + +extension UIControlProtocol { + private var actionTargets: [ActionTarget] { + get { + return objc_getAssociatedObject(self, &associatedObjectKey) as? [ActionTarget] ?? [] + } + set { + objc_setAssociatedObject(self, &associatedObjectKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public func on(_ events: UIControl.Event, do closure: @escaping (Self) -> Void) { + let actionTarget = ActionTarget(owner: self) { control in closure(control as! Self) } + + actionTargets.append(actionTarget) + + addTarget(actionTarget, action: #selector(ActionTarget.handle), for: events) + } +} + +extension UIControl: UIControlProtocol { + public func on(_ events: UIControl.Event, file: String = #file, line: Int = #line, dispatch action: Action) { + on(events, file: file, line: line) { action } + } + + public func on(_ events: UIControl.Event, file: String = #file, line: Int = #line, dispatch closure: @escaping () -> Action?) { + on(events) { control in + guard let action = closure() else { return } + + control.dispatch(action, file: file, line: line) + } + } + + public func on(_ events: UIControl.Event, file: String = #file, line: Int = #line, push identifier: N) { + on(events, file: file, line: line, push: identifier.typeErased) + } + + public func on(_ events: UIControl.Event, file: String = #file, line: Int = #line, push identifier: AnyPresentationIdentifier) { + on(events, file: file, line: line, dispatch: ImplicitPresentationAction.push(identifier)) + } +} diff --git a/Sources/Resin/UIGestureRecognizer+Redux.swift b/Sources/Resin/UIGestureRecognizer+Redux.swift new file mode 100644 index 0000000..6c1dfcc --- /dev/null +++ b/Sources/Resin/UIGestureRecognizer+Redux.swift @@ -0,0 +1,30 @@ +import UIKit + +private var associatedObjectKey = "resin_UIGestureRecognizerActionTargetKey" + +extension UIGestureRecognizer { + private var actionTargets: [ActionTarget] { + get { + return objc_getAssociatedObject(self, &associatedObjectKey) as? [ActionTarget] ?? [] + } + set { + objc_setAssociatedObject(self, &associatedObjectKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public func addAction(file: String = #file, line: Int = #line, dispatch action: Action) { + addAction(file: file, line: line) { action } + } + + public func addAction(file: String = #file, line: Int = #line, dispatch closure: @escaping () -> Action?) { + let actionTarget = ActionTarget(owner: self) { gestureRecognizer in + guard let action = closure() else { return } + + gestureRecognizer.view?.dispatch(action, file: file, line: line) + } + + actionTargets.append(actionTarget) + + addTarget(actionTarget, action: #selector(ActionTarget.handle)) + } +} diff --git a/Sources/Resin/UINavigationController+Redux.swift b/Sources/Resin/UINavigationController+Redux.swift new file mode 100644 index 0000000..8849f5d --- /dev/null +++ b/Sources/Resin/UINavigationController+Redux.swift @@ -0,0 +1,92 @@ +import Incremental +import UIKit + +private var navigationDelegateProxyKey = "navigationDelegateProxyKey" +private var navigationViewControllerCacheKey = "navigationViewControllerCacheKey" +private var navigationIdentifiersKeyPathKey = "navigationIdentifiersKeyPathKey" + +extension UINavigationController { + fileprivate var navigationDelegateProxy: NavigationDelegateProxy? { + get { + return objc_getAssociatedObject(self, &navigationDelegateProxyKey) as? NavigationDelegateProxy + } + set { + objc_setAssociatedObject(self, &navigationDelegateProxyKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + var navigationIdentifiersKeyPath: AnyKeyPath? { + get { + return objc_getAssociatedObject(self, &navigationIdentifiersKeyPathKey) as? AnyKeyPath + } + set { + objc_setAssociatedObject(self, &navigationIdentifiersKeyPathKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private(set) internal var navigationViewControllerCache: ViewControllerCache { + get { + if let cache = objc_getAssociatedObject(self, &navigationViewControllerCacheKey) as? ViewControllerCache { + return cache + } + + let cache = ViewControllerCache(owner: self) + + self.navigationViewControllerCache = cache + + return cache + } + set { + objc_setAssociatedObject(self, &navigationViewControllerCacheKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public convenience init(store: Store, root: AnyPresentationIdentifier? = nil, navigationStack keyPath: KeyPath) { + self.init() + + self.navigationIdentifiersKeyPath = store.keyPath.appending(path: keyPath) + self.navigationDelegateProxy = NavigationDelegateProxy() + + self.delegate = self.navigationDelegateProxy + + let identifiers = store.state[keyPath] + .map { ids -> [AnyPresentationIdentifier] in + if let r = root { + return [r] + ids + } else { + return ids + } + } + + whileVisible.do { navigationController in + let _navigationController = navigationController + + return identifiers + .map(_navigationController.navigationViewControllerCache.createViewControllersIfNeeded) + .observe { [unowned _navigationController] viewControllers in + let inTransition = _navigationController.isBeingPresented || _navigationController.isBeingDismissed + let isVisible = _navigationController.viewIfLoaded?.window != nil + + let animated = !inTransition && isVisible + + if animated && _navigationController.viewControllers == Array(viewControllers.dropLast()) { + _navigationController.pushViewController(viewControllers.last!, animated: animated) + } else { + _navigationController.setViewControllers(viewControllers, animated: animated) + } + } + } + } +} + +private class NavigationDelegateProxy: NSObject, UINavigationControllerDelegate { + public func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { + let targetCount = navigationController.viewControllers.count - 1 + + guard let target = navigationController.navigationIdentifiersKeyPath else { return } + + let action = SynchronousPresentationAction(popToTargetCount: targetCount, from: target) + + navigationController.dispatchWithSynchronousExecution(action) + } +} diff --git a/Sources/Resin/UIView+Redux.swift b/Sources/Resin/UIView+Redux.swift new file mode 100644 index 0000000..afba812 --- /dev/null +++ b/Sources/Resin/UIView+Redux.swift @@ -0,0 +1,46 @@ +import UIKit +import ObjectiveC + +private var actionHandlerKey = "actionHandlerKey" + +extension UIView { + /// The `ActionHandler` the receiver should send its actions to. + /// + /// Set the corresponding `actionHandler` property on the view controller + /// that owns the view tree containing the receiver to make it accessible + /// here. + internal(set) public var actionHandler: ActionHandler? { + get { + return objc_getAssociatedObject(self, &actionHandlerKey) as? ActionHandler ?? superview?.actionHandler + } + set { + objc_setAssociatedObject(self, &actionHandlerKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private var responderChain: AnyIterator { + var responder = next + + return AnyIterator { + defer { + responder = responder?.next + } + + return responder + } + } + + public func dispatch(_ action: Action, file: String = #file, line: Int = #line) { + let controller = responderChain.first { $0 is UIViewController } as? UIViewController + + let context = ActionContext(file: file, line: line, view: self, viewController: controller) + + actionHandler?.dispatch(action, context: context) + } + + public func dispatchWithSynchronousExecution(_ action: SystemResponseAction, file: String = #file, line: Int = #line) { + let context = ActionContext(file: file, line: line, view: self) + + actionHandler?.dispatchWithSynchronousExecution(action, context: context) + } +} diff --git a/Sources/Resin/UIViewController+Lifecycle.swift b/Sources/Resin/UIViewController+Lifecycle.swift new file mode 100644 index 0000000..aa16ead --- /dev/null +++ b/Sources/Resin/UIViewController+Lifecycle.swift @@ -0,0 +1,162 @@ +import Dispatch +import Foundation +import ObjectiveC +import UIKit + +// Run this only once. +private let installAppearanceHooks: Void = { + let `class`: AnyClass = objc_getClass("UIViewController") as! AnyClass + + do { + typealias BlockSignature = @convention(block) (/* self */ UIViewController) -> Void + typealias FunctionSignature = @convention(c) (/* self */ UIViewController, /* cmd */ Selector) -> Void + + let selector = #selector(UIViewController.viewDidLoad) + + let viewDidLoad = class_getInstanceMethod(`class`, selector)! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewDidLoad), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self` in + oldImplementation(self, selector) + + self.whileViewIsLoadedLifecycle.start() + } + + method_setImplementation(viewDidLoad, imp_implementationWithBlock(newImplementation)) + } + + typealias BlockSignature = @convention(block) (/* self */ UIViewController, /* animated */ Bool) -> Void + typealias FunctionSignature = @convention(c) (/* self */ UIViewController, /* cmd */ Selector, /* animated */ Bool) -> Void + + do { + let selector = #selector(UIViewController.viewWillAppear(_:)) + + let viewWillAppear = class_getInstanceMethod(`class`, selector)! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewWillAppear), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self`, animated in + oldImplementation(self, selector, animated) + + self.whileVisibleLifecycle.start() + } + + method_setImplementation(viewWillAppear, imp_implementationWithBlock(newImplementation)) + } + + do { + let selector = #selector(UIViewController.viewDidAppear(_:)) + + let viewDidAppear = class_getInstanceMethod(`class`, selector)! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewDidAppear), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self`, animated in + oldImplementation(self, selector, animated) + + self.whileAbleToPresentLifecycle.start() + } + + method_setImplementation(viewDidAppear, imp_implementationWithBlock(newImplementation)) + } + + do { + let selector = #selector(UIViewController.viewDidDisappear(_:)) + + let viewDidDisappear = class_getInstanceMethod(`class`, selector)! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewDidDisappear), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self`, animated in + self.whileVisibleLifecycle.stop() + + let isRootViewController = self == UIApplication.shared.keyWindow?.rootViewController + + if self.presentingViewController == nil && !isRootViewController { + self.whileAbleToPresentLifecycle.stop() + } + + oldImplementation(self, selector, animated) + } + + method_setImplementation(viewDidDisappear, imp_implementationWithBlock(newImplementation)) + } +}() + +public protocol UIViewControllerLifecycle: AnyObject {} + +private var appearanceLifecycleKey = "appearanceLifecycle" +private var presentabilityLifecycleLifecycleKey = "inViewHierarchyLifecycle" +private var viewIsLoadedLifecycleKey = "viewIsLoadedLifecycle" + +extension UIViewControllerLifecycle { + /// A `Lifecycle` that is running as long as the receiver's able to present + /// view controllers. Runs from `viewDidAppear(_:)` until it is removed + /// from thew view controller hierarchy. + public var whileAbleToPresent: Lifecycle { + return Lifecycle(underlying: whileAbleToPresentLifecycle) + } + + fileprivate var whileAbleToPresentLifecycle: AnyLifecycle { + if let lifecycle = objc_getAssociatedObject(self, &presentabilityLifecycleLifecycleKey) as? AnyLifecycle { + return lifecycle + } + + installAppearanceHooks + + let lifecycle = AnyLifecycle(owner: self) + + objc_setAssociatedObject(self, &presentabilityLifecycleLifecycleKey, lifecycle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + return lifecycle + } + + /// A `Lifecycle` that is running as long as the receiver's view is loaded, + /// as determined by `viewDidLoad(_:)`, and until the receiver is + /// deallocated. + public var whileViewIsLoaded: Lifecycle { + return Lifecycle(underlying: whileViewIsLoadedLifecycle) + } + + fileprivate var whileViewIsLoadedLifecycle: AnyLifecycle { + if let lifecycle = objc_getAssociatedObject(self, &viewIsLoadedLifecycleKey) as? AnyLifecycle { + return lifecycle + } + + installAppearanceHooks + + let lifecycle = AnyLifecycle(owner: self) + + if (self as! UIViewController).isViewLoaded { + lifecycle.start() + } + + objc_setAssociatedObject(self, &viewIsLoadedLifecycleKey, lifecycle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + return lifecycle + } + + /// A `Lifecycle` that is running as long as the receiver's view is visible, + /// as determined by the calls to `viewWillAppear(_:)` and + /// `viewDidDisappear(_:)`. + public var whileVisible: Lifecycle { + return Lifecycle(underlying: whileVisibleLifecycle) + } + + fileprivate var whileVisibleLifecycle: AnyLifecycle { + if let lifecycle = objc_getAssociatedObject(self, &appearanceLifecycleKey) as? AnyLifecycle { + return lifecycle + } + + installAppearanceHooks + + let lifecycle = AnyLifecycle(owner: self) + + objc_setAssociatedObject(self, &appearanceLifecycleKey, lifecycle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + return lifecycle + } +} + +extension UIViewController: UIViewControllerLifecycle {} diff --git a/Sources/Resin/UIViewController+ModalPresentation.swift b/Sources/Resin/UIViewController+ModalPresentation.swift new file mode 100644 index 0000000..fb16d6f --- /dev/null +++ b/Sources/Resin/UIViewController+ModalPresentation.swift @@ -0,0 +1,161 @@ +import Incremental +import UIKit + +public extension UIViewController { + func bindPresentedViewControllers(store: Store, navigationStack keyPath: WritableKeyPath) -> Disposable { + precondition(presentationIdentifiersKeyPath == nil, "Presented view controllers are already bound.") + + self.presentationIdentifiersKeyPath = store.keyPath.appending(path: keyPath) + + let identifiers = store.state[keyPath] + + return [ + identifiers + .map(self.presentationViewControllerCache.createViewControllersIfNeeded) + .observe { viewControllers in + let keyPath = self.presentationIdentifiersKeyPath + + /// In order to keep the navigation stack in sync with the + /// modal presentation, dispatch a + /// `SynchronousPresentationAction` whenever one of the view + /// controllers gets dismissed. + viewControllers.enumerated().forEach { (index, viewController) in + let synchronousAction = SynchronousPresentationAction(popToTargetCount: index, from: keyPath!) + + viewController.onDismiss = { [unowned viewController] in + viewController.dispatchWithSynchronousExecution(synchronousAction) + } + } + + self.present(viewControllers: viewControllers, animated: true) + }, + Disposable { + self.presentationIdentifiersKeyPath = nil + } + ] + } +} + +// Run this only once. +private let installDisappearanceHooks: Void = { + typealias BlockSignature = @convention(block) (/* self */ UIViewController, /* animated */ Bool) -> Void + typealias FunctionSignature = @convention(c) (/* self */ UIViewController, /* cmd */ Selector, /* animated */ Bool) -> Void + + let `class`: AnyClass = objc_getClass("UIViewController") as! AnyClass + + do { + let viewDidDisappear = class_getInstanceMethod(`class`, #selector(UIViewController.viewDidDisappear(_:)))! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewDidDisappear), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self`, animated in + if self.presentingViewController == nil { + self.onDismiss?() + self.onDismiss = nil + } + + oldImplementation(self, #selector(UIViewController.viewDidDisappear(_:)), animated) + } + + method_setImplementation(viewDidDisappear, imp_implementationWithBlock(newImplementation)) + } +}() + +private var onDismissKey = "onDismissKey" +private var presentationIdentifiersKeyPathKey = "presentationIdentifiersKeyPathKey" +private var presentationViewControllerCacheKey = "presentationViewControllerCacheKey" + +extension UIViewController { + var onDismiss: (() -> ())? { + get { + installDisappearanceHooks + + return objc_getAssociatedObject(self, &onDismissKey) as? () -> () + } + set { + installDisappearanceHooks + + objc_setAssociatedObject(self, &onDismissKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + } + + var presentationIdentifiersKeyPath: AnyKeyPath? { + get { + return objc_getAssociatedObject(self, &presentationIdentifiersKeyPathKey) as? AnyKeyPath + } + set { + objc_setAssociatedObject(self, &presentationIdentifiersKeyPathKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private var presentationViewControllerCache: ViewControllerCache { + get { + if let cache = objc_getAssociatedObject(self, &presentationViewControllerCacheKey) as? ViewControllerCache { + return cache + } + + let cache = ViewControllerCache(owner: self) + + self.presentationViewControllerCache = cache + + return cache + } + set { + objc_setAssociatedObject(self, &presentationViewControllerCacheKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +extension UIViewController { + var resolvedPresentationKeyPath: AnyKeyPath? { + // Walk up the modal presentation chain first, as we assume that there + // are no two view controllers that bind modal presentation on top of + // each other. + return presentingViewController?.resolvedPresentationKeyPath + // If we're bound ourselves, return our + // `presentationIdentifiersKeyPath`. + ?? presentationIdentifiersKeyPath + // Otherwise, recursively ask the parent. + ?? parent?.resolvedPresentationKeyPath + } +} + +private extension UIViewController { + func present(viewControllers: [UIViewController], animated: Bool) { + guard !viewControllers.isEmpty else { + if presentedViewController != nil { + dismiss(animated: animated) + } + return + } + + let toBePresented = viewControllers.first! + + guard presentedViewController != toBePresented else { + toBePresented.present(viewControllers: Array(viewControllers.dropFirst()), animated: animated) + return + } + + // If presenting multiple view conrollers at once, only animate the + // presentation of the last view controller. + // + // This prevents loading issues in `SFSafariViewController`. + let actuallyAnimated = animated && viewControllers.count == 1 + + if presentedViewController != nil { + presentedViewController?.actionHandler = nil + presentedViewController?.presentationRouter = nil + + dismiss(animated: actuallyAnimated) { + self.present(viewControllers: viewControllers, animated: animated) + } + } else { + toBePresented.actionHandler = actionHandler + toBePresented.presentationRouter = presentationRouter + + self.present(toBePresented, animated: actuallyAnimated) { + toBePresented.present(viewControllers: Array(viewControllers.dropFirst()), animated: animated) + } + } + } +} diff --git a/Sources/Resin/UIViewController+Redux.swift b/Sources/Resin/UIViewController+Redux.swift new file mode 100644 index 0000000..24f8bf4 --- /dev/null +++ b/Sources/Resin/UIViewController+Redux.swift @@ -0,0 +1,40 @@ +import UIKit + +private var presentationRouterKey = "presentationRouterKey" + +extension UIViewController { + /// The `ActionHandler` used by this view controller. + /// + /// It's a view controllers responsibility to bridge its child views and the + /// `Action` infrastructure. Use this property to make an `ActionHandler` + /// available to the entire view tree owned by the receiver. + public var actionHandler: ActionHandler? { + get { + return view.actionHandler + } + set { + view.actionHandler = newValue + } + } + + public var presentationRouter: AnyPresentationRouter? { + get { + return objc_getAssociatedObject(self, &presentationRouterKey) as? AnyPresentationRouter ?? parent?.presentationRouter + } + set { + objc_setAssociatedObject(self, &presentationRouterKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public func dispatch(_ action: Action, file: String = #file, line: Int = #line) { + let context = ActionContext(file: file, line: line, viewController: self) + + actionHandler?.dispatch(action, context: context) + } + + public func dispatchWithSynchronousExecution(_ action: SystemResponseAction, file: String = #file, line: Int = #line) { + let context = ActionContext(file: file, line: line, viewController: self) + + actionHandler?.dispatchWithSynchronousExecution(action, context: context) + } +} diff --git a/Sources/Resin/UIViewController+RequiresModalPresentation.swift b/Sources/Resin/UIViewController+RequiresModalPresentation.swift new file mode 100644 index 0000000..0aa492e --- /dev/null +++ b/Sources/Resin/UIViewController+RequiresModalPresentation.swift @@ -0,0 +1,26 @@ +import UIKit +import SafariServices + +extension SFSafariViewController { + @objc override open class var requiresModalPresentation: Bool { + return true + } +} + +extension UIActivityViewController { + @objc override open class var requiresModalPresentation: Bool { + return true + } +} + +extension UINavigationController { + @objc override open class var requiresModalPresentation: Bool { + return true + } +} + +extension UIViewController { + @objc open class var requiresModalPresentation: Bool { + return false + } +} diff --git a/Sources/Resin/ViewControllerCache.swift b/Sources/Resin/ViewControllerCache.swift new file mode 100644 index 0000000..dacb798 --- /dev/null +++ b/Sources/Resin/ViewControllerCache.swift @@ -0,0 +1,45 @@ +import Incremental +import UIKit + +public final class ViewControllerCache { + internal struct ControllerItemAssociation: Equatable { + var controller: UIViewController + var item: AnyPresentationIdentifier + + static func == (lhs: ControllerItemAssociation, rhs: ControllerItemAssociation) -> Bool { + return lhs.controller === rhs.controller && lhs.item == rhs.item + } + } + + private var currentControllers: [ControllerItemAssociation] = [] + + unowned let owner: UIViewController + + public init(owner: UIViewController) { + self.owner = owner + } + + private func makeController(index: Int, identifier: AnyPresentationIdentifier) -> ControllerItemAssociation? { + // If the controller at the given index is already of the correct type, + // nothing needs to be done. + if currentControllers.indices.contains(index) && currentControllers[index].item == identifier { + return currentControllers[index] + } + + guard let controller = owner.presentationRouter?.makeViewController(identifier: identifier) else { + return nil + } + + return ControllerItemAssociation(controller: controller, item: identifier) + } + + public func createViewControllersIfNeeded(presentationIdentifiers: [AnyPresentationIdentifier]) -> [UIViewController] { + let associations = presentationIdentifiers.enumerated().compactMap(makeController) + + defer { + currentControllers = associations + } + + return associations.map { $0.controller } + } +} diff --git a/Sources/Resin/ViewModel.swift b/Sources/Resin/ViewModel.swift new file mode 100644 index 0000000..3e2c47e --- /dev/null +++ b/Sources/Resin/ViewModel.swift @@ -0,0 +1,38 @@ +import Foundation + +public protocol ViewModel: Equatable { + /// A view model that represents the empty state. + static var empty: Self { get } + + /// This method produces a "best effort" fallback `ViewModel` in cases where + /// the right data isn't available but a `ViewModel` is required regardless. + /// + /// - Parameters: + /// - previous: The last view model that was available. + /// - current: The best view model that is available currently. + /// - Returns: A "best effort" view model to use if no data is available or + /// `current` otherwise. + static func fallback(previous: Self, current: Self?) -> Self + + /// If `true`, the view model is considered out of date and represents the + /// last known good state. + var isStale: Bool { get set } +} + +public extension ViewModel { + static func fallback(previous: Self, current: Self?) -> Self { + if let current = current { + return current + } else { + var copy = previous + copy.isStale = true + return copy + } + } +} + +public extension ViewModel where Self: DefaultInit { + static var empty: Self { + return Self() + } +} diff --git a/readme.md b/readme.md index 1af9075..239bfc0 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,54 @@ +# Incremental + +This repository provides two libraries: **Incremental** and **Resin**. + +## Incremental + This is an implementation of incremental programming. It's based on the ideas in [incremental](https://blog.janestreet.com/introducing-incremental/), which are on turn based on the ideas of [self-adjusting computation](http://www.umut-acar.org/self-adjusting-computation). For usage, see the [laufpark stechlin](https://github.com/chriseidhof/laufpark-stechlin) app. + +## Resin + +Resin uses Incremental and additionally provides a framework way to manage application state. + +An app using Resin would typically have a single, shared struct that contains the entire application state. Different parts of the app would then *focus* this state to only operate on their subset of the state. This helps keep the concerns of different modules isolated from each other. + +As a trivial example, consider + +```swift +struct AppState { + var foo: Foo + var bar: Bar +} +``` + +where the app has two components (see *Parcel* below), one of which would operates on a state of the type `Foo`, and the other one on `Bar`. + +### Actions + +Resin enforces that changes to this global state can only be done through an `Action`. + +An `Action` has value semantics. And it can trigger two things: a `Reducer` can use an action to udpate the app state; and a `Middleware` can handle an `Action` to trigger side effects. + +A note of caution: It is important to stress, that the channel to submit actions **is not** intended to be used as an event stream, and should **absolutely not** be misused as such. An `Action` is supposed to be a way for one part of he app to tell another to **do something**. This is **not** a way to notify about events of changes. `NSNotification` and observing the app state are better vehicles for that. + +### Store + +The app has a (root) store that holds onto the `AppState`. It only exposes the `AppState` through an (Incremental) `I` such that various parts of the app can observe change of the state and react upon those changes. + +`Middleware` and `Reducer`s can be registered with the store and any `Action` that gets dispatched will then be send to those. The `Reducer`s are the only ones that are given an opportunity to update the state. Their handler gets an `inout AppState` to operate on. + +As different parts of the app operate on subsets of the all encompasing `AppState`, each part can operate on a `Store` that’s tied to the root store, but only operates on its subset (as noted above). The `Middleware` and `Reducer`s can then also operate on such a store’s subset, which transparently ties into the root store. + +### Navigation + +tbd + +### Parcel + +Each part (usually a Swift module) of an app has a combination of `Reducer`s, `Middleware`, and `NavigationRoute`s to implement its features. All of these needs to be registered with the store. In order to facilitate that, a `Parcel` can wrap a combination of all of these. The parcel can then be registered with the store, which in turn will register all of its `Reducer`s, `Middleware`, and `NavigationRoute`s. + +The benefit of this, is that the app itself only needs to register the `Parcel`s that it uses, and doesn’t have to know which `Reducer`s, `Middleware`, and `NavigationRoute`s it needs. This makes updating code within a specific part / domain of an app easier. If, e.g. a new `Middleware` is added, it just needs to be added to its `Parcel`, and will then automatically be registered with the app. + +Additionally, a module that has a parcel, does not have to expose its `Reducer`s, etc. as `public`. They only need to be added to the module’s `Parcel`. This reduces the API surface of a module. From fbee19b668b2efb2133de43f0ca85886562a1dd4 Mon Sep 17 00:00:00 2001 From: Julien Grimault Date: Sun, 29 Mar 2020 15:30:49 -0400 Subject: [PATCH 2/3] Update `Resin` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This updates `Resin` where we move away from the `Environment` parameter in `Store` and `Middleware`. We found that in practice, it is easier to deal with injecting the required dependencies at the `Parcel` level. Each Middleware/Reducer that has dependencies can get them passed in when the parcel is constructed. Co-authored-by: Sommer Panage Co-authored-by: Teddy Wyly Co-authored-by: Julien Grimault Co-authored-by: Robb Böhnke Co-authored-by: Daniel Eggert --- .../Incremental/Incremental+NSObject.swift | 12 ++- Sources/Resin/AnyMiddleware+Optics.swift | 95 +++++++++++++++++++ Sources/Resin/AnyReducer+Optics.swift | 19 +++- Sources/Resin/Daemon.swift | 41 ++++++++ .../Resin/ImplicitPresentationAction.swift | 10 +- Sources/Resin/Middleware.swift | 44 +-------- Sources/Resin/MiddlewareFactory.swift | 27 ------ Sources/Resin/Parcel.swift | 63 ++++++++---- Sources/Resin/PresentationAction.swift | 12 +-- Sources/Resin/PresentationRoute.swift | 6 +- Sources/Resin/PresentationRouter.swift | 18 ++-- Sources/Resin/Store+Parcel.swift | 10 +- Sources/Resin/Store.swift | 21 ++-- ...edux.swift => UIBarButtonItem+Resin.swift} | 0 ...trol+Redux.swift => UIControl+Resin.swift} | 0 ....swift => UIGestureRecognizer+Resin.swift} | 0 ...ift => UINavigationController+Resin.swift} | 0 ...{UIView+Redux.swift => UIView+Resin.swift} | 10 +- ...dux.swift => UIViewController+Resin.swift} | 5 +- readme.md | 4 +- 20 files changed, 241 insertions(+), 156 deletions(-) create mode 100644 Sources/Resin/AnyMiddleware+Optics.swift create mode 100644 Sources/Resin/Daemon.swift delete mode 100644 Sources/Resin/MiddlewareFactory.swift rename Sources/Resin/{UIBarButtonItem+Redux.swift => UIBarButtonItem+Resin.swift} (100%) rename Sources/Resin/{UIControl+Redux.swift => UIControl+Resin.swift} (100%) rename Sources/Resin/{UIGestureRecognizer+Redux.swift => UIGestureRecognizer+Resin.swift} (100%) rename Sources/Resin/{UINavigationController+Redux.swift => UINavigationController+Resin.swift} (100%) rename Sources/Resin/{UIView+Redux.swift => UIView+Resin.swift} (75%) rename Sources/Resin/{UIViewController+Redux.swift => UIViewController+Resin.swift} (82%) diff --git a/Sources/Incremental/Incremental+NSObject.swift b/Sources/Incremental/Incremental+NSObject.swift index 0098aa7..e3a7aa5 100644 --- a/Sources/Incremental/Incremental+NSObject.swift +++ b/Sources/Incremental/Incremental+NSObject.swift @@ -66,9 +66,15 @@ extension IBox where V: NSObject { extension NSObjectProtocol where Self: NSObject { /// One-way binding - public func bind(keyPath: ReferenceWritableKeyPath, _ i: I) -> Disposable { - return i.observe { - self[keyPath: keyPath] = $0 + public func bind(keyPath: ReferenceWritableKeyPath, to i: I) -> Disposable { + return i.observe { [weak self] in + self?[keyPath: keyPath] = $0 + } + } + + public func bind(keyPath: ReferenceWritableKeyPath, to i: I) -> Disposable { + return i.observe { [weak self] in + self?[keyPath: keyPath] = $0 } } } diff --git a/Sources/Resin/AnyMiddleware+Optics.swift b/Sources/Resin/AnyMiddleware+Optics.swift new file mode 100644 index 0000000..c5cd2a5 --- /dev/null +++ b/Sources/Resin/AnyMiddleware+Optics.swift @@ -0,0 +1,95 @@ +import Foundation + +/// This method create a new Middleware for a state that contains the +/// state of the receiver. +/// +/// This allows us to write `Middleware` focused on most specific state +/// they need to know about and defer the integration into the `Superstate`. + +/// - Parameters: +/// - by: A `WritableKeyPath` that maps from the superstate to the state +/// of the receiver. +extension AnyMiddleware { + public func unfocused(by keyPath: KeyPath) -> AnyMiddleware { + return ProxyMiddleware(keyPath: keyPath, proxied: self) + } +} + +extension AnyMiddleware where State == ArbitraryState { + public func unfocused(to superState: Superstate.Type = Superstate.self) -> AnyMiddleware { + return unfocused(by: ArbitraryState.arbitraryKeyPath()) + } +} + +private final class ProxyMiddleware: AnyMiddleware { + override var actionHandler: ActionHandler? { + get { + return proxied.actionHandler + } + set { + proxied.actionHandler = newValue + } + } + + let keyPath: KeyPath + + let proxied: AnyMiddleware + + init(keyPath: KeyPath, proxied: AnyMiddleware) { + self.keyPath = keyPath + self.proxied = proxied + } + + override public func transform(action: Resin.Action, context: ActionContext, state: Superstate) -> Resin.Action? { + return proxied.transform(action: action, context: context, state: state[keyPath: keyPath]) + } +} + +/// this unfocus a non-optional middleware into an optional one, only performing the transform if the state is non-nil +extension Middleware { + public func optional(behavior: OptionalMiddlwareBehavior) -> Middleware { + return OptionalProxyMiddleware(behavior: behavior, proxied: self) + } +} + +/// This defines a middleware behavior when the optional `State` is nil. +/// - `consume`: the middleware "consumes" the action and returns nil, other middlewares or reducers will NOT be able to act on it +/// - `passthrough`: the middleware returns the action untouched, so that it can be handled by other middlewares and reducers +public enum OptionalMiddlwareBehavior { + case consume + case passthrough +} + +private final class OptionalProxyMiddleware: Middleware { + override var actionHandler: ActionHandler? { + get { + return proxied.actionHandler + } + set { + proxied.actionHandler = newValue + } + } + + let proxied: AnyMiddleware + let behavior: OptionalMiddlwareBehavior + + init(behavior: OptionalMiddlwareBehavior, proxied: AnyMiddleware) { + self.behavior = behavior + self.proxied = proxied + } + + override public func transform(action: Action, context: ActionContext, state: State?) -> Resin.Action? { + guard let actualState = state else { + switch behavior { + case .consume: + print("\(type(of: proxied)) optional middleware : state(\(type(of: state))) is nil => consuming \(type(of: action))") + return nil + case .passthrough: + print("\(type(of: proxied)) optional middleware : state(\(type(of: state))) is nil => letting \(type(of: action)) pass through") + return action + } + } + + return proxied.transform(action: action, context: context, state: actualState) + } +} diff --git a/Sources/Resin/AnyReducer+Optics.swift b/Sources/Resin/AnyReducer+Optics.swift index c6a3d0b..afd3523 100644 --- a/Sources/Resin/AnyReducer+Optics.swift +++ b/Sources/Resin/AnyReducer+Optics.swift @@ -4,9 +4,9 @@ extension AnyReducer { /// These methods create a new Reducer for a state that contains the /// state of the receiver. /// - /// This allows us to write a `Reducer` focused on the most specific state + /// This allows us to write `Reducer` focused on most specific state /// they need to know about and defer the integration into the `Superstate`. - /// + /// - Parameters: /// - by: A `WritableKeyPath` that maps from the superstate to the state /// of the receiver. @@ -20,3 +20,18 @@ extension AnyReducer { } } } + +extension AnyReducer { + /// this unfocus a non-optional reducer into an optional one, only applying the action if the state is non-nil + public func optional() -> AnyReducer { + return AnyReducer(actionIdentifier: actionIdentifier) { [perform] action, optionalState in + guard var state = optionalState else { + print("\(type(of: self)) optional reducer : state \(type(of: optionalState)) is nil => not running") + return + } + + perform(action, &state) + optionalState = state + } + } +} diff --git a/Sources/Resin/Daemon.swift b/Sources/Resin/Daemon.swift new file mode 100644 index 0000000..ff103a8 --- /dev/null +++ b/Sources/Resin/Daemon.swift @@ -0,0 +1,41 @@ +import Foundation +import Incremental + +/// A `Daemon` offers a way to observe `State` instead of catching an `Action` +/// +/// Usually used for background processing, such as ensuring some outside system remains +/// in sync with `State`. +open class Daemon: AnyMiddleware { + private var disposable: Disposable? + + private let state: I + + public init(state: I) { + self.state = state + + super.init() + } + + open func observe(state: I) -> Disposable? { + return nil + } + + override public var actionHandler: ActionHandler? { + willSet { + stop() + } + didSet { + guard actionHandler != nil else { return } + + start() + } + } + + open func start() { + disposable = observe(state: state) + } + + open func stop() { + disposable = nil + } +} diff --git a/Sources/Resin/ImplicitPresentationAction.swift b/Sources/Resin/ImplicitPresentationAction.swift index a2b9284..d8fbe16 100644 --- a/Sources/Resin/ImplicitPresentationAction.swift +++ b/Sources/Resin/ImplicitPresentationAction.swift @@ -39,7 +39,7 @@ public struct ImplicitPresentationAction: Action { } } -internal final class ImplicitPresentationMiddleware: Middleware { +internal final class ImplicitPresentationMiddleware: Middleware { override func transform(action: ImplicitPresentationAction, context: ActionContext, state: ArbitraryState) -> Action? { guard let viewController = context.viewController else { fatalError("Action \(action) was not dispatched from a view controller context in \(context.file):\(context.line).") @@ -93,12 +93,6 @@ internal final class ImplicitPresentationMiddleware: Middleware - - let factory: MiddlewareFactory = ImplicitPresentationMiddleware.factory - .lift { $0 as Any } - .unfocused(by: keyPath) - - add(middleware: factory) + add(middleware: ImplicitPresentationMiddleware().unfocused()) } } diff --git a/Sources/Resin/Middleware.swift b/Sources/Resin/Middleware.swift index 053f5d5..cb4d7cd 100644 --- a/Sources/Resin/Middleware.swift +++ b/Sources/Resin/Middleware.swift @@ -21,19 +21,7 @@ open class AnyMiddleware { } } -open class Middleware: AnyMiddleware { - public let environment: Environment - - open class var factory: MiddlewareFactory { - return MiddlewareFactory { state, environment in - return self.init(state: state, environment: environment) - } - } - - required public init(state: I, environment: Environment) { - self.environment = environment - } - +open class Middleware: AnyMiddleware { open func transform(action: Action, context: ActionContext, state: State) -> Resin.Action? { return action } @@ -46,33 +34,3 @@ open class Middleware: AnyM } } } - -private final class ProxyMiddleware: AnyMiddleware { - override var actionHandler: ActionHandler? { - get { - return proxied.actionHandler - } - set { - proxied.actionHandler = newValue - } - } - - let keyPath: KeyPath - - let proxied: AnyMiddleware - - init(keyPath: KeyPath, proxied: AnyMiddleware) { - self.keyPath = keyPath - self.proxied = proxied - } - - override public func transform(action: Resin.Action, context: ActionContext, state: Superstate) -> Resin.Action? { - return proxied.transform(action: action, context: context, state: state[keyPath: keyPath]) - } -} - -extension AnyMiddleware { - public func unfocused(by keyPath: KeyPath) -> AnyMiddleware { - return ProxyMiddleware(keyPath: keyPath, proxied: self) - } -} diff --git a/Sources/Resin/MiddlewareFactory.swift b/Sources/Resin/MiddlewareFactory.swift deleted file mode 100644 index fe4f2c7..0000000 --- a/Sources/Resin/MiddlewareFactory.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Incremental - -public class MiddlewareFactory { - private let closure: (I, Environment) -> AnyMiddleware - - public init(closure: @escaping (I, Environment) -> AnyMiddleware) { - self.closure = closure - } - - public func make(state: I, environment: Environment) -> AnyMiddleware { - return closure(state, environment) - } - - public func unfocused(by keyPath: KeyPath) -> MiddlewareFactory { - return MiddlewareFactory { state, environment in - let substate = state[keyPath] - - return self.make(state: substate, environment: environment).unfocused(by: keyPath) - } - } - - public func lift(proof: @escaping (E) -> Environment) -> MiddlewareFactory { - return MiddlewareFactory { state, environment in - return self.make(state: state, environment: proof(environment)) - } - } -} diff --git a/Sources/Resin/Parcel.swift b/Sources/Resin/Parcel.swift index 4d12b30..ab8c0d3 100644 --- a/Sources/Resin/Parcel.swift +++ b/Sources/Resin/Parcel.swift @@ -1,43 +1,64 @@ import Foundation -public struct Parcel { - @available(*, deprecated, message: "Use middleware instead") - public var legacyMiddleware: [AnyMiddleware] = [] +/// This struct encapsulates everything a module needs to expose to be integrated into a store +public struct Parcel { + public struct Delivery { + public var middlewares: [AnyMiddleware] - public var middleware: [MiddlewareFactory] = [] + public var presentationRoutes: [AnyPresentationRoute] - public var presentationRoutes: [AnyPresentationRoute] = [] + public var reducers: [AnyReducer] - public var reducers: [AnyReducer] = [] + public init(middlewares: [AnyMiddleware] = [], presentationRoutes: [AnyPresentationRoute] = [], reducers: [AnyReducer] = []) { + self.middlewares = middlewares + self.presentationRoutes = presentationRoutes + self.reducers = reducers + } + } + + public var build: (Store) -> Parcel.Delivery - public init() {} + public init(build: @escaping (Store) -> Parcel.Delivery) { + self.build = build + } } extension Parcel { - public func unfocused(by keyPath: WritableKeyPath) -> Parcel { - var result = Parcel() - result.legacyMiddleware = legacyMiddleware.map { $0.unfocused(by: keyPath) } - result.middleware = middleware.map { $0.unfocused(by: keyPath) } + public func unfocused(by keyPath: WritableKeyPath) -> Parcel { + return Parcel { store in + let localStore = store.focused(by: keyPath) + return self.build(localStore).unfocused(by: keyPath) + } + } + + public func combining(with other: Parcel) -> Parcel { + Parcel { store in + self.build(store).combining(with: other.build(store)) + } + } +} + +extension Parcel.Delivery { + public func unfocused(by keyPath: WritableKeyPath) -> Parcel.Delivery { + var result = Parcel.Delivery() + result.middlewares = middlewares.map { $0.unfocused(by: keyPath) } result.presentationRoutes = presentationRoutes.map { $0.unfocused(by: keyPath) } result.reducers = reducers.map { $0.unfocused(by: keyPath) } - return result } - public func lift(proof: @escaping (E) -> Environment) -> Parcel { - var result = Parcel() - result.legacyMiddleware = legacyMiddleware - result.middleware = middleware.map { $0.lift(proof: proof) } - result.presentationRoutes = presentationRoutes - result.reducers = reducers - - return result + public func combining(with other: Parcel.Delivery) -> Parcel.Delivery { + Parcel.Delivery( + middlewares: middlewares + other.middlewares, + presentationRoutes: presentationRoutes + other.presentationRoutes, + reducers: reducers + other.reducers + ) } } extension Parcel where State == ArbitraryState { /// Unfocuses a `Parcel` to a `Parcel` for any `S`. - public func arbitrarilyUnfocused(to superstate: Superstate.Type) -> Parcel { + public func unfocused(to superstate: Superstate.Type = Superstate.self) -> Parcel { return unfocused(by: ArbitraryState.arbitraryKeyPath()) } } diff --git a/Sources/Resin/PresentationAction.swift b/Sources/Resin/PresentationAction.swift index 4bf1534..819a27b 100644 --- a/Sources/Resin/PresentationAction.swift +++ b/Sources/Resin/PresentationAction.swift @@ -77,7 +77,7 @@ internal struct PresentationReducer: Reducer { // swiftlint:disable:next cyclomatic_complexity public func reduce(action: PresentationAction, state: inout State) { guard let keyPath = action.target as? WritableKeyPath else { - print("Target key path %@ is not compatible with %@", "\(action.target)", "\(State.self)") + print("Target key path \(action.target) is not compatible with \(State.self))") return } @@ -95,7 +95,7 @@ internal struct PresentationReducer: Reducer { stack = identifiers case .pop: guard !stack.isEmpty else { - print("Couldn't pop from %@", "\(action.target)") + print("Couldn't pop from \(action.target)") return } @@ -106,7 +106,7 @@ internal struct PresentationReducer: Reducer { } case .popToTargetCount(let count): guard stack.count >= count else { - print("Couldn't pop to target count from %@", "\(action.target)") + print("Couldn't pop to target count from \(action.target)") return } @@ -115,7 +115,7 @@ internal struct PresentationReducer: Reducer { stack.append(identifier) case .replace(let identifier): guard !stack.isEmpty else { - print("No identifiers to replace from %@", "\(action.target)") + print("No identifiers to replace from \(action.target)") return } @@ -148,12 +148,12 @@ internal struct SynchronousPresentationReducer: Reducer { switch action.presentation { case .popToTargetCount(let count): guard let keyPath = action.target as? WritableKeyPath else { - print("Target key path %@ is not compatible with %@", "\(action.target)", "\(State.self)") + print("Target key path \(action.target) is not compatible with \(State.self)") return } guard state[keyPath: keyPath].count >= count else { - print("Couldn't pop to target count from %@", "\(action.target)") + print("Couldn't pop to target count from \(action.target)") return } diff --git a/Sources/Resin/PresentationRoute.swift b/Sources/Resin/PresentationRoute.swift index b0b4951..5a4d13c 100644 --- a/Sources/Resin/PresentationRoute.swift +++ b/Sources/Resin/PresentationRoute.swift @@ -47,7 +47,7 @@ extension PresentationRoute where State == ArbitraryState { } public func makeViewController(identifier: Identifier) -> ViewController { - let store = RootStore(state: ArbitraryState(), environment: Void()) + let store = RootStore(state: ArbitraryState()) return make(identifier, store) } @@ -95,12 +95,12 @@ extension AnyPresentationRoute where State == ArbitraryState { public func makeViewController(identifier: AnyPresentationIdentifier) -> UIViewController { precondition(underlyingTypeIdentifier == identifier.underlyingTypeIdentifier) - let store = RootStore(state: ArbitraryState(), environment: Void()) + let store = RootStore(state: ArbitraryState()) return make(identifier, store) } - public func arbitrarilyUnfocused(to superstate: Superstate.Type) -> AnyPresentationRoute { + public func unfocused(to superstate: Superstate.Type = Superstate.self) -> AnyPresentationRoute { let make = self.make return AnyPresentationRoute(isModal: isModal, underlyingTypeIdentifier: underlyingTypeIdentifier) { identifier, store in diff --git a/Sources/Resin/PresentationRouter.swift b/Sources/Resin/PresentationRouter.swift index c1ace73..87e8d67 100644 --- a/Sources/Resin/PresentationRouter.swift +++ b/Sources/Resin/PresentationRouter.swift @@ -39,16 +39,6 @@ public final class PresentationRouter { } } -extension PresentationRouter { - public func register(route: AnyPresentationRoute) { - register(route: route.arbitrarilyUnfocused(to: State.self)) - } - - public func register(routes: [AnyPresentationRoute]) { - register(routes: routes.map { $0.arbitrarilyUnfocused(to: State.self) }) - } -} - extension PresentationRouter { public var typeErased: AnyPresentationRouter { return AnyPresentationRouter(presentationRouter: self) @@ -73,3 +63,11 @@ public final class AnyPresentationRouter { return isModal(identifier) } } + +extension PresentationRouter { + public func register(delivery: Parcel.Delivery) { + delivery.presentationRoutes.forEach { route in + register(route: route) + } + } +} diff --git a/Sources/Resin/Store+Parcel.swift b/Sources/Resin/Store+Parcel.swift index 37ed45c..e9b2db7 100644 --- a/Sources/Resin/Store+Parcel.swift +++ b/Sources/Resin/Store+Parcel.swift @@ -1,16 +1,12 @@ import Foundation extension RootStore { - public func integrate(parcel: Parcel) { - for middleware in parcel.legacyMiddleware { + public func integrate(delivery: Parcel.Delivery) { + for middleware in delivery.middlewares { add(middleware: middleware) } - for factory in parcel.middleware { - add(middleware: factory) - } - - for reducer in parcel.reducers { + for reducer in delivery.reducers { add(reducer: reducer) } } diff --git a/Sources/Resin/Store.swift b/Sources/Resin/Store.swift index 50ce59b..a476eb6 100644 --- a/Sources/Resin/Store.swift +++ b/Sources/Resin/Store.swift @@ -51,7 +51,7 @@ public class Store: AnyStore { /// /// This allows us to give separate components a different view on the same /// data. -public final class RootStore: Store { +public final class RootStore: Store { var middleware: [AnyMiddleware] = [] var reducers: [TypeIdentifier: AnyReducer] = [:] @@ -60,8 +60,6 @@ public final class RootStore: Store { private var isCurrentlyExecutingAction = false - public let environment: Environment - private var underlyingState: State { didSet { stateInput.write(underlyingState) @@ -73,12 +71,11 @@ public final class RootStore: Store { /// - Parameters: /// - state: The initial state of the store. All further modifications to /// this state will be made by handling actions. - public init(state: State, environment: Environment) { - self.environment = environment + public init(state: State) { self.stateInput = Input(state) self.underlyingState = state - super.init(state: stateInput.i, parent: nil, keyPath: \State.keyPathSelf) + super.init(state: stateInput.i, parent: nil, keyPath: \State.self) } public func add(middleware: AnyMiddleware) { @@ -87,19 +84,13 @@ public final class RootStore: Store { self.middleware.append(middleware) } - public func add(middleware factory: MiddlewareFactory) { - let middleware = factory.make(state: self.state, environment: environment) - - add(middleware: middleware) - } - public func add(reducer: AnyReducer) { reducers[reducer.actionIdentifier] = reducer } public override func handle(_ action: Action, context: ActionContext) { guard !isCurrentlyExecutingAction else { - print("Action caused another Action to dispatch before completing") + print("Error: Action caused another Action to dispatch before completing") return } @@ -108,7 +99,7 @@ public final class RootStore: Store { isCurrentlyExecutingAction = false } - print("Handling action %@", String(describing: type(of: action))) + print("Handling action \(type(of: action))") // Run our Action through the middleware, if at any step action gets // caught, abort. @@ -125,7 +116,7 @@ public final class RootStore: Store { dispatchPrecondition(condition: .onQueue(.main)) guard let reducer = reducers[TypeIdentifier(value: action)] else { - print("Action dispatched in %@:%i was not handled.", context.file, context.line) + print("Action dispatched in \(context.file):\(context.line) was not handled.") return } diff --git a/Sources/Resin/UIBarButtonItem+Redux.swift b/Sources/Resin/UIBarButtonItem+Resin.swift similarity index 100% rename from Sources/Resin/UIBarButtonItem+Redux.swift rename to Sources/Resin/UIBarButtonItem+Resin.swift diff --git a/Sources/Resin/UIControl+Redux.swift b/Sources/Resin/UIControl+Resin.swift similarity index 100% rename from Sources/Resin/UIControl+Redux.swift rename to Sources/Resin/UIControl+Resin.swift diff --git a/Sources/Resin/UIGestureRecognizer+Redux.swift b/Sources/Resin/UIGestureRecognizer+Resin.swift similarity index 100% rename from Sources/Resin/UIGestureRecognizer+Redux.swift rename to Sources/Resin/UIGestureRecognizer+Resin.swift diff --git a/Sources/Resin/UINavigationController+Redux.swift b/Sources/Resin/UINavigationController+Resin.swift similarity index 100% rename from Sources/Resin/UINavigationController+Redux.swift rename to Sources/Resin/UINavigationController+Resin.swift diff --git a/Sources/Resin/UIView+Redux.swift b/Sources/Resin/UIView+Resin.swift similarity index 75% rename from Sources/Resin/UIView+Redux.swift rename to Sources/Resin/UIView+Resin.swift index afba812..50cb758 100644 --- a/Sources/Resin/UIView+Redux.swift +++ b/Sources/Resin/UIView+Resin.swift @@ -1,20 +1,16 @@ import UIKit import ObjectiveC -private var actionHandlerKey = "actionHandlerKey" - extension UIView { /// The `ActionHandler` the receiver should send its actions to. /// /// Set the corresponding `actionHandler` property on the view controller /// that owns the view tree containing the receiver to make it accessible /// here. - internal(set) public var actionHandler: ActionHandler? { + public var actionHandler: ActionHandler? { get { - return objc_getAssociatedObject(self, &actionHandlerKey) as? ActionHandler ?? superview?.actionHandler - } - set { - objc_setAssociatedObject(self, &actionHandlerKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + let controller = responderChain.first { $0 is UIViewController } as? UIViewController + return controller?.actionHandler } } diff --git a/Sources/Resin/UIViewController+Redux.swift b/Sources/Resin/UIViewController+Resin.swift similarity index 82% rename from Sources/Resin/UIViewController+Redux.swift rename to Sources/Resin/UIViewController+Resin.swift index 24f8bf4..162f654 100644 --- a/Sources/Resin/UIViewController+Redux.swift +++ b/Sources/Resin/UIViewController+Resin.swift @@ -1,5 +1,6 @@ import UIKit +private var actionHandlerKey = "actionHandlerKey" private var presentationRouterKey = "presentationRouterKey" extension UIViewController { @@ -10,10 +11,10 @@ extension UIViewController { /// available to the entire view tree owned by the receiver. public var actionHandler: ActionHandler? { get { - return view.actionHandler + return objc_getAssociatedObject(self, &actionHandlerKey) as? ActionHandler ?? parent?.actionHandler } set { - view.actionHandler = newValue + objc_setAssociatedObject(self, &actionHandlerKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } diff --git a/readme.md b/readme.md index 239bfc0..a6960b8 100644 --- a/readme.md +++ b/readme.md @@ -47,8 +47,8 @@ tbd ### Parcel -Each part (usually a Swift module) of an app has a combination of `Reducer`s, `Middleware`, and `NavigationRoute`s to implement its features. All of these needs to be registered with the store. In order to facilitate that, a `Parcel` can wrap a combination of all of these. The parcel can then be registered with the store, which in turn will register all of its `Reducer`s, `Middleware`, and `NavigationRoute`s. +Each part (usually a Swift module) of an app has a combination of `Reducer`s, `Middleware`, and `PresentationRoute`s to implement its features. All of these needs to be registered with the store. In order to facilitate that, a `Parcel` can wrap a combination of all of these. The parcel can then be registered with the store, which in turn will register all of its `Reducer`s, `Middleware`, and `PresentationRoute`s. -The benefit of this, is that the app itself only needs to register the `Parcel`s that it uses, and doesn’t have to know which `Reducer`s, `Middleware`, and `NavigationRoute`s it needs. This makes updating code within a specific part / domain of an app easier. If, e.g. a new `Middleware` is added, it just needs to be added to its `Parcel`, and will then automatically be registered with the app. +The benefit of this, is that the app itself only needs to register the `Parcel`s that it uses, and doesn’t have to know which `Reducer`s, `Middleware`, and `PresentationRoute`s it needs. This makes updating code within a specific part / domain of an app easier. If, e.g. a new `Middleware` is added, it just needs to be added to its `Parcel`, and will then automatically be registered with the app. Additionally, a module that has a parcel, does not have to expose its `Reducer`s, etc. as `public`. They only need to be added to the module’s `Parcel`. This reduces the API surface of a module. From f0120d23abe7643f2a16678ee5cf81d4711eda23 Mon Sep 17 00:00:00 2001 From: Julien Grimault Date: Sun, 29 Mar 2020 19:55:53 -0400 Subject: [PATCH 3/3] Small application showcasing how we can use Resin to build an app. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sommer Panage Co-authored-by: Teddy Wyly Co-authored-by: Julien Grimault Co-authored-by: Robb Böhnke Co-authored-by: Daniel Eggert --- .gitignore | 1 - SampleApp/SampleApp.xcodeproj/project.pbxproj | 455 +++++ .../contents.xcworkspacedata | 7 + .../AppIcon.appiconset/Contents.json | 98 ++ .../SampleApp/Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + SampleApp/SampleApp/Info.plist | 43 + .../SampleApp/MainApp/AppCoordinator.swift | 67 + SampleApp/SampleApp/MainApp/AppDelegate.swift | 22 + SampleApp/SampleApp/MainApp/AppState.swift | 19 + .../SampleApp/MainApp/ForegroundDaemon.swift | 22 + ...afariViewController+PresentationItem.swift | 18 + .../SampleApp/MainApp/SetTabAction.swift | 14 + .../SampleApp/MainApp/TabController.swift | 56 + .../Actions/LoadLinesInfoAction.swift | 60 + .../Actions/SyncFavoritesActions.swift | 69 + .../Actions/ToggleFavoriteAction.swift | 21 + SampleApp/SampleApp/Underground/Station.swift | 21 + .../SampleApp/Underground/StationsState.swift | 1488 +++++++++++++++++ .../Underground/UndergroundParcel.swift | 35 + .../Underground/UndergroundState.swift | 18 + .../LineListViewController.swift | 105 ++ .../StationListViewController.swift | 118 ++ .../StationsDetailsViewController.swift | 183 ++ SampleApp/SampleApp/main.swift | 4 + 25 files changed, 2974 insertions(+), 1 deletion(-) create mode 100644 SampleApp/SampleApp.xcodeproj/project.pbxproj create mode 100644 SampleApp/SampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 SampleApp/SampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 SampleApp/SampleApp/Assets.xcassets/Contents.json create mode 100644 SampleApp/SampleApp/Base.lproj/LaunchScreen.storyboard create mode 100644 SampleApp/SampleApp/Info.plist create mode 100644 SampleApp/SampleApp/MainApp/AppCoordinator.swift create mode 100644 SampleApp/SampleApp/MainApp/AppDelegate.swift create mode 100644 SampleApp/SampleApp/MainApp/AppState.swift create mode 100644 SampleApp/SampleApp/MainApp/ForegroundDaemon.swift create mode 100644 SampleApp/SampleApp/MainApp/SFSafariViewController+PresentationItem.swift create mode 100644 SampleApp/SampleApp/MainApp/SetTabAction.swift create mode 100644 SampleApp/SampleApp/MainApp/TabController.swift create mode 100644 SampleApp/SampleApp/Underground/Actions/LoadLinesInfoAction.swift create mode 100644 SampleApp/SampleApp/Underground/Actions/SyncFavoritesActions.swift create mode 100644 SampleApp/SampleApp/Underground/Actions/ToggleFavoriteAction.swift create mode 100644 SampleApp/SampleApp/Underground/Station.swift create mode 100644 SampleApp/SampleApp/Underground/StationsState.swift create mode 100644 SampleApp/SampleApp/Underground/UndergroundParcel.swift create mode 100644 SampleApp/SampleApp/Underground/UndergroundState.swift create mode 100644 SampleApp/SampleApp/Underground/ViewControllers/LineListViewController.swift create mode 100644 SampleApp/SampleApp/Underground/ViewControllers/StationListViewController.swift create mode 100644 SampleApp/SampleApp/Underground/ViewControllers/StationsDetailsViewController.swift create mode 100644 SampleApp/SampleApp/main.swift diff --git a/.gitignore b/.gitignore index 0b835f4..3bbbf52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .build/ .swiftpm/ -*.xcodeproj .hg .svn diff --git a/SampleApp/SampleApp.xcodeproj/project.pbxproj b/SampleApp/SampleApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8229dde --- /dev/null +++ b/SampleApp/SampleApp.xcodeproj/project.pbxproj @@ -0,0 +1,455 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + F9B67C50243390F90060F96F /* SetTabAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C46243390F90060F96F /* SetTabAction.swift */; }; + F9B67C52243390F90060F96F /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C48243390F90060F96F /* AppState.swift */; }; + F9B67C53243390F90060F96F /* SFSafariViewController+PresentationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C49243390F90060F96F /* SFSafariViewController+PresentationItem.swift */; }; + F9B67C54243390F90060F96F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F9B67C4A243390F90060F96F /* Assets.xcassets */; }; + F9B67C55243390F90060F96F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4B243390F90060F96F /* AppDelegate.swift */; }; + F9B67C56243390F90060F96F /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4C243390F90060F96F /* main.swift */; }; + F9B67C57243390F90060F96F /* ForegroundDaemon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4D243390F90060F96F /* ForegroundDaemon.swift */; }; + F9B67C58243390F90060F96F /* TabController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4E243390F90060F96F /* TabController.swift */; }; + F9B67C59243390F90060F96F /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4F243390F90060F96F /* AppCoordinator.swift */; }; + F9B67C5C243391030060F96F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F9B67C5A243391030060F96F /* LaunchScreen.storyboard */; }; + F9B68C87243391820060F96F /* UndergroundState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C7B243391820060F96F /* UndergroundState.swift */; }; + F9B68C88243391820060F96F /* StationsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C7C243391820060F96F /* StationsState.swift */; }; + F9B68C89243391820060F96F /* Station.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C7D243391820060F96F /* Station.swift */; }; + F9B68C8A243391820060F96F /* StationListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C7F243391820060F96F /* StationListViewController.swift */; }; + F9B68C8B243391820060F96F /* StationsDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C80243391820060F96F /* StationsDetailsViewController.swift */; }; + F9B68C8C243391820060F96F /* LineListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C81243391820060F96F /* LineListViewController.swift */; }; + F9B68C8D243391820060F96F /* SyncFavoritesActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C83243391820060F96F /* SyncFavoritesActions.swift */; }; + F9B68C8E243391820060F96F /* ToggleFavoriteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C84243391820060F96F /* ToggleFavoriteAction.swift */; }; + F9B68C8F243391820060F96F /* LoadLinesInfoAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C85243391820060F96F /* LoadLinesInfoAction.swift */; }; + F9B68C90243391820060F96F /* UndergroundParcel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C86243391820060F96F /* UndergroundParcel.swift */; }; + F9B68C9D243393890060F96F /* Incremental in Frameworks */ = {isa = PBXBuildFile; productRef = F9B68C9C243393890060F96F /* Incremental */; }; + F9B68C9F243393890060F96F /* Resin in Frameworks */ = {isa = PBXBuildFile; productRef = F9B68C9E243393890060F96F /* Resin */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + F9B67C2F243390B80060F96F /* SampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F9B67C46243390F90060F96F /* SetTabAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetTabAction.swift; sourceTree = ""; }; + F9B67C47243390F90060F96F /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F9B67C48243390F90060F96F /* AppState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + F9B67C49243390F90060F96F /* SFSafariViewController+PresentationItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SFSafariViewController+PresentationItem.swift"; sourceTree = ""; }; + F9B67C4A243390F90060F96F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F9B67C4B243390F90060F96F /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + F9B67C4C243390F90060F96F /* main.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + F9B67C4D243390F90060F96F /* ForegroundDaemon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForegroundDaemon.swift; sourceTree = ""; }; + F9B67C4E243390F90060F96F /* TabController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabController.swift; sourceTree = ""; }; + F9B67C4F243390F90060F96F /* AppCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + F9B67C5B243391030060F96F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + F9B68C7B243391820060F96F /* UndergroundState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UndergroundState.swift; sourceTree = ""; }; + F9B68C7C243391820060F96F /* StationsState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StationsState.swift; sourceTree = ""; }; + F9B68C7D243391820060F96F /* Station.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Station.swift; sourceTree = ""; }; + F9B68C7F243391820060F96F /* StationListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StationListViewController.swift; sourceTree = ""; }; + F9B68C80243391820060F96F /* StationsDetailsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StationsDetailsViewController.swift; sourceTree = ""; }; + F9B68C81243391820060F96F /* LineListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LineListViewController.swift; sourceTree = ""; }; + F9B68C83243391820060F96F /* SyncFavoritesActions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncFavoritesActions.swift; sourceTree = ""; }; + F9B68C84243391820060F96F /* ToggleFavoriteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToggleFavoriteAction.swift; sourceTree = ""; }; + F9B68C85243391820060F96F /* LoadLinesInfoAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadLinesInfoAction.swift; sourceTree = ""; }; + F9B68C86243391820060F96F /* UndergroundParcel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UndergroundParcel.swift; sourceTree = ""; }; + F9B68C9A243393720060F96F /* incremental-simplified */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "incremental-simplified"; path = ..; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F9B67C2C243390B80060F96F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F9B68C9F243393890060F96F /* Resin in Frameworks */, + F9B68C9D243393890060F96F /* Incremental in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F9B67C26243390B80060F96F = { + isa = PBXGroup; + children = ( + F9B68C9A243393720060F96F /* incremental-simplified */, + F9B67C31243390B80060F96F /* SampleApp */, + F9B67C30243390B80060F96F /* Products */, + F9B68C9B243393890060F96F /* Frameworks */, + ); + sourceTree = ""; + }; + F9B67C30243390B80060F96F /* Products */ = { + isa = PBXGroup; + children = ( + F9B67C2F243390B80060F96F /* SampleApp.app */, + ); + name = Products; + sourceTree = ""; + }; + F9B67C31243390B80060F96F /* SampleApp */ = { + isa = PBXGroup; + children = ( + F9B68C7A243391820060F96F /* Underground */, + F9B68C79243391690060F96F /* MainApp */, + F9B67C5A243391030060F96F /* LaunchScreen.storyboard */, + F9B67C4A243390F90060F96F /* Assets.xcassets */, + F9B67C47243390F90060F96F /* Info.plist */, + F9B67C4C243390F90060F96F /* main.swift */, + ); + path = SampleApp; + sourceTree = ""; + }; + F9B68C79243391690060F96F /* MainApp */ = { + isa = PBXGroup; + children = ( + F9B67C4F243390F90060F96F /* AppCoordinator.swift */, + F9B67C4B243390F90060F96F /* AppDelegate.swift */, + F9B67C48243390F90060F96F /* AppState.swift */, + F9B67C4D243390F90060F96F /* ForegroundDaemon.swift */, + F9B67C46243390F90060F96F /* SetTabAction.swift */, + F9B67C49243390F90060F96F /* SFSafariViewController+PresentationItem.swift */, + F9B67C4E243390F90060F96F /* TabController.swift */, + ); + path = MainApp; + sourceTree = ""; + }; + F9B68C7A243391820060F96F /* Underground */ = { + isa = PBXGroup; + children = ( + F9B68C7B243391820060F96F /* UndergroundState.swift */, + F9B68C7C243391820060F96F /* StationsState.swift */, + F9B68C7D243391820060F96F /* Station.swift */, + F9B68C7E243391820060F96F /* ViewControllers */, + F9B68C82243391820060F96F /* Actions */, + F9B68C86243391820060F96F /* UndergroundParcel.swift */, + ); + path = Underground; + sourceTree = ""; + }; + F9B68C7E243391820060F96F /* ViewControllers */ = { + isa = PBXGroup; + children = ( + F9B68C7F243391820060F96F /* StationListViewController.swift */, + F9B68C80243391820060F96F /* StationsDetailsViewController.swift */, + F9B68C81243391820060F96F /* LineListViewController.swift */, + ); + path = ViewControllers; + sourceTree = ""; + }; + F9B68C82243391820060F96F /* Actions */ = { + isa = PBXGroup; + children = ( + F9B68C83243391820060F96F /* SyncFavoritesActions.swift */, + F9B68C84243391820060F96F /* ToggleFavoriteAction.swift */, + F9B68C85243391820060F96F /* LoadLinesInfoAction.swift */, + ); + path = Actions; + sourceTree = ""; + }; + F9B68C9B243393890060F96F /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F9B67C2E243390B80060F96F /* SampleApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = F9B67C43243390BB0060F96F /* Build configuration list for PBXNativeTarget "SampleApp" */; + buildPhases = ( + F9B67C2B243390B80060F96F /* Sources */, + F9B67C2C243390B80060F96F /* Frameworks */, + F9B67C2D243390B80060F96F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SampleApp; + packageProductDependencies = ( + F9B68C9C243393890060F96F /* Incremental */, + F9B68C9E243393890060F96F /* Resin */, + ); + productName = SampleApp; + productReference = F9B67C2F243390B80060F96F /* SampleApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F9B67C27243390B80060F96F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1130; + LastUpgradeCheck = 1130; + TargetAttributes = { + F9B67C2E243390B80060F96F = { + CreatedOnToolsVersion = 11.3.1; + LastSwiftMigration = 1130; + }; + }; + }; + buildConfigurationList = F9B67C2A243390B80060F96F /* Build configuration list for PBXProject "SampleApp" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F9B67C26243390B80060F96F; + packageReferences = ( + ); + productRefGroup = F9B67C30243390B80060F96F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F9B67C2E243390B80060F96F /* SampleApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F9B67C2D243390B80060F96F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F9B67C5C243391030060F96F /* LaunchScreen.storyboard in Resources */, + F9B67C54243390F90060F96F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F9B67C2B243390B80060F96F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F9B67C58243390F90060F96F /* TabController.swift in Sources */, + F9B68C8B243391820060F96F /* StationsDetailsViewController.swift in Sources */, + F9B68C90243391820060F96F /* UndergroundParcel.swift in Sources */, + F9B68C8C243391820060F96F /* LineListViewController.swift in Sources */, + F9B67C55243390F90060F96F /* AppDelegate.swift in Sources */, + F9B67C56243390F90060F96F /* main.swift in Sources */, + F9B68C8E243391820060F96F /* ToggleFavoriteAction.swift in Sources */, + F9B67C50243390F90060F96F /* SetTabAction.swift in Sources */, + F9B68C88243391820060F96F /* StationsState.swift in Sources */, + F9B67C59243390F90060F96F /* AppCoordinator.swift in Sources */, + F9B67C53243390F90060F96F /* SFSafariViewController+PresentationItem.swift in Sources */, + F9B68C87243391820060F96F /* UndergroundState.swift in Sources */, + F9B68C89243391820060F96F /* Station.swift in Sources */, + F9B67C57243390F90060F96F /* ForegroundDaemon.swift in Sources */, + F9B67C52243390F90060F96F /* AppState.swift in Sources */, + F9B68C8F243391820060F96F /* LoadLinesInfoAction.swift in Sources */, + F9B68C8A243391820060F96F /* StationListViewController.swift in Sources */, + F9B68C8D243391820060F96F /* SyncFavoritesActions.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + F9B67C5A243391030060F96F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + F9B67C5B243391030060F96F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + F9B67C41243390BB0060F96F /* 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++14"; + 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_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 = 13.2; + 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; + }; + F9B67C42243390BB0060F96F /* 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++14"; + 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_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 = 13.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F9B67C44243390BB0060F96F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = SampleApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.resin.SampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F9B67C45243390BB0060F96F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = SampleApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.resin.SampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F9B67C2A243390B80060F96F /* Build configuration list for PBXProject "SampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F9B67C41243390BB0060F96F /* Debug */, + F9B67C42243390BB0060F96F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F9B67C43243390BB0060F96F /* Build configuration list for PBXNativeTarget "SampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F9B67C44243390BB0060F96F /* Debug */, + F9B67C45243390BB0060F96F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + F9B68C9C243393890060F96F /* Incremental */ = { + isa = XCSwiftPackageProductDependency; + productName = Incremental; + }; + F9B68C9E243393890060F96F /* Resin */ = { + isa = XCSwiftPackageProductDependency; + productName = Resin; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = F9B67C27243390B80060F96F /* Project object */; +} diff --git a/SampleApp/SampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SampleApp/SampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/SampleApp/SampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SampleApp/SampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/SampleApp/SampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/SampleApp/SampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SampleApp/SampleApp/Assets.xcassets/Contents.json b/SampleApp/SampleApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/SampleApp/SampleApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SampleApp/SampleApp/Base.lproj/LaunchScreen.storyboard b/SampleApp/SampleApp/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/SampleApp/SampleApp/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SampleApp/SampleApp/Info.plist b/SampleApp/SampleApp/Info.plist new file mode 100644 index 0000000..5a63475 --- /dev/null +++ b/SampleApp/SampleApp/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/SampleApp/SampleApp/MainApp/AppCoordinator.swift b/SampleApp/SampleApp/MainApp/AppCoordinator.swift new file mode 100644 index 0000000..091e41b --- /dev/null +++ b/SampleApp/SampleApp/MainApp/AppCoordinator.swift @@ -0,0 +1,67 @@ +import Resin +import SafariServices +import UIKit + +typealias AppEnvironment = ( + userDefaults: UserDefaults, + loadStations: (@escaping (Result) -> Void) -> Void +) + +public final class AppCoordinator { + let presentationRouter: PresentationRouter + + let tabBarController: TabBarController + + let store: Store + + public init() { + let store = RootStore(state: AppState()) + + let environment: AppEnvironment = ( + userDefaults: .standard, + loadStations: dummyLoadStations + ) + + self.presentationRouter = PresentationRouter(store: store) + + let parcels: [Parcel] = [ + mapAppStateParcel(), + makeUndergroundParcel(environment: environment).unfocused(by: \AppState.undergroundState), + ] + + for p in parcels { + let delivery = p.build(store) + store.integrate(delivery: delivery) + presentationRouter.register(delivery: delivery) + } + + store.addImplicitNavigationMiddleware() + store.addPresentationReducers() + + self.store = store + + tabBarController = presentationRouter.makeViewController(identifier: TabBarController.PresentationIdentifier().typeErased) as! TabBarController + tabBarController.presentationRouter = presentationRouter.typeErased + tabBarController.actionHandler = store + } +} + +func mapAppStateParcel() -> Parcel { + Parcel { store in + var delivery = Parcel.Delivery() + + delivery.middlewares = [ + ForegroundDaemon(state: store.state.map { _ in ArbitraryState() }).unfocused(), + ] + delivery.reducers = [ + SetTabReducer().typeErased, + ] + + delivery.presentationRoutes = [ + SFSafariViewController.presentationRoute.typeErased.unfocused(), + TabBarController.presentationRoute.typeErased, + ] + + return delivery + } +} diff --git a/SampleApp/SampleApp/MainApp/AppDelegate.swift b/SampleApp/SampleApp/MainApp/AppDelegate.swift new file mode 100644 index 0000000..3760289 --- /dev/null +++ b/SampleApp/SampleApp/MainApp/AppDelegate.swift @@ -0,0 +1,22 @@ +import Foundation +import UIKit + +public final class AppDelegate: NSObject { + @objc public var window: UIWindow? + + let appCoordinator = AppCoordinator() + + public override init() { + super.init() + } +} + +extension AppDelegate: UIApplicationDelegate { + public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + window = UIWindow(frame: UIScreen.main.bounds) + window!.rootViewController = appCoordinator.tabBarController + window!.makeKeyAndVisible() + + return true + } +} diff --git a/SampleApp/SampleApp/MainApp/AppState.swift b/SampleApp/SampleApp/MainApp/AppState.swift new file mode 100644 index 0000000..8d5cbea --- /dev/null +++ b/SampleApp/SampleApp/MainApp/AppState.swift @@ -0,0 +1,19 @@ +import Foundation +import Resin + +public struct AppState: Equatable { + enum Tab: Int, Equatable { + case underground = 0 + case ticket = 1 + } + + var counter: Int = 0 + + var undergroundState: UndergroundState = UndergroundState() + + var modalStack: [AnyPresentationIdentifier] = [] + + var selectedTab: Tab = .underground +} + + diff --git a/SampleApp/SampleApp/MainApp/ForegroundDaemon.swift b/SampleApp/SampleApp/MainApp/ForegroundDaemon.swift new file mode 100644 index 0000000..cc0cfc4 --- /dev/null +++ b/SampleApp/SampleApp/MainApp/ForegroundDaemon.swift @@ -0,0 +1,22 @@ +import Foundation +import Incremental +import Resin +import UIKit + +final class ForegroundDaemon: Daemon { + public override func observe(state: I) -> Disposable? { + NotificationCenter.default.addObserver( + self, + selector: #selector(observe(note:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + + return nil + } + + @objc + func observe(note: Notification) { + dispatch(LoadStationsAction()) + } +} diff --git a/SampleApp/SampleApp/MainApp/SFSafariViewController+PresentationItem.swift b/SampleApp/SampleApp/MainApp/SFSafariViewController+PresentationItem.swift new file mode 100644 index 0000000..5570828 --- /dev/null +++ b/SampleApp/SampleApp/MainApp/SFSafariViewController+PresentationItem.swift @@ -0,0 +1,18 @@ +import Resin +import SafariServices + +extension SFSafariViewController { + public struct PresentationIdentifier: Resin.PresentationIdentifier { + public var url: URL + + public init(url: URL) { + self.url = url + } + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { identifier, _ in + SFSafariViewController(url: identifier.url) + } + } +} diff --git a/SampleApp/SampleApp/MainApp/SetTabAction.swift b/SampleApp/SampleApp/MainApp/SetTabAction.swift new file mode 100644 index 0000000..75a5e35 --- /dev/null +++ b/SampleApp/SampleApp/MainApp/SetTabAction.swift @@ -0,0 +1,14 @@ +import Foundation +import Resin + +struct SetTabAction: Action { + var tab: AppState.Tab +} + +struct SetTabReducer: Reducer { + func reduce(action: SetTabAction, state: inout AppState) { + state.selectedTab = action.tab + } +} + + diff --git a/SampleApp/SampleApp/MainApp/TabController.swift b/SampleApp/SampleApp/MainApp/TabController.swift new file mode 100644 index 0000000..716303a --- /dev/null +++ b/SampleApp/SampleApp/MainApp/TabController.swift @@ -0,0 +1,56 @@ +import Foundation +import Incremental +import Resin +import UIKit + +class TabBarController: UITabBarController, UITabBarControllerDelegate { + struct NavigationIdentifier: Resin.PresentationIdentifier {} + + private var viewControllerCache: ViewControllerCache! + + public init(tabs: I<[AnyPresentationIdentifier]>) { + super.init(nibName: nil, bundle: nil) + + delegate = self + + viewControllerCache = ViewControllerCache(owner: self) + + whileVisible.do { + $0.bind(keyPath: \.viewControllers, to: tabs.map(self.viewControllerCache.createViewControllersIfNeeded)) + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + let tab = AppState.Tab(rawValue: selectedIndex) ?? .underground + dispatch(SetTabAction(tab: tab)) + } +} + +extension TabBarController { + struct PresentationIdentifier: Resin.PresentationIdentifier { + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { _, store in + let tabs: [AnyPresentationIdentifier] = [ + StationListViewController.NavigationPresentationIdentifier().typeErased, + ] + + let controller = TabBarController(tabs: I(constant: tabs)) + + controller.whileVisible.do { controller in + controller.bind(keyPath: \.selectedIndex, to: store.state[\.selectedTab.rawValue]) + } + + controller.whileAbleToPresent.do { controller in + controller.bindPresentedViewControllers(store: store, navigationStack: \.modalStack) + } + + return controller + } + } +} diff --git a/SampleApp/SampleApp/Underground/Actions/LoadLinesInfoAction.swift b/SampleApp/SampleApp/Underground/Actions/LoadLinesInfoAction.swift new file mode 100644 index 0000000..18be639 --- /dev/null +++ b/SampleApp/SampleApp/Underground/Actions/LoadLinesInfoAction.swift @@ -0,0 +1,60 @@ +import Combine +import Foundation +import Resin + +public struct LoadStationsAction: Action { +} + +final class LoadStationsMiddleware: Middleware { + var loadStations: (@escaping (Result) -> Void) -> Void + + init(loadStations: @escaping (@escaping (Result) -> Void) -> Void) { + self.loadStations = loadStations + } + + override func transform(action: LoadStationsAction, context: ActionContext, state: UndergroundState) -> Resin.Action? { + self.loadStations { [weak self] result in + self?.dispatch(SetStationsAction.done(result)) + } + + return SetStationsAction.loading + } +} + +enum SetStationsAction: Action { + case none + case loading + case done(Result) +} + +struct SetStationsReducer: Reducer { + func reduce(action: SetStationsAction, state: inout UndergroundState) { + switch action { + case .none: + state.stationStateRequest = .none + + case .loading: + state.stationStateRequest = .loading + + case let .done(result): + switch result { + case let .success(stations): + state.stationStateRequest = .success + state.stationsState = stations + + case .failure: + state.stationStateRequest = .failure + // leave the loast sucessfully loaded stations visible + } + } + } +} + + +func dummyLoadStations(completionHandler: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + 2) { + completionHandler(.success(StationsState.dummyData)) + } +} + + diff --git a/SampleApp/SampleApp/Underground/Actions/SyncFavoritesActions.swift b/SampleApp/SampleApp/Underground/Actions/SyncFavoritesActions.swift new file mode 100644 index 0000000..59d0366 --- /dev/null +++ b/SampleApp/SampleApp/Underground/Actions/SyncFavoritesActions.swift @@ -0,0 +1,69 @@ +import Foundation +import Incremental +import Resin + +enum SyncFavoritesAction: Action { + case fromUserDefaults(Set) + case toUserDefaults(Set) +} + +class SyncFavoritesMiddleware: Middleware { + private let userDefaults: UserDefaults + + private var observer: NSKeyValueObservation? + + private var disposable: Disposable? + + required public init(favoritesStations: I>, userDefaults: UserDefaults) { + self.userDefaults = userDefaults + + super.init() + + observer = userDefaults.observe(\.favorites) { [unowned self] _, changes in + if changes.newValue != changes.oldValue { + self.dispatch(SyncFavoritesAction.fromUserDefaults(changes.newValue ?? Set())) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [unowned self] in + self.dispatch(SyncFavoritesAction.fromUserDefaults(self.userDefaults.favorites)) + + self.disposable = favoritesStations.observe { [unowned self] favorites in + self.dispatch(SyncFavoritesAction.toUserDefaults(favorites)) + } + } + } + + override func transform(action: SyncFavoritesAction, context: ActionContext, state: UndergroundState) -> Resin.Action? { + guard case let .toUserDefaults(favorites) = action else { + return action + } + + userDefaults.favorites = favorites + return nil + } +} + +struct SyncFavoritesReducer: Reducer { + public func reduce(action: SyncFavoritesAction, state: inout UndergroundState) { + guard case let .fromUserDefaults(favorites) = action else { + return + } + + state.favoriteStations = favorites + } +} + + +extension UserDefaults { + @objc var favorites: Set { + get { + let array = object(forKey: "favorites") as? [String] ?? [] + + return Set(array) + } + set { + set(Array(newValue), forKey: "favorites") + } + } +} diff --git a/SampleApp/SampleApp/Underground/Actions/ToggleFavoriteAction.swift b/SampleApp/SampleApp/Underground/Actions/ToggleFavoriteAction.swift new file mode 100644 index 0000000..91f5092 --- /dev/null +++ b/SampleApp/SampleApp/Underground/Actions/ToggleFavoriteAction.swift @@ -0,0 +1,21 @@ +import Foundation +import Incremental +import Resin + +struct ToggleFavoriteAction: Action { + var station: String + + public init(station: String) { + self.station = station + } +} + +struct ToggleFavoriteReducer: Reducer { + public func reduce(action: ToggleFavoriteAction, state: inout UndergroundState) { + if state.favoriteStations.contains(action.station) { + state.favoriteStations.remove(action.station) + } else { + state.favoriteStations.insert(action.station) + } + } +} diff --git a/SampleApp/SampleApp/Underground/Station.swift b/SampleApp/SampleApp/Underground/Station.swift new file mode 100644 index 0000000..bc3d0c7 --- /dev/null +++ b/SampleApp/SampleApp/Underground/Station.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct Station: Equatable { + var lines: [String] + + var name: String + + var zones: [Int] + + public init(name: String, lines: [String], zones: [Int]) { + self.name = name + self.lines = lines + self.zones = zones + } +} + +extension Station: Hashable { + public func hash(into hasher: inout Hasher) { + name.hash(into: &hasher) + } +} diff --git a/SampleApp/SampleApp/Underground/StationsState.swift b/SampleApp/SampleApp/Underground/StationsState.swift new file mode 100644 index 0000000..7f69c6d --- /dev/null +++ b/SampleApp/SampleApp/Underground/StationsState.swift @@ -0,0 +1,1488 @@ +import Foundation + +public struct StationsState: Equatable { + var stations: [Station] = [] + var lines: [String: [String]] = [:] // line name -> [Station names] +} + + +extension StationsState { + func find(byName name: String) -> Station? { + return stations.first { $0.name == name } + } + + func find(byLine line: String) -> Station? { + return stations.first { $0.lines.contains(line) } + } + + func find(inZone zone: Int) -> Station? { + return stations.first { $0.zones.contains(zone) } + } +} + +extension StationsState { + static var dummyData: StationsState { + StationsState(stations: allStations, lines: stationsByLine) + } +} + +private let stationsByLine = [ + "Bakerloo": [ + "Harrow & Wealdstone", + "Kenton", + "South Kenton", + "North Wembley", + "Wembley Central", + "Stonebridge Park", + "Harlesden", + "Willesden Junction", + "Kensal Green", + "Queen's Park", + "Kilburn Park", + "Maida Vale", + "Warwick Avenue", + "Paddington", + "Edgware Road", + "Marylebone", + "Baker Street", + "Regent's Park", + "Oxford Circus", + "Piccadilly Circus", + "Charing Cross", + "Embankment", + "Waterloo", + "Lambeth North", + "Elephant & Castle" + ], + "Central": [ + "West Ruislip", + "Ruislip Gardens", + "South Ruislip", + "Northolt", + "Greenford", + "Perivale", + "Hanger Lane", + "Ealing Broadway", + "West Acton", + "North Acton", + "East Acton", + "White City", + "Shepherd's Bush", + "Holland Park", + "Notting Hill Gate", + "Queensway", + "Lancaster Gate", + "Marble Arch", + "Bond Street", + "Oxford Circus", + "Tottenham Court Road", + "Holborn", + "Chancery Lane", + "St. Paul's", + "Bank", + "Liverpool Street", + "Bethnal Green", + "Mile End", + "Stratford", + "Leyton", + "Leytonstone", + "Wanstead", + "Redbridge", + "Gants Hill", + "Newbury Park", + "Barkingside", + "Fairlop", + "Hainault", + "Grange Hill", + "Chigwell", + "Roding Valley", + "Snaresbrook", + "South Woodford", + "Woodford", + "Buckhurst Hill", + "Loughton", + "Debden", + "Theydon Bois", + "Epping" + ], + "Hammersmith & City": [ + "Hammersmith", + "Goldhawk Road", + "Shepherd's Bush Market", + "Wood Lane", + "Latimer Road", + "Ladbroke Grove", + "Westbourne Park", + "Royal Oak", + "Paddington", + "Edgware Road", + "Baker Street", + "Great Portland Street", + "Euston Square", + "King's Cross St Pancras", + "Farringdon", + "Barbican", + "Moorgate", + "Liverpool Street", + "Aldgate East", + "Whitechapel", + "Stepney Green", + "Mile End", + "Bow Road", + "Bromley-by-Bow", + "West Ham", + "Plaistow", + "Upton Park", + "East Ham", + "Barking" + ] +] + + +private let allStations = [ + Station( + name: "Acton Town", + lines: ["District", "Piccadilly"], + zones: [3] + ), + Station( + name: "Aldgate", + lines: ["Metropolitan", "Circle"], + zones: [1] + ), + Station( + name: "Aldgate East", + lines: ["Hammersmith & City", "District"], + zones: [1] + ), + Station( + name: "Alperton", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Amersham", + lines: ["Metropolitan"], + zones: [9] + ), + Station( + name: "Angel", + lines: ["Northern"], + zones: [1] + ), + Station( + name: "Archway", + lines: ["Northern"], + zones: [2, 3] + ), + Station( + name: "Arnos Grove", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Arsenal", + lines: ["Piccadilly"], + zones: [2] + ), + Station( + name: "Baker Street", + lines: ["Metropolitan", "Bakerloo", "Circle", "Jubilee", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Balham", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Bank", + lines: ["Waterloo & City", "Northern", "Central"], + zones: [1] + ), + Station( + name: "Barbican", + lines: ["Metropolitan", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Barking", + lines: ["District", "Hammersmith & City"], + zones: [4] + ), + Station( + name: "Barkingside", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Barons Court", + lines: ["District", "Piccadilly"], + zones: [2] + ), + Station( + name: "Bayswater", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Becontree", + lines: ["District"], + zones: [5] + ), + Station( + name: "Belsize Park", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Bermondsey", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Bethnal Green", + lines: ["Central"], + zones: [2] + ), + Station( + name: "Blackfriars", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Blackhorse Road", + lines: ["Victoria"], + zones: [3] + ), + Station( + name: "Bond Street", + lines: ["Central", "Jubilee"], + zones: [1] + ), + Station( + name: "Borough", + lines: ["Northern"], + zones: [1] + ), + Station( + name: "Boston Manor", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Bounds Green", + lines: ["Piccadilly"], + zones: [3, 4] + ), + Station( + name: "Bow Road", + lines: ["District", "Hammersmith & City"], + zones: [2] + ), + Station( + name: "Brent Cross", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Brixton", + lines: ["Victoria"], + zones: [2] + ), + Station( + name: "Bromley-by-Bow", + lines: ["District", "Hammersmith & City"], + zones: [2, 3] + ), + Station( + name: "Buckhurst Hill", + lines: ["Central"], + zones: [5] + ), + Station( + name: "Burnt Oak", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Caledonian Road", + lines: ["Piccadilly"], + zones: [2] + ), + Station( + name: "Camden Town", + lines: ["Northern"], + zones: [2] + ), + + Station( + name: "Canada Water", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Canary Wharf", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Canning Town", + lines: ["Jubilee"], + zones: [3] + ), + Station( + name: "Cannon Street", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Canons Park", + lines: ["Jubilee"], + zones: [5] + ), + Station( + name: "Chalfont & Latimer", + lines: ["Metropolitan"], + zones: [8] + ), + Station( + name: "Chalk Farm", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Chancery Lane", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Charing Cross", + lines: ["Bakerloo", "Northern"], + zones: [1] + ), + Station( + name: "Chesham", + lines: ["Metropolitan"], + zones: [9] + ), + Station( + name: "Chigwell", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Chiswick Park", + lines: ["District"], + zones: [3] + ), + Station( + name: "Chorleywood", + lines: ["Metropolitan"], + zones: [7] + ), + Station( + name: "Clapham Common", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Clapham North", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Clapham South", + lines: ["Northern"], + zones: [2, 3] + ), + Station( + name: "Cockfosters", + lines: ["Piccadilly"], + zones: [5] + ), + Station( + name: "Colindale", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Colliers Wood", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Covent Garden", + lines: ["Piccadilly"], + zones: [1] + ), + Station( + name: "Croxley", + lines: ["Metropolitan"], + zones: [7] + ), + Station( + name: "Dagenham East", + lines: ["District"], + zones: [5] + ), + Station( + name: "Dagenham Heathway", + lines: ["District"], + zones: [5] + ), + Station( + name: "Debden", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Dollis Hill", + lines: ["Jubilee"], + zones: [3] + ), + Station( + name: "Ealing Broadway", + lines: ["District", "Central"], + zones: [3] + ), + Station( + name: "Ealing Common", + lines: ["District", "Piccadilly"], + zones: [3] + ), + Station( + name: "Earl's Court", + lines: ["District", "Piccadilly"], + zones: [1, 2] + ), + Station( + name: "East Acton", + lines: ["Central"], + zones: [2] + ), + Station( + name: "East Finchley", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "East Ham", + lines: ["District", "Hammersmith & City"], + zones: [3, 4] + ), + Station( + name: "East Putney", + lines: ["District"], + zones: [2, 3] + ), + Station( + name: "Eastcote", + lines: ["Metropolitan", "Piccadilly"], + zones: [5] + ), + Station( + name: "Edgware", + lines: ["Northern"], + zones: [5] + ), + Station( + name: "Edgware Road", + lines: ["Bakerloo"], + zones: [1] + ), + Station( + name: "Edgware Road", + lines: ["Hammersmith & City", "District", "Circle"], + zones: [1] + ), + Station( + name: "Elephant & Castle", + lines: ["Northern", "Bakerloo"], + zones: [1, 2] + ), + Station( + name: "Elm Park", + lines: ["District"], + zones: [6] + ), + Station( + name: "Embankment", + lines: ["District", "Bakerloo", "Northern", "Circle"], + zones: [1] + ), + Station( + name: "Epping", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Euston", + lines: ["Northern", "Victoria"], + zones: [1] + ), + Station( + name: "Euston Square", + lines: ["Metropolitan", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Fairlop", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Farringdon", + lines: ["Metropolitan", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Finchley Central", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Finchley Road", + lines: ["Metropolitan", "Jubilee"], + zones: [2] + ), + Station( + name: "Finsbury Park", + lines: ["Piccadilly", "Victoria"], + zones: [2] + ), + Station( + name: "Fulham Broadway", + lines: ["District"], + zones: [2] + ), + Station( + name: "Gants Hill", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Gloucester Road", + lines: ["District", "Piccadilly", "Circle"], + zones: [1] + ), + Station( + name: "Golders Green", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Goldhawk Road", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Goodge Street", + lines: ["Northern"], + zones: [1] + ), + Station( + name: "Grange Hill", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Great Portland Street", + lines: ["Metropolitan", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Greenford", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Green Park", + lines: ["Piccadilly", "Victoria", "Jubilee"], + zones: [1] + ), + Station( + name: "Gunnersbury", + lines: ["District"], + zones: [3] + ), + Station( + name: "Hainault", + lines: ["Central"], + zones: [4] + ), + /// These are technically two separate stations of the same name. + /// + /// - SeeAlso: https://en.wikipedia.org/wiki/Hammersmith_tube_station_(Hammersmith_%26_City_and_Circle_lines) + /// - SeeAlso: https://en.wikipedia.org/wiki/Hammersmith_tube_station_(Piccadilly_and_District_lines) + Station( + name: "Hammersmith", + lines: ["Hammersmith & City", "Circle"] + ["District", "Piccadilly"], + zones: [2] + ), + Station( + name: "Hampstead", + lines: ["Northern"], + zones: [2, 3] + ), + Station( + name: "Hanger Lane", + lines: ["Central"], + zones: [3] + ), + Station( + name: "Harlesden", + lines: ["Bakerloo"], + zones: [3] + ), + Station( + name: "Harrow & Wealdstone", + lines: ["Bakerloo"], + zones: [5] + ), + Station( + name: "Harrow-on-the-Hill", + lines: ["Metropolitan"], + zones: [5] + ), + Station( + name: "Hatton Cross", + lines: ["Piccadilly"], + zones: [5, 6] + ), + Station( + name: "Heathrow Terminals 1, 2, 3", + lines: ["Piccadilly"], + zones: [6] + ), + Station( + name: "Heathrow Terminal 4", + lines: ["Piccadilly"], + zones: [6] + ), + Station( + name: "Heathrow Terminal 5", + lines: ["Piccadilly"], + zones: [6] + ), + Station( + name: "Hendon Central", + lines: ["Northern"], + zones: [3, 4] + ), + Station( + name: "High Barnet", + lines: ["Northern"], + zones: [5] + ), + Station( + name: "Highbury & Islington", + lines: ["Victoria"], + zones: [2] + ), + Station( + name: "Highgate", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "High Street Kensington", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Hillingdon", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Holborn", + lines: ["Central", "Piccadilly"], + zones: [1] + ), + Station( + name: "Holland Park", + lines: ["Central"], + zones: [2] + ), + Station( + name: "Holloway Road", + lines: ["Piccadilly"], + zones: [2] + ), + Station( + name: "Hornchurch", + lines: ["District"], + zones: [6] + ), + Station( + name: "Hounslow Central", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Hounslow East", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Hounslow West", + lines: ["Piccadilly"], + zones: [5] + ), + Station( + name: "Hyde Park Corner", + lines: ["Piccadilly"], + zones: [1] + ), + Station( + name: "Ickenham", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Kennington", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Kensal Green", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Kensington (Olympia)", + lines: ["District"], + zones: [2] + ), + Station( + name: "Kentish Town", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Kenton", + lines: ["Bakerloo"], + zones: [4] + ), + Station( + name: "Kew Gardens", + lines: ["District"], + zones: [3, 4] + ), + Station( + name: "Kilburn", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Kilburn Park", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Kingsbury", + lines: ["Jubilee"], + zones: [4] + ), + Station( + name: "King's Cross St. Pancras", + lines: ["Metropolitan", "Northern", "Piccadilly", "Circle", "Victoria", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Knightsbridge", + lines: ["Piccadilly"], + zones: [1] + ), + Station( + name: "Ladbroke Grove", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Lambeth North", + lines: ["Bakerloo"], + zones: [1] + ), + Station( + name: "Lancaster Gate", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Latimer Road", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Leicester Square", + lines: ["Piccadilly", "Northern"], + zones: [1] + ), + Station( + name: "Leyton", + lines: ["Central"], + zones: [3] + ), + Station( + name: "Leytonstone", + lines: ["Central"], + zones: [3, 4] + ), + Station( + name: "Liverpool Street", + lines: ["Metropolitan", "Central", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "London Bridge", + lines: ["Northern", "Jubilee"], + zones: [1] + ), + Station( + name: "Loughton", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Maida Vale", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Manor House", + lines: ["Piccadilly"], + zones: [2, 3] + ), + Station( + name: "Mansion House", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Marble Arch", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Marylebone", + lines: ["Bakerloo"], + zones: [1] + ), + Station( + name: "Mile End", + lines: ["District", "Hammersmith & City", "Central"], + zones: [2] + ), + Station( + name: "Mill Hill East", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Monument", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Moorgate", + lines: ["Metropolitan", "Northern", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Moor Park", + lines: ["Metropolitan"], + zones: [6, 7] + ), + Station( + name: "Morden", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Mornington Crescent", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Neasden", + lines: ["Jubilee"], + zones: [3] + ), + Station( + name: "Newbury Park", + lines: ["Central"], + zones: [4] + ), + Station( + name: "North Acton", + lines: ["Central"], + zones: [2, 3] + ), + Station( + name: "North Ealing", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "North Greenwich", + lines: ["Jubilee"], + zones: [2, 3] + ), + Station( + name: "North Harrow", + lines: ["Metropolitan"], + zones: [5] + ), + Station( + name: "North Wembley", + lines: ["Bakerloo"], + zones: [4] + ), + Station( + name: "Northfields", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "Northolt", + lines: ["Central"], + zones: [5] + ), + Station( + name: "Northwick Park", + lines: ["Metropolitan"], + zones: [4] + ), + Station( + name: "Northwood", + lines: ["Metropolitan"], + zones: [6] + ), + Station( + name: "Northwood Hills", + lines: ["Metropolitan"], + zones: [6] + ), + Station( + name: "Notting Hill Gate", + lines: ["District", "Central", "Circle"], + zones: [1, 2] + ), + Station( + name: "Oakwood", + lines: ["Piccadilly"], + zones: [5] + ), + Station( + name: "Old Street", + lines: ["Northern"], + zones: [1] + ), + Station( + name: "Osterley", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Oval", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Oxford Circus", + lines: ["Central", "Bakerloo", "Victoria"], + zones: [1] + ), + Station( + name: "Paddington", + lines: ["Hammersmith & City", "District", "Circle", "Bakerloo"], + zones: [1] + ), + Station( + name: "Park Royal", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "Parsons Green", + lines: ["District"], + zones: [2] + ), + Station( + name: "Perivale", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Piccadilly Circus", + lines: ["Bakerloo", "Piccadilly"], + zones: [1] + ), + Station( + name: "Pimlico", + lines: ["Victoria"], + zones: [1] + ), + Station( + name: "Pinner", + lines: ["Metropolitan"], + zones: [5] + ), + Station( + name: "Plaistow", + lines: ["District", "Hammersmith & City"], + zones: [3] + ), + Station( + name: "Preston Road", + lines: ["Metropolitan"], + zones: [4] + ), + Station( + name: "Putney Bridge", + lines: ["District"], + zones: [2] + ), + Station( + name: "Queen's Park", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Queensbury", + lines: ["Jubilee"], + zones: [4] + ), + Station( + name: "Queensway", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Ravenscourt Park", + lines: ["District"], + zones: [2] + ), + Station( + name: "Rayners Lane", + lines: ["Metropolitan", "Piccadilly"], + zones: [5] + ), + Station( + name: "Redbridge", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Regent's Park", + lines: ["Bakerloo"], + zones: [1] + ), + Station( + name: "Richmond", + lines: ["District"], + zones: [4] + ), + Station( + name: "Rickmansworth", + lines: ["Metropolitan"], + zones: [7] + ), + Station( + name: "Roding Valley", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Royal Oak", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Ruislip", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Ruislip Gardens", + lines: ["Central"], + zones: [5] + ), + Station( + name: "Ruislip Manor", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Russell Square", + lines: ["Piccadilly"], + zones: [1] + ), + Station( + name: "St. James's Park", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "St. John's Wood", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "St. Paul's", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Seven Sisters", + lines: ["Victoria"], + zones: [3] + ), + Station( + name: "Shepherd's Bush", + lines: ["Central"], + zones: [2] + ), + Station( + name: "Shepherd's Bush Market", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Sloane Square", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Snaresbrook", + lines: ["Central"], + zones: [4] + ), + Station( + name: "South Ealing", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "South Harrow", + lines: ["Piccadilly"], + zones: [5] + ), + Station( + name: "South Kensington", + lines: ["District", "Piccadilly", "Circle"], + zones: [1] + ), + Station( + name: "South Kenton", + lines: ["Bakerloo"], + zones: [4] + ), + Station( + name: "South Ruislip", + lines: ["Central"], + zones: [5] + ), + Station( + name: "South Wimbledon", + lines: ["Northern"], + zones: [3, 4] + ), + Station( + name: "South Woodford", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Southfields", + lines: ["District"], + zones: [3] + ), + Station( + name: "Southgate", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Southwark", + lines: ["Jubilee"], + zones: [1] + ), + Station( + name: "Stamford Brook", + lines: ["District"], + zones: [2] + ), + Station( + name: "Stanmore", + lines: ["Jubilee"], + zones: [5] + ), + Station( + name: "Stepney Green", + lines: ["District", "Hammersmith & City"], + zones: [2] + ), + Station( + name: "Stockwell", + lines: ["Northern", "Victoria"], + zones: [2] + ), + Station( + name: "Stonebridge Park", + lines: ["Bakerloo"], + zones: [3] + ), + Station( + name: "Stratford", + lines: ["Central", "Jubilee"], + zones: [3] + ), + Station( + name: "Sudbury Hill", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Sudbury Town", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Swiss Cottage", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Temple", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Theydon Bois", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Tooting Bec", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Tooting Broadway", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Tottenham Court Road", + lines: ["Central", "Northern"], + zones: [1] + ), + Station( + name: "Tottenham Hale", + lines: ["Victoria"], + zones: [3] + ), + Station( + name: "Totteridge & Whetstone", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Tower Hill", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Tufnell Park", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Turnham Green", + lines: ["District", "Piccadilly"], + zones: [2, 3] + ), + Station( + name: "Turnpike Lane", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "Upminster", + lines: ["District"], + zones: [6] + ), + Station( + name: "Upminster Bridge", + lines: ["District"], + zones: [6] + ), + Station( + name: "Upney", + lines: ["District"], + zones: [4] + ), + Station( + name: "Upton Park", + lines: ["District", "Hammersmith & City"], + zones: [3] + ), + Station( + name: "Uxbridge", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Vauxhall", + lines: ["Victoria"], + zones: [1, 2] + ), + Station( + name: "Victoria", + lines: ["District", "Circle", "Victoria"], + zones: [1] + ), + Station( + name: "Walthamstow Central", + lines: ["Victoria"], + zones: [3] + ), + Station( + name: "Wanstead", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Warren Street", + lines: ["Northern", "Victoria"], + zones: [1] + ), + Station( + name: "Warwick Avenue", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Waterloo", + lines: ["Waterloo & City", "Bakerloo", "Northern", "Jubilee"], + zones: [1] + ), + Station( + name: "Watford", + lines: ["Metropolitan"], + zones: [7] + ), + Station( + name: "Wembley Central", + lines: ["Bakerloo"], + zones: [4] + ), + Station( + name: "Wembley Park", + lines: ["Metropolitan", "Jubilee"], + zones: [4] + ), + Station( + name: "West Acton", + lines: ["Central"], + zones: [3] + ), + Station( + name: "West Brompton", + lines: ["District"], + zones: [2] + ), + Station( + name: "West Finchley", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "West Ham", + lines: ["District", "Hammersmith & City", "Jubilee"], + zones: [3] + ), + Station( + name: "West Hampstead", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "West Harrow", + lines: ["Metropolitan"], + zones: [5] + ), + Station( + name: "West Kensington", + lines: ["District"], + zones: [2] + ), + Station( + name: "West Ruislip", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Westbourne Park", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Westminster", + lines: ["District", "Circle", "Jubilee"], + zones: [1] + ), + Station( + name: "White City", + lines: ["Central"], + zones: [2] + ), + Station( + name: "Whitechapel", + lines: ["District", "Hammersmith & City"], + zones: [2] + ), + Station( + name: "Willesden Green", + lines: ["Jubilee"], + zones: [2, 3] + ), + Station( + name: "Willesden Junction", + lines: ["Bakerloo"], + zones: [2, 3] + ), + Station( + name: "Wimbledon", + lines: ["District"], + zones: [3] + ), + Station( + name: "Wimbledon Park", + lines: ["District"], + zones: [3] + ), + Station( + name: "Wood Green", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "Wood Lane", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Woodford", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Woodside Park", + lines: ["Northern"], + zones: [4] + ) +] diff --git a/SampleApp/SampleApp/Underground/UndergroundParcel.swift b/SampleApp/SampleApp/Underground/UndergroundParcel.swift new file mode 100644 index 0000000..2be8a9c --- /dev/null +++ b/SampleApp/SampleApp/Underground/UndergroundParcel.swift @@ -0,0 +1,35 @@ +import Incremental +import Resin +import SafariServices +import UIKit + +public typealias Environment = ( + userDefaults: UserDefaults, + loadStations: (@escaping (Result) -> Void) -> Void +) + +public func makeUndergroundParcel(environment: Environment) -> Parcel { + return Parcel { store in + var result = Parcel.Delivery() + + result.presentationRoutes = [ + LineListViewController.presentationRoute.unfocused(by: \.stationsState).typeErased, + StationDetailViewController.presentationRoute.typeErased, + StationListViewController.presentationRoute.typeErased, + StationListViewController.navigationPresentationRoute.typeErased, + ] + + result.middlewares = [ + LoadStationsMiddleware(loadStations: environment.loadStations), + SyncFavoritesMiddleware(favoritesStations: store.state[\.favoriteStations], userDefaults: environment.userDefaults), + ] + + result.reducers = [ + SetStationsReducer().typeErased, + ToggleFavoriteReducer().typeErased, + SyncFavoritesReducer().typeErased, + ] + + return result + } +} diff --git a/SampleApp/SampleApp/Underground/UndergroundState.swift b/SampleApp/SampleApp/Underground/UndergroundState.swift new file mode 100644 index 0000000..89b6e2f --- /dev/null +++ b/SampleApp/SampleApp/Underground/UndergroundState.swift @@ -0,0 +1,18 @@ +import Foundation +import Resin + +public struct UndergroundState: Equatable { + var navigationStack: [AnyPresentationIdentifier] = [] + + var favoriteStations: Set = [] + + var stationsState = StationsState() + var stationStateRequest: RequestState = .none +} + +enum RequestState: Equatable { + case none + case loading + case failure + case success +} diff --git a/SampleApp/SampleApp/Underground/ViewControllers/LineListViewController.swift b/SampleApp/SampleApp/Underground/ViewControllers/LineListViewController.swift new file mode 100644 index 0000000..6f11b3e --- /dev/null +++ b/SampleApp/SampleApp/Underground/ViewControllers/LineListViewController.swift @@ -0,0 +1,105 @@ +import Foundation +import Incremental +import Resin +import UIKit + +class LineListViewController: UITableViewController { + let viewModel: I + + private var dataSource: DataSource! + + init(viewModel: I) { + self.viewModel = viewModel + super.init(style: .grouped) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + + dataSource = DataSource(tableView: tableView) + tableView.dataSource = dataSource + tableView.delegate = dataSource + + whileVisible.do { (owner: LineListViewController) in + [ + owner.bind(keyPath: \.title, to: owner.viewModel[\.line]), + owner.bind(keyPath: \.dataSource.stations, to: owner.viewModel[\.stations]), + ] + } + } + +} + +extension LineListViewController { + class DataSource: NSObject, UITableViewDataSource, UITableViewDelegate { + var stations: [String] = [] { + didSet { + tableView?.reloadData() + } + } + + weak var tableView: UITableView? + + init(tableView: UITableView) { + self.tableView = tableView + super.init() + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return stations.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = stations[indexPath.row] + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let name = stations[indexPath.row] + let identifier = StationDetailViewController.PresentationIdentifier(name: name) + + tableView.dispatch(ImplicitPresentationAction.push(identifier)) + } + } +} + +extension LineListViewController { + struct ViewModel: Resin.ViewModel, DefaultInit { + var line: String = "" + var stations: [String] = [] + var isStale = false + } +} + +extension LineListViewController { + struct PresentationIdentifier: Resin.PresentationIdentifier { + public var line: String + + public init(line: String) { + self.line = line + } + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { identifier, store in + let vm = store.state.map { stationState -> ViewModel in + let stations = stationState.lines[identifier.line] ?? [] + + return ViewModel( + line: identifier.line, + stations: stations + ) + } + + return LineListViewController(viewModel: vm) + } + } +} diff --git a/SampleApp/SampleApp/Underground/ViewControllers/StationListViewController.swift b/SampleApp/SampleApp/Underground/ViewControllers/StationListViewController.swift new file mode 100644 index 0000000..c5aa28b --- /dev/null +++ b/SampleApp/SampleApp/Underground/ViewControllers/StationListViewController.swift @@ -0,0 +1,118 @@ +import Foundation +import Incremental +import Resin +import UIKit + +class StationListViewController: UITableViewController { + let viewModel: I + + private var dataSource: DataSource! + + init(viewModel: I) { + self.viewModel = viewModel + super.init(style: .grouped) + + title = "All Stations" + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + + dataSource = DataSource(tableView: tableView) + tableView.dataSource = dataSource + tableView.delegate = dataSource + + whileVisible.do { (owner: StationListViewController) in + [ + owner.bind(keyPath: \.dataSource.stations, to: owner.viewModel[\.stations]), + ] + } + } +} + +extension StationListViewController { + class DataSource: NSObject, UITableViewDataSource, UITableViewDelegate { + var stations: [Station] = [] { + didSet { + tableView?.reloadData() + } + } + + weak var tableView: UITableView? + + init(tableView: UITableView) { + self.tableView = tableView + super.init() + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return stations.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = stations[indexPath.row].name + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let name = stations[indexPath.row].name + + let identifier = StationDetailViewController.PresentationIdentifier(name: name) + tableView.dispatch(ImplicitPresentationAction.push(identifier)) + } + } +} + +extension StationListViewController { + struct ViewModel: Resin.ViewModel, DefaultInit { + var stations: [Station] = [] + var isLoading = false + var isStale = false + } +} + + + +extension StationListViewController { + // To Present the VC individually + struct PresentationIdentifier: Resin.PresentationIdentifier { + public init() {} + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { _, store in + + let vm = store.state.map { undergroundState -> ViewModel in + ViewModel( + stations: undergroundState.stationsState.stations, + isLoading: undergroundState.stationStateRequest == .loading + ) + } + return StationListViewController(viewModel: vm) + } + } +} + +extension StationListViewController { + // to present he VC in a UINavigationController bound to the state + struct NavigationPresentationIdentifier: Resin.PresentationIdentifier { + } + + static var navigationPresentationRoute: PresentationRoute { + return PresentationRoute { _, store in + UINavigationController( + store: store, + root: StationListViewController.PresentationIdentifier().typeErased, + navigationStack: \.navigationStack + ) + } + } +} diff --git a/SampleApp/SampleApp/Underground/ViewControllers/StationsDetailsViewController.swift b/SampleApp/SampleApp/Underground/ViewControllers/StationsDetailsViewController.swift new file mode 100644 index 0000000..08c6c5a --- /dev/null +++ b/SampleApp/SampleApp/Underground/ViewControllers/StationsDetailsViewController.swift @@ -0,0 +1,183 @@ +import Foundation +import Incremental +import Resin +import SafariServices +import UIKit + +class StationDetailViewController: UITableViewController { + let viewModel: I + + private var dataSource: DataSource! + + init(viewModel: I) { + self.viewModel = viewModel + super.init(style: .grouped) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + + dataSource = DataSource(tableView: tableView) + tableView.dataSource = dataSource + tableView.delegate = dataSource + + whileVisible.do { (owner: StationDetailViewController) in + [ + owner.bind(keyPath: \.title, to: owner.viewModel[\.station.name]), + owner.bind(keyPath: \.dataSource.viewModel, to: owner.viewModel), + ] + } + } +} + +extension StationDetailViewController { + class DataSource: NSObject, UITableViewDataSource, UITableViewDelegate { + var viewModel = StationDetailViewController.ViewModel() { + didSet { + tableView?.reloadData() + } + } + + weak var tableView: UITableView? + + init(tableView: UITableView) { + self.tableView = tableView + super.init() + } + + func numberOfSections(in tableView: UITableView) -> Int { + return 4 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch section { + case 0: + return 2 + case 1: + return viewModel.station.lines.count + case 2: + return 1 + case 3: + return 1 + default: + fatalError() + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: UITableViewCell + + switch (indexPath.section, indexPath.row) { + case (0, 0): + cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.textLabel?.text = "Name" + cell.detailTextLabel?.text = viewModel.station.name + + case (0, 1): + cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.textLabel?.text = "Zones" + cell.detailTextLabel?.text = viewModel.station.zones.map(String.init).joined(separator: " & ") + + case (1, let line): + cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = viewModel.station.lines[line] + + case (2, 0): + let `switch` = UISwitch() + `switch`.setOn(viewModel.isFavorite, animated: false) + `switch`.on(.valueChanged, dispatch: ToggleFavoriteAction(station: viewModel.station.name)) + + cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.accessoryView = `switch` + cell.textLabel?.text = "Favorite" + + case (3, 0): + cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = "Wikipedia Article" + default: + fatalError() + } + + return cell + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch section { + case 0, 2, 3: + return nil + case 1: + return "Lines" + default: + fatalError() + } + } + + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + return indexPath.section == 1 || indexPath.section == 3 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch (indexPath.section, indexPath.row) { + case (1, let line): + let identifier = LineListViewController.PresentationIdentifier(line: viewModel.station.lines[line]) + + tableView.dispatch(ImplicitPresentationAction.push(identifier)) + case (3, 0): + let identifier = SFSafariViewController.PresentationIdentifier(url: URL(string: "http://example.org")!) + + tableView.dispatch(ImplicitPresentationAction.push(identifier)) + default: + fatalError() + } + } + } +} + +extension StationDetailViewController { + struct ViewModel: Resin.ViewModel, DefaultInit { + var station: Station = Station(name: "", lines: [], zones: []) + var isFavorite = false + var isStale = false + } +} + +extension StationDetailViewController { + struct PresentationIdentifier: Resin.PresentationIdentifier { + public var name: String + + public init(name: String) { + self.name = name + } + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { identifier, store in + let viewModel = store.state + .mapWithFallback { + StationDetailViewController.ViewModel(stationName: identifier.name, state: $0) + } + + return StationDetailViewController(viewModel: viewModel) + } + } +} + +extension StationDetailViewController.ViewModel { + init?(stationName: String, state: UndergroundState) { + guard let station = state.stationsState.find(byName: stationName) else { + return nil + } + + self.init( + station: station, + isFavorite: state.favoriteStations.contains(stationName) + ) + } +} diff --git a/SampleApp/SampleApp/main.swift b/SampleApp/SampleApp/main.swift new file mode 100644 index 0000000..4246795 --- /dev/null +++ b/SampleApp/SampleApp/main.swift @@ -0,0 +1,4 @@ +import Foundation +import UIKit + +UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, NSStringFromClass(UIApplication.self), NSStringFromClass(AppDelegate.self))