├── .gitignore ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── Navidux.xcscheme ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── Example.xcscheme └── Example │ ├── App │ ├── AppDelegate.swift │ └── SceneDelegate.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Info.plist │ ├── Navidux+Ext │ ├── NaviduxScreen+Extension.swift │ ├── NaviduxScreenAssembler.swift │ └── NaviduxScreenFactory.swift │ └── Screens │ ├── ButtonView.swift │ ├── FirstContentView.swift │ ├── SecondContentView.swift │ ├── ThirdContentTableViewCell.swift │ └── ThirdContentViewController.swift ├── Package.swift ├── README.md ├── Sources └── Navidux │ ├── NaviduxScreen.swift │ ├── Navigation.swift │ ├── NavigationController.swift │ ├── NavigationCoordinator.swift │ ├── NavigationCoordinatorProxy.swift │ ├── Router.swift │ ├── ScreenAssembler.swift │ ├── ScreenFactory.swift │ ├── alert │ ├── AlertConfiguration.swift │ ├── AlertFactory.swift │ └── AlertScreen.swift │ ├── extensions │ ├── CGFloat+Extension.swift │ ├── CGPoint+Extension.swift │ ├── ExtendableEnum.swift │ ├── Storyboard.swift │ ├── UIImage+Extension.swift │ └── UIPanGestureRecognizer+Extension.swift │ ├── implemention │ ├── NavigationControllerImpl.swift │ ├── NavigationReducer.swift │ ├── NavigationRouter.swift │ ├── NavigationStore.swift │ └── Payload.swift │ ├── resources │ └── Media.xcassets │ │ ├── Contents.json │ │ └── PullbarIcon.imageset │ │ ├── Contents.json │ │ └── PullBarIcon.pdf │ └── screens │ ├── ActivityViewController.swift │ ├── BottomSheet │ ├── BSPresentationController.swift │ ├── BSScrollableViews │ │ ├── BSCollectionView.swift │ │ ├── BSScrollView.swift │ │ └── BSTableView.swift │ ├── BSTransitionDriver.swift │ ├── BSTransitioningDelegate.swift │ ├── CoverVerticalDismissAnimatedTransitioning.swift │ ├── CoverVerticalPresentAnimatedTransitioning.swift │ └── PullBar.swift │ ├── HostingController.swift │ ├── NavigationScreen.swift │ ├── ScreenConfig.swift │ └── ViewController.swift ├── Tests └── NaviduxTests │ ├── Fixtures │ ├── NaviduxFixture.swift │ ├── NaviduxScreenFixture.swift │ ├── NavigationControllerStub.swift │ ├── PayloadStub.swift │ └── ScreenAssemblerStub.swift │ ├── NavigationControllerTests.swift │ └── NavigationCoordinatorTests.swift └── readme ├── Navidux_scheme.png └── Roadmap.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecs.plist 10 | .netrc 11 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Navidux.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 68 | 74 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 4F45EBC72AFB701C00A92472 /* ThirdContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F45EBC62AFB701C00A92472 /* ThirdContentViewController.swift */; }; 11 | 4F45EBC92AFB8AD000A92472 /* ThirdContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F45EBC82AFB8AD000A92472 /* ThirdContentTableViewCell.swift */; }; 12 | E239A3BE2915756C00A03EB6 /* FirstContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E239A3BD2915756C00A03EB6 /* FirstContentView.swift */; }; 13 | E239A3C02915756D00A03EB6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E239A3BF2915756D00A03EB6 /* Assets.xcassets */; }; 14 | E239A3CC291578A500A03EB6 /* Navidux in Frameworks */ = {isa = PBXBuildFile; productRef = E239A3CB291578A500A03EB6 /* Navidux */; }; 15 | E239A3D1291579FB00A03EB6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E239A3D0291579FB00A03EB6 /* AppDelegate.swift */; }; 16 | E239A3D329157A0E00A03EB6 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E239A3D229157A0E00A03EB6 /* SceneDelegate.swift */; }; 17 | E239A3D729157B6F00A03EB6 /* NaviduxScreen+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E239A3D629157B6F00A03EB6 /* NaviduxScreen+Extension.swift */; }; 18 | E239A3D929157B8600A03EB6 /* NaviduxScreenAssembler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E239A3D829157B8600A03EB6 /* NaviduxScreenAssembler.swift */; }; 19 | E239A3DB29157B9300A03EB6 /* NaviduxScreenFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E239A3DA29157B9300A03EB6 /* NaviduxScreenFactory.swift */; }; 20 | E239A3E02916E14800A03EB6 /* SecondContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E239A3DF2916E14800A03EB6 /* SecondContentView.swift */; }; 21 | E239A3E22916E38300A03EB6 /* ButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E239A3E12916E38300A03EB6 /* ButtonView.swift */; }; 22 | /* End PBXBuildFile section */ 23 | 24 | /* Begin PBXFileReference section */ 25 | 4F45EBC62AFB701C00A92472 /* ThirdContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdContentViewController.swift; sourceTree = ""; }; 26 | 4F45EBC82AFB8AD000A92472 /* ThirdContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdContentTableViewCell.swift; sourceTree = ""; }; 27 | E239A3B82915756C00A03EB6 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28 | E239A3BD2915756C00A03EB6 /* FirstContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstContentView.swift; sourceTree = ""; }; 29 | E239A3BF2915756D00A03EB6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 30 | E239A3D0291579FB00A03EB6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 31 | E239A3D229157A0E00A03EB6 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 32 | E239A3D629157B6F00A03EB6 /* NaviduxScreen+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NaviduxScreen+Extension.swift"; sourceTree = ""; }; 33 | E239A3D829157B8600A03EB6 /* NaviduxScreenAssembler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NaviduxScreenAssembler.swift; sourceTree = ""; }; 34 | E239A3DA29157B9300A03EB6 /* NaviduxScreenFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NaviduxScreenFactory.swift; sourceTree = ""; }; 35 | E239A3DE2916DE4700A03EB6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 36 | E239A3DF2916E14800A03EB6 /* SecondContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondContentView.swift; sourceTree = ""; }; 37 | E239A3E12916E38300A03EB6 /* ButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonView.swift; sourceTree = ""; }; 38 | E2D206252AB32DB30066EE5D /* navidux_fork */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = navidux_fork; path = ..; sourceTree = ""; }; 39 | /* End PBXFileReference section */ 40 | 41 | /* Begin PBXFrameworksBuildPhase section */ 42 | E239A3B52915756C00A03EB6 /* Frameworks */ = { 43 | isa = PBXFrameworksBuildPhase; 44 | buildActionMask = 2147483647; 45 | files = ( 46 | E239A3CC291578A500A03EB6 /* Navidux in Frameworks */, 47 | ); 48 | runOnlyForDeploymentPostprocessing = 0; 49 | }; 50 | /* End PBXFrameworksBuildPhase section */ 51 | 52 | /* Begin PBXGroup section */ 53 | E239A3AF2915756C00A03EB6 = { 54 | isa = PBXGroup; 55 | children = ( 56 | E2D206242AB32DB30066EE5D /* Packages */, 57 | E239A3BA2915756C00A03EB6 /* Example */, 58 | E239A3B92915756C00A03EB6 /* Products */, 59 | E239A3CA291578A500A03EB6 /* Frameworks */, 60 | ); 61 | sourceTree = ""; 62 | }; 63 | E239A3B92915756C00A03EB6 /* Products */ = { 64 | isa = PBXGroup; 65 | children = ( 66 | E239A3B82915756C00A03EB6 /* Example.app */, 67 | ); 68 | name = Products; 69 | sourceTree = ""; 70 | }; 71 | E239A3BA2915756C00A03EB6 /* Example */ = { 72 | isa = PBXGroup; 73 | children = ( 74 | E239A3DE2916DE4700A03EB6 /* Info.plist */, 75 | E239A3D529157A8100A03EB6 /* Navidux+Ext */, 76 | E239A3CF291578FD00A03EB6 /* Screens */, 77 | E239A3CE291578F100A03EB6 /* App */, 78 | E239A3BF2915756D00A03EB6 /* Assets.xcassets */, 79 | ); 80 | path = Example; 81 | sourceTree = ""; 82 | }; 83 | E239A3CA291578A500A03EB6 /* Frameworks */ = { 84 | isa = PBXGroup; 85 | children = ( 86 | ); 87 | name = Frameworks; 88 | sourceTree = ""; 89 | }; 90 | E239A3CE291578F100A03EB6 /* App */ = { 91 | isa = PBXGroup; 92 | children = ( 93 | E239A3D0291579FB00A03EB6 /* AppDelegate.swift */, 94 | E239A3D229157A0E00A03EB6 /* SceneDelegate.swift */, 95 | ); 96 | path = App; 97 | sourceTree = ""; 98 | }; 99 | E239A3CF291578FD00A03EB6 /* Screens */ = { 100 | isa = PBXGroup; 101 | children = ( 102 | E239A3BD2915756C00A03EB6 /* FirstContentView.swift */, 103 | E239A3DF2916E14800A03EB6 /* SecondContentView.swift */, 104 | E239A3E12916E38300A03EB6 /* ButtonView.swift */, 105 | 4F45EBC62AFB701C00A92472 /* ThirdContentViewController.swift */, 106 | 4F45EBC82AFB8AD000A92472 /* ThirdContentTableViewCell.swift */, 107 | ); 108 | path = Screens; 109 | sourceTree = ""; 110 | }; 111 | E239A3D529157A8100A03EB6 /* Navidux+Ext */ = { 112 | isa = PBXGroup; 113 | children = ( 114 | E239A3D629157B6F00A03EB6 /* NaviduxScreen+Extension.swift */, 115 | E239A3D829157B8600A03EB6 /* NaviduxScreenAssembler.swift */, 116 | E239A3DA29157B9300A03EB6 /* NaviduxScreenFactory.swift */, 117 | ); 118 | path = "Navidux+Ext"; 119 | sourceTree = ""; 120 | }; 121 | E2D206242AB32DB30066EE5D /* Packages */ = { 122 | isa = PBXGroup; 123 | children = ( 124 | E2D206252AB32DB30066EE5D /* navidux_fork */, 125 | ); 126 | name = Packages; 127 | sourceTree = ""; 128 | }; 129 | /* End PBXGroup section */ 130 | 131 | /* Begin PBXNativeTarget section */ 132 | E239A3B72915756C00A03EB6 /* Example */ = { 133 | isa = PBXNativeTarget; 134 | buildConfigurationList = E239A3C62915756D00A03EB6 /* Build configuration list for PBXNativeTarget "Example" */; 135 | buildPhases = ( 136 | E239A3B42915756C00A03EB6 /* Sources */, 137 | E239A3B52915756C00A03EB6 /* Frameworks */, 138 | E239A3B62915756C00A03EB6 /* Resources */, 139 | ); 140 | buildRules = ( 141 | ); 142 | dependencies = ( 143 | ); 144 | name = Example; 145 | packageProductDependencies = ( 146 | E239A3CB291578A500A03EB6 /* Navidux */, 147 | ); 148 | productName = Example; 149 | productReference = E239A3B82915756C00A03EB6 /* Example.app */; 150 | productType = "com.apple.product-type.application"; 151 | }; 152 | /* End PBXNativeTarget section */ 153 | 154 | /* Begin PBXProject section */ 155 | E239A3B02915756C00A03EB6 /* Project object */ = { 156 | isa = PBXProject; 157 | attributes = { 158 | BuildIndependentTargetsInParallel = 1; 159 | LastSwiftUpdateCheck = 1400; 160 | LastUpgradeCheck = 1400; 161 | TargetAttributes = { 162 | E239A3B72915756C00A03EB6 = { 163 | CreatedOnToolsVersion = 14.0; 164 | }; 165 | }; 166 | }; 167 | buildConfigurationList = E239A3B32915756C00A03EB6 /* Build configuration list for PBXProject "Example" */; 168 | compatibilityVersion = "Xcode 14.0"; 169 | developmentRegion = en; 170 | hasScannedForEncodings = 0; 171 | knownRegions = ( 172 | en, 173 | Base, 174 | ); 175 | mainGroup = E239A3AF2915756C00A03EB6; 176 | packageReferences = ( 177 | ); 178 | productRefGroup = E239A3B92915756C00A03EB6 /* Products */; 179 | projectDirPath = ""; 180 | projectRoot = ""; 181 | targets = ( 182 | E239A3B72915756C00A03EB6 /* Example */, 183 | ); 184 | }; 185 | /* End PBXProject section */ 186 | 187 | /* Begin PBXResourcesBuildPhase section */ 188 | E239A3B62915756C00A03EB6 /* Resources */ = { 189 | isa = PBXResourcesBuildPhase; 190 | buildActionMask = 2147483647; 191 | files = ( 192 | E239A3C02915756D00A03EB6 /* Assets.xcassets in Resources */, 193 | ); 194 | runOnlyForDeploymentPostprocessing = 0; 195 | }; 196 | /* End PBXResourcesBuildPhase section */ 197 | 198 | /* Begin PBXSourcesBuildPhase section */ 199 | E239A3B42915756C00A03EB6 /* Sources */ = { 200 | isa = PBXSourcesBuildPhase; 201 | buildActionMask = 2147483647; 202 | files = ( 203 | 4F45EBC72AFB701C00A92472 /* ThirdContentViewController.swift in Sources */, 204 | 4F45EBC92AFB8AD000A92472 /* ThirdContentTableViewCell.swift in Sources */, 205 | E239A3E02916E14800A03EB6 /* SecondContentView.swift in Sources */, 206 | E239A3D1291579FB00A03EB6 /* AppDelegate.swift in Sources */, 207 | E239A3E22916E38300A03EB6 /* ButtonView.swift in Sources */, 208 | E239A3BE2915756C00A03EB6 /* FirstContentView.swift in Sources */, 209 | E239A3DB29157B9300A03EB6 /* NaviduxScreenFactory.swift in Sources */, 210 | E239A3D929157B8600A03EB6 /* NaviduxScreenAssembler.swift in Sources */, 211 | E239A3D329157A0E00A03EB6 /* SceneDelegate.swift in Sources */, 212 | E239A3D729157B6F00A03EB6 /* NaviduxScreen+Extension.swift in Sources */, 213 | ); 214 | runOnlyForDeploymentPostprocessing = 0; 215 | }; 216 | /* End PBXSourcesBuildPhase section */ 217 | 218 | /* Begin XCBuildConfiguration section */ 219 | E239A3C42915756D00A03EB6 /* Debug */ = { 220 | isa = XCBuildConfiguration; 221 | buildSettings = { 222 | ALWAYS_SEARCH_USER_PATHS = NO; 223 | CLANG_ANALYZER_NONNULL = YES; 224 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 225 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 226 | CLANG_ENABLE_MODULES = YES; 227 | CLANG_ENABLE_OBJC_ARC = YES; 228 | CLANG_ENABLE_OBJC_WEAK = YES; 229 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 230 | CLANG_WARN_BOOL_CONVERSION = YES; 231 | CLANG_WARN_COMMA = YES; 232 | CLANG_WARN_CONSTANT_CONVERSION = YES; 233 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 234 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 235 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 236 | CLANG_WARN_EMPTY_BODY = YES; 237 | CLANG_WARN_ENUM_CONVERSION = YES; 238 | CLANG_WARN_INFINITE_RECURSION = YES; 239 | CLANG_WARN_INT_CONVERSION = YES; 240 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 241 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 242 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 243 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 244 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 245 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 246 | CLANG_WARN_STRICT_PROTOTYPES = YES; 247 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 248 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 249 | CLANG_WARN_UNREACHABLE_CODE = YES; 250 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 251 | COPY_PHASE_STRIP = NO; 252 | DEBUG_INFORMATION_FORMAT = dwarf; 253 | ENABLE_STRICT_OBJC_MSGSEND = YES; 254 | ENABLE_TESTABILITY = YES; 255 | GCC_C_LANGUAGE_STANDARD = gnu11; 256 | GCC_DYNAMIC_NO_PIC = NO; 257 | GCC_NO_COMMON_BLOCKS = YES; 258 | GCC_OPTIMIZATION_LEVEL = 0; 259 | GCC_PREPROCESSOR_DEFINITIONS = ( 260 | "DEBUG=1", 261 | "$(inherited)", 262 | ); 263 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 264 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 265 | GCC_WARN_UNDECLARED_SELECTOR = YES; 266 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 267 | GCC_WARN_UNUSED_FUNCTION = YES; 268 | GCC_WARN_UNUSED_VARIABLE = YES; 269 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 270 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 271 | MTL_FAST_MATH = YES; 272 | ONLY_ACTIVE_ARCH = YES; 273 | SDKROOT = iphoneos; 274 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 275 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 276 | }; 277 | name = Debug; 278 | }; 279 | E239A3C52915756D00A03EB6 /* Release */ = { 280 | isa = XCBuildConfiguration; 281 | buildSettings = { 282 | ALWAYS_SEARCH_USER_PATHS = NO; 283 | CLANG_ANALYZER_NONNULL = YES; 284 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 285 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 286 | CLANG_ENABLE_MODULES = YES; 287 | CLANG_ENABLE_OBJC_ARC = YES; 288 | CLANG_ENABLE_OBJC_WEAK = YES; 289 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 290 | CLANG_WARN_BOOL_CONVERSION = YES; 291 | CLANG_WARN_COMMA = YES; 292 | CLANG_WARN_CONSTANT_CONVERSION = YES; 293 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 294 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 295 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 296 | CLANG_WARN_EMPTY_BODY = YES; 297 | CLANG_WARN_ENUM_CONVERSION = YES; 298 | CLANG_WARN_INFINITE_RECURSION = YES; 299 | CLANG_WARN_INT_CONVERSION = YES; 300 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 301 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 302 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 303 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 304 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 305 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 306 | CLANG_WARN_STRICT_PROTOTYPES = YES; 307 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 308 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 309 | CLANG_WARN_UNREACHABLE_CODE = YES; 310 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 311 | COPY_PHASE_STRIP = NO; 312 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 313 | ENABLE_NS_ASSERTIONS = NO; 314 | ENABLE_STRICT_OBJC_MSGSEND = YES; 315 | GCC_C_LANGUAGE_STANDARD = gnu11; 316 | GCC_NO_COMMON_BLOCKS = YES; 317 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 318 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 319 | GCC_WARN_UNDECLARED_SELECTOR = YES; 320 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 321 | GCC_WARN_UNUSED_FUNCTION = YES; 322 | GCC_WARN_UNUSED_VARIABLE = YES; 323 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 324 | MTL_ENABLE_DEBUG_INFO = NO; 325 | MTL_FAST_MATH = YES; 326 | SDKROOT = iphoneos; 327 | SWIFT_COMPILATION_MODE = wholemodule; 328 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 329 | VALIDATE_PRODUCT = YES; 330 | }; 331 | name = Release; 332 | }; 333 | E239A3C72915756D00A03EB6 /* Debug */ = { 334 | isa = XCBuildConfiguration; 335 | buildSettings = { 336 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 337 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 338 | CODE_SIGN_STYLE = Automatic; 339 | CURRENT_PROJECT_VERSION = 1; 340 | DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; 341 | DEVELOPMENT_TEAM = 7898RN388J; 342 | ENABLE_PREVIEWS = YES; 343 | GENERATE_INFOPLIST_FILE = YES; 344 | INFOPLIST_FILE = Example/Info.plist; 345 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 346 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 347 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 348 | LD_RUNPATH_SEARCH_PATHS = ( 349 | "$(inherited)", 350 | "@executable_path/Frameworks", 351 | ); 352 | MARKETING_VERSION = 1.0; 353 | PRODUCT_BUNDLE_IDENTIFIER = evseev.com.Example; 354 | PRODUCT_NAME = "$(TARGET_NAME)"; 355 | SWIFT_EMIT_LOC_STRINGS = YES; 356 | SWIFT_VERSION = 5.0; 357 | TARGETED_DEVICE_FAMILY = "1,2"; 358 | }; 359 | name = Debug; 360 | }; 361 | E239A3C82915756D00A03EB6 /* Release */ = { 362 | isa = XCBuildConfiguration; 363 | buildSettings = { 364 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 365 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 366 | CODE_SIGN_STYLE = Automatic; 367 | CURRENT_PROJECT_VERSION = 1; 368 | DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; 369 | DEVELOPMENT_TEAM = 7898RN388J; 370 | ENABLE_PREVIEWS = YES; 371 | GENERATE_INFOPLIST_FILE = YES; 372 | INFOPLIST_FILE = Example/Info.plist; 373 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 374 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 375 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 376 | LD_RUNPATH_SEARCH_PATHS = ( 377 | "$(inherited)", 378 | "@executable_path/Frameworks", 379 | ); 380 | MARKETING_VERSION = 1.0; 381 | PRODUCT_BUNDLE_IDENTIFIER = evseev.com.Example; 382 | PRODUCT_NAME = "$(TARGET_NAME)"; 383 | SWIFT_EMIT_LOC_STRINGS = YES; 384 | SWIFT_VERSION = 5.0; 385 | TARGETED_DEVICE_FAMILY = "1,2"; 386 | }; 387 | name = Release; 388 | }; 389 | /* End XCBuildConfiguration section */ 390 | 391 | /* Begin XCConfigurationList section */ 392 | E239A3B32915756C00A03EB6 /* Build configuration list for PBXProject "Example" */ = { 393 | isa = XCConfigurationList; 394 | buildConfigurations = ( 395 | E239A3C42915756D00A03EB6 /* Debug */, 396 | E239A3C52915756D00A03EB6 /* Release */, 397 | ); 398 | defaultConfigurationIsVisible = 0; 399 | defaultConfigurationName = Release; 400 | }; 401 | E239A3C62915756D00A03EB6 /* Build configuration list for PBXNativeTarget "Example" */ = { 402 | isa = XCConfigurationList; 403 | buildConfigurations = ( 404 | E239A3C72915756D00A03EB6 /* Debug */, 405 | E239A3C82915756D00A03EB6 /* Release */, 406 | ); 407 | defaultConfigurationIsVisible = 0; 408 | defaultConfigurationName = Release; 409 | }; 410 | /* End XCConfigurationList section */ 411 | 412 | /* Begin XCSwiftPackageProductDependency section */ 413 | E239A3CB291578A500A03EB6 /* Navidux */ = { 414 | isa = XCSwiftPackageProductDependency; 415 | productName = Navidux; 416 | }; 417 | /* End XCSwiftPackageProductDependency section */ 418 | }; 419 | rootObject = E239A3B02915756C00A03EB6 /* Project object */; 420 | } 421 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Example/Example/App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @main 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 6 | return true 7 | } 8 | 9 | // MARK: UISceneSession Lifecycle 10 | 11 | func application( 12 | _ application: UIApplication, 13 | configurationForConnecting connectingSceneSession: UISceneSession, 14 | options: UIScene.ConnectionOptions 15 | ) -> UISceneConfiguration { 16 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Example/Example/App/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import Navidux 2 | import UIKit 3 | 4 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 5 | 6 | var window: UIWindow? 7 | 8 | func scene( 9 | _ scene: UIScene, 10 | willConnectTo session: UISceneSession, 11 | options connectionOptions: UIScene.ConnectionOptions 12 | ) { 13 | guard let windowScene = (scene as? UIWindowScene) else { return } 14 | 15 | let window = UIWindow(windowScene: windowScene) 16 | 17 | let navigation = NavigationControllerImpl { controller in 18 | controller.view.backgroundColor = .green 19 | controller.navigationBar.isTranslucent = false 20 | controller.navigationBar.backgroundColor = .green 21 | controller.navigationBar.shadowImage = .init() 22 | controller.navigationBar.barTintColor = .green 23 | controller.navigationBar.tintColor = .black 24 | controller.navigationBar.titleTextAttributes = [ 25 | NSAttributedString.Key.foregroundColor: UIColor.black 26 | ] 27 | } 28 | let screenFactory = NaviduxScreenFactory() 29 | let alertFactory = AlertFactoryImpl() 30 | let navigationCoordinatorProxy = NavigationCoordinatorProxy() 31 | let screenAssembler = NaviduxScreenAssembler( 32 | screenFactory: screenFactory, 33 | alertFactory: alertFactory, 34 | screenCoordinator: navigationCoordinatorProxy 35 | ) 36 | let navigationCoordinator = NavigationCoordinator( 37 | navigation, 38 | screenAssembler: screenAssembler 39 | ) 40 | navigationCoordinatorProxy.subject = navigationCoordinator 41 | navigationCoordinatorProxy.route( 42 | with: .push( 43 | .firstScreen, 44 | ScreenConfig(navigationTitle: "First screen", isNeedSetBackButton: false), 45 | .fullscreen 46 | ) 47 | ) 48 | 49 | window.rootViewController = navigation 50 | self.window = window 51 | window.makeKeyAndVisible() 52 | 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Example/Example/Navidux+Ext/NaviduxScreen+Extension.swift: -------------------------------------------------------------------------------- 1 | import Navidux 2 | 3 | extension NaviduxScreen { 4 | public static let firstScreen = NaviduxScreen( 5 | screenClass: HostingController.self 6 | ) 7 | 8 | public static let secondScreen = NaviduxScreen( 9 | screenClass: HostingController.self 10 | ) 11 | 12 | public static let thirdScreen = NaviduxScreen( 13 | screenClass: ThirdContentViewController.self 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /Example/Example/Navidux+Ext/NaviduxScreenAssembler.swift: -------------------------------------------------------------------------------- 1 | import Navidux 2 | 3 | public final class NaviduxScreenAssembler: Navidux.ScreenAssembler { 4 | private var screenFactory: any Navidux.ScreenFactory 5 | private var alertFactory: Navidux.AlertFactory 6 | private var screenCoordinator: Navidux.NavigationCoordinatorProxy? 7 | 8 | public init( 9 | screenFactory: Navidux.ScreenFactory, 10 | alertFactory: Navidux.AlertFactory, 11 | screenCoordinator: Navidux.NavigationCoordinatorProxy? 12 | ) { 13 | self.screenFactory = screenFactory 14 | self.alertFactory = alertFactory 15 | self.screenCoordinator = screenCoordinator 16 | } 17 | 18 | public func assemblyScreen(screenType: NaviduxScreen, config: ScreenConfig) -> any NavigationScreen { 19 | switch screenType { 20 | case .firstScreen: 21 | return screenFactory.firstScreenFactory(screenCoordinator?.subject, config) 22 | case .secondScreen: 23 | return screenFactory.secondScreenFactory(screenCoordinator?.subject, config) 24 | case .thirdScreen: 25 | return screenFactory.thirdScreenFactory(screenCoordinator?.subject, config) 26 | default: 27 | return ViewController(navigation: nil) 28 | } 29 | } 30 | 31 | public func assemblyScreen(components: ScreenAsseblyComponents) -> any NavigationScreen { 32 | assemblyScreen(screenType: components.screenType, config: components.config) 33 | } 34 | 35 | public func assemblyAlert(configuration: AlertConfiguration) -> AlertScreen { 36 | alertFactory.createAlert(configuration: configuration) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Example/Example/Navidux+Ext/NaviduxScreenFactory.swift: -------------------------------------------------------------------------------- 1 | import Navidux 2 | 3 | extension Navidux.ScreenFactory { 4 | var firstScreenFactory: (NavigationCoordinator?, ScreenConfig) -> any NavigationScreen { 5 | { coordinator, screenConfig in 6 | let viewContent = FirstContentView(navigation: coordinator) 7 | let viewController = HostingController( 8 | title: screenConfig.navigationTitle, 9 | isNeedBackButton: false, 10 | tag: "FirstContentView", 11 | navigation: coordinator, 12 | content: viewContent 13 | ) 14 | 15 | return viewController 16 | } 17 | } 18 | 19 | var secondScreenFactory: (NavigationCoordinator?, ScreenConfig) -> any NavigationScreen { 20 | { coordinator, screenConfig in 21 | let viewContent = SecondContentView(navigation: coordinator) 22 | let viewController = HostingController( 23 | title: screenConfig.navigationTitle, 24 | isNeedBackButton: true, 25 | tag: "SecondContentView", 26 | navigation: coordinator, 27 | content: viewContent 28 | ) 29 | 30 | return viewController 31 | } 32 | } 33 | 34 | var thirdScreenFactory: (NavigationCoordinator?, ScreenConfig) -> any NavigationScreen { 35 | { coordinator, screenConfig in 36 | ThirdContentViewController() 37 | } 38 | } 39 | } 40 | 41 | final class NaviduxScreenFactory: Navidux.ScreenFactory { 42 | init() {} 43 | } 44 | -------------------------------------------------------------------------------- /Example/Example/Screens/ButtonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonView.swift 3 | // Example 4 | // 5 | // Created by Александр Евсеев on 06.11.2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ButtonView: View { 11 | let action: () -> Void 12 | let title: String 13 | 14 | var body: some View { 15 | Button( 16 | action: action) { 17 | HStack { 18 | Text(title) 19 | Spacer() 20 | Image(systemName: "chevron.right") 21 | } 22 | } 23 | .padding(.horizontal, 32) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Example/Example/Screens/FirstContentView.swift: -------------------------------------------------------------------------------- 1 | import Navidux 2 | import SwiftUI 3 | 4 | struct FirstContentView: View { 5 | let navigation: NavigationCoordinator? 6 | 7 | var body: some View { 8 | VStack(spacing: 8) { 9 | Image(systemName: "globe") 10 | .imageScale(.large) 11 | .foregroundColor(.accentColor) 12 | Text("Hello, world!") 13 | ButtonView( 14 | action: { [weak navigation] in 15 | navigation?.route(with: 16 | .push( 17 | .secondScreen, 18 | ScreenConfig(navigationTitle: "Second Screen"), 19 | .fullscreen 20 | ) 21 | ) 22 | }, 23 | title: "Open fullscreen second screen" 24 | ) 25 | .padding(.top, 40) 26 | } 27 | .padding() 28 | .navigationBarBackButtonHidden() 29 | } 30 | } 31 | 32 | struct FirstContentView_Previews: PreviewProvider { 33 | static var previews: some View { 34 | FirstContentView(navigation: nil) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Example/Example/Screens/SecondContentView.swift: -------------------------------------------------------------------------------- 1 | import Navidux 2 | import SwiftUI 3 | 4 | struct SecondContentView: View { 5 | let navigation: NavigationCoordinator? 6 | 7 | var body: some View { 8 | VStack(spacing: 8) { 9 | Image(systemName: "globe") 10 | .imageScale(.large) 11 | .foregroundColor(.accentColor) 12 | Text("Hello, world!") 13 | ButtonView( 14 | action: { [weak navigation] in 15 | navigation?.route(with: .pop(nil)) 16 | }, 17 | title: "Back" 18 | ) 19 | .padding(.top, 40) 20 | ButtonView( 21 | action: { [weak navigation] in 22 | navigation?.route(with: .push( 23 | .thirdScreen, 24 | ScreenConfig(), 25 | .bottomSheet(.auto) 26 | )) 27 | }, 28 | title: "Present bottom sheet - auto" 29 | ) 30 | ButtonView( 31 | action: { [weak navigation] in 32 | navigation?.route(with: .push( 33 | .thirdScreen, 34 | ScreenConfig(), 35 | .bottomSheet(.fixed(120)) 36 | )) 37 | }, 38 | title: "Present bottom sheet - fixed height" 39 | ) 40 | ButtonView( 41 | action: { [weak navigation] in 42 | navigation?.route(with: .push( 43 | .thirdScreen, 44 | ScreenConfig(), 45 | .bottomSheet(.fullScreen) 46 | )) 47 | }, 48 | title: "Present bottom sheet - full screen" 49 | ) 50 | ButtonView( 51 | action: { [weak navigation] in 52 | navigation?.route(with: .push( 53 | .thirdScreen, 54 | ScreenConfig(), 55 | .bottomSheet(.halfScreen) 56 | )) 57 | }, 58 | title: "Present bottom sheet - half screen" 59 | ) 60 | } 61 | .padding() 62 | .navigationBarBackButtonHidden() 63 | } 64 | } 65 | 66 | struct SecondContentView_Previews: PreviewProvider { 67 | static var previews: some View { 68 | SecondContentView(navigation: nil) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Example/Example/Screens/ThirdContentTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThirdContentTableViewCell.swift 3 | // Example 4 | // 5 | // Created by Oleg Krasnov on 08/11/2023. 6 | // 7 | 8 | import UIKit 9 | 10 | struct ThirdContentCellModel { 11 | let image: UIImage 12 | let title: String 13 | } 14 | 15 | final class ThirdContentTableViewCell: UITableViewCell { 16 | 17 | private lazy var iconImageView = UIImageView() 18 | private lazy var label = UILabel() 19 | 20 | override init( 21 | style: UITableViewCell.CellStyle, 22 | reuseIdentifier: String? 23 | ) { 24 | super.init(style: style, reuseIdentifier: reuseIdentifier) 25 | setup() 26 | } 27 | 28 | required init?(coder _: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | func configureWith(image: UIImage, title: String) { 33 | imageView?.image = image 34 | label.text = title 35 | } 36 | 37 | private func setup() { 38 | backgroundColor = .white 39 | [iconImageView, label].forEach { 40 | $0.translatesAutoresizingMaskIntoConstraints = false 41 | contentView.addSubview($0) 42 | } 43 | 44 | NSLayoutConstraint.activate([ 45 | iconImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), 46 | iconImageView.topAnchor.constraint(equalTo: contentView.topAnchor), 47 | iconImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), 48 | iconImageView.heightAnchor.constraint(equalToConstant: 32), 49 | iconImageView.widthAnchor.constraint(equalToConstant: 32), 50 | 51 | label.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 8), 52 | label.topAnchor.constraint(equalTo: contentView.topAnchor), 53 | label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), 54 | label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), 55 | label.heightAnchor.constraint(equalToConstant: 64) 56 | ]) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Example/Example/Screens/ThirdContentViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThirdContentViewController.swift 3 | // Example 4 | // 5 | // Created by Oleg Krasnov on 08/11/2023. 6 | // 7 | 8 | import Navidux 9 | import UIKit 10 | 11 | final class ThirdContentViewController: ViewController { 12 | 13 | private let dataSource: [ThirdContentCellModel] = [ 14 | .init(image: .actions, title: "Actions"), 15 | .init(image: .add, title: "Add"), 16 | .init(image: .checkmark, title: "Checkmark"), 17 | .init(image: .remove, title: "Remove"), 18 | .init(image: .strokedCheckmark, title: "StrokedCheckmark"), 19 | 20 | .init(image: .actions, title: "Actions"), 21 | .init(image: .add, title: "Add"), 22 | .init(image: .checkmark, title: "Checkmark"), 23 | .init(image: .remove, title: "Remove"), 24 | .init(image: .strokedCheckmark, title: "StrokedCheckmark"), 25 | 26 | .init(image: .actions, title: "Actions"), 27 | .init(image: .add, title: "Add"), 28 | .init(image: .checkmark, title: "Checkmark"), 29 | .init(image: .remove, title: "Remove"), 30 | .init(image: .strokedCheckmark, title: "StrokedCheckmark") 31 | ] 32 | 33 | private lazy var pullBar = PullBar() 34 | 35 | private lazy var tableView: BSTableView = { 36 | let tableView = BSTableView(frame: .zero, style: .plain) 37 | tableView.delegate = self 38 | tableView.dataSource = self 39 | tableView.backgroundColor = .white 40 | tableView.register( 41 | ThirdContentTableViewCell.self, 42 | forCellReuseIdentifier: String( 43 | describing: ThirdContentTableViewCell.self 44 | ) 45 | ) 46 | return tableView 47 | }() 48 | 49 | init() { 50 | super.init() 51 | } 52 | 53 | required init?(coder: NSCoder) { nil } 54 | 55 | override func viewDidLoad() { 56 | super.viewDidLoad() 57 | [pullBar ,tableView].forEach { 58 | $0.translatesAutoresizingMaskIntoConstraints = false 59 | view.addSubview($0) 60 | } 61 | 62 | NSLayoutConstraint.activate([ 63 | pullBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), 64 | pullBar.topAnchor.constraint(equalTo: view.topAnchor), 65 | pullBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), 66 | pullBar.heightAnchor.constraint(equalToConstant: 36), 67 | 68 | tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 69 | tableView.topAnchor.constraint(equalTo: pullBar.bottomAnchor), 70 | tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 71 | tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) 72 | ]) 73 | } 74 | 75 | } 76 | 77 | extension ThirdContentViewController: UITableViewDataSource { 78 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 79 | dataSource.count 80 | } 81 | 82 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 83 | guard let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ThirdContentTableViewCell.self), for: indexPath) as? ThirdContentTableViewCell 84 | else { 85 | return UITableViewCell() 86 | } 87 | cell.configureWith( 88 | image: dataSource[indexPath.row].image, 89 | title: dataSource[indexPath.row].title 90 | ) 91 | return cell 92 | } 93 | } 94 | 95 | extension ThirdContentViewController: UITableViewDelegate { 96 | func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 97 | 40 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | //swift-tools-version: 5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Navidux", 7 | platforms: [.iOS(.v13)], 8 | products: [ 9 | .library( 10 | name: "Navidux", 11 | targets: ["Navidux"] 12 | ), 13 | ], 14 | dependencies: [ 15 | ], 16 | targets: [ 17 | .target( 18 | name: "Navidux", 19 | dependencies: [], 20 | resources: [.process("resources/Media.xcassets")] 21 | ), 22 | .testTarget( 23 | name: "NaviduxTests", 24 | dependencies: ["Navidux"] 25 | ), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Navidux 2 | 3 | [![SPM compatible](https://img.shields.io/badge/spm-compatible-brightgreen.svg?style=flat)](https://swift.org/package-manager) 4 | [![Swift 5.7](https://img.shields.io/badge/swift-5.7-red.svg?style=flat)](https://developer.apple.com/swift) 5 | 6 | Navidux is easy and simple module to build your navigation without thinking about complicated routes in factories. 7 | 8 | ## Table of Contents 9 | - [Navidux](#navidux) 10 | - [Table of Contents](#contents) 11 | - [About Navidux](#about-navidux) 12 | - [Requirements](#requirements) 13 | - [Installation](#installation) 14 | - [Preparation](#preparation) 15 | - [Usage](#usage) 16 | - [Initialisation phase](#initialisation-phase) 17 | - [Using phase](#using-phase) 18 | - [Roadmap](#roadmap) 19 | 20 | ## About Navidux 21 | We create this package with router to facilitate routing duties and improve reading in complicated projects. Analyzing previous project give us idea of creating independently navigation. It's did not depend on your project and may use in different combination and variations. Of course it can uses with Storyboard, UIKit and SwiftUI screens. 22 | The goals we want to achieve: 23 | - Easy using and create routes in screen modules. 24 | - Use with most architectual approaches (MVVM, MVC, VIPER, MVI, MVP etc). 25 | - Not complicated logic of library engine. 26 | 27 | ![Navidux scheme](readme/Navidux_scheme.png) 28 | 29 | NavigationCoordinator core consists from: 30 | - Reducer function - ``actionReducer(action:)``; 31 | - ScreenAssembler - that implements in navigation depended module; 32 | - State - contains important properties for better functionality; 33 | - NavigationController - component that directry implements routing and hold some important information, like Screen Stack. 34 | Most core components have documentation on the spot with some examples. 35 | 36 | ## Requirements 37 | - iOS 13.0+ 38 | 39 | ## Installation 40 | Swift Package Manager 41 | 42 | Swift Package Manager is a tool for managing the distribution of Swift code. It’s integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies. 43 | 44 | Xcode 11+ is required to build Navidux using Swift Package Manager. 45 | To integrate Navidux into your Xcode project using Swift Package Manager, add it to the dependencies value of your Package.swift: 46 | ``` swift 47 | dependencies: [ 48 | .package(url: "https://github.com/RedMadRobot/navidux.git") 49 | ] 50 | ``` 51 | 52 | ### Preparation 53 | Next you have to extends 2 object and implement 1 to use Navidux at maximum. Some were in your APP module extends: 54 | ``` swift 55 | extension NaviduxScreen { 56 | static let newScreen = NaviduxScreen( 57 | description: "someDescription", 58 | screenClass: YourScreenTypeInheritedFromUIViewController.self 59 | ) 60 | } 61 | ``` 62 | ``` swift 63 | extension Navidux.ScreenFactory { 64 | public var someScreenFactory: (NavigationCoordinator?, ScreenConfig) -> any NavigationScreen { 65 | { coordinator, config 66 | return MyViewControllerConformedNavigationScreen() 67 | } 68 | } 69 | ``` 70 | 71 | And implement Navidux.ScreenAssembler protocol. 72 | 73 | ## Usage 74 | ### Initialisation phase 75 | To set Navidux as initial navigation controller. You need do installation and preparation phases. After you may initialise NavigationCoordinator like example below. 76 | ``` swift 77 | let navigationController = NavigationControllerImpl() 78 | let screenFactory: ScreenFactory = NaviduxScreenFactory() 79 | let alertFactory: AlertFactory = AlertFactoryImpl() 80 | let navigationCoordinatorProxy = NavigationCoordinatorProxy() 81 | let screenAssembler = NaviduxScreenAssembler( 82 | screenFactory: screenFactory, 83 | alertFactory: alertFactory, 84 | screenCoordinator: navigationCoordinatorProxy 85 | ) 86 | 87 | let navigationCoordinator = NavigationCoordinator( 88 | navigationController, 89 | screenAssembler: screenAssembler 90 | ) 91 | navigationCoordinatorProxy.subject = navigationCoordinator 92 | navigationCoordinator.actionReducer( 93 | action: .push(.firstScreen, .init(navigationTitle: ""), .fullscreen) 94 | ) 95 | 96 | window?.rootViewController = navigationController 97 | ``` 98 | 99 | ### Using phase 100 | For example in your UIViewController you may call NavigationCoordinator and ask it for action: 101 | ``` swift 102 | navigation?.actionReducer( 103 | action: .push(.nextScreen, .init(navigationTitle: "My title"), .fullscreen) 104 | ) 105 | ``` 106 | 107 | ## Roadmap 108 | ![Roadmap there](readme/Roadmap.md) 109 | -------------------------------------------------------------------------------- /Sources/Navidux/NaviduxScreen.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// NaviduxScreen its alternative to simple Enumeration with possibility to extension. 4 | /// if you want to add case with new screen you have to: 5 | /// ``` swift 6 | /// extension NaviduxScreen { 7 | /// static let newScreen = NaviduxScreen( 8 | /// screenClass: YourScreenTypeInheritedFromUIViewController.self 9 | /// ) 10 | /// } 11 | /// ``` 12 | /// - Note: You can place extension in another module. 13 | public class NaviduxScreen: ExtendableEnum { 14 | public var asScreenClass: UIViewController.Type 15 | 16 | public init(screenClass: UIViewController.Type) { 17 | self.asScreenClass = screenClass 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Navidux/Navigation.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | 4 | public enum Navigation: Equatable { 5 | /// PresentationStyle uses to choose correct form of pushing your screen. 6 | /// - Note: 7 | /// + fullscren - its normal presentation like `UINavigationController.push(...)` without additional settings. 8 | /// + modal - its modal presentation like `UINavigationController.present(...)` without additional settings. 9 | /// + bottomSheet - its modal presentation of the screen from bottom part with simple animation. 10 | /// + custom - its fully customisable animation for `UINavigationController.push(...)` with your own parameters. *WORK IN PROGRESS* 11 | public enum PresentationStyle { 12 | case fullscreen 13 | case modal 14 | case bottomSheet(BottomSheetSize) 15 | case custom(UIViewControllerTransitioningDelegate) 16 | } 17 | 18 | public enum BottomSheetSize { 19 | case fixed(CGFloat) 20 | case halfScreen 21 | case fullScreen 22 | case auto 23 | } 24 | 25 | public enum RestructActionAnimation { 26 | case forward 27 | case backward 28 | } 29 | 30 | public enum Action { 31 | case push(NaviduxScreen, ScreenConfig, PresentationStyle) 32 | case pop(NullablePayload) 33 | case popUntil(NaviduxScreen, NullablePayload) 34 | case restruct(screens: [NavigationRestructable], animationType: RestructActionAnimation) 35 | case replaceCertain(NaviduxScreen, ScreenConfig, RestructActionAnimation) 36 | case showAlert(AlertConfiguration) 37 | } 38 | } 39 | 40 | public protocol NavigationRestructable {} 41 | -------------------------------------------------------------------------------- /Sources/Navidux/NavigationController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// NavigationController its abstract object uses for navigation. Its a closest wrapper to UINavigation controller or alternative. 4 | public protocol NavigationController { 5 | /// - **screens**: return some representation of screen from UINavigationController.viewController. Difference in track screen in navigation stack. 6 | var screens: [any NavigationScreen] { get set } 7 | /// - **topScreen**: return screen from top of the 8 | var topScreen: (any NavigationScreen)? { get } 9 | 10 | /// - **addToStack**: method uses to add **NavigationScreen** into screen property. 11 | func addToStack(screen: any NavigationScreen) 12 | /// - **removeLastFromStack**: method uses to remove last **NavigationScreen** from screen property. 13 | func removeLastFromStack() 14 | /// - **removeTillFromStack**: method uses to remove last n **NavigationScreen** from screen property before condition met. 15 | func removeTillFromStack(screen: any NavigationScreen) 16 | /// - **rebuildNavStack**: method uses to replace all **NavigationScreen** from screen property with new ones. 17 | func rebuildNavStack(with screens: [any NavigationScreen]) 18 | 19 | // MARK: UINavigationController properties and methods 20 | 21 | /// - **topViewController**: (UINavigationController compatibility) The top view controller on the stack. 22 | var topViewController: UIViewController? { get } 23 | /// - **viewControllers**: (UINavigationController compatibility) The current view controller stack. 24 | var viewControllers: [UIViewController] { get set } 25 | 26 | /// - **pushViewController(viewController:, animated:)**: (UINavigationController compatibility) Uses a horizontal slide transition. Has no effect if the view controller is already in the stack. 27 | func pushViewController(_ viewController: UIViewController, animated: Bool) 28 | /// - **popViewController(animated:)**: (UINavigationController compatibility) Returns the popped controller. 29 | @discardableResult 30 | func popViewController(animated: Bool) -> UIViewController? 31 | /// - **popToViewController(viewController:, animated:)**: (UINavigationController compatibility) Pops view controllers until the one specified is on top. Returns the popped controllers. 32 | @discardableResult 33 | func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? 34 | /// - **present(viewControllerToPresent:, animated:, completion:)**: (UINavigationController compatibility) Presents a view controller modally.. 35 | func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?) 36 | /// - **dismiss(animated:, completion:)**:(UINavigationController compatibility) Dismisses the view controller that was presented modally by the view controller. 37 | func dismiss(animated flag: Bool, completion: (() -> Void)?) 38 | /// - **setViewControllers(viewController:, animated:)**:(UINavigationController compatibility) If animated is YES, then simulate a push or pop depending on whether the new top view controller was previously in the stack. 39 | func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Navidux/NavigationCoordinator.swift: -------------------------------------------------------------------------------- 1 | public final class NavigationCoordinator: Router { 2 | var navigationController: NavigationController 3 | var screenAssembler: ScreenAssembler 4 | public var state: NavigationStore 5 | let bottomSheetTransitioningDelegate = BSTransitioningDelegate() 6 | 7 | public init( 8 | _ controller: NavigationController, 9 | screenAssembler: some ScreenAssembler, 10 | state: NavigationStore = NavigationStore() 11 | ) { 12 | navigationController = controller 13 | self.screenAssembler = screenAssembler 14 | self.state = state 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Navidux/NavigationCoordinatorProxy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SafariServices 3 | 4 | ///Proxy give possibility to create `ScreenAssembler` that nessasary for `NavigationCoordinator`. 5 | ///Usage: after declaration `NavigationCoordinatorProxy` you can create `ScreenAssembler`. 6 | ///Next step to create real `Router`. And last stage is set subject property as real `Router`. 7 | /// - Example: 8 | ///``` swift 9 | ///let navigationController = NavigationControllerImpl() 10 | ///let screenFactory = NaviduxScreenFactory() 11 | ///let alertFactory = AlertFactoryImpl() 12 | ///let navigationCoordinatorProxy = NavigationCoordinatorProxy() 13 | ///let screenAssembler = NaviduxScreenAssembler( 14 | /// screenFactory: screenFactory, 15 | /// alertFactory: alertFactory, 16 | /// screenCoordinator: navigationCoordinatorProxy 17 | ///) 18 | /// 19 | ///let navigationCoordinator = NavigationCoordinator( 20 | /// navigationController, 21 | /// screenAssembler: screenAssembler 22 | ///) 23 | ///navigationCoordinatorProxy.subject = navigationCoordinator 24 | ///``` 25 | public final class NavigationCoordinatorProxy: Router { 26 | public var subject: NavigationCoordinator! 27 | 28 | public init(subject: NavigationCoordinator? = nil) { 29 | self.subject = subject 30 | } 31 | 32 | public func route(with action: Navigation.Action) { 33 | subject.route(with: action) 34 | } 35 | 36 | public func findCertain(controller: NaviduxScreen) -> (any NavigationScreen)? { 37 | subject.findCertain(controller: controller) 38 | } 39 | 40 | public func findFirstCertain(controller: NaviduxScreen) -> (any NavigationScreen)? { 41 | subject.findFirstCertain(controller: controller) 42 | } 43 | 44 | public func presentSFSafaryViewController(url: URL, delegate: SFSafariViewControllerDelegate?) { 45 | subject.presentSFSafaryViewController(url: url, delegate: delegate) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Navidux/Router.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SafariServices 3 | 4 | /// Main Object that used in navigation. Have many different types of presentation and inner methods to manipulate navigation stack. 5 | /// Work on states with associated values. 6 | /// - Note use **route(with:)** function with some action to change state of navigation stack. It's trigger inner function to change navigation screen. 7 | public protocol Router: AnyObject { 8 | func route(with action: Navigation.Action) 9 | func findCertain(controller: NaviduxScreen) -> (any NavigationScreen)? 10 | func findFirstCertain(controller: NaviduxScreen) -> (any NavigationScreen)? 11 | func presentSFSafaryViewController(url: URL, delegate: SFSafariViewControllerDelegate?) 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Navidux/ScreenAssembler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SafariServices 3 | 4 | public protocol ScreenAssembler { 5 | func assemblyScreen(components: ScreenAsseblyComponents) -> any NavigationScreen 6 | func assemblyAlert(configuration: AlertConfiguration) -> AlertScreen 7 | func assemblySFSafaryViewController(url: URL, delegate: SFSafariViewControllerDelegate?) -> SFSafariViewController 8 | } 9 | 10 | extension ScreenAssembler { 11 | public func assemblySFSafaryViewController(url: URL, delegate: SFSafariViewControllerDelegate?) -> SFSafariViewController { 12 | let controller = SFSafariViewController(url: url) 13 | controller.delegate = delegate 14 | return controller 15 | } 16 | } 17 | 18 | public struct ScreenAsseblyComponents: NavigationRestructable { 19 | public let screenType: NaviduxScreen 20 | public let config: ScreenConfig 21 | 22 | public init(screenType: NaviduxScreen, config: ScreenConfig) { 23 | self.screenType = screenType 24 | self.config = config 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Navidux/ScreenFactory.swift: -------------------------------------------------------------------------------- 1 | /// ScreenFactory uses as part of `ScreenAssembly` module and helps you to create `NavigationScreen` in `assmblyScreen` function. 2 | /// You can extend protocol with `(Dependencies) -> (Router, Configuration) -> Resulted VC` function and call them in assembler 3 | /// on call. 4 | /// - Example: 5 | /// ``` swift 6 | /// import Navidux 7 | /// 8 | /// extension Navidux.ScreenFactory { 9 | /// public var someScreenFactory: (Coordinator?, ScreenConfig) -> any NavigationScreen { 10 | /// { router, config 11 | /// return MyViewControllerConformedNavigationScreen() 12 | /// } 13 | /// } 14 | /// ``` 15 | public protocol ScreenFactory {} 16 | -------------------------------------------------------------------------------- /Sources/Navidux/alert/AlertConfiguration.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public struct AlertConfiguration: Equatable { 4 | public struct ButtonAction: Equatable { 5 | let title: String 6 | let style: ButtonStyle 7 | let action: () -> Void 8 | 9 | public static func == (lhs: Self, rhs: Self) -> Bool { 10 | lhs.title == rhs.title && lhs.style == rhs.style 11 | } 12 | } 13 | 14 | public enum ButtonStyle: Equatable { 15 | case `default` 16 | case cancel 17 | case destructive 18 | } 19 | 20 | public enum PresentationStyle: Equatable { 21 | case actionSheet 22 | case center 23 | } 24 | 25 | let title: String 26 | let message: String 27 | let style: PresentationStyle 28 | let actions: [ButtonAction] 29 | } 30 | 31 | extension AlertConfiguration.ButtonStyle { 32 | var asUIAlertActionStyle: UIAlertAction.Style { 33 | switch self { 34 | case .cancel: 35 | return .cancel 36 | case .destructive: 37 | return .destructive 38 | case .default: 39 | return .default 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Navidux/alert/AlertFactory.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public protocol AlertFactory { 4 | func createAlert(configuration: AlertConfiguration) -> AlertScreen 5 | } 6 | 7 | public final class AlertFactoryImpl: AlertFactory { 8 | public func createAlert(configuration : AlertConfiguration) -> AlertScreen { 9 | AlertScreen(configuration: configuration) 10 | } 11 | 12 | public init() {} 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Navidux/alert/AlertScreen.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class AlertScreen { 4 | let configuration: AlertConfiguration 5 | 6 | public init(configuration: AlertConfiguration) { 7 | self.configuration = configuration 8 | } 9 | 10 | func generateAlert(dismissedCallback: @escaping () -> Void) -> UIAlertController { 11 | let alert = UIAlertController( 12 | title: configuration.title, 13 | message: configuration.message, 14 | preferredStyle: configuration.style == .center ? .alert : .actionSheet 15 | ) 16 | configuration.actions.map { config in 17 | UIAlertAction( 18 | title: config.title, 19 | style: config.style.asUIAlertActionStyle, 20 | handler: { _ in 21 | dismissedCallback() 22 | config.action() 23 | } 24 | ) 25 | }.forEach { alert.addAction($0) } 26 | return alert 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Navidux/extensions/CGFloat+Extension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension CGFloat { 4 | func projectedOffset(decelerationRate: UIScrollView.DecelerationRate) -> CGFloat { 5 | let multiplier = 1 / (1 - decelerationRate.rawValue) / 1_000 6 | return self * multiplier 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Navidux/extensions/CGPoint+Extension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension CGPoint { 4 | func projectedOffset(decelerationRate: UIScrollView.DecelerationRate) -> CGPoint { 5 | CGPoint(x: x.projectedOffset(decelerationRate: decelerationRate), 6 | y: y.projectedOffset(decelerationRate: decelerationRate)) 7 | } 8 | 9 | static func + (left: CGPoint, right: CGPoint) -> CGPoint { 10 | CGPoint(x: left.x + right.x, 11 | y: left.y + right.y) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Navidux/extensions/ExtendableEnum.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ExtendableEnum: AnyObject, Hashable { } 4 | 5 | extension ExtendableEnum { 6 | public func hash(into hasher: inout Hasher) { 7 | hasher.combine(ObjectIdentifier(self)) 8 | } 9 | } 10 | 11 | public func ==(lhs: T, rhs: T) -> Bool { 12 | return lhs === rhs 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Navidux/extensions/Storyboard.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol Storyboarded { 4 | static func instantiate() -> Self 5 | } 6 | 7 | extension Storyboarded where Self: UIViewController { 8 | static func instantiate(storyboardName: String) -> Self { 9 | let className = NSStringFromClass(self).components(separatedBy: ".").last 10 | let storyboard = UIStoryboard(name: storyboardName, bundle: Bundle.main) 11 | return storyboard.instantiateViewController(withIdentifier: className!) as! Self 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Navidux/extensions/UIImage+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Extension.swift 3 | // 4 | // 5 | // Created by Oleg Krasnov on 09/11/2023. 6 | // 7 | 8 | import UIKit 9 | 10 | public extension UIImage { 11 | static let pullBarIcon = UIImage(named: "PullBarIcon", in: .module, with: nil) 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Navidux/extensions/UIPanGestureRecognizer+Extension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIPanGestureRecognizer { 4 | func incrementToBottom(maxTranslation: CGFloat) -> CGFloat { 5 | let translation = self.translation(in: view).y 6 | setTranslation(.zero, in: nil) 7 | 8 | let percentIncrement = translation / maxTranslation 9 | return percentIncrement 10 | } 11 | 12 | // На основе смещения и его скорости рассчитываем, хотел ли пользователь закрыть экран. 13 | func isProjectedToDownHalf(maxTranslation: CGFloat, percentComplete: CGFloat) -> Bool { 14 | let velocityOffset = velocity(in: view).projectedOffset(decelerationRate: .normal) 15 | let verticalTranslation = maxTranslation * percentComplete 16 | let translation = CGPoint(x: 0, y: verticalTranslation) + velocityOffset 17 | 18 | let isPresentationCompleted = translation.y > maxTranslation / 2 19 | return isPresentationCompleted 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Navidux/implemention/NavigationControllerImpl.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class NavigationControllerImpl: UINavigationController, NavigationController { 4 | 5 | // MARK: - Private properties 6 | 7 | private var navbarConfiguration: (UINavigationController) -> Void = { controller in 8 | controller.view.backgroundColor = .white 9 | controller.navigationBar.isTranslucent = false 10 | controller.navigationBar.backgroundColor = .white 11 | controller.navigationBar.shadowImage = .init() 12 | controller.navigationBar.barTintColor = .white 13 | controller.navigationBar.tintColor = .black 14 | controller.navigationBar.titleTextAttributes = [ 15 | NSAttributedString.Key.foregroundColor: UIColor.black 16 | ] 17 | } 18 | 19 | // MARK: - Public properties 20 | 21 | public var screens: [any NavigationScreen] = [] { 22 | didSet { debugPrint("screenStack: \(screens.map { $0.tag })") } 23 | } 24 | 25 | public var topScreen: (any NavigationScreen)? { 26 | screens.last 27 | } 28 | 29 | // MARK: - Init 30 | 31 | public init( 32 | navbarConfiguration: ((UINavigationController) -> Void)? = nil 33 | ) { 34 | if let navbarConfiguration { 35 | self.navbarConfiguration = navbarConfiguration 36 | } 37 | super.init(nibName: nil, bundle: nil) 38 | } 39 | 40 | @available(*, deprecated, message: "use init() instead.") 41 | required init?(coder aDecoder: NSCoder) { 42 | super.init(coder: aDecoder) 43 | } 44 | 45 | // MARK: - Lifecycle 46 | 47 | public override func viewDidLoad() { 48 | super.viewDidLoad() 49 | configureAppearance() 50 | } 51 | 52 | // MARK: - Public methods 53 | 54 | public func addToStack(screen: any NavigationScreen) { 55 | screens.append(screen) 56 | } 57 | 58 | public func removeLastFromStack() { 59 | guard screens.count != 1 else { 60 | print("Navigation Coordinator can't pop last screen") 61 | return 62 | } 63 | screens.removeLast() 64 | } 65 | 66 | public func removeTillFromStack(screen: any NavigationScreen) { 67 | if let idx = screens.lastIndex(where: { $0 == screen }) { 68 | screens.removeLast(screens.count - (idx + 1)) 69 | } 70 | } 71 | 72 | public func rebuildNavStack(with screens: [any NavigationScreen]) { 73 | self.screens = screens 74 | } 75 | 76 | public override var childForStatusBarStyle: UIViewController? { 77 | self.topViewController 78 | } 79 | 80 | // MARK: - Private methods 81 | 82 | public func configureAppearance() { 83 | navbarConfiguration(self) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/Navidux/implemention/NavigationReducer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SafariServices 3 | 4 | extension NavigationCoordinator { 5 | 6 | // MARK: - Public methods 7 | 8 | public func route(with action: Navigation.Action) { 9 | switch action { 10 | case let .push(screen, config, presentationStyle): 11 | var controller: any NavigationScreen 12 | controller = screenAssembler.assemblyScreen( 13 | components: ScreenAsseblyComponents( 14 | screenType: screen, 15 | config: config 16 | ) 17 | ) 18 | pushNew(screen: controller, style: presentationStyle, animated: true) 19 | 20 | case let .pop(payload): 21 | popLast(animated: true) 22 | if let topScreen = navigationController.topScreen { 23 | topScreen.gotUpdatedData(payload) 24 | topScreen.output(payload) 25 | } 26 | 27 | case let .popUntil(screen, payload): 28 | let certainController = findCertain(controller: screen, in: navigationController.screens) 29 | if let vc = certainController { 30 | popTo(screen: vc, animated: true) 31 | if let topScreen = navigationController.topScreen { 32 | topScreen.gotUpdatedData(payload) 33 | topScreen.output(payload) 34 | } 35 | } 36 | 37 | case let .restruct(screens, animationType): 38 | let controllers: [any NavigationScreen] = screens.compactMap { screen in 39 | switch screen { 40 | case let components as ScreenAsseblyComponents: 41 | return screenAssembler.assemblyScreen(components: components) 42 | case let navigationScreen as any NavigationScreen: 43 | return navigationScreen 44 | default: 45 | return nil 46 | } 47 | } 48 | restruct(with: controllers, animated: true, animationType: animationType) 49 | 50 | case let .replaceCertain(screen, config, animationType): 51 | var controllers = navigationController.viewControllers.compactMap { $0 as? any NavigationScreen } 52 | let newController = screenAssembler.assemblyScreen( 53 | components: ScreenAsseblyComponents( 54 | screenType: screen, 55 | config: config 56 | ) 57 | ) 58 | if controllers.last != nil { 59 | controllers[controllers.count - 1] = newController 60 | } else { 61 | controllers = [newController] 62 | } 63 | restruct(with: controllers, animated: true, animationType: animationType) 64 | 65 | case let .showAlert(configuration): 66 | let assembledAlert = screenAssembler.assemblyAlert(configuration: configuration) 67 | showAlert(alert: assembledAlert) 68 | } 69 | } 70 | 71 | public func findCertain(controller: NaviduxScreen) -> (any NavigationScreen)? { 72 | findCertain(controller: controller, in: navigationController.screens) 73 | } 74 | 75 | public func findFirstCertain(controller: NaviduxScreen) -> (any NavigationScreen)? { 76 | findFirstCertain(controller: controller, in: navigationController.screens) 77 | } 78 | 79 | public func presentSFSafaryViewController(url: URL, delegate: SFSafariViewControllerDelegate?) { 80 | let controller = screenAssembler.assemblySFSafaryViewController(url: url, delegate: delegate) 81 | presentSFSafaryViewController(controller, animated: true) 82 | } 83 | 84 | // MARK: - Private methods 85 | 86 | private func findCertain( 87 | controller: NaviduxScreen, 88 | in stack: [any NavigationScreen] 89 | ) -> (any NavigationScreen)? { 90 | return stack.last(where: { [controller] in 91 | $0.isKind(of: controller.asScreenClass) 92 | }) 93 | } 94 | 95 | private func findFirstCertain( 96 | controller: NaviduxScreen, 97 | in stack: [any NavigationScreen] 98 | ) -> (any NavigationScreen)? { 99 | return stack.first(where: { [controller] in 100 | $0.isKind(of: controller.asScreenClass) 101 | }) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/Navidux/implemention/NavigationRouter.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SafariServices 3 | 4 | extension NavigationCoordinator { 5 | 6 | // MARK: - Public methods 7 | 8 | func pushNew(screen: any NavigationScreen, style: Navigation.PresentationStyle, animated: Bool) { 9 | switch style { 10 | case .fullscreen: 11 | screen.navigationCallback = { [weak self, weak screen] in 12 | self?.controllerDismissed(screenTag: screen?.tag) 13 | } 14 | navigationController.pushViewController(screen, animated: animated) 15 | 16 | case .modal: 17 | screen.isModal = true 18 | screen.navigationCallback = { [weak self, weak screen] in 19 | self?.modalControllerDismissed(screenTag: screen?.tag) 20 | } 21 | if state.hasOverlay { 22 | navigationController.topScreen?.present(screen, animated: animated, completion: nil) 23 | } else { 24 | navigationController.present(screen, animated: animated, completion: nil) 25 | } 26 | state.hasOverlay = true 27 | 28 | // TODO: - Допилить SheetViewController 29 | case let .bottomSheet(size): 30 | screen.isModal = true 31 | screen.navigationCallback = { [weak self, weak screen] in 32 | self?.modalControllerDismissed(screenTag: screen?.tag) 33 | } 34 | state.hasOverlay = true 35 | 36 | switch size { 37 | case .auto: 38 | bottomSheetTransitioningDelegate.sheetSize = .auto 39 | screen.transitioningDelegate = bottomSheetTransitioningDelegate 40 | screen.modalPresentationStyle = .custom 41 | case .fixed(let height): 42 | bottomSheetTransitioningDelegate.sheetSize = .fixed(height) 43 | screen.transitioningDelegate = bottomSheetTransitioningDelegate 44 | screen.modalPresentationStyle = .custom 45 | case .halfScreen: 46 | bottomSheetTransitioningDelegate.sheetSize = .halfScreen 47 | screen.transitioningDelegate = bottomSheetTransitioningDelegate 48 | screen.modalPresentationStyle = .custom 49 | case .fullScreen: 50 | screen.modalPresentationStyle = .formSheet 51 | } 52 | 53 | navigationController.present(screen, animated: true, completion: nil) 54 | 55 | // TODO: - Реализовать 56 | case let .custom(delegate): 57 | break 58 | } 59 | navigationController.addToStack(screen: screen) 60 | } 61 | 62 | func showAlert(alert: AlertScreen) { 63 | guard !state.isAlertShow else { return } 64 | state.isAlertShow = true 65 | navigationController.present( 66 | alert.generateAlert( 67 | dismissedCallback: { [weak self] in 68 | self?.alertControllerDismissed() 69 | } 70 | ), 71 | animated: true, 72 | completion: nil 73 | ) 74 | } 75 | 76 | func popLast(animated: Bool) { 77 | if state.hasOverlay { 78 | state.hasOverlay = false 79 | navigationController.dismiss(animated: animated, completion: nil) 80 | } else { 81 | navigationController.popViewController(animated: animated) 82 | } 83 | 84 | navigationController.removeLastFromStack() 85 | } 86 | 87 | func popTo(screen: any NavigationScreen, animated: Bool) { 88 | if state.hasOverlay { 89 | state.hasOverlay = false 90 | navigationController.dismiss(animated: animated, completion: nil) 91 | } 92 | navigationController.popToViewController(screen, animated: animated) 93 | navigationController.removeTillFromStack(screen: screen) 94 | } 95 | 96 | func restruct( 97 | with screens: [any NavigationScreen], 98 | animated: Bool, 99 | animationType: Navigation.RestructActionAnimation 100 | ) { 101 | guard navigationController.topViewController != screens.last else { 102 | navigationController.viewControllers = screens 103 | return 104 | } 105 | 106 | switch animationType { 107 | case .forward: 108 | updateStackWithForwardAnimation( 109 | screens: screens, 110 | navigationController: &navigationController, 111 | store: &state, 112 | animated: animated 113 | ) 114 | case .backward: 115 | updateStackWithBackwardAnimation( 116 | screens: screens, 117 | navigationController: &navigationController, 118 | store: &state, 119 | animated: animated 120 | ) 121 | } 122 | 123 | navigationController.rebuildNavStack(with: screens) 124 | } 125 | 126 | func presentSFSafaryViewController(_ controller: SFSafariViewController, animated: Bool) { 127 | if state.hasOverlay { 128 | navigationController.topScreen?.present(controller, animated: animated, completion: nil) 129 | } else { 130 | navigationController.present(controller, animated: animated, completion: nil) 131 | } 132 | state.hasOverlay = true 133 | } 134 | 135 | // MARK: - Private methods 136 | 137 | private func updateStackWithBackwardAnimation( 138 | screens: [any NavigationScreen], 139 | navigationController: inout NavigationController, 140 | store: inout NavigationStore, 141 | animated: Bool 142 | ) { 143 | var newStack = (screens.map { $0 as UIViewController }) 144 | if !store.hasOverlay { 145 | newStack += [navigationController.topViewController].compactMap { $0 } 146 | } 147 | navigationController.viewControllers = newStack 148 | 149 | if navigationController.topScreen?.isModal ?? false { 150 | store.hasOverlay = false 151 | navigationController.dismiss(animated: animated, completion: nil) 152 | } else { 153 | navigationController.popViewController(animated: animated) 154 | } 155 | } 156 | 157 | private func updateStackWithForwardAnimation( 158 | screens: [any NavigationScreen], 159 | navigationController: inout NavigationController, 160 | store: inout NavigationStore, 161 | animated: Bool 162 | ) { 163 | let newStack = screens.map { $0 as UIViewController } 164 | navigationController.setViewControllers(newStack, animated: animated) 165 | } 166 | 167 | private func checkEquality(lhs: [any NavigationScreen], rhs: [any NavigationScreen]) -> Bool { 168 | guard lhs.count == rhs.count else { return false } 169 | 170 | var result: Bool = true 171 | 172 | for (leftElement, rightElement) in zip(lhs, rhs) { 173 | if leftElement.tag != rightElement.tag { 174 | result = false 175 | break 176 | } 177 | } 178 | 179 | return result 180 | } 181 | 182 | private func modalControllerDismissed(screenTag: String?) { 183 | guard let screenTag = screenTag else { return } 184 | 185 | let topScreen = navigationController.topScreen 186 | if state.hasOverlay, 187 | topScreen?.isModal ?? false, 188 | topScreen?.tag == screenTag { 189 | navigationController.removeLastFromStack() 190 | if !(navigationController.topScreen?.isModal ?? false) { 191 | state.hasOverlay = false 192 | } 193 | navigationController.topScreen?.gotUpdatedData(topScreen?.dataToSendFromModal) 194 | } 195 | } 196 | 197 | private func alertControllerDismissed() { 198 | state.isAlertShow = false 199 | } 200 | 201 | private func controllerDismissed(screenTag: String?) { 202 | guard let screenTag = screenTag else { return } 203 | 204 | let topScreen = navigationController.topScreen 205 | if !(topScreen?.isModal ?? true) && topScreen?.tag == screenTag { 206 | navigationController.removeLastFromStack() 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Sources/Navidux/implemention/NavigationStore.swift: -------------------------------------------------------------------------------- 1 | public struct NavigationStore { 2 | public var isAlertShow: Bool = false 3 | public var hasOverlay: Bool = false 4 | 5 | public init(isAlertShow: Bool = false, hasOverlay: Bool = false) { 6 | self.isAlertShow = isAlertShow 7 | self.hasOverlay = hasOverlay 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Navidux/implemention/Payload.swift: -------------------------------------------------------------------------------- 1 | public typealias NullablePayload = (any Payload)? 2 | 3 | /// Payload uses to transfer some data from screen to screen. 4 | /// In default implementation on push new screen `Payload` added to `ScreenConfig` objet. 5 | /// You have access to data in `ScreenAssemble` object. 6 | /// Another case with access, you have on `.pop` and `.popUntil` actions. 7 | /// This payload return as parameter in default implementation of `gotUpdatedData(_:)` or `output: ((NullablePayload) -> Void)` in `NavigationScreen` protocol. 8 | /// If you want access data at this access point you have to override `gotUpdatedData(_:)` function in your inherited ViewController or use `output: ((NullablePayload) -> Void)`. 9 | public protocol Payload: Hashable { 10 | associatedtype T: Hashable 11 | var data: T { get } 12 | } 13 | 14 | /// It's shortcut for Payload. 15 | public struct VoidPayload: Payload { 16 | public var data = false 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Navidux/resources/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Navidux/resources/Media.xcassets/PullbarIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "PullBarIcon.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Navidux/resources/Media.xcassets/PullbarIcon.imageset/PullBarIcon.pdf: -------------------------------------------------------------------------------- 1 | %PDF-1.7 2 | 3 | 1 0 obj 4 | << /ExtGState << /E1 << /ca 0.200000 >> >> >> 5 | endobj 6 | 7 | 2 0 obj 8 | << /Length 3 0 R >> 9 | stream 10 | /DeviceRGB CS 11 | /DeviceRGB cs 12 | q 13 | /E1 gs 14 | 1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm 15 | 0.000000 0.000000 0.000000 scn 16 | 0.000000 2.000000 m 17 | 0.000000 3.104569 0.895431 4.000000 2.000000 4.000000 c 18 | 46.000000 4.000000 l 19 | 47.104568 4.000000 48.000000 3.104569 48.000000 2.000000 c 20 | 48.000000 2.000000 l 21 | 48.000000 0.895431 47.104568 0.000000 46.000000 0.000000 c 22 | 2.000001 0.000000 l 23 | 0.895431 0.000000 0.000000 0.895431 0.000000 2.000000 c 24 | 0.000000 2.000000 l 25 | h 26 | f 27 | n 28 | Q 29 | 30 | endstream 31 | endobj 32 | 33 | 3 0 obj 34 | 466 35 | endobj 36 | 37 | 4 0 obj 38 | << /Annots [] 39 | /Type /Page 40 | /MediaBox [ 0.000000 0.000000 48.000000 4.000000 ] 41 | /Resources 1 0 R 42 | /Contents 2 0 R 43 | /Parent 5 0 R 44 | >> 45 | endobj 46 | 47 | 5 0 obj 48 | << /Kids [ 4 0 R ] 49 | /Count 1 50 | /Type /Pages 51 | >> 52 | endobj 53 | 54 | 6 0 obj 55 | << /Pages 5 0 R 56 | /Type /Catalog 57 | >> 58 | endobj 59 | 60 | xref 61 | 0 7 62 | 0000000000 65535 f 63 | 0000000010 00000 n 64 | 0000000074 00000 n 65 | 0000000596 00000 n 66 | 0000000618 00000 n 67 | 0000000790 00000 n 68 | 0000000864 00000 n 69 | trailer 70 | << /ID [ (some) (id) ] 71 | /Root 6 0 R 72 | /Size 7 73 | >> 74 | startxref 75 | 923 76 | %%EOF -------------------------------------------------------------------------------- /Sources/Navidux/screens/ActivityViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityViewController.swift 3 | // Navidux 4 | // 5 | // Created by Stanislav Anatskii on 20.12.2022. 6 | // Copyright © 2022 red_mad_robot. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class ActivityViewController: UIActivityViewController, NavigationScreen { 12 | 13 | // MARK: - Public properties 14 | 15 | public var tag: String 16 | public var isModal: Bool = true 17 | public var navigationCallback: (() -> Void)? 18 | public var onBackCallback: () -> Void 19 | public var dataToSendFromModal: NullablePayload 20 | public var output: ((NullablePayload) -> Void) 21 | 22 | // MARK: - Init 23 | 24 | public init( 25 | activityItems: [Any], 26 | applicationActivities: [UIActivity]?, 27 | tag: String = UUID().uuidString, 28 | output: @escaping (NullablePayload) -> Void 29 | ) { 30 | onBackCallback = { } 31 | self.output = output 32 | self.tag = tag 33 | self.dataToSendFromModal = nil 34 | 35 | super.init( 36 | activityItems: activityItems, 37 | applicationActivities: applicationActivities 38 | ) 39 | } 40 | 41 | // MARK: - Lifecycle 42 | 43 | public override func viewWillDisappear(_ animated: Bool) { 44 | super.viewWillDisappear(animated) 45 | navigationCallback?() 46 | } 47 | 48 | // MARK: - Public methods 49 | 50 | public func gotUpdatedData(_ payload: NullablePayload) {} 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/BottomSheet/BSPresentationController.swift: -------------------------------------------------------------------------------- 1 | // https://habr.com/ru/companies/koshelek/articles/703260/ 2 | import UIKit 3 | 4 | final class BSPresentationController: UIPresentationController { 5 | 6 | public var sheetSize: Navigation.BottomSheetSize = .auto 7 | 8 | private lazy var dimmView: UIView = { 9 | let view = UIView() 10 | view.backgroundColor = UIColor.black.withAlphaComponent(0.75) 11 | view.addGestureRecognizer(tapRecognizer) 12 | return view 13 | }() 14 | 15 | private lazy var tapRecognizer: UITapGestureRecognizer = { 16 | let recognizer = UITapGestureRecognizer( 17 | target: self, 18 | action: #selector(handleTap) 19 | ) 20 | return recognizer 21 | }() 22 | 23 | override var shouldPresentInFullscreen: Bool { 24 | false 25 | } 26 | 27 | // Этот метод UIKit вызовет перед стартом презентации. 28 | // Расположит презентуемый контроллер в containerView presentation controller’а. 29 | override func presentationTransitionWillBegin() { 30 | super.presentationTransitionWillBegin() 31 | 32 | guard let containerView = containerView, 33 | let presentedView = presentedView 34 | else { return } 35 | 36 | [dimmView, presentedView].forEach { 37 | $0.translatesAutoresizingMaskIntoConstraints = false 38 | containerView.addSubview($0) 39 | } 40 | 41 | dimmView.alpha = 0 42 | performAlongsideTransitionIfPossible { 43 | self.dimmView.alpha = 1 44 | } 45 | 46 | var constraints: [NSLayoutConstraint] = [ 47 | presentedView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), 48 | presentedView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), 49 | presentedView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), 50 | 51 | dimmView.topAnchor.constraint(equalTo: containerView.topAnchor), 52 | dimmView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), 53 | dimmView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), 54 | dimmView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) 55 | ] 56 | 57 | switch sheetSize { 58 | case .fixed(let height): 59 | constraints.append( 60 | presentedView.heightAnchor.constraint(equalToConstant: height) 61 | ) 62 | case .halfScreen: 63 | constraints.append( 64 | presentedView.heightAnchor.constraint( 65 | lessThanOrEqualTo: containerView.heightAnchor, 66 | constant: -(containerView.bounds.height / 2) 67 | ) 68 | ) 69 | case .auto: 70 | constraints.append( 71 | presentedView.heightAnchor.constraint( 72 | lessThanOrEqualTo: containerView.heightAnchor, 73 | constant: -containerView.safeAreaInsets.top 74 | ) 75 | ) 76 | case .fullScreen: 77 | break 78 | } 79 | 80 | NSLayoutConstraint.activate(constraints) 81 | } 82 | 83 | // Удаляем subviews из контейнера, если транзишен был прерван. 84 | override func presentationTransitionDidEnd(_ completed: Bool) { 85 | if !completed { 86 | dimmView.removeFromSuperview() 87 | presentedView?.removeFromSuperview() 88 | } 89 | } 90 | 91 | override func dismissalTransitionWillBegin() { 92 | super.dismissalTransitionWillBegin() 93 | performAlongsideTransitionIfPossible { 94 | self.dimmView.alpha = 0 95 | } 96 | } 97 | 98 | private func performAlongsideTransitionIfPossible(_ animation: @escaping () -> Void ) { 99 | guard let coordinator = presentedViewController.transitionCoordinator else { 100 | animation() 101 | return 102 | } 103 | 104 | coordinator.animate { _ in 105 | animation() 106 | } 107 | } 108 | 109 | @objc 110 | private func handleTap(_ sender: UITapGestureRecognizer) { 111 | presentingViewController.dismiss(animated: true) 112 | } 113 | } 114 | 115 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/BottomSheet/BSScrollableViews/BSCollectionView.swift: -------------------------------------------------------------------------------- 1 | // https://habr.com/ru/companies/koshelek/articles/703260/ 2 | import UIKit 3 | 4 | open class BSCollectionView: UICollectionView { 5 | 6 | // При получении самого актуального значения высоты contentSize, запускаем обновление константы 7 | open override var contentSize: CGSize { 8 | didSet { 9 | fixHeight() 10 | } 11 | } 12 | 13 | // Создаем констрейнт с минимальным приоритетом, чтобы в случае, если высота контента будет больше, 14 | // чем может быть высота bottom sheet, не случился конфликт приоритетов. 15 | open lazy var collectionHeightConstraint: NSLayoutConstraint = { 16 | let constraint = heightAnchor.constraint(equalToConstant: 0) 17 | constraint.priority = .defaultLow 18 | constraint.isActive = true 19 | return constraint 20 | }() 21 | 22 | // Также, кроме высоты, учитываем и все дополнительные отступы и инсеты. 23 | open func fixHeight() { 24 | var height = collectionViewLayout.collectionViewContentSize.height 25 | + contentInset.top 26 | + contentInset.bottom 27 | + safeAreaInsets.bottom 28 | (collectionViewLayout as? UICollectionViewFlowLayout).map { height += $0.sectionInset.top } 29 | (collectionViewLayout as? UICollectionViewFlowLayout).map { height += $0.sectionInset.bottom } 30 | 31 | // Значение высоты равное 0 нет смысла обновлять, а равное infinity — ломает констрейнты, а вместе с ними и autoLayout 32 | if height != 0 && height != CGFloat.infinity { 33 | collectionHeightConstraint.constant = height 34 | } 35 | } 36 | 37 | open override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 38 | guard gestureRecognizer === panGestureRecognizer else { 39 | return true 40 | } 41 | 42 | if contentOffset.y == -contentInset.top, panGestureRecognizer.velocity(in: nil).y > 0 { 43 | return false 44 | } 45 | 46 | if contentOffset.y < -contentInset.top { 47 | return false 48 | } 49 | 50 | return true 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/BottomSheet/BSScrollableViews/BSScrollView.swift: -------------------------------------------------------------------------------- 1 | // https://habr.com/ru/companies/koshelek/articles/703260/ 2 | 3 | import UIKit 4 | 5 | open class BSScrollView: UIScrollView { 6 | 7 | // При получении самого актуального значения высоты contentSize, запускаем обновление константы 8 | open override var contentSize: CGSize { 9 | didSet { 10 | fixHeight() 11 | } 12 | } 13 | 14 | // Создаем констрейнт с минимальным приоритетом, чтобы в случае, если высота контента будет больше, 15 | // чем может быть высота bottom sheet, не случился конфликт приоритетов. 16 | open lazy var scrollHeightConstraint: NSLayoutConstraint = { 17 | let constraint = heightAnchor.constraint(equalToConstant: 0) 18 | constraint.priority = .defaultLow 19 | constraint.isActive = true 20 | return constraint 21 | }() 22 | 23 | // Обновляем констрейнт высоты на основе содержимого и отступов. 24 | open func fixHeight() { 25 | var height = contentSize.height 26 | height += contentInset.top 27 | height += contentInset.bottom 28 | height += safeAreaInsets.bottom 29 | 30 | // Значение высоты равное 0 нет смысла обновлять, а равное infinity — ломает констрейнты, а вместе с ними и autoLayout 31 | if height != 0 && height != CGFloat.infinity { 32 | scrollHeightConstraint.constant = height 33 | } 34 | } 35 | 36 | open override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 37 | guard gestureRecognizer === panGestureRecognizer else { 38 | return true 39 | } 40 | 41 | if contentOffset.y <= -contentInset.top, panGestureRecognizer.velocity(in: self).y > 0 { 42 | return false 43 | } 44 | 45 | if contentOffset.y < -contentInset.top { 46 | return false 47 | } 48 | 49 | return true 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/BottomSheet/BSScrollableViews/BSTableView.swift: -------------------------------------------------------------------------------- 1 | // https://habr.com/ru/companies/koshelek/articles/703260/ 2 | 3 | import UIKit 4 | 5 | open class BSTableView: UITableView { 6 | 7 | // При получении самого актуального значения высоты contentSize, запускаем обновление константы 8 | open override var contentSize: CGSize { 9 | didSet { 10 | fixHeight() 11 | } 12 | } 13 | 14 | // Создаем констрейнт с минимальным приоритетом, чтобы в случае, если высота контента будет больше, 15 | // чем может быть высота bottom sheet, не случился конфликт приоритетов. 16 | open lazy var collectionHeightConstraint: NSLayoutConstraint = { 17 | let constraint = heightAnchor.constraint(equalToConstant: 0) 18 | constraint.priority = .defaultLow 19 | constraint.isActive = true 20 | return constraint 21 | }() 22 | 23 | // Также, кроме высоты, учитываем и все дополнительные отступы и инсеты. 24 | open func fixHeight() { 25 | var height = contentSize.height 26 | height += contentInset.top 27 | height += contentInset.bottom 28 | height += safeAreaInsets.bottom 29 | height += (tableHeaderView?.frame.height ?? 0) 30 | height += (tableFooterView?.frame.height ?? 0) 31 | 32 | // Значение высоты равное 0 нет смысла обновлять, а равное infinity — ломает констрейнты, а вместе с ними и autoLayout 33 | if height != 0 && height != .infinity { 34 | collectionHeightConstraint.constant = height 35 | } 36 | } 37 | 38 | open override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 39 | guard gestureRecognizer === panGestureRecognizer else { 40 | return true 41 | } 42 | 43 | if contentOffset.y == -contentInset.top, panGestureRecognizer.velocity(in: nil).y > 0 { 44 | return false 45 | } 46 | 47 | if contentOffset.y < -contentInset.top { 48 | return false 49 | } 50 | 51 | return true 52 | } 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/BottomSheet/BSTransitionDriver.swift: -------------------------------------------------------------------------------- 1 | // https://habr.com/ru/companies/koshelek/articles/703260/ 2 | import UIKit 3 | 4 | // TODO: - тут наверное нужно рефакторить в будущем т.к. старт закрытия должен происходить в том месте, где происходит логика переходов между экранами 5 | final class BSTransitionDriver: UIPercentDrivenInteractiveTransition { 6 | private weak var presentedController: UIViewController? 7 | 8 | // Максимальное расстояние, на которое можно сместить презентованный контроллер — это высота контроллера, 9 | // поэтому используем её как максимально возможное смещение 10 | private var maxTranslation: CGFloat? { 11 | let height = presentedController?.view.frame.height ?? 0 12 | return height > 0 ? height : nil 13 | } 14 | 15 | // Жест, который будет отслеживать движение пальца. 16 | // Так мы сможем посчитать прямую зависимость между длиной сдвига и процентом анимации. 17 | private lazy var panRecognizer: UIPanGestureRecognizer = { 18 | let panRecognizer = UIPanGestureRecognizer( 19 | target: self, 20 | action: #selector(handleDismiss) 21 | ) 22 | panRecognizer.delegate = self 23 | return panRecognizer 24 | }() 25 | 26 | // В случае, если этот флаг false, даёт возможность воспроизвести интерактивную анимацию как обычную. 27 | // По умолчанию всегда true. 28 | // Если не добавить условие интерактивного старта, то, при нажатии в область затемнения, анимация транзишена будет перехвачена driver’ом и остановится в стартовой позиции в ожидании дальнейших команд. 29 | // Добавляем условие, чтобы интерактивным транзишен становился только в случае, если случился жест свайпа. 30 | override var wantsInteractiveStart: Bool { 31 | get { 32 | panRecognizer.state == .began 33 | } 34 | set { 35 | super.wantsInteractiveStart = newValue 36 | } 37 | } 38 | 39 | init(controller: UIViewController) { 40 | super.init() 41 | 42 | controller.view.addGestureRecognizer(panRecognizer) 43 | presentedController = controller 44 | } 45 | 46 | @objc 47 | private func handleDismiss(_ sender: UIPanGestureRecognizer) { 48 | guard let maxTranslation = maxTranslation else { return } 49 | switch sender.state { 50 | case .began: 51 | let isRunning = percentComplete != 0 52 | // Чтобы избежать сбоев, проверяем, что анимация ещё не запущена другим способом. 53 | if !isRunning { 54 | presentedController?.dismiss(animated: true) 55 | } 56 | 57 | // На старте интерактивного транзишена анимация уже находится в состоянии паузы, 58 | // но если пользователь свайпнет и сразу передумает, то сможет поймать закрытие и поставить на паузу 59 | pause() 60 | 61 | case .changed: 62 | // На каждый шаг смещения будем обновлять процент анимации через update(_:) до момента, 63 | // пока пользователь не поднимет палец. 64 | let increment = sender.incrementToBottom(maxTranslation: maxTranslation) 65 | update(percentComplete + increment) 66 | 67 | case .ended, .cancelled: 68 | // Когда жест будет завершён или отменён, нужно будет рассчитать, как поступить с транзишеном. 69 | // Если смещение было больше половины или скорость смещения можно расценивать как быстрый свайп вниз, тогда мы вызываем finish() и транзишен закрытия завершается анимированно. 70 | // В противном случае отменяем транзишен и экран остаётся открытым. 71 | if sender.isProjectedToDownHalf( 72 | maxTranslation: maxTranslation, 73 | percentComplete: percentComplete 74 | ) { 75 | finish() 76 | } else { 77 | cancel() 78 | } 79 | 80 | case .failed: 81 | cancel() 82 | 83 | default: 84 | break 85 | } 86 | } 87 | } 88 | 89 | extension BSTransitionDriver: UIGestureRecognizerDelegate { 90 | // Жест будет обработан, если его скорость по оси Y больше 0, то есть направлен вниз, и скорость по оси Y больше чем X, чтобы не реагировать на боковые свайпы. 91 | func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 92 | let velocity = panRecognizer.velocity(in: nil) 93 | if velocity.y > 0, abs(velocity.y) > abs(velocity.x) { 94 | return true 95 | } else { 96 | return false 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/BottomSheet/BSTransitioningDelegate.swift: -------------------------------------------------------------------------------- 1 | // https://habr.com/ru/companies/koshelek/articles/703260/ 2 | import UIKit 3 | 4 | final class BSTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate { 5 | 6 | private var driver: BSTransitionDriver? 7 | var sheetSize: Navigation.BottomSheetSize = .auto 8 | 9 | // Cоздаем presentation controller. 10 | // Он является контейнером для презентуемого контроллера и отвечает за его положение и размеры. 11 | func presentationController( 12 | // контроллер, который хотим отобразить 13 | forPresented presented: UIViewController, 14 | // контроллер, поверх которого будет отображён презентуемый контроллер 15 | presenting: UIViewController?, 16 | // контроллер, который вызвал метод present(_:animate:completion:) 17 | source: UIViewController 18 | ) -> UIPresentationController? { 19 | driver = BSTransitionDriver(controller: presented) 20 | let presentationController = BSPresentationController( 21 | presentedViewController: presented, 22 | presenting: presenting ?? source 23 | ) 24 | presentationController.sheetSize = sheetSize 25 | return presentationController 26 | } 27 | 28 | // Метод для создания анимации, с которой презентуемый контроллер будет появляться на экране. 29 | func animationController( 30 | forPresented presented: UIViewController, 31 | presenting: UIViewController, 32 | source: UIViewController 33 | ) -> UIViewControllerAnimatedTransitioning? { 34 | CoverVerticalPresentAnimatedTransitioning() 35 | } 36 | 37 | // Метод для создания анимации, с которой контроллер будет исчезать. 38 | func animationController( 39 | forDismissed dismissed: UIViewController 40 | ) -> UIViewControllerAnimatedTransitioning? { 41 | CoverVerticalDismissAnimatedTransitioning() 42 | } 43 | 44 | // Метод для анимации закрытия при свайпе. 45 | func interactionControllerForDismissal( 46 | using animator: UIViewControllerAnimatedTransitioning 47 | ) -> UIViewControllerInteractiveTransitioning? { 48 | driver 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/BottomSheet/CoverVerticalDismissAnimatedTransitioning.swift: -------------------------------------------------------------------------------- 1 | // https://habr.com/ru/companies/koshelek/articles/703260/ 2 | import UIKit 3 | 4 | final class CoverVerticalDismissAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning { 5 | 6 | private let duration: TimeInterval = 0.35 7 | 8 | func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { 9 | duration 10 | } 11 | 12 | func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { 13 | let animator = makeAnimator(using: transitionContext) 14 | animator?.startAnimation() 15 | } 16 | 17 | func interruptibleAnimator( 18 | using transitionContext: UIViewControllerContextTransitioning 19 | ) -> UIViewImplicitlyAnimating { 20 | makeAnimator(using: transitionContext) ?? UIViewPropertyAnimator() 21 | } 22 | 23 | // MARK: Private 24 | private func makeAnimator( 25 | using transitionContext: UIViewControllerContextTransitioning 26 | ) -> UIViewImplicitlyAnimating? { 27 | guard let fromView = transitionContext.view(forKey: .from) 28 | else { 29 | return nil 30 | } 31 | 32 | let animator = UIViewPropertyAnimator( 33 | duration: duration, 34 | controlPoint1: CGPoint(x: 0.2, y: 1), 35 | controlPoint2: CGPoint(x: 0.42, y: 1) 36 | ) { 37 | fromView.transform = CGAffineTransform.identity.translatedBy(x: 0, y: fromView.frame.height) 38 | } 39 | 40 | animator.addCompletion { _ in 41 | transitionContext.completeTransition(!transitionContext.transitionWasCancelled) 42 | } 43 | 44 | return animator 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/BottomSheet/CoverVerticalPresentAnimatedTransitioning.swift: -------------------------------------------------------------------------------- 1 | // https://habr.com/ru/companies/koshelek/articles/703260/ 2 | import UIKit 3 | 4 | final class CoverVerticalPresentAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning { 5 | // Стандартное время транзишена в iOS — 0.35. 6 | private let duration: TimeInterval = 0.35 7 | 8 | // Перед стартом анимации UIKit запросит время анимации транзакции открытия экрана. 9 | func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { 10 | duration 11 | } 12 | 13 | // Перед стартом транзишена UIKit вызовет этот метод с контекстом, в котором хранится необходимая информации об участниках. 14 | func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { 15 | let animator = makeAnimator(using: transitionContext) 16 | animator?.startAnimation() 17 | } 18 | 19 | private func makeAnimator( 20 | using transitionContext: UIViewControllerContextTransitioning 21 | ) -> UIViewImplicitlyAnimating? { 22 | guard let toView = transitionContext.view(forKey: .to) 23 | else { 24 | return nil 25 | } 26 | 27 | // Анимация построена на смещении view по y из-за нижней границы экрана, поэтому для начала принудительно обновляем layout view контроллера. 28 | // Так как размеры и положение у нас заданы в BSPresentationController с помощью констрейнтов, то layoutIfNeeded спровоцирует UIKit на перерасчёт. 29 | let containerView = transitionContext.containerView 30 | containerView.layoutIfNeeded() 31 | 32 | // Смещаем view вниз за экран на его же высоту до старта анимации с помощью трансформации. 33 | toView.transform = CGAffineTransform.identity.translatedBy(x: 0, y: toView.frame.height) 34 | 35 | let animator = UIViewPropertyAnimator( 36 | duration: duration, 37 | controlPoint1: CGPoint(x: 0.2, y: 1), 38 | controlPoint2: CGPoint(x: 0.42, y: 1) 39 | ) { 40 | // В блоке аниматора вернём view к исходному положению. 41 | toView.transform = .identity 42 | } 43 | 44 | animator.addCompletion { _ in 45 | // После завершения анимации необходимо вызвать у контекста метод completeTransition(_ didComplete:) для индикации, что все анимации завершены со значением true, если анимация не была прервана. 46 | transitionContext.completeTransition(!transitionContext.transitionWasCancelled) 47 | } 48 | 49 | return animator 50 | } 51 | } 52 | 53 | 54 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/BottomSheet/PullBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PullBar.swift 3 | // 4 | // 5 | // Created by Oleg Krasnov on 08/11/2023. 6 | // 7 | 8 | import UIKit 9 | 10 | // TODO: - Возможно нужно подумать о том, чтобы автоматически добавлять pull bar к bottom sheet. 11 | // Сейчас нужно добавлять на каждый экран отдельно 12 | open class PullBar: UIView { 13 | 14 | private let topCornerRadius: Int 15 | private let icon: UIImage? 16 | private let iconSize: CGSize 17 | private let pullBarBackgroundColor: UIColor 18 | 19 | private lazy var imageView = UIImageView(image: icon) 20 | 21 | public init( 22 | topCornerRadius: Int = 20, 23 | icon: UIImage? = .pullBarIcon, 24 | iconSize: CGSize = .init(width: 48, height: 4), 25 | pullBarBackgroundColor: UIColor = .white 26 | ) { 27 | self.topCornerRadius = topCornerRadius 28 | self.icon = icon 29 | self.iconSize = iconSize 30 | self.pullBarBackgroundColor = pullBarBackgroundColor 31 | super.init(frame: .zero) 32 | setupUI() 33 | } 34 | 35 | public required init?(coder: NSCoder) { nil } 36 | 37 | open override func layoutSubviews() { 38 | super.layoutSubviews() 39 | let path = UIBezierPath( 40 | roundedRect: bounds, 41 | byRoundingCorners:[.topRight, .topLeft], 42 | cornerRadii: CGSize( 43 | width: topCornerRadius, 44 | height: topCornerRadius 45 | ) 46 | ) 47 | 48 | let maskLayer = CAShapeLayer() 49 | maskLayer.path = path.cgPath 50 | layer.mask = maskLayer 51 | } 52 | 53 | open func setupUI() { 54 | backgroundColor = pullBarBackgroundColor 55 | imageView.translatesAutoresizingMaskIntoConstraints = false 56 | addSubview(imageView) 57 | 58 | NSLayoutConstraint.activate([ 59 | imageView.widthAnchor.constraint(equalToConstant: iconSize.width), 60 | imageView.heightAnchor.constraint(equalToConstant: iconSize.height), 61 | imageView.topAnchor.constraint(equalTo: topAnchor, constant: 8), 62 | imageView.centerXAnchor.constraint(equalTo: centerXAnchor) 63 | ]) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/HostingController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | public final class HostingController: UIHostingController, 5 | NavigationScreen, 6 | DismissCheckable, 7 | UIGestureRecognizerDelegate { 8 | 9 | // MARK: - Public properties 10 | 11 | public var tag: String 12 | public var isModal: Bool = false 13 | public weak var navigation: (any Router)? 14 | public var navigationCallback: (() -> Void)? = nil 15 | public var onBackCallback: () -> Void 16 | public var backButtonImage: UIImage? = UIImage(systemName: "chevron.backward") 17 | public var isNeedBackButton: Bool 18 | public var dataToSendFromModal: NullablePayload = nil 19 | public var output: (NullablePayload) -> Void 20 | 21 | // MARK: - Init 22 | 23 | public init( 24 | title: String, 25 | isNeedBackButton: Bool, 26 | tag: String, 27 | navigation: (any Router)?, 28 | content: ViewContent, 29 | output: @escaping (NullablePayload) -> Void = { _ in } 30 | ) { 31 | self.tag = tag 32 | self.navigation = navigation 33 | self.isNeedBackButton = isNeedBackButton 34 | self.onBackCallback = { [weak navigation] in 35 | navigation?.route(with: .pop(nil)) 36 | } 37 | self.output = output 38 | super.init(rootView: content) 39 | self.title = title 40 | } 41 | 42 | @available(*, deprecated, message: "use init with params instead.") 43 | public required init?(coder _: NSCoder) { 44 | return nil 45 | } 46 | 47 | // MARK: - Lifecycle 48 | 49 | public override func viewWillAppear(_ animated: Bool) { 50 | super.viewWillAppear(animated) 51 | if isNeedBackButton { 52 | configureNavigationBackButton(#selector(onBack)) 53 | } else { 54 | navigationItem.hidesBackButton = true 55 | } 56 | } 57 | 58 | public override func viewDidAppear(_ animated: Bool) { 59 | super.viewDidAppear(animated) 60 | navigationController?.interactivePopGestureRecognizer?.delegate = self 61 | } 62 | 63 | public override func viewDidDisappear(_ animated: Bool) { 64 | super.viewDidDisappear(animated) 65 | if isNeedBackButton { 66 | cleanBackNavigationButton() 67 | } 68 | } 69 | 70 | // MARK: - Public methods 71 | 72 | public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 73 | guard 74 | gestureRecognizer.isEqual(navigationController?.interactivePopGestureRecognizer) 75 | else { 76 | return true 77 | } 78 | 79 | onBack() 80 | 81 | return false 82 | } 83 | 84 | @objc 85 | public func onBack() { 86 | onBackCallback() 87 | } 88 | 89 | // TODO: - Подумать как использовать 90 | public func gotUpdatedData(_ payload: NullablePayload) {} 91 | } 92 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/NavigationScreen.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Main element of Navigation on Redux (Navidux). Uses for store/move screens in navigation stack. 4 | public protocol NavigationScreen: UIViewController, AnyObject, NavigationRestructable where Self: Equatable { 5 | /// - **tag**: The unique tag of the screen. Use for search in nav stack. Can be set on screen setup. 6 | var tag: String { get set } 7 | /// - **isModal**: property indicates that screen will be present as modal or not. Edited only from NavigationRouter. 8 | var isModal: Bool { get set } 9 | /// - **navigationCallback**: used for additional checking in navigation core and support consistency of the navigation state. Edited only from NavigationRouter. 10 | var navigationCallback: (() -> Void)? { get set } 11 | /// - **onBackCallback**: function that fired then user use back button or swipe. Can be set on screen setup. 12 | var onBackCallback: () -> Void { get set } 13 | /// - **dataToSendFromModal**: Data storage that will be used on dismiss screen and send to new top screen. 14 | var dataToSendFromModal: NullablePayload { get } 15 | /// - **gotUpdatedData**: function that fired on then upper screen remove from nav stack and current screen become topScreen. Can be overrided. 16 | @available(*, deprecated, message: "Please, use 'output' property instead") 17 | func gotUpdatedData(_ payload: NullablePayload) 18 | /// - **output**: property can be used to get call back when upper screen will be removed from nav stack and current screen will become topScreen 19 | var output: ((NullablePayload) -> Void) { get } 20 | } 21 | 22 | extension NavigationScreen { 23 | static func == (lhs: Self, rhs: Self) -> Bool { 24 | lhs.tag == rhs.tag 25 | } 26 | } 27 | 28 | public protocol DismissCheckable { 29 | var backButtonImage: UIImage? { get set } 30 | var isNeedBackButton: Bool { get set } 31 | func configureNavigationBackButton(_ selector: Selector) 32 | func cleanBackNavigationButton() 33 | func onBack() 34 | } 35 | 36 | public extension DismissCheckable where Self: UIViewController { 37 | 38 | func configureNavigationBackButton(_ selector: Selector) { 39 | navigationItem.hidesBackButton = true 40 | 41 | let backButton = UIBarButtonItem( 42 | image: backButtonImage, 43 | style: .plain, 44 | target: self, 45 | action: selector 46 | ) 47 | 48 | navigationItem.leftBarButtonItem = backButton 49 | } 50 | 51 | func cleanBackNavigationButton() { 52 | guard navigationController?.viewControllers.first === self else { 53 | return 54 | } 55 | navigationItem.leftBarButtonItem = nil 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/ScreenConfig.swift: -------------------------------------------------------------------------------- 1 | public struct ScreenConfig: Equatable { 2 | public let navigationTitle: String 3 | public var isNeedSetBackButton: Bool 4 | public var initialPayload: NullablePayload 5 | public var output: ((NullablePayload) -> Void) 6 | 7 | /// - Parameters: 8 | /// - navigationTitle: Set navigation title for screen on init. 9 | /// - isNeedSetBackButton: Set or remove back button in navigation bar. Action on callback can be overrided in `NavigationScreen` with function `onBackCallback`. 10 | /// - initialPayload: Uses as additional parameter with some data to initialise `NavigationScreen` or some Module/Fabric. 11 | public init( 12 | navigationTitle: String = "", 13 | isNeedSetBackButton: Bool = true, 14 | initialPayload: NullablePayload = nil, 15 | output: ((NullablePayload) -> Void)? = nil 16 | ) { 17 | self.navigationTitle = navigationTitle 18 | self.isNeedSetBackButton = isNeedSetBackButton 19 | self.initialPayload = initialPayload 20 | self.output = output ?? { _ in } 21 | } 22 | 23 | public static func == (lhs: ScreenConfig, rhs: ScreenConfig) -> Bool { 24 | lhs.navigationTitle == rhs.navigationTitle 25 | && lhs.isNeedSetBackButton == rhs.isNeedSetBackButton 26 | && lhs.initialPayload?.data.hashValue == rhs.initialPayload?.data.hashValue 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Navidux/screens/ViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | open class ViewController: UIViewController, 4 | NavigationScreen, 5 | DismissCheckable, 6 | UIGestureRecognizerDelegate { 7 | 8 | // MARK: - Public properties 9 | 10 | public var tag: String 11 | public var isModal: Bool = false 12 | public weak var navigation: (any Router)? 13 | public var navigationCallback: (() -> Void)? = nil 14 | public var onBackCallback: () -> Void 15 | public var backButtonImage: UIImage? = UIImage(systemName: "chevron.backward") 16 | public var isNeedBackButton: Bool 17 | open var dataToSendFromModal: NullablePayload = nil 18 | public var output: ((NullablePayload) -> Void) 19 | 20 | // MARK: - Init 21 | 22 | public init( 23 | title: String = "", 24 | isNeedBackButton: Bool = true, 25 | navigation: (any Router)? = nil, 26 | tag: String = UUID().uuidString, 27 | output: @escaping (NullablePayload) -> Void = { _ in } 28 | ) { 29 | self.navigation = navigation 30 | self.tag = tag 31 | self.isNeedBackButton = isNeedBackButton 32 | self.onBackCallback = { [weak navigation] in 33 | navigation?.route(with: .pop(nil)) 34 | } 35 | self.output = output 36 | super.init(nibName: nil, bundle: nil) 37 | self.title = title 38 | } 39 | 40 | @available(*, deprecated, message: "use init() instead.") 41 | public required init?(coder: NSCoder) { 42 | fatalError("init(coder:) has not been implemented") 43 | } 44 | 45 | // MARK: - Lifecycle 46 | 47 | open override func viewWillAppear(_ animated: Bool) { 48 | super.viewWillAppear(animated) 49 | if isNeedBackButton { 50 | configureNavigationBackButton(#selector(onBack)) 51 | } else { 52 | navigationItem.hidesBackButton = true 53 | } 54 | } 55 | 56 | open override func viewDidAppear(_ animated: Bool) { 57 | super.viewDidAppear(animated) 58 | navigationController?.interactivePopGestureRecognizer?.delegate = self 59 | } 60 | 61 | open override func viewDidDisappear(_ animated: Bool) { 62 | super.viewDidDisappear(animated) 63 | if isNeedBackButton { 64 | cleanBackNavigationButton() 65 | } 66 | } 67 | 68 | 69 | // MARK: - Public methods 70 | 71 | public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 72 | guard 73 | gestureRecognizer.isEqual(navigationController?.interactivePopGestureRecognizer) 74 | else { 75 | return true 76 | } 77 | 78 | onBack() 79 | 80 | return false 81 | } 82 | 83 | @objc 84 | public func onBack() { 85 | onBackCallback() 86 | } 87 | 88 | open func gotUpdatedData(_ payload: NullablePayload) { 89 | //HINT: Do some action on getted response from previous screen 90 | debugPrint("Screen recieved data: \(String(describing: payload))") 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Tests/NaviduxTests/Fixtures/NaviduxFixture.swift: -------------------------------------------------------------------------------- 1 | @testable import Navidux 2 | 3 | struct NaviduxFixture { 4 | static let oneScreenTag = "MockNavigationScreen" 5 | static let mockScreenTag = "Dummy" 6 | 7 | static func mockNavigationScreen( 8 | coordinator: Router? = nil, 9 | tag: String = NaviduxFixture.mockScreenTag, 10 | output: ((NullablePayload) -> Void)? = nil 11 | ) -> any NavigationScreen { 12 | return ViewController( 13 | navigation: coordinator, 14 | tag: tag, 15 | output: output ?? { _ in } 16 | ) 17 | } 18 | 19 | static func mockScreenConfig() -> ScreenConfig { 20 | ScreenConfig( 21 | navigationTitle: "Default Mock", 22 | output: { payload in 23 | print("OUTPUT_PAYLOAD: \(String(describing: payload))") 24 | } 25 | ) 26 | } 27 | } 28 | 29 | struct ScreenFactoryFixture: ScreenFactory { 30 | var mockNavigationScreen: any NavigationScreen 31 | 32 | var findPersonScreenFactory: (Router, ScreenConfig) -> any NavigationScreen { 33 | { _, _ in 34 | mockNavigationScreen 35 | } 36 | } 37 | 38 | var employeePersonalInfoScreenFactory: (Router, ScreenConfig) -> any NavigationScreen { 39 | { _, _ in 40 | mockNavigationScreen 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/NaviduxTests/Fixtures/NaviduxScreenFixture.swift: -------------------------------------------------------------------------------- 1 | @testable import Navidux 2 | 3 | extension NaviduxScreen { 4 | public static let firstScreen = NaviduxScreen( 5 | screenClass: FirstController.self 6 | ) 7 | public static let secondScreen = NaviduxScreen( 8 | screenClass: SecondController.self 9 | ) 10 | public static let thirdScreen = NaviduxScreen( 11 | screenClass: ThirdController.self 12 | ) 13 | } 14 | 15 | final class FirstController: ViewController {} 16 | 17 | final class SecondController: ViewController {} 18 | 19 | final class ThirdController: ViewController {} 20 | -------------------------------------------------------------------------------- /Tests/NaviduxTests/Fixtures/NavigationControllerStub.swift: -------------------------------------------------------------------------------- 1 | @testable import Navidux 2 | import UIKit 3 | 4 | enum NavigationControllerCallingMethods: Equatable { 5 | case addToStack(tag: String) 6 | case removeLastFromStack 7 | case removeTillFromStack(tag: String) 8 | case rebuildNavStack(tags: [String]) 9 | case pushViewController(tag: String?) 10 | case popViewController 11 | case popToViewController(tag: String?) 12 | case present(tag: String?) 13 | case dismiss 14 | case setViewControllers 15 | } 16 | 17 | final class NavigationControllerStub: NavigationController { 18 | var callingStack: [NavigationControllerCallingMethods] = [] 19 | 20 | var screens: [any NavigationScreen] = [] 21 | 22 | var topScreen: (any NavigationScreen)? { 23 | screens.last 24 | } 25 | 26 | var topViewController: UIViewController? { 27 | screens.last 28 | } 29 | 30 | var viewControllers: [UIViewController] = [] 31 | 32 | func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { 33 | self.viewControllers = viewControllers 34 | callingStack.append(.setViewControllers) 35 | } 36 | 37 | func addToStack(screen: any NavigationScreen) { 38 | screens.append(screen) 39 | callingStack.append(.addToStack(tag: screen.tag)) 40 | } 41 | 42 | func removeLastFromStack() { 43 | screens.removeLast() 44 | callingStack.append(.removeLastFromStack) 45 | } 46 | 47 | func removeTillFromStack(screen: any NavigationScreen) { 48 | callingStack.append(.removeTillFromStack(tag: screen.tag)) 49 | } 50 | 51 | func rebuildNavStack(with screens: [any NavigationScreen]) { 52 | self.screens = screens 53 | callingStack.append(.rebuildNavStack(tags: screens.map { $0.tag })) 54 | } 55 | 56 | func pushViewController(_ viewController: UIViewController, animated: Bool) { 57 | let vcTag = (viewController as? (any NavigationScreen))?.tag 58 | callingStack.append(.pushViewController(tag: vcTag)) 59 | } 60 | 61 | @discardableResult 62 | func popViewController(animated: Bool) -> UIViewController? { 63 | callingStack.append(.popViewController) 64 | return nil 65 | } 66 | 67 | @discardableResult 68 | func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? { 69 | let vcTag = (viewController as? (any NavigationScreen))?.tag 70 | callingStack.append(.popToViewController(tag: vcTag)) 71 | return nil 72 | } 73 | 74 | func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?) { 75 | let vcTag = (viewControllerToPresent as? (any NavigationScreen))?.tag 76 | callingStack.append(.present(tag: vcTag)) 77 | } 78 | 79 | func dismiss(animated flag: Bool, completion: (() -> Void)?) { 80 | callingStack.append(.dismiss) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tests/NaviduxTests/Fixtures/PayloadStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PayloadStub.swift 3 | // 4 | // 5 | // Created by Stanislav Anatskii on 09.10.2023. 6 | // 7 | 8 | @testable import Navidux 9 | 10 | struct PayloadStub { 11 | let value: Int 12 | 13 | init(value: Int) { 14 | self.value = value 15 | } 16 | } 17 | 18 | extension PayloadStub: Payload { 19 | var data: some Hashable { 20 | self 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/NaviduxTests/Fixtures/ScreenAssemblerStub.swift: -------------------------------------------------------------------------------- 1 | @testable import Navidux 2 | 3 | enum ScreenAssemblerStubAction: Equatable { 4 | case assembleScreen(Navidux.NaviduxScreen) 5 | case assembleAlert(Navidux.AlertConfiguration) 6 | } 7 | 8 | final class ScreenAssemblerStub: ScreenAssembler { 9 | var actions = [ScreenAssemblerStubAction]() 10 | var navigation: Router? 11 | var vcTag: String? 12 | var screenToPush: (any Navidux.NavigationScreen)? 13 | 14 | init(navigation: Router? = nil, vcTag: String? = nil, screenToPush: (any Navidux.NavigationScreen)? = nil) { 15 | self.navigation = navigation 16 | self.vcTag = vcTag 17 | self.screenToPush = screenToPush 18 | } 19 | 20 | func assemblyScreen(components: Navidux.ScreenAsseblyComponents) -> any Navidux.NavigationScreen { 21 | screenToPush ?? NaviduxFixture.mockNavigationScreen( 22 | coordinator: navigation, 23 | tag: vcTag ?? "", 24 | output: components.config.output 25 | ) 26 | } 27 | 28 | func assemblyAlert(configuration: Navidux.AlertConfiguration) -> Navidux.AlertScreen { 29 | AlertScreen(configuration: configuration) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/NaviduxTests/NavigationControllerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Navidux 2 | import XCTest 3 | 4 | final class NavigationControllerTests: XCTestCase { 5 | let navigation = NavigationControllerImpl() 6 | 7 | func test_Init_withZeroScreens() { 8 | XCTAssertTrue(navigation.screens.isEmpty) 9 | } 10 | 11 | func test_topScreen_returnLastScreen() { 12 | let screen1 = NaviduxFixture.mockNavigationScreen(tag: "1") 13 | let screen2 = NaviduxFixture.mockNavigationScreen(tag: "2") 14 | navigation.screens = [screen1, screen2] 15 | 16 | XCTAssertNotNil(navigation.topScreen) 17 | XCTAssertEqual(navigation.topScreen?.tag, screen2.tag) 18 | } 19 | 20 | func test_addToStackScreen_updateScreenProperty() { 21 | let screen = NaviduxFixture.mockNavigationScreen() 22 | 23 | navigation.addToStack(screen: screen) 24 | 25 | XCTAssertEqual(navigation.screens.count, 1) 26 | XCTAssertEqual(navigation.screens.first?.tag, screen.tag) 27 | } 28 | 29 | func test_removeLastFromStack_updateScreenProperty() { 30 | let screen = NaviduxFixture.mockNavigationScreen() 31 | navigation.screens = [screen] 32 | 33 | navigation.removeLastFromStack() 34 | 35 | XCTAssertTrue(navigation.screens.count == 1) 36 | } 37 | 38 | func test_removeTillFromStack_removeLastToScreens() { 39 | let screen1 = NaviduxFixture.mockNavigationScreen(tag: "1") 40 | let screen2 = NaviduxFixture.mockNavigationScreen(tag: "2") 41 | let screen3 = NaviduxFixture.mockNavigationScreen(tag: "3") 42 | navigation.screens = [screen1, screen2, screen3] 43 | 44 | navigation.removeTillFromStack(screen: screen1) 45 | 46 | XCTAssertEqual(navigation.screens.count, 1) 47 | XCTAssertEqual(navigation.screens.first?.tag, screen1.tag) 48 | } 49 | 50 | func test_rebuildNavStack_changes_screens() { 51 | let screen1 = NaviduxFixture.mockNavigationScreen(tag: "1") 52 | let screen2 = NaviduxFixture.mockNavigationScreen(tag: "2") 53 | let screen3 = NaviduxFixture.mockNavigationScreen(tag: "3") 54 | navigation.screens = [screen1, screen2] 55 | 56 | navigation.rebuildNavStack(with: [screen3]) 57 | 58 | XCTAssertEqual(navigation.screens.count, 1) 59 | XCTAssertEqual(navigation.screens.first?.tag, screen3.tag) 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /Tests/NaviduxTests/NavigationCoordinatorTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Navidux 2 | import XCTest 3 | 4 | final class NavigationCoordinatorTests: XCTestCase { 5 | let navigationController = NavigationControllerStub() 6 | var expectedPayload: PayloadStub? 7 | lazy var navigationScreen = NaviduxFixture.mockNavigationScreen( 8 | tag: NaviduxFixture.oneScreenTag, 9 | output: { [weak self] payload in 10 | self?.expectedPayload = payload as? PayloadStub 11 | } 12 | ) 13 | lazy var screenAssembler = ScreenAssemblerStub( 14 | vcTag: NaviduxFixture.oneScreenTag, 15 | screenToPush: navigationScreen 16 | ) 17 | 18 | lazy var navigationCoordinator = NavigationCoordinator( 19 | navigationController, 20 | screenAssembler: screenAssembler 21 | ) 22 | 23 | func test_pushFullscreen_addCallForPush() { 24 | screenAssembler.navigation = navigationCoordinator 25 | 26 | navigationCoordinator.route( 27 | with: .push( 28 | .firstScreen, 29 | NaviduxFixture.mockScreenConfig(), 30 | .fullscreen 31 | ) 32 | ) 33 | 34 | XCTAssertEqual( 35 | navigationController.callingStack, 36 | [ 37 | .pushViewController(tag: NaviduxFixture.oneScreenTag), 38 | .addToStack(tag: NaviduxFixture.oneScreenTag) 39 | ] 40 | ) 41 | XCTAssertFalse(navigationScreen.isModal) 42 | } 43 | 44 | func test_pushModal_addCallForPresentAndSetState() { 45 | screenAssembler.navigation = navigationCoordinator 46 | 47 | navigationCoordinator.route( 48 | with: .push( 49 | .firstScreen, 50 | NaviduxFixture.mockScreenConfig(), 51 | .modal 52 | ) 53 | ) 54 | 55 | XCTAssertEqual( 56 | navigationController.callingStack, 57 | [ 58 | .present(tag: NaviduxFixture.oneScreenTag), 59 | .addToStack(tag: NaviduxFixture.oneScreenTag) 60 | ] 61 | ) 62 | 63 | XCTAssertTrue(navigationCoordinator.state.hasOverlay) 64 | XCTAssertTrue(navigationScreen.isModal) 65 | } 66 | 67 | func test_pushBottomSheet_addCallForPresentAndSetState() { 68 | screenAssembler.navigation = navigationCoordinator 69 | 70 | navigationCoordinator.route( 71 | with: .push( 72 | .firstScreen, 73 | NaviduxFixture.mockScreenConfig(), 74 | .bottomSheet(.halfScreen) 75 | ) 76 | ) 77 | 78 | XCTAssertEqual( 79 | navigationController.callingStack, 80 | [ 81 | .present(tag: NaviduxFixture.oneScreenTag), 82 | .addToStack(tag: NaviduxFixture.oneScreenTag) 83 | ] 84 | ) 85 | 86 | XCTAssertTrue(navigationCoordinator.state.hasOverlay) 87 | XCTAssertTrue(navigationScreen.isModal) 88 | } 89 | 90 | func test_popFullscreen_addCallForPop() { 91 | screenAssembler.navigation = navigationCoordinator 92 | 93 | navigationCoordinator.route( 94 | with: .push( 95 | .firstScreen, 96 | NaviduxFixture.mockScreenConfig(), 97 | .fullscreen 98 | ) 99 | ) 100 | 101 | navigationCoordinator.route(with: .pop(nil)) 102 | 103 | XCTAssertEqual( 104 | navigationController.callingStack, 105 | [ 106 | .pushViewController(tag: NaviduxFixture.oneScreenTag), 107 | .addToStack(tag: NaviduxFixture.oneScreenTag), 108 | .popViewController, 109 | .removeLastFromStack 110 | ] 111 | ) 112 | } 113 | 114 | func test_popModal_addCallForDismiss() { 115 | screenAssembler.navigation = navigationCoordinator 116 | 117 | navigationCoordinator.route( 118 | with: .push( 119 | .firstScreen, 120 | NaviduxFixture.mockScreenConfig(), 121 | .modal 122 | ) 123 | ) 124 | 125 | navigationCoordinator.route(with: .pop(nil)) 126 | 127 | XCTAssertEqual( 128 | navigationController.callingStack, 129 | [ 130 | .present(tag: NaviduxFixture.oneScreenTag), 131 | .addToStack(tag: NaviduxFixture.oneScreenTag), 132 | .dismiss, 133 | .removeLastFromStack 134 | ] 135 | ) 136 | } 137 | 138 | func test_pushFullscreen_setCallback() { 139 | screenAssembler.navigation = navigationCoordinator 140 | 141 | navigationCoordinator.route( 142 | with: .push( 143 | .firstScreen, 144 | NaviduxFixture.mockScreenConfig(), 145 | .fullscreen 146 | ) 147 | ) 148 | 149 | XCTAssertNotNil(navigationScreen.navigationCallback) 150 | } 151 | 152 | //Спорный тест из-за restruct 153 | func test_popToFullscreen_addCallForPop() { 154 | screenAssembler.navigation = navigationCoordinator 155 | 156 | navigationCoordinator.route( 157 | with: .restruct( 158 | screens: [ 159 | ScreenAsseblyComponents(screenType: .firstScreen, config: NaviduxFixture.mockScreenConfig()), 160 | ScreenAsseblyComponents(screenType: .secondScreen, config: NaviduxFixture.mockScreenConfig()), 161 | ScreenAsseblyComponents(screenType: .thirdScreen, config: NaviduxFixture.mockScreenConfig()), 162 | ], 163 | animationType: .backward 164 | ) 165 | ) 166 | 167 | navigationCoordinator.route( 168 | with: .popUntil(.firstScreen, nil) 169 | ) 170 | 171 | XCTAssertEqual( 172 | navigationController.callingStack, 173 | [ 174 | .popViewController, 175 | .rebuildNavStack(tags: [ 176 | NaviduxFixture.oneScreenTag, 177 | NaviduxFixture.oneScreenTag, 178 | NaviduxFixture.oneScreenTag 179 | ]), 180 | ] 181 | ) 182 | } 183 | 184 | //Спорный тест из-за restruct 185 | func test_popToModal_addCallForPop() { 186 | screenAssembler.navigation = navigationCoordinator 187 | 188 | navigationCoordinator.route( 189 | with: .restruct( 190 | screens: [ 191 | ScreenAsseblyComponents(screenType: .firstScreen, config: NaviduxFixture.mockScreenConfig()), 192 | ScreenAsseblyComponents(screenType: .secondScreen, config: NaviduxFixture.mockScreenConfig()), 193 | ScreenAsseblyComponents(screenType: .thirdScreen, config: NaviduxFixture.mockScreenConfig()), 194 | ], 195 | animationType: .backward 196 | ) 197 | ) 198 | navigationCoordinator.route( 199 | with: .push( 200 | .firstScreen, 201 | NaviduxFixture.mockScreenConfig(), 202 | .modal 203 | ) 204 | ) 205 | 206 | navigationCoordinator.route( 207 | with: .popUntil(.firstScreen, nil) 208 | ) 209 | 210 | XCTAssertEqual( 211 | navigationController.callingStack, 212 | [ 213 | .popViewController, 214 | .rebuildNavStack(tags: [ 215 | NaviduxFixture.oneScreenTag, 216 | NaviduxFixture.oneScreenTag, 217 | NaviduxFixture.oneScreenTag 218 | ]), 219 | .present(tag: NaviduxFixture.oneScreenTag), 220 | .addToStack(tag: NaviduxFixture.oneScreenTag) 221 | ] 222 | ) 223 | } 224 | 225 | func test_popWithPayload() { 226 | screenAssembler.navigation = navigationCoordinator 227 | 228 | navigationCoordinator.route(with: .push( 229 | .firstScreen, 230 | NaviduxFixture.mockScreenConfig(), 231 | .fullscreen) 232 | ) 233 | 234 | navigationCoordinator.route(with: .push( 235 | .secondScreen, 236 | NaviduxFixture.mockScreenConfig(), 237 | .fullscreen) 238 | ) 239 | 240 | let outputPayload = PayloadStub(value: 123) 241 | navigationCoordinator.route(with: .pop(outputPayload)) 242 | 243 | XCTAssertEqual( 244 | navigationController.callingStack, 245 | [ 246 | .pushViewController(tag: NaviduxFixture.oneScreenTag), 247 | .addToStack(tag: NaviduxFixture.oneScreenTag), 248 | 249 | .pushViewController(tag: NaviduxFixture.oneScreenTag), 250 | .addToStack(tag: NaviduxFixture.oneScreenTag), 251 | 252 | .popViewController, 253 | .removeLastFromStack 254 | ] 255 | ) 256 | 257 | XCTAssertEqual(navigationController.screens.count, 1) 258 | 259 | XCTAssertEqual(outputPayload, expectedPayload) 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /readme/Navidux_scheme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/navidux/e69482cee684b2cdb233092546561cf6a3acdb1e/readme/Navidux_scheme.png -------------------------------------------------------------------------------- /readme/Roadmap.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | - ~~Add functionality to push/present bottomsheet controllers.~~ 4 | - Add functionality to work with tabbar. 5 | - Improve inner logic to use with "Rebuild Stack" function. 6 | - Update mechanic to deliver data between screens. 7 | - Work with UIWindow and UISplitViewController. 8 | - When initialize screens need create transitioDelegate and set in in new instance by default (see BSTransitionalDriver TODO) 9 | --------------------------------------------------------------------------------