diff --git a/0288-modern-uikit-pt8/README.md b/0288-modern-uikit-pt8/README.md new file mode 100644 index 00000000..cea1a2b9 --- /dev/null +++ b/0288-modern-uikit-pt8/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Modern UIKit: Stack Navigation, Part 2](https://www.pointfree.co/episodes/ep288-modern-uikit-stack-navigation-part-2) +> +> We round out our stack navigation tools with support for an `@Environment`-like feature for holding onto the stack’s path, a `NavigationLink`-like feature for pushing features onto the stack from anywhere, and we’ll handle every corner case from deep-linking to user dismissal. diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit.xcodeproj/project.pbxproj b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit.xcodeproj/project.pbxproj new file mode 100644 index 00000000..eedac401 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit.xcodeproj/project.pbxproj @@ -0,0 +1,433 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 2A1CEB272BFD271600753A66 /* Perception in Frameworks */ = {isa = PBXBuildFile; productRef = 2A1CEB262BFD271600753A66 /* Perception */; }; + 2A6230292BFEA5C600930179 /* AppFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A6230282BFEA5C600930179 /* AppFeature.swift */; }; + 2AC91F492C45A84A00087457 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AC91F482C45A84A00087457 /* UIControl.swift */; }; + 4B54C2252BFD0D1900E95174 /* ModernUIKitApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B54C2242BFD0D1900E95174 /* ModernUIKitApp.swift */; }; + 4B54C2292BFD0D1A00E95174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B54C2282BFD0D1A00E95174 /* Assets.xcassets */; }; + 4B54C22C2BFD0D1A00E95174 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B54C22B2BFD0D1A00E95174 /* Preview Assets.xcassets */; }; + 4B54C24F2BFD0D9B00E95174 /* CounterFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B54C24E2BFD0D9B00E95174 /* CounterFeature.swift */; }; + 4B69F6382BFD151E009B28BB /* Observation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B69F6372BFD151E009B28BB /* Observation.swift */; }; + 4B69F63B2BFD365B009B28BB /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = 4B69F63A2BFD365B009B28BB /* SwiftUINavigation */; }; + 4B69F63D2BFD365B009B28BB /* SwiftUINavigationCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4B69F63C2BFD365B009B28BB /* SwiftUINavigationCore */; }; + 4B69F63F2BFD46DB009B28BB /* Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B69F63E2BFD46DB009B28BB /* Navigation.swift */; }; + 4BAE7F8F2BFEAEEE00B9FEAB /* NavigationStackController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BAE7F8E2BFEAEEE00B9FEAB /* NavigationStackController.swift */; }; + 4BB60BE22BFE77E4002516B4 /* UIBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB60BE12BFE77E4002516B4 /* UIBinding.swift */; }; + 4BB60BE42BFE8098002516B4 /* SettingsFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB60BE32BFE8098002516B4 /* SettingsFeature.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2A1CEB242BFD1A3800753A66 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 2A6230282BFEA5C600930179 /* AppFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFeature.swift; sourceTree = ""; }; + 2AC91F482C45A84A00087457 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; + 4B54C2212BFD0D1900E95174 /* ModernUIKit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ModernUIKit.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4B54C2242BFD0D1900E95174 /* ModernUIKitApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernUIKitApp.swift; sourceTree = ""; }; + 4B54C2282BFD0D1A00E95174 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 4B54C22B2BFD0D1A00E95174 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 4B54C24E2BFD0D9B00E95174 /* CounterFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterFeature.swift; sourceTree = ""; }; + 4B69F6372BFD151E009B28BB /* Observation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observation.swift; sourceTree = ""; }; + 4B69F63E2BFD46DB009B28BB /* Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigation.swift; sourceTree = ""; }; + 4BAE7F8E2BFEAEEE00B9FEAB /* NavigationStackController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackController.swift; sourceTree = ""; }; + 4BB60BE12BFE77E4002516B4 /* UIBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBinding.swift; sourceTree = ""; }; + 4BB60BE32BFE8098002516B4 /* SettingsFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFeature.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4B54C21E2BFD0D1900E95174 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B69F63B2BFD365B009B28BB /* SwiftUINavigation in Frameworks */, + 4B69F63D2BFD365B009B28BB /* SwiftUINavigationCore in Frameworks */, + 2A1CEB272BFD271600753A66 /* Perception in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4B54C2182BFD0D1900E95174 = { + isa = PBXGroup; + children = ( + 4B54C2232BFD0D1900E95174 /* ModernUIKit */, + 4B54C2222BFD0D1900E95174 /* Products */, + ); + sourceTree = ""; + }; + 4B54C2222BFD0D1900E95174 /* Products */ = { + isa = PBXGroup; + children = ( + 4B54C2212BFD0D1900E95174 /* ModernUIKit.app */, + ); + name = Products; + sourceTree = ""; + }; + 4B54C2232BFD0D1900E95174 /* ModernUIKit */ = { + isa = PBXGroup; + children = ( + 2A1CEB242BFD1A3800753A66 /* Info.plist */, + 2A6230282BFEA5C600930179 /* AppFeature.swift */, + 4B54C24E2BFD0D9B00E95174 /* CounterFeature.swift */, + 4B54C2242BFD0D1900E95174 /* ModernUIKitApp.swift */, + 4B69F63E2BFD46DB009B28BB /* Navigation.swift */, + 4BAE7F8E2BFEAEEE00B9FEAB /* NavigationStackController.swift */, + 4B69F6372BFD151E009B28BB /* Observation.swift */, + 4BB60BE32BFE8098002516B4 /* SettingsFeature.swift */, + 4BB60BE12BFE77E4002516B4 /* UIBinding.swift */, + 2AC91F482C45A84A00087457 /* UIControl.swift */, + 4B54C2282BFD0D1A00E95174 /* Assets.xcassets */, + 4B54C22A2BFD0D1A00E95174 /* Preview Content */, + ); + path = ModernUIKit; + sourceTree = ""; + }; + 4B54C22A2BFD0D1A00E95174 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 4B54C22B2BFD0D1A00E95174 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 4B54C2202BFD0D1900E95174 /* ModernUIKit */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4B54C2452BFD0D1A00E95174 /* Build configuration list for PBXNativeTarget "ModernUIKit" */; + buildPhases = ( + 4B54C21D2BFD0D1900E95174 /* Sources */, + 4B54C21E2BFD0D1900E95174 /* Frameworks */, + 4B54C21F2BFD0D1900E95174 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ModernUIKit; + packageProductDependencies = ( + 2A1CEB262BFD271600753A66 /* Perception */, + 4B69F63A2BFD365B009B28BB /* SwiftUINavigation */, + 4B69F63C2BFD365B009B28BB /* SwiftUINavigationCore */, + ); + productName = ModernUIKit; + productReference = 4B54C2212BFD0D1900E95174 /* ModernUIKit.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4B54C2192BFD0D1900E95174 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + TargetAttributes = { + 4B54C2202BFD0D1900E95174 = { + CreatedOnToolsVersion = 15.4; + }; + }; + }; + buildConfigurationList = 4B54C21C2BFD0D1900E95174 /* Build configuration list for PBXProject "ModernUIKit" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4B54C2182BFD0D1900E95174; + packageReferences = ( + 2A1CEB252BFD271600753A66 /* XCRemoteSwiftPackageReference "swift-perception" */, + 4B69F6392BFD365B009B28BB /* XCRemoteSwiftPackageReference "swiftui-navigation" */, + ); + productRefGroup = 4B54C2222BFD0D1900E95174 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 4B54C2202BFD0D1900E95174 /* ModernUIKit */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4B54C21F2BFD0D1900E95174 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B54C22C2BFD0D1A00E95174 /* Preview Assets.xcassets in Resources */, + 4B54C2292BFD0D1A00E95174 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4B54C21D2BFD0D1900E95174 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B54C2252BFD0D1900E95174 /* ModernUIKitApp.swift in Sources */, + 4B69F6382BFD151E009B28BB /* Observation.swift in Sources */, + 4B54C24F2BFD0D9B00E95174 /* CounterFeature.swift in Sources */, + 4BB60BE42BFE8098002516B4 /* SettingsFeature.swift in Sources */, + 4BAE7F8F2BFEAEEE00B9FEAB /* NavigationStackController.swift in Sources */, + 4BB60BE22BFE77E4002516B4 /* UIBinding.swift in Sources */, + 2A6230292BFEA5C600930179 /* AppFeature.swift in Sources */, + 4B69F63F2BFD46DB009B28BB /* Navigation.swift in Sources */, + 2AC91F492C45A84A00087457 /* UIControl.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4B54C2432BFD0D1A00E95174 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + }; + name = Debug; + }; + 4B54C2442BFD0D1A00E95174 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 4B54C2462BFD0D1A00E95174 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ModernUIKit/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ModernUIKit/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ModernUIKit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4B54C2472BFD0D1A00E95174 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ModernUIKit/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ModernUIKit/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ModernUIKit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 4B54C21C2BFD0D1900E95174 /* Build configuration list for PBXProject "ModernUIKit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B54C2432BFD0D1A00E95174 /* Debug */, + 4B54C2442BFD0D1A00E95174 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4B54C2452BFD0D1A00E95174 /* Build configuration list for PBXNativeTarget "ModernUIKit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B54C2462BFD0D1A00E95174 /* Debug */, + 4B54C2472BFD0D1A00E95174 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 2A1CEB252BFD271600753A66 /* XCRemoteSwiftPackageReference "swift-perception" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-perception"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.7; + }; + }; + 4B69F6392BFD365B009B28BB /* XCRemoteSwiftPackageReference "swiftui-navigation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swiftui-navigation.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.3.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 2A1CEB262BFD271600753A66 /* Perception */ = { + isa = XCSwiftPackageProductDependency; + package = 2A1CEB252BFD271600753A66 /* XCRemoteSwiftPackageReference "swift-perception" */; + productName = Perception; + }; + 4B69F63A2BFD365B009B28BB /* SwiftUINavigation */ = { + isa = XCSwiftPackageProductDependency; + package = 4B69F6392BFD365B009B28BB /* XCRemoteSwiftPackageReference "swiftui-navigation" */; + productName = SwiftUINavigation; + }; + 4B69F63C2BFD365B009B28BB /* SwiftUINavigationCore */ = { + isa = XCSwiftPackageProductDependency; + package = 4B69F6392BFD365B009B28BB /* XCRemoteSwiftPackageReference "swiftui-navigation" */; + productName = SwiftUINavigationCore; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 4B54C2192BFD0D1900E95174 /* Project object */; +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/AppFeature.swift b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/AppFeature.swift new file mode 100644 index 00000000..15b7d74d --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/AppFeature.swift @@ -0,0 +1,114 @@ +import Perception +import SwiftUI + +@Perceptible +class AppModel { + var path: [Path] + init(path: [Path] = []) { + self.path = path + } + + enum Path: Hashable { + case counter(CounterModel) + case settings(SettingsModel) + } +} + +struct AppView: View { + @Perception.Bindable var model: AppModel + + var body: some View { + WithPerceptionTracking { + NavigationStack(path: $model.path) { + Form { + Button("Counter") { + model.path.append(.counter(CounterModel())) + } + Button("Settings") { + model.path.append(.settings(SettingsModel())) + } + + NavigationLink("Counter w/ value", value: AppModel.Path.counter(CounterModel())) + } + .navigationDestination(for: AppModel.Path.self) { path in + switch path { + case let .counter(model): + CounterView(model: model) + case let .settings(model): + SettingsView(model: model) + } + } + } + } + } +} + +extension NavigationStackController where Data == [AppModel.Path] { + convenience init(model: AppModel) { + @UIBindable var model = model + self.init(path: $model.path) { + RootViewController() + } destination: { path in + switch path { + case let .counter(model): + CounterViewController(model: model) + case let .settings(model): + SettingsViewController(model: model) + } + } + if #available(iOS 17.0, *) { + self.traitOverrides.path = $model.path + } else { + // Fallback on earlier versions + } + } +} + +private enum PathTrait: UITraitDefinition { + static var defaultValue: UIBinding<[AppModel.Path]> { + @UIBindable var model = AppModel() + return $model.path + } +} + +extension UITraitCollection { + @available(iOS 17.0, *) + var path: UIBinding<[AppModel.Path]> { + self[PathTrait.self] + } +} + +@available(iOS 17.0, *) +extension UIMutableTraits { + fileprivate(set) var path: UIBinding<[AppModel.Path]> { + get { self[PathTrait.self] } + set { self[PathTrait.self] = newValue } + } +} + +final class RootViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + + let counterButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + self?.navigationController?.push(value: AppModel.Path.counter(CounterModel())) + }) + counterButton.setTitle("Counter", for: .normal) + let settingsButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + self?.navigationController?.push(value: AppModel.Path.settings(SettingsModel())) + }) + settingsButton.setTitle("Settings", for: .normal) + let stack = UIStackView(arrangedSubviews: [ + counterButton, + settingsButton, + ]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Assets.xcassets/AccentColor.colorset/Contents.json b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Assets.xcassets/AppIcon.appiconset/Contents.json b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Assets.xcassets/Contents.json b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/CounterFeature.swift b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/CounterFeature.swift new file mode 100644 index 00000000..9f9c1513 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/CounterFeature.swift @@ -0,0 +1,308 @@ +import Perception +@preconcurrency import SwiftUI +import SwiftUINavigation + +@MainActor +@Perceptible +class CounterModel: HashableObject { + @CasePathable + enum Destination { + case fact(Fact) + case settings(SettingsModel) + } + + init(count: Int = 0, destination: Destination? = nil, factIsLoading: Bool = false) { + self.count = count + self.destination = destination + self.factIsLoading = factIsLoading + } + + var count = 0 { + didSet { + isTextFocused = !count.isMultiple(of: 2) + } + } + var destination: Destination? + var factIsLoading = false + var isTextFocused = false { + didSet { + print(isTextFocused) + } + } + var text = "" + struct Fact: Identifiable { + var value: String + var id: String { value } + } + func incrementButtonTapped() { + count += 1 + destination = nil + } + func decrementButtonTapped() { + count -= 1 + destination = nil + } + func factButtonTapped() async { + withUIAnimation { + self.destination = nil + } + self.factIsLoading = true + defer { self.factIsLoading = false } + + do { + try await Task.sleep(for: .seconds(1)) + let loadedFact = try await String( + decoding: URLSession.shared + .data( + from: URL(string: "http://www.numberapi.com/\(count)")! + ).0, + as: UTF8.self + ) + destination = nil + try await Task.sleep(for: .seconds(0.1)) + self.destination = .fact(Fact(value: loadedFact)) + } catch { + // TODO: error handling + } + } + func settingsButtonTapped() { + destination = .settings(SettingsModel()) + } +} + +struct CounterView: View { + @Perception.Bindable var model: CounterModel + @State var isFlagged = false + + var body: some View { + WithPerceptionTracking { + Form { +// Text("\(model.count)") +// Button("Decrement") { model.decrementButtonTapped() } +// Button("Increment") { model.incrementButtonTapped() } + + Stepper( + "\(model.count)", + value: $model.count[flaggedToDouble: isFlagged] +// Binding( +// get: { +// isFlagged ? Double(model.count) : 0 +// }, +// set: { newValue, transaction in +// model.count = isFlagged ? Int(newValue) : 0 +// } +// ) + //$model.count.toDouble + ) + + if model.factIsLoading { + ProgressView().id(UUID()) + } + + Button("Get fact") { + Task { + await model.factButtonTapped() + } + } + } + .disabled(model.factIsLoading) + .sheet(item: ($model.destination as Binding).fact) { fact in + Text(fact.value) + } + .navigationDestination(item: $model.destination.settings) { model in + SettingsView(model: model) + } + .toolbar { + ToolbarItem { + Button("Settings") { + model.settingsButtonTapped() + } + } + } + } + } +} + +#Preview("SwiftUI") { + NavigationStack { + CounterView(model: CounterModel()) + } +} + +final class CounterViewController: UIViewController { + @UIBindable var model: CounterModel + + init(model: CounterModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + let countLabel = UILabel() + countLabel.textAlignment = .center + + let counter = UIStepper(frame: .zero, value: $model.count.toDouble) + + let textField = UITextField(frame: .zero, text: $model.text) + textField.bind(focus: $model.isTextFocused) + textField.placeholder = "Some text" + textField.borderStyle = .bezel + +// let counter = UIStepper(frame: .zero, primaryAction: UIAction { [weak self] action in +// self?.model.count = Int((action.sender as! UIStepper).value) +// }) +// observe { [weak self] in +// guard let self else { return } +// counter.value = Double(model.count) +// } + +// let decrementButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in +// self?.model.decrementButtonTapped() +// }) +// decrementButton.setTitle("Decrement", for: .normal) +// let incrementButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in +// self?.model.incrementButtonTapped() +// }) +// incrementButton.setTitle("Increment", for: .normal) + + let factLabel = UILabel() + factLabel.numberOfLines = 0 + let activityIndicator = UIActivityIndicatorView() + activityIndicator.startAnimating() + let factButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + Task { await self.model.factButtonTapped() } + }) + factButton.setTitle("Get fact", for: .normal) + let resetButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + model.count = 0 + }) + resetButton.setTitle("Reset", for: .normal) + + let counterStack = UIStackView(arrangedSubviews: [ + countLabel, + counter, + resetButton, + textField, + UITextField(), +// decrementButton, +// incrementButton, + factLabel, + activityIndicator, + factButton, + ]) + counterStack.axis = .vertical + counterStack.spacing = 12 + counterStack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(counterStack) + NSLayoutConstraint.activate([ + counterStack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + counterStack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + counterStack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + counterStack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Settings", primaryAction: UIAction { [weak self] _ in + self?.model.settingsButtonTapped() + }) + + observe { [weak self] in + guard let self else { return } + countLabel.text = "\(model.count)" + + activityIndicator.isHidden = !model.factIsLoading + counter.isEnabled = !model.factIsLoading +// decrementButton.isEnabled = !model.factIsLoading +// incrementButton.isEnabled = !model.factIsLoading + factButton.isEnabled = !model.factIsLoading + } + + present(item: $model.destination.fact) { fact in + FactViewController(fact: fact.value) + } + + navigationController?.pushViewController(item: $model.destination.settings) { model in + SettingsViewController(model: model) + } + } +} + +extension Int { + fileprivate var toDouble: Double { + get { + Double(self) + } + set { + self = Int(newValue) + } + } + + subscript(flaggedToDouble isFlagged: Bool) -> Double { + get { isFlagged ? Double(self) : 0 } + set { + self = isFlagged ? Int(newValue) : 0 + } + } + + func foo() { + let tmp1 = \Int[flaggedToDouble: true] + let tmp2 = \Int[flaggedToDouble: false] + } +} + +class FactViewController: UIViewController { + let fact: String + init(fact: String) { + self.fact = fact + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .white + let factLabel = UILabel() + factLabel.text = fact + factLabel.numberOfLines = 0 + factLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(factLabel) + NSLayoutConstraint.activate([ + factLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + factLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + factLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + factLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + ]) + } +} + +#Preview("UIKit") { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: CounterViewController(model: CounterModel()) + ) + } +} + +struct UIViewControllerRepresenting: UIViewControllerRepresentable { + let base: UIViewController + init(base: () -> UIViewController) { + self.base = base() + } + + func makeUIViewController(context: Context) -> some UIViewController { + return base + } + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Info.plist b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Info.plist new file mode 100644 index 00000000..6a6654d9 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/ModernUIKitApp.swift b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/ModernUIKitApp.swift new file mode 100644 index 00000000..ecb88d08 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/ModernUIKitApp.swift @@ -0,0 +1,16 @@ +import SwiftUI + +@main +struct ModernUIKitApp: App { + var body: some Scene { + WindowGroup { + UIViewControllerRepresenting { + NavigationStackController( + model: AppModel( + path: [] + ) + ) + } + } + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Navigation.swift b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Navigation.swift new file mode 100644 index 00000000..84b86011 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Navigation.swift @@ -0,0 +1,135 @@ +import ObjectiveC +import UIKit +import SwiftUI + +extension UIViewController { + func present( + item: UIBinding, + content: @escaping (Item) -> UIViewController + ) { + observe { [weak self] in + guard let self else { return } + if let unwrappedItem = item.wrappedValue { + @MainActor + func presentNewController() { + let controller = content(unwrappedItem) + controller.onDeinit = OnDeinit { [weak self] in + if AnyHashable(unwrappedItem.id) == self?.presented[item]?.id { + item.wrappedValue = nil + } + } + presented[item] = Presented(controller: controller, id: unwrappedItem.id) + Task { + self.present(controller, animated: true) + } + } + if let presented = presented[item] { + guard AnyHashable(unwrappedItem.id) != presented.id + else { return } + presented.controller?.dismiss(animated: true, completion: { + presentNewController() + }) + } else { + presentNewController() + } + } else if item.wrappedValue == nil, let presented = presented[item] { + presented.controller?.dismiss(animated: true) + self.presented[item] = nil + } + } + } + + fileprivate var presented: [AnyHashable: Presented] { + get { + objc_getAssociatedObject( + self, + presentedKey + ) as? [AnyHashable: Presented] + ?? [:] + } + set { + objc_setAssociatedObject( + self, + presentedKey, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + fileprivate var onDeinit: OnDeinit? { + get { + objc_getAssociatedObject( + self, + onDeinitKey + ) as? OnDeinit + } + set { + objc_setAssociatedObject( + self, + onDeinitKey, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } +} + +extension UINavigationController { + func pushViewController( + item: UIBinding, + content: @escaping (Item) -> UIViewController + ) { + observe { [weak self] in + guard let self else { return } + if let unwrappedItem = item.wrappedValue, presented[item] == nil { + let controller = content(unwrappedItem) + controller.onDeinit = OnDeinit { + item.wrappedValue = nil + } + presented[item] = Presented(controller: controller) + pushViewController(controller, animated: true) + } else if item.wrappedValue == nil, let presented = presented[item] { + if let controller = presented.controller { + popFromViewController(controller, animated: true) + } + self.presented[item] = nil + } + } + } + + private func popFromViewController( + _ controller: UIViewController, + animated: Bool + ) { + guard + let index = viewControllers.firstIndex(of: controller), + index != 0 + else { + return + } + popToViewController(viewControllers[index - 1], animated: true) + } +} + +private let presentedKey = malloc(1)! +private let onDeinitKey = malloc(1)! + +final fileprivate class OnDeinit { + let onDismiss: () -> Void + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + } + deinit { + onDismiss() + } +} + +fileprivate final class Presented { + weak var controller: UIViewController? + let id: AnyHashable? + init(controller: UIViewController? = nil, id: AnyHashable? = nil) { + self.controller = controller + self.id = id + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/NavigationStackController.swift b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/NavigationStackController.swift new file mode 100644 index 00000000..18a96301 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/NavigationStackController.swift @@ -0,0 +1,102 @@ +import UIKit + +@MainActor +private protocol _NavigationStackController: AnyObject { + associatedtype Data where Data: RangeReplaceableCollection + + var path: Data { get set } +} + +open class NavigationStackController: UINavigationController, _NavigationStackController, UINavigationControllerDelegate +where Data.Element: Hashable { + @UIBinding var path: Data + let root: UIViewController + let destination: (Data.Element) -> UIViewController + + init( + path: UIBinding, + root: () -> UIViewController, + destination: @escaping (Data.Element) -> UIViewController + ) { + self._path = path + self.root = root() + self.destination = destination + super.init(nibName: nil, bundle: nil) + self.delegate = self + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + open override func viewDidLoad() { + super.viewDidLoad() + + observe { [weak self] in + guard let self else { return } + + setViewControllers( + [root] + path.map { element in + guard let existingController = self.viewControllers.first( + where: { $0.navigationID == AnyHashable(element) } + ) + else { + let controller = self.destination(element) + controller.navigationID = element + return controller + } + return existingController + }, + animated: true + ) + } + } + + public func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + let diff = path.count - (viewControllers.count - 1) + if diff > 0 { + path.removeLast(diff) + } + } +} + +extension UIViewController { + fileprivate var navigationID: AnyHashable? { + get { + return objc_getAssociatedObject(self, navigationIDKey) as? AnyHashable + } + set { + objc_setAssociatedObject( + self, + navigationIDKey, + newValue, + objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } +} +private let navigationIDKey = malloc(1)! + +extension UINavigationController { + func push(value: Element) { + func open(_ controller: some _NavigationStackController) { + guard Element.self == Data.Element.self + else { + // TODO: runtime warning + return + } + + controller.path.append(value as! Data.Element) + } + guard let navStack = self as? any _NavigationStackController + else { + // TODO: runtime warning + return + } + open(navStack) + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Observation.swift b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Observation.swift new file mode 100644 index 00000000..917afe08 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Observation.swift @@ -0,0 +1,52 @@ +import Foundation +import Perception +import UIKit + +@MainActor +func observe( + apply: @escaping @MainActor @Sendable () -> Void +) { + onChange(apply: apply) +} +@MainActor +func onChange( + apply: @escaping @MainActor @Sendable () -> Void +) { + withPerceptionTracking { + apply() + } onChange: { + Task { @MainActor in + if let animation = UIAnimation.current { + UIView.animate(withDuration: animation.duration) { + onChange(apply: apply) + } + } else { + onChange(apply: apply) + } + } + } +} + +extension NSObject { + @MainActor + func observe( + apply: @escaping @MainActor @Sendable () -> Void + ) { + ModernUIKit.observe(apply: apply) + } +} + +struct UIAnimation { + @TaskLocal fileprivate static var current: Self? + var duration: TimeInterval +} + +@MainActor +func withUIAnimation( + _ animation: UIAnimation? = UIAnimation(duration: 0.3), + body: @escaping () -> Void +) { + UIAnimation.$current.withValue(animation) { + body() + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Preview Content/Preview Assets.xcassets/Contents.json b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/SettingsFeature.swift b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/SettingsFeature.swift new file mode 100644 index 00000000..05898d4d --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/SettingsFeature.swift @@ -0,0 +1,71 @@ +import Perception +import SwiftUI +import SwiftUINavigation + +@MainActor +@Perceptible +class SettingsModel: HashableObject { + var isOn = false +} + +struct SettingsView: View { + @Perception.Bindable var model: SettingsModel + + var body: some View { + WithPerceptionTracking { + Form { + Toggle(isOn: $model.isOn) { + Text("Is on?") + } + } + } + } +} + +class SettingsViewController: UIViewController { + let model: SettingsModel + + init(model: SettingsModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + let isOnSwitch = UISwitch() + isOnSwitch.addAction( + UIAction { [weak model = self.model, weak isOnSwitch] _ in + guard let model, let isOnSwitch + else { return } + model.isOn = isOnSwitch.isOn + }, + for: .valueChanged + ) + isOnSwitch.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(isOnSwitch) + + NSLayoutConstraint.activate([ + isOnSwitch.centerXAnchor.constraint(equalTo: view.centerXAnchor), + isOnSwitch.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + isOnSwitch.setOn(model.isOn, animated: true) + } + } +} + +#Preview("SwiftUI") { + SettingsView(model: SettingsModel()) +} +#Preview("UIKit") { + UIViewControllerRepresenting { + SettingsViewController(model: SettingsModel()) + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/UIBinding.swift b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/UIBinding.swift new file mode 100644 index 00000000..3465bb1a --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/UIBinding.swift @@ -0,0 +1,81 @@ +import CasePaths +import Perception +import SwiftUI + +@dynamicMemberLookup +@propertyWrapper +struct UIBinding: Hashable { + fileprivate let base: AnyObject + fileprivate let keyPath: AnyKeyPath + + // init(get: () -> Value, set: (Value) -> Void) + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(base)) + hasher.combine(keyPath) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.base === rhs.base && lhs.keyPath == rhs.keyPath + } + + var wrappedValue: Value { + get { + (base as Any)[keyPath: keyPath] as! Value + } + nonmutating set { + func open(_ root: Root) { + root[keyPath: keyPath as! ReferenceWritableKeyPath] = newValue + } + _openExistential(base, do: open) + } + } + + subscript(dynamicMember keyPath: WritableKeyPath) -> UIBinding { + UIBinding(base: base, keyPath: self.keyPath.appending(path: keyPath)!) + } + + public subscript( + dynamicMember keyPath: KeyPath> + ) -> UIBinding + where Value == Enum? { + self[keyPath] + } +} + +@dynamicMemberLookup +@propertyWrapper +struct UIBindable { + var wrappedValue: Value + var projectedValue: Self { + get { self } + set { self = newValue } + } + + init(wrappedValue: Value) where Value: Perceptible, Value: AnyObject { + self.wrappedValue = wrappedValue + } + + subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> UIBinding + where Value: AnyObject { + UIBinding(base: wrappedValue, keyPath: keyPath) + } +} + +extension Optional where Wrapped: CasePathable { + fileprivate subscript( + keyPath: KeyPath> + ) -> Member? { + get { + guard let wrapped = self else { return nil } + return Wrapped.allCasePaths[keyPath: keyPath].extract(from: wrapped) + } + set { + guard let newValue else { + self = nil + return + } + self = Wrapped.allCasePaths[keyPath: keyPath].embed(newValue) + } + } +} diff --git a/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/UIControl.swift b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/UIControl.swift new file mode 100644 index 00000000..c3a299be --- /dev/null +++ b/0289-modern-uikit-pt9/ModernUIKit/ModernUIKit/UIControl.swift @@ -0,0 +1,79 @@ +import UIKit + +@MainActor +private protocol _UIControl: UIControl {} +extension UIControl: _UIControl {} + +extension _UIControl { + func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath, + for event: UIControl.Event + ) { + observe { [weak self] in + guard let self else { return } + self[keyPath: keyPath] = binding.wrappedValue + } + self.addAction( + UIAction { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + }, + for: event + ) + } +} + +extension UIStepper { + convenience init( + frame: CGRect = .zero, + value: UIBinding + ) { + self.init(frame: frame) + self.bind(value: value) + } + + func bind(value: UIBinding) { + self.bind(value, to: \.value, for: .valueChanged) + } +} + +extension UITextField { + convenience init( + frame: CGRect = .zero, + text: UIBinding + ) { + self.init(frame: frame) + self.bind(text: text) + } + + func bind(text: UIBinding) { + self.bind(text.toOptional, to: \.text, for: .editingChanged) + } + + func bind(focus: UIBinding) { + observe { [weak self] in + guard let self else { return } + if focus.wrappedValue { + becomeFirstResponder() + } else { + resignFirstResponder() + } + } + addAction( + UIAction { _ in focus.wrappedValue = true }, + for: .editingDidBegin + ) + addAction( + UIAction { _ in focus.wrappedValue = false }, + for: .editingDidEnd + ) + } +} + +extension String { + fileprivate var toOptional: String? { + get { self } + set { self = newValue ?? "" } + } +} diff --git a/0289-modern-uikit-pt9/README.md b/0289-modern-uikit-pt9/README.md new file mode 100644 index 00000000..bd069811 --- /dev/null +++ b/0289-modern-uikit-pt9/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Modern UIKit: UIControl Bindings](https://www.pointfree.co/episodes/ep289-modern-uikit-uicontrol-bindings) +> +> While we rebuilt SwiftUI bindings in UIKit to power state-driven navigation, that’s not all SwiftUI uses them for! Let’s see what it takes to power `UIControl`s from model bindings. And finally, let’s ask “what’s the point?” by comparing the tools we’ve built over many episodes with the alternative. diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings.xcodeproj/project.pbxproj b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings.xcodeproj/project.pbxproj new file mode 100644 index 00000000..119d4a17 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings.xcodeproj/project.pbxproj @@ -0,0 +1,387 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 2AC91F4B2C45B96B00087457 /* LegacyConnectToNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AC91F4A2C45B96B00087457 /* LegacyConnectToNetwork.swift */; }; + 2AE82B912BFBF629006DF43B /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE82B902BFBF629006DF43B /* Network.swift */; }; + 2AE82B932BFBF67C006DF43B /* NetworkDetailFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE82B922BFBF67C006DF43B /* NetworkDetailFeature.swift */; }; + 4B2E62142BFBF79500261C0F /* ConnectToNetworkFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2E62132BFBF79500261C0F /* ConnectToNetworkFeature.swift */; }; + 4B2E62162BFBF88300261C0F /* WiFiSettingsFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2E62152BFBF88300261C0F /* WiFiSettingsFeature.swift */; }; + DC93432A2BFBDE4600E0797C /* WiFiSettingsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9343292BFBDE4600E0797C /* WiFiSettingsApp.swift */; }; + DC93432E2BFBDE4800E0797C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC93432D2BFBDE4800E0797C /* Assets.xcassets */; }; + DC9343312BFBDE4800E0797C /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC9343302BFBDE4800E0797C /* Preview Assets.xcassets */; }; + DC9343562BFBDEA600E0797C /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DC9343552BFBDEA600E0797C /* UIKitNavigation */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2AC91F4A2C45B96B00087457 /* LegacyConnectToNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyConnectToNetwork.swift; sourceTree = ""; }; + 2AE82B902BFBF629006DF43B /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; + 2AE82B922BFBF67C006DF43B /* NetworkDetailFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDetailFeature.swift; sourceTree = ""; }; + 4B2E62132BFBF79500261C0F /* ConnectToNetworkFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToNetworkFeature.swift; sourceTree = ""; }; + 4B2E62152BFBF88300261C0F /* WiFiSettingsFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WiFiSettingsFeature.swift; sourceTree = ""; }; + DC9343262BFBDE4600E0797C /* WiFiSettings.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WiFiSettings.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DC9343292BFBDE4600E0797C /* WiFiSettingsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WiFiSettingsApp.swift; sourceTree = ""; }; + DC93432D2BFBDE4800E0797C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DC9343302BFBDE4800E0797C /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + DC9343532BFBDE8D00E0797C /* swiftui-navigation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = "swiftui-navigation"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DC9343232BFBDE4600E0797C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC9343562BFBDEA600E0797C /* UIKitNavigation in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + DC93431D2BFBDE4600E0797C = { + isa = PBXGroup; + children = ( + DC9343282BFBDE4600E0797C /* WiFiSettings */, + DC9343272BFBDE4600E0797C /* Products */, + DC9343542BFBDEA600E0797C /* Frameworks */, + DC9343532BFBDE8D00E0797C /* swiftui-navigation */, + ); + sourceTree = ""; + }; + DC9343272BFBDE4600E0797C /* Products */ = { + isa = PBXGroup; + children = ( + DC9343262BFBDE4600E0797C /* WiFiSettings.app */, + ); + name = Products; + sourceTree = ""; + }; + DC9343282BFBDE4600E0797C /* WiFiSettings */ = { + isa = PBXGroup; + children = ( + 4B2E62132BFBF79500261C0F /* ConnectToNetworkFeature.swift */, + 2AC91F4A2C45B96B00087457 /* LegacyConnectToNetwork.swift */, + 2AE82B902BFBF629006DF43B /* Network.swift */, + 2AE82B922BFBF67C006DF43B /* NetworkDetailFeature.swift */, + DC9343292BFBDE4600E0797C /* WiFiSettingsApp.swift */, + 4B2E62152BFBF88300261C0F /* WiFiSettingsFeature.swift */, + DC93432D2BFBDE4800E0797C /* Assets.xcassets */, + DC93432F2BFBDE4800E0797C /* Preview Content */, + ); + path = WiFiSettings; + sourceTree = ""; + }; + DC93432F2BFBDE4800E0797C /* Preview Content */ = { + isa = PBXGroup; + children = ( + DC9343302BFBDE4800E0797C /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + DC9343542BFBDEA600E0797C /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DC9343252BFBDE4600E0797C /* WiFiSettings */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC93434A2BFBDE4800E0797C /* Build configuration list for PBXNativeTarget "WiFiSettings" */; + buildPhases = ( + DC9343222BFBDE4600E0797C /* Sources */, + DC9343232BFBDE4600E0797C /* Frameworks */, + DC9343242BFBDE4600E0797C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WiFiSettings; + packageProductDependencies = ( + DC9343552BFBDEA600E0797C /* UIKitNavigation */, + ); + productName = WiFiSettings; + productReference = DC9343262BFBDE4600E0797C /* WiFiSettings.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DC93431E2BFBDE4600E0797C /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + TargetAttributes = { + DC9343252BFBDE4600E0797C = { + CreatedOnToolsVersion = 15.4; + }; + }; + }; + buildConfigurationList = DC9343212BFBDE4600E0797C /* Build configuration list for PBXProject "WiFiSettings" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = DC93431D2BFBDE4600E0797C; + productRefGroup = DC9343272BFBDE4600E0797C /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DC9343252BFBDE4600E0797C /* WiFiSettings */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + DC9343242BFBDE4600E0797C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC9343312BFBDE4800E0797C /* Preview Assets.xcassets in Resources */, + DC93432E2BFBDE4800E0797C /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DC9343222BFBDE4600E0797C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2AE82B912BFBF629006DF43B /* Network.swift in Sources */, + DC93432A2BFBDE4600E0797C /* WiFiSettingsApp.swift in Sources */, + 4B2E62162BFBF88300261C0F /* WiFiSettingsFeature.swift in Sources */, + 2AE82B932BFBF67C006DF43B /* NetworkDetailFeature.swift in Sources */, + 4B2E62142BFBF79500261C0F /* ConnectToNetworkFeature.swift in Sources */, + 2AC91F4B2C45B96B00087457 /* LegacyConnectToNetwork.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + DC9343482BFBDE4800E0797C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + }; + name = Debug; + }; + DC9343492BFBDE4800E0797C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DC93434B2BFBDE4800E0797C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"WiFiSettings/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.WiFiSettings; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DC93434C2BFBDE4800E0797C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"WiFiSettings/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.WiFiSettings; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + DC9343212BFBDE4600E0797C /* Build configuration list for PBXProject "WiFiSettings" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC9343482BFBDE4800E0797C /* Debug */, + DC9343492BFBDE4800E0797C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC93434A2BFBDE4800E0797C /* Build configuration list for PBXNativeTarget "WiFiSettings" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC93434B2BFBDE4800E0797C /* Debug */, + DC93434C2BFBDE4800E0797C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + DC9343552BFBDEA600E0797C /* UIKitNavigation */ = { + isa = XCSwiftPackageProductDependency; + productName = UIKitNavigation; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = DC93431E2BFBDE4600E0797C /* Project object */; +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Assets.xcassets/AccentColor.colorset/Contents.json b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Assets.xcassets/AppIcon.appiconset/Contents.json b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Assets.xcassets/Contents.json b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/ConnectToNetworkFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/ConnectToNetworkFeature.swift new file mode 100644 index 00000000..e0459789 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/ConnectToNetworkFeature.swift @@ -0,0 +1,106 @@ +import Observation +import UIKit +import UIKitNavigation + +@Perceptible +@MainActor +class ConnectToNetworkModel { + var incorrectPasswordAlertIsPresented = false + var isConnecting = false + var onConnect: (Network) -> Void + let network: Network + var password = "" + + init(network: Network, onConnect: @escaping (Network) -> Void) { + self.onConnect = onConnect + self.network = network + } + + func joinButtonTapped() async { + isConnecting = true + defer { isConnecting = false } + try? await Task.sleep(for: .seconds(1)) + if password == "blob" { + onConnect(network) + } else { + incorrectPasswordAlertIsPresented = true + } + } +} + +final class ConnectToNetworkViewController: UIViewController { + @UIBindable var model: ConnectToNetworkModel + + init(model: ConnectToNetworkModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = "Enter the password for “\(model.network.name)”" + + let passwordTextField = UITextField(text: $model.password) + passwordTextField.borderStyle = .line + passwordTextField.isSecureTextEntry = true + passwordTextField.becomeFirstResponder() + let joinButton = UIButton(type: .system, primaryAction: UIAction { _ in + Task { + await self.model.joinButtonTapped() + } + }) + joinButton.setTitle("Join network", for: .normal) + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.startAnimating() + + let stack = UIStackView(arrangedSubviews: [ + passwordTextField, + joinButton, + activityIndicator, + ]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.widthAnchor.constraint(equalToConstant: 200) + ]) + + observe { [weak self] in + guard let self else { return } + passwordTextField.isEnabled = !model.isConnecting + joinButton.isEnabled = !model.isConnecting + activityIndicator.isHidden = !model.isConnecting + } + + present(isPresented: $model.incorrectPasswordAlertIsPresented) { [model] in + let controller = UIAlertController( + title: "Incorrect password for “\(model.network.name)”", + message: nil, + preferredStyle: .alert + ) + controller.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in })) + return controller + } + } +} + +import SwiftUI +#Preview { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: ConnectToNetworkViewController( + model: ConnectToNetworkModel( + network: Network(name: "Blob's WiFi"), + onConnect: { _ in } + ) + ) + ) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/LegacyConnectToNetwork.swift b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/LegacyConnectToNetwork.swift new file mode 100644 index 00000000..9aff3ba5 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/LegacyConnectToNetwork.swift @@ -0,0 +1,141 @@ +import Combine +import SwiftUI + +@MainActor +class LegacyConnectToNetworkModel { + @Published var incorrectPasswordAlertIsPresented = false + @Published var isConnecting = false + @Published var onConnect: (Network) -> Void + let network: Network + @Published var password = "" + + init(network: Network, onConnect: @escaping (Network) -> Void) { + self.onConnect = onConnect + self.network = network + } + + func joinButtonTapped() async { + isConnecting = true + defer { isConnecting = false } + try? await Task.sleep(for: .seconds(1)) + if password == "blob" { + onConnect(network) + } else { + incorrectPasswordAlertIsPresented = true + } + } +} + +final class LegacyConnectToNetworkViewController: UIViewController { + let model: LegacyConnectToNetworkModel + var cancellables: Set = [] + + init(model: LegacyConnectToNetworkModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = "Enter the password for “\(model.network.name)”" + + let passwordTextField = UITextField() + passwordTextField.borderStyle = .line + passwordTextField.isSecureTextEntry = true + passwordTextField.becomeFirstResponder() + let joinButton = UIButton(type: .system, primaryAction: UIAction { _ in + Task { + await self.model.joinButtonTapped() + } + }) + passwordTextField.addAction( + UIAction { [weak self, weak passwordTextField] action in + self?.model.password = passwordTextField?.text ?? "" + }, + for: .editingChanged + ) + + joinButton.setTitle("Join network", for: .normal) + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.startAnimating() + + let stack = UIStackView(arrangedSubviews: [ + passwordTextField, + joinButton, + activityIndicator, + ]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.widthAnchor.constraint(equalToConstant: 200) + ]) + + model.$isConnecting.map(!) + .assign(to: \.isEnabled, on: passwordTextField) + .store(in: &cancellables) + + model.$isConnecting.map(!) + .assign(to: \.isEnabled, on: joinButton) + .store(in: &cancellables) + + model.$isConnecting.map(!) + .assign(to: \.isHidden, on: activityIndicator) + .store(in: &cancellables) + + model.$password.map(Optional.init) + .assign(to: \.text, on: passwordTextField) + .store(in: &cancellables) + + var alert: UIAlertController? + model.$incorrectPasswordAlertIsPresented + .removeDuplicates() + .sink { [weak self] isPresented in + guard let self else { return } + + if isPresented { + alert = UIAlertController( + title: "Incorrect password for “\(model.network.name)”", + message: nil, + preferredStyle: .alert + ) + alert!.addAction( + UIAlertAction( + title: "OK", + style: .default, + handler: { [weak self] _ in + guard let self else { return } + model.incorrectPasswordAlertIsPresented = false + }) + ) + present(alert!, animated: true) + } else { + alert?.dismiss(animated: true) + alert = nil + } + } + .store(in: &cancellables) + } +} + + +import UIKitNavigation +#Preview { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: LegacyConnectToNetworkViewController( + model: LegacyConnectToNetworkModel( + network: Network(name: "Blob's WiFi"), + onConnect: { _ in } + ) + ) + ) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Network.swift b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Network.swift new file mode 100644 index 00000000..9a659c01 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Network.swift @@ -0,0 +1,8 @@ +import Foundation + +struct Network: Identifiable, Hashable { + let id = UUID() + var name = "" + var isSecured = true + var connectivity = 1.0 +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/NetworkDetailFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/NetworkDetailFeature.swift new file mode 100644 index 00000000..929300d8 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/NetworkDetailFeature.swift @@ -0,0 +1,94 @@ +import Observation +import UIKit +import UIKitNavigation + +@MainActor +@Perceptible +class NetworkDetailModel { + var forgetAlertIsPresented = false + let onConfirmForget: () -> Void + let network: Network + + init( + forgetAlertIsPresented: Bool = false, + network: Network, + onConfirmForget: @escaping () -> Void = {} + ) { + self.forgetAlertIsPresented = forgetAlertIsPresented + self.onConfirmForget = onConfirmForget + self.network = network + } + + func forgetNetworkButtonTapped() { + forgetAlertIsPresented = true + } + + func confirmForgetNetworkButtonTapped() { + onConfirmForget() + } +} + +final class NetworkDetailViewController: UIViewController { + @UIBindable var model: NetworkDetailModel + + init(model: NetworkDetailModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = model.network.name + + let forgetButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + model.forgetNetworkButtonTapped() + }) + forgetButton.setTitle("Forget network", for: .normal) + forgetButton.setTitleColor(.red, for: .normal) + forgetButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(forgetButton) + NSLayoutConstraint.activate([ + forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + // present(<#T##viewControllerToPresent: UIViewController##UIViewController#>, animated: <#T##Bool#>) + + present(isPresented: $model.forgetAlertIsPresented) { [model] in + let controller = UIAlertController( + title: "Forget Wi-Fi Network “\(model.network.name)”?", + message: """ + Your devices using iCloud Keychain will no longer join this Wi-Fi \ + network. + """, + preferredStyle: .alert + ) + controller.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { _ in })) + controller.addAction(UIAlertAction(title: "Forget", style: .destructive, handler: { _ in + model.confirmForgetNetworkButtonTapped() + })) + return controller + } + } +} + +import SwiftUI +#Preview { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: NetworkDetailViewController( + model: NetworkDetailModel( + forgetAlertIsPresented: true, + network: Network(name: "Blob's WiFi"), + onConfirmForget: { } + ) + ) + ) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Preview Content/Preview Assets.xcassets/Contents.json b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/WiFiSettingsApp.swift b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/WiFiSettingsApp.swift new file mode 100644 index 00000000..bfabbe44 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/WiFiSettingsApp.swift @@ -0,0 +1,19 @@ +import SwiftUI +import UIKitNavigation + +@main +struct WiFiSettingsApp: App { + var body: some Scene { + WindowGroup { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: WiFiSettingsViewController( + model: WiFiSettingsModel( + foundNetworks: .mocks + ) + ) + ) + } + } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/WiFiSettingsFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/WiFiSettingsFeature.swift new file mode 100644 index 00000000..3323a52b --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/WiFiSettings/WiFiSettingsFeature.swift @@ -0,0 +1,339 @@ +import Observation +import UIKit +import UIKitNavigation + +@Perceptible +@MainActor +class WiFiSettingsModel { + @CasePathable + enum Destination { + case connect(ConnectToNetworkModel) + case detail(NetworkDetailModel) + } + + var destination: Destination? + var foundNetworks: [Network] + var isOn: Bool + var selectedNetworkID: Network.ID? + + init( + destination: Destination? = nil, + foundNetworks: [Network], + isOn: Bool = true, + selectedNetworkID: Network.ID? = nil + ) { + self.destination = destination + self.foundNetworks = foundNetworks + self.isOn = isOn + self.selectedNetworkID = selectedNetworkID + } + + func networkTapped(_ network: Network) { + if network.id == selectedNetworkID { + self.destination = .detail( + NetworkDetailModel( + network: network, + onConfirmForget: { [weak self] in + guard let self else { return } + destination = nil + selectedNetworkID = nil + } + ) + ) + } else if network.isSecured { + destination = .connect( + ConnectToNetworkModel( + network: network, + onConnect: { [weak self] network in + guard let self else { return } + destination = nil + selectedNetworkID = network.id + } + ) + ) + } else { + selectedNetworkID = network.id + } + } + + func infoButtonTapped(network: Network) { + destination = .detail( + NetworkDetailModel( + network: network, + onConfirmForget: { [weak self] in + guard let self else { return } + destination = nil + selectedNetworkID = nil + } + ) + ) + } +} + +class WiFiSettingsViewController: UICollectionViewController { + @UIBindable var model: WiFiSettingsModel + var dataSource: UICollectionViewDiffableDataSource! + + init(model: WiFiSettingsModel) { + self.model = model + super.init( + collectionViewLayout: UICollectionViewCompositionalLayout.list( + using: UICollectionLayoutListConfiguration(appearance: .insetGrouped) + ) + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.title = "Wi-Fi" + + let cellRegistration = UICollectionView.CellRegistration { + [weak self] cell, indexPath, item in + guard let self else { return } + configure(cell: cell, indexPath: indexPath, item: item) + } + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: item + ) + } + + observe { [weak self] in + guard let self else { return } + dataSource.apply(.init(model: model), animatingDifferences: true) + } + + present(item: $model.destination.connect) { model in + UINavigationController( + rootViewController: ConnectToNetworkViewController( + model: model + ) + ) + } + + navigationController?.pushViewController(item: $model.destination.detail) { model in + NetworkDetailViewController(model: model) + } + } + + override func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + guard case let .foundNetwork(network) = dataSource.itemIdentifier(for: indexPath) + else { return } + model.networkTapped(network) + } + + private func configure( + cell: UICollectionViewListCell, + indexPath: IndexPath, + item: Item + ) { + var configuration = cell.defaultContentConfiguration() + defer { cell.contentConfiguration = configuration } + + switch item { + case .isOn: + configuration.text = "Wi-Fi" + cell.accessories = [ + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: UISwitch(isOn: $model.isOn), + placement: .trailing(displayed: .always) + ) + ) + ] + case let .selectedNetwork(networkID): + guard let network = model.foundNetworks.first(where: { $0.id == networkID }) + else { return } + configureNetwork( + configuration: &configuration, + cell: cell, + network: network, + indexPath: indexPath, + item: item + ) + case let .foundNetwork(network): + configureNetwork( + configuration: &configuration, + cell: cell, + network: network, + indexPath: indexPath, + item: item + ) + } + } + + func configureNetwork( + configuration: inout UIListContentConfiguration, + cell: UICollectionViewListCell, + network: Network, + indexPath: IndexPath, + item: Item + ) { + configuration.text = network.name + cell.accessories = [ + .detail(displayed: .always) { [weak self] in + guard let self else { return } + model.infoButtonTapped(network: network) + } + ] + if network.isSecured { + let image = UIImage(systemName: "lock.fill")! + let imageView = UIImageView(image: image) + imageView.tintColor = .darkText + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: imageView, + placement: .trailing(displayed: .always) + ) + ) + ) + } + let image = UIImage(systemName: "wifi", variableValue: network.connectivity)! + let imageView = UIImageView(image: image) + imageView.tintColor = .darkText + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: imageView, + placement: .trailing(displayed: .always) + ) + ) + ) + if network.id == model.selectedNetworkID { + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: UIImageView(image: UIImage(systemName: "checkmark")!), + placement: .leading(displayed: .always), + reservedLayoutWidth: .custom(1) + ) + ) + ) + } + } + + enum Section { + case top + case foundNetworks + } + + enum Item: Hashable { + case isOn + case selectedNetwork(Network.ID) + case foundNetwork(Network) + } +} + +extension NSDiffableDataSourceSnapshot { + @MainActor + init(model: WiFiSettingsModel) { + self.init() + + appendSections([.top]) + appendItems([.isOn], toSection: .top) + + guard model.isOn + else { return } + + if let selectedNetworkID = model.selectedNetworkID { + appendItems([.selectedNetwork(selectedNetworkID)], toSection: .top) + } + + appendSections([.foundNetworks]) + + appendItems( + model.foundNetworks + .filter { $0.id != model.selectedNetworkID } + .map { .foundNetwork($0) }, + toSection: .foundNetworks + ) + } +} + +import SwiftUI +#Preview { + let model = WiFiSettingsModel(foundNetworks: .mocks) + return UIViewControllerRepresenting { + UINavigationController( + rootViewController: WiFiSettingsViewController(model: model) + ) + } + .task { + while true { + try? await Task.sleep(for: .seconds(1)) + guard Bool.random() else { continue } + if Bool.random() && Bool.random() { + guard let randomIndex = (0.. + + + + diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/App.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/App.swift new file mode 100644 index 00000000..398327fc --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/App.swift @@ -0,0 +1,45 @@ +import SwiftUI +import UIKitNavigation + +@main +@MainActor +struct ExamplesApp: App { + static let navigationController = { + // @UIBindable var model = AppModel() + // model.path.append(AppModel.Path.counter(CounterModel())) + // model.path.append(AppModel.Path.form(FormModel())) + // + // let navigationController = UINavigationController(path: $model.path) { + // NavigationRootViewController() + // } + // // let navigationController = UINavigationController( + // // rootViewController: NavigationRootViewController() + // // ) + // navigationController.navigationDestination(for: AppModel.Path.self) { route in + // switch route { + // case let .collection(model): + // CollectionViewController(model: model) + // case let .counter(model): + // CounterViewController(model: model) + // case let .form(model): + // FormViewController(model: model) + // } + // } + + UINavigationController( + rootViewController: SearchNetworksViewController( + model: SearchNetworksModel( + foundNetworks: .mocks + ) + ) + ) + }() + + var body: some Scene { + WindowGroup { + UIViewControllerRepresenting { + Self.navigationController + } + } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/Assets.xcassets/Contents.json b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/CollectionFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/CollectionFeature.swift new file mode 100644 index 00000000..d8762bbb --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/CollectionFeature.swift @@ -0,0 +1,115 @@ +import IdentifiedCollections +import UIKitNavigation + +extension UICollectionView { + @available(iOS 14, *) + public convenience init( + frame: CGRect = .zero, + collectionViewLayout: UICollectionViewLayout = UICollectionViewLayout(), + data: UIBinding>, + content: @escaping (Cell, IndexPath, UIBinding) -> Void + ) { + self.init(frame: frame, collectionViewLayout: collectionViewLayout) + + let cellRegistration = UICollectionView.CellRegistration< + Cell, UIBinding + > { cell, indexPath, item in + _perceive { // TODO: Should this `perceive` be here? + content(cell, indexPath, item) + } + } + + let dataSource = UICollectionViewDiffableDataSource< + Section, UIBinding + >(collectionView: self) { collectionView, indexPath, item in + MainActor.assumeIsolated { + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, for: indexPath, item: item + ) + } + } + + _perceive { + var snapshot = NSDiffableDataSourceSnapshot>() + snapshot.appendSections([.main]) + snapshot.appendItems( + data.wrappedValue.ids.map { UIBinding(data[id: $0])! } + ) + dataSource.apply(snapshot, animatingDifferences: true) + } + } +} + +private enum Section { case main } + +// ... + +import SwiftUI + +@Perceptible +final class CollectionModel: Hashable { + struct Item: Identifiable, Comparable { + let id = UUID() + var count = 0 + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.count < rhs.count + } + } + var items: IdentifiedArrayOf = [ + Item(count: 1), + Item(count: 2), + Item(count: 3) + ] + + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + nonisolated static func == (lhs: CollectionModel, rhs: CollectionModel) -> Bool { + lhs === rhs + } +} + +final class CollectionViewController: UIViewController { + @UIBindable var model: CollectionModel + + init(model: CollectionModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let collectionView = UICollectionView( + frame: view.bounds, + collectionViewLayout: UICollectionViewCompositionalLayout.list( + using: UICollectionLayoutListConfiguration(appearance: .insetGrouped) + ), + data: $model.items + ) { (cell: UICollectionViewListCell, indexPath, $item) in + var content = cell.defaultContentConfiguration() + content.text = "\(item.count)" + cell.contentConfiguration = content + } + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(collectionView) + + // collectionView.delegate = self + + Task { [weak self] in + while true { + guard let self else { return } + try await Task.sleep(for: .seconds(1)) + for position in model.items.indices { + model.items[position].count += .random() ? 1 : -1 + } + model.items.sort() + print(model.items) + } + } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/CounterFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/CounterFeature.swift new file mode 100644 index 00000000..35add6aa --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/CounterFeature.swift @@ -0,0 +1,104 @@ +import UIKitNavigation + +@MainActor +@Perceptible +class CounterModel: Hashable { + var count = 0 + + func decrementButtonTapped() { + count -= 1 + } + + func incrementButtonTapped() { + count += 1 + } + + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + nonisolated static func == (lhs: CounterModel, rhs: CounterModel) -> Bool { + lhs === rhs + } +} + +final class CounterViewController: UIViewController { + let model: CounterModel + + init(model: CounterModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + let countLabel = UILabel() + countLabel.textAlignment = .center + let decrementButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + model.decrementButtonTapped() + }) + decrementButton.setTitle("Decrement", for: .normal) + let incrementButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + model.incrementButtonTapped() + if #available(iOS 17, *) { + traitCollection.dismiss() + } else { + // Fallback on earlier versions + } + }) + incrementButton.setTitle("Increment", for: .normal) + let pushCollectionButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + navigationController?.push(value: AppModel.Path.collection(CollectionModel())) + }) + pushCollectionButton.setTitle("Push collection feature", for: .normal) + let pushCounterButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + navigationController?.push(value: AppModel.Path.counter(CounterModel())) + }) + pushCounterButton.setTitle("Push counter feature", for: .normal) + let pushFormButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + navigationController?.push(value: AppModel.Path.form(FormModel())) + }) + pushFormButton.setTitle("Push form feature", for: .normal) + let counterStack = UIStackView(arrangedSubviews: [ + countLabel, + decrementButton, + incrementButton, + pushCollectionButton, + pushCounterButton, + pushFormButton, + ]) + counterStack.axis = .vertical + counterStack.spacing = 12 + counterStack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(counterStack) + NSLayoutConstraint.activate([ + counterStack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + counterStack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + counterStack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + counterStack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + _perceive { [weak self] in + guard let self else { return } + + countLabel.text = "\(model.count)" + navigationItem.title = "Counter: \(model.count)" + } + } +} + +#Preview { + UIViewControllerRepresenting { + CounterViewController(model: CounterModel()) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/FormFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/FormFeature.swift new file mode 100644 index 00000000..25912364 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/FormFeature.swift @@ -0,0 +1,155 @@ +import UIKitNavigation + +@MainActor +@Perceptible +final class FormModel: Hashable { + var color: UIColor? = .white + var date = Date() + var isOn = false + var isDrillDownPresented = false + var isSheetPresented = false + var sheet: Sheet? + var sliderValue: Float = 0.5 + var stepperValue: Double = 5 + var text = "Blob" + + struct Sheet: Identifiable { + var text = "Hi" + var id: String { text } + } + + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + nonisolated static func == (lhs: FormModel, rhs: FormModel) -> Bool { + lhs === rhs + } +} + +@MainActor +final class FormViewController: UIViewController { + @UIBindable var model: FormModel + + init(model: FormModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let myColorWell = UIColorWell(selectedColor: $model.color) + + let myDatePicker = UIDatePicker(date: $model.date) + + let mySlider = UISlider(value: $model.sliderValue) + + let myStepper = UIStepper(value: $model.stepperValue) + + let mySwitch = UISwitch(isOn: $model.isOn) + + let myTextField = UITextField(text: $model.text) + myTextField.borderStyle = .roundedRect + + let sheetButton = UIButton(configuration: .plain(), primaryAction: UIAction { [weak self] _ in + self?.model.sheet = .init(text: "Blob") +// Task { +// try await Task.sleep(for: .seconds(2)) +// self?.model.sheet = .init(text: "Blob, Jr.") +// } + }) + sheetButton.setTitle("Present sheet", for: .normal) + + let drillDownButton = UIButton( + configuration: .plain(), + primaryAction: UIAction { [weak self] _ in self?.model.isDrillDownPresented = true } + ) + drillDownButton.setTitle("Present drill-down", for: .normal) + + let myLabel = UILabel() + myLabel.numberOfLines = 0 + + let stack = UIStackView(arrangedSubviews: [ + myColorWell, + myDatePicker, + mySlider, + myStepper, + mySwitch, + myTextField, + myLabel, + sheetButton, + drillDownButton, + ]) + stack.axis = .vertical + stack.isLayoutMarginsRelativeArrangement = true + stack.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + + _perceive { [weak self] in + guard let self else { return } + + view.backgroundColor = model.color + myLabel.text = """ + MyModel( + color: \(String(describing: model.color)), + date: \(model.date), + isOn: \(model.isOn), + sliderValue: \(model.sliderValue) + stepperValue: \(model.stepperValue), + text: \(model.text) + ) + """ + } + + navigationController?.pushViewController(isPresented: $model.isDrillDownPresented) { + ChildController() + } + + present(item: $model.sheet) { item in + ChildController(text: item.text) + } + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + myColorWell.heightAnchor.constraint(equalToConstant: 50), + ]) + } +} + +final class ChildController: UIViewController { + let text: String + init(text: String = "Hello!") { + self.text = text + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + let label = UILabel() + label.text = text + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + Task { + try await Task.sleep(for: .seconds(1)) + if #available(iOS 17.0, *) { + self.traitCollection.dismiss() + } + } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/NavigationRootFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/NavigationRootFeature.swift new file mode 100644 index 00000000..7855b7f8 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/NavigationRootFeature.swift @@ -0,0 +1,24 @@ +import UIKit + +final class NavigationRootViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + + let counterButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + navigationController?.push(value: AppModel.Path.counter(CounterModel())) + }) + counterButton.setTitle("Counter", for: .normal) + let stack = UIStackView(arrangedSubviews: [ + counterButton + ]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/NavigationStackCaseStudy.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/NavigationStackCaseStudy.swift new file mode 100644 index 00000000..6190888a --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/NavigationStackCaseStudy.swift @@ -0,0 +1,17 @@ +import UIKitNavigation + +@MainActor +@Perceptible +class AppModel { +// var path: [Path] = [] + var path = UINavigationPath() + init() { + self.path = path + } + + enum Path: Hashable { + case collection(CollectionModel) + case counter(CounterModel) + case form(FormModel) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/ConnectToNetworkFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/ConnectToNetworkFeature.swift new file mode 100644 index 00000000..c100a63c --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/ConnectToNetworkFeature.swift @@ -0,0 +1,101 @@ +import UIKitNavigation + +@Perceptible +@MainActor +class ConnectToNetworkModel { + var incorrectPasswordAlertIsPresented = false + var isConnecting = false + var onConnect: (Network) -> Void + let network: Network + var password = "" + init(network: Network, onConnect: @escaping (Network) -> Void) { + self.onConnect = onConnect + self.network = network + } + func joinButtonTapped() async { + isConnecting = true + defer { isConnecting = false } + try? await Task.sleep(for: .seconds(1)) + if password == "blob" { + onConnect(network) + } else { + incorrectPasswordAlertIsPresented = true + } + } +} + +final class ConnectToNetworkViewController: UIViewController { + @UIBindable var model: ConnectToNetworkModel + + init(model: ConnectToNetworkModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = "Enter the password for “\(model.network.name)”" + + let passwordTextField = UITextField(text: $model.password) + passwordTextField.borderStyle = .line + passwordTextField.isSecureTextEntry = true + passwordTextField.becomeFirstResponder() + let joinButton = UIButton(type: .system, primaryAction: UIAction { _ in + Task { + await self.model.joinButtonTapped() + } + }) + joinButton.setTitle("Join network", for: .normal) + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.startAnimating() + + let stack = UIStackView(arrangedSubviews: [ + passwordTextField, + joinButton, + activityIndicator, + ]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.widthAnchor.constraint(equalToConstant: 200) + ]) + + _perceive { [weak self] in + guard let self else { return } + passwordTextField.isEnabled = !model.isConnecting + joinButton.isEnabled = !model.isConnecting + activityIndicator.isHidden = !model.isConnecting + } + + present(isPresented: $model.incorrectPasswordAlertIsPresented) { [unowned self] in + let controller = UIAlertController( + title: "Incorrect password for “\(model.network.name)”", + message: nil, + preferredStyle: .alert + ) + controller.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in })) + return controller + } + } +} + +#Preview { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: ConnectToNetworkViewController( + model: ConnectToNetworkModel( + network: Network(name: "Blob's WiFi"), + onConnect: { _ in } + ) + ) + ) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/Network.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/Network.swift new file mode 100644 index 00000000..9a659c01 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/Network.swift @@ -0,0 +1,8 @@ +import Foundation + +struct Network: Identifiable, Hashable { + let id = UUID() + var name = "" + var isSecured = true + var connectivity = 1.0 +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/NetworkDetailFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/NetworkDetailFeature.swift new file mode 100644 index 00000000..166185d4 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/NetworkDetailFeature.swift @@ -0,0 +1,85 @@ +import UIKitNavigation + +@MainActor +@Perceptible +class NetworkDetailModel { + var forgetAlertIsPresented = false + let onConfirmForget: () -> Void + let network: Network + + init( + network: Network, + onConfirmForget: @escaping () -> Void + ) { + self.onConfirmForget = onConfirmForget + self.network = network + } + + func forgetNetworkButtonTapped() { + forgetAlertIsPresented = true + } + + func confirmForgetNetworkButtonTapped() { + onConfirmForget() + } +} + +final class NetworkDetailViewController: UIViewController { + @UIBindable var model: NetworkDetailModel + + init(model: NetworkDetailModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = model.network.name + + let forgetButton = UIButton(type: .system, primaryAction: UIAction { _ in + self.model.forgetNetworkButtonTapped() + }) + forgetButton.setTitle("Forget network", for: .normal) + forgetButton.setTitleColor(.red, for: .normal) + forgetButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(forgetButton) + NSLayoutConstraint.activate([ + forgetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + forgetButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + present(isPresented: $model.forgetAlertIsPresented) { [unowned self] in + let controller = UIAlertController( + title: "Forget Wi-Fi Network “\(model.network.name)”?", + message: """ + Your iPhone and other devices using iCloud Keychain will no longer join this Wi-Fi \ + network. + """, + preferredStyle: .alert + ) + controller.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { _ in })) + controller.addAction(UIAlertAction(title: "Forget", style: .destructive, handler: { _ in + self.model.confirmForgetNetworkButtonTapped() + })) + return controller + } + } +} + +#Preview { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: NetworkDetailViewController( + model: NetworkDetailModel( + network: Network(name: "Blob's WiFi"), + onConfirmForget: { } + ) + ) + ) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/SearchNetworksFeature.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/SearchNetworksFeature.swift new file mode 100644 index 00000000..74d54630 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Examples/Examples/WiFiFeature/SearchNetworksFeature.swift @@ -0,0 +1,475 @@ +import UIKitNavigation + +@Perceptible +class FooBar {} +struct Foo: View { + @Perception.Bindable var x: FooBar + + init(x: FooBar) { + self.x = x + } + + var body: some View { EmptyView() } +} + +@Perceptible +@MainActor +class SearchNetworksModel { + var destination: Destination? + var foundNetworks: [Network] + var isOn: Bool + var selectedNetworkID: Network.ID? + + @CasePathable + enum Destination { + case connect(ConnectToNetworkModel) + case detail(NetworkDetailModel) + } + + init( + foundNetworks: [Network] = [], + isOn: Bool = true, + selectedNetworkID: Network.ID? = nil + ) { + self.foundNetworks = foundNetworks + self.isOn = isOn + self.selectedNetworkID = selectedNetworkID + } + + func infoButtonTapped(network: Network) { + self.destination = .detail( + NetworkDetailModel(network: network) { [weak self] in + guard let self else { return } + destination = nil + selectedNetworkID = nil + } + ) + } + + func networkTapped(_ network: Network) { + if network.id == selectedNetworkID { + infoButtonTapped(network: network) + } else if network.isSecured { + destination = .connect( + ConnectToNetworkModel( + network: network, + onConnect: { [weak self] network in + guard let self else { return } + destination = nil + selectedNetworkID = network.id + } + ) + ) + } else { + self.selectedNetworkID = network.id + } + } +} + +class SearchNetworksViewController: UICollectionViewController { + @UIBindable var model: SearchNetworksModel + var dataSource: UICollectionViewDiffableDataSource! + + init(model: SearchNetworksModel) { + self.model = model + super.init( + collectionViewLayout: UICollectionViewCompositionalLayout.list( + using: UICollectionLayoutListConfiguration(appearance: .insetGrouped) + ) + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.title = "Wi-Fi" + + let cellRegistration = UICollectionView.CellRegistration { + [weak self] cell, indexPath, item in + + guard let self else { return } + configure(cell: cell, indexPath: indexPath, item: item) + } + + self.dataSource = UICollectionViewDiffableDataSource( + collectionView: self.collectionView + ) { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: item + ) + } + + _perceive { [weak self] in + guard let self else { return } + dataSource.apply(model.dataSourceSnapshot(), animatingDifferences: true) + } + + self.present(item: $model.destination.connect) { model in + UINavigationController( + rootViewController: ConnectToNetworkViewController(model: model) + ) + } + + self.navigationController?.pushViewController(item: $model.destination.detail) { model in + NetworkDetailViewController(model: model) + } + } + + private func configure( + cell: UICollectionViewListCell, + indexPath: IndexPath, + item: Item + ) { + var configuration = cell.defaultContentConfiguration() + defer { cell.contentConfiguration = configuration } + cell.accessories = [] + + switch item { + case .isOn: + configuration.text = "Wi-Fi" + cell.accessories = [ + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: UISwitch(isOn: $model.isOn), + placement: .trailing(displayed: .always) + ) + ) + ] + + case let .selectedNetwork(networkID): + guard let network = model.foundNetworks.first(where: { $0.id == networkID }) + else { return } + configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) + + case let .foundNetwork(network): + configureNetwork(cell: cell, network: network, indexPath: indexPath, item: item) + } + + func configureNetwork( + cell: UICollectionViewListCell, + network: Network, + indexPath: IndexPath, + item: Item + ) { + configuration.text = network.name + cell.accessories.append( + .detail(displayed: .always) { [weak self] in + guard let self else { return } + model.infoButtonTapped(network: network) + } + ) + if network.isSecured { + let image = UIImage(systemName: "lock.fill")! + let imageView = UIImageView(image: image) + imageView.tintColor = .darkText + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: imageView, + placement: .trailing(displayed: .always) + ) + ) + ) + } + let image = UIImage(systemName: "wifi", variableValue: network.connectivity)! + let imageView = UIImageView(image: image) + imageView.tintColor = .darkText + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: imageView, + placement: .trailing(displayed: .always) + ) + ) + ) + if network.id == model.selectedNetworkID { + cell.accessories.append( + .customView( + configuration: UICellAccessory.CustomViewConfiguration( + customView: UIImageView(image: UIImage(systemName: "checkmark")!), + placement: .leading(displayed: .always), + reservedLayoutWidth: .custom(1) + ) + ) + ) + } + } + } + + override func collectionView( + _ collectionView: UICollectionView, + shouldSelectItemAt indexPath: IndexPath + ) -> Bool { + indexPath.section != 0 || indexPath.row != 0 + } + + override func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + guard let network = dataSource.itemIdentifier(for: indexPath)?.foundNetwork + else { return } + model.networkTapped(network) + } + + enum Section: Hashable, Sendable { + case top + case foundNetworks + } + + @CasePathable + @dynamicMemberLookup + enum Item: Hashable, Sendable { + case isOn + case selectedNetwork(Network.ID) + case foundNetwork(Network) + } +} + +extension SearchNetworksModel { + func dataSourceSnapshot() -> NSDiffableDataSourceSnapshot< + SearchNetworksViewController.Section, + SearchNetworksViewController.Item + > { + var snapshot = NSDiffableDataSourceSnapshot< + SearchNetworksViewController.Section, + SearchNetworksViewController.Item + >() + + snapshot.appendSections([.top]) + snapshot.appendItems([.isOn], toSection: .top) + + guard isOn + else { return snapshot } + + if let selectedNetworkID { + snapshot.appendItems([.selectedNetwork(selectedNetworkID)], toSection: .top) + } + + snapshot.appendSections([.foundNetworks]) + snapshot.appendItems( + foundNetworks + .sorted { lhs, rhs in + (lhs.isSecured ? 1 : 0, lhs.connectivity) + > (rhs.isSecured ? 1 : 0, rhs.connectivity) + } + .compactMap { network in + network.id == selectedNetworkID + ? nil + : .foundNetwork(network) + } + , + toSection: .foundNetworks + ) + + return snapshot + } +} + +#Preview { + let model = SearchNetworksModel(foundNetworks: .mocks) + return UIViewControllerRepresenting { + UINavigationController( + rootViewController: SearchNetworksViewController(model: model) + ) + } + .task { + while true { + try? await Task.sleep(for: .seconds(1)) + guard Bool.random() else { continue } + if Bool.random() { + guard let randomIndex = (0.. for more information on how to use this API. + /// + /// - Parameters: + /// - state: A binding to optional alert state that determines whether an alert should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and used to + /// populate the fields of an alert that the system displays to the user. When the user + /// presses or taps one of the alert's actions, the system sets this value to `nil` and + /// dismisses the alert, and the action is fed to the `action` closure. + /// - handler: A closure that is called with an action from a particular alert button when + /// tapped. + public func alert( + _ state: Binding?>, + action handler: @escaping (Value?) -> Void = { (_: Never?) in } + ) -> some View { + self.alert( + (state.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: state.isPresent(), + presenting: state.wrappedValue, + actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + }, + message: { $0.message.map { Text($0) } } + ) + } + + /// Presents an alert from a binding to optional alert state. + /// + /// See for more information on how to use this API. + /// + /// > Warning: Async closures cannot be performed with animation. If the underlying action is + /// > animated, a runtime warning will be emitted. + /// + /// - Parameters: + /// - state: A binding to optional alert state that determines whether an alert should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and used to + /// populate the fields of an alert that the system displays to the user. When the user + /// presses or taps one of the alert's actions, the system sets this value to `nil` and + /// dismisses the alert, and the action is fed to the `action` closure. + /// - handler: A closure that is called with an action from a particular alert button when + /// tapped. + public func alert( + _ state: Binding?>, + action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + ) -> some View { + self.alert( + (state.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: state.isPresent(), + presenting: state.wrappedValue, + actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + }, + message: { $0.message.map { Text($0) } } + ) + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Binding.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Binding.swift new file mode 100644 index 00000000..c6e0441f --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Binding.swift @@ -0,0 +1,159 @@ +#if canImport(SwiftUI) + import CasePaths + import SwiftUI + + extension Binding { + #if swift(>=5.9) + /// Returns a binding to the associated value of a given case key path. + /// + /// Useful for producing bindings to values held in enum state. + /// + /// - Parameter keyPath: A case key path to a specific associated value. + /// - Returns: A new binding. + public subscript( + dynamicMember keyPath: KeyPath> + ) -> Binding? + where Value: CasePathable { + Binding(unwrapping: self[keyPath]) + } + + /// Returns a binding to the associated value of a given case key path. + /// + /// Useful for driving navigation off an optional enumeration of destinations. + /// + /// - Parameter keyPath: A case key path to a specific associated value. + /// - Returns: A new binding. + public subscript( + dynamicMember keyPath: KeyPath> + ) -> Binding + where Value == Enum? { + self[keyPath] + } + #endif + + /// Creates a binding by projecting the base value to an unwrapped value. + /// + /// Useful for producing non-optional bindings from optional ones. + /// + /// See ``IfLet`` for a view builder-friendly version of this initializer. + /// + /// > Note: SwiftUI comes with an equivalent failable initializer, `Binding.init(_:)`, but using + /// > it can lead to crashes at runtime. [Feedback][FB8367784] has been filed, but in the meantime + /// > this initializer exists as a workaround. + /// + /// [FB8367784]: https://gist.github.com/stephencelis/3a232a1b718bab0ae1127ebd5fcf6f97 + /// + /// - Parameter base: A value to project to an unwrapped value. + /// - Returns: A new binding or `nil` when `base` is `nil`. + public init?(unwrapping base: Binding) { + guard let value = base.wrappedValue else { return nil } + self = base[default: DefaultSubscript(value)] + } + + /// Creates a binding that ignores writes to its wrapped value when equivalent to the new value. + /// + /// Useful to minimize writes to bindings passed to SwiftUI APIs. For example, [`NavigationLink` + /// may write `nil` twice][FB9404926] when dismissing its destination via the navigation bar's + /// back button. Logic attached to this dismissal will execute twice, which may not be desirable. + /// + /// [FB9404926]: https://gist.github.com/mbrandonw/70df235e42d505b3b1b9b7d0d006b049 + /// + /// - Parameter isDuplicate: A closure to evaluate whether two elements are equivalent, for + /// purposes of filtering writes. Return `true` from this closure to indicate that the second + /// element is a duplicate of the first. + public func removeDuplicates(by isDuplicate: @escaping (Value, Value) -> Bool) -> Self { + .init( + get: { self.wrappedValue }, + set: { newValue, transaction in + guard !isDuplicate(self.wrappedValue, newValue) else { return } + self.transaction(transaction).wrappedValue = newValue + } + ) + } + } + + extension Binding where Value: Equatable { + /// Creates a binding that ignores writes to its wrapped value when equivalent to the new value. + /// + /// Useful to minimize writes to bindings passed to SwiftUI APIs. For example, [`NavigationLink` + /// may write `nil` twice][FB9404926] when dismissing its destination via the navigation bar's + /// back button. Logic attached to this dismissal will execute twice, which may not be desirable. + /// + /// [FB9404926]: https://gist.github.com/mbrandonw/70df235e42d505b3b1b9b7d0d006b049 + public func removeDuplicates() -> Self { + self.removeDuplicates(by: ==) + } + } + + extension Binding { + public func _printChanges(_ prefix: String = "") -> Self { + Self( + get: { self.wrappedValue }, + set: { newValue, transaction in + var oldDescription = "" + debugPrint(self.wrappedValue, terminator: "", to: &oldDescription) + var newDescription = "" + debugPrint(newValue, terminator: "", to: &newDescription) + print("\(prefix.isEmpty ? "\(Self.self)" : prefix):", oldDescription, "=", newDescription) + self.transaction(transaction).wrappedValue = newValue + } + ) + } + } + + extension Optional { + fileprivate subscript(default defaultSubscript: DefaultSubscript) -> Wrapped { + get { + defaultSubscript.value = self ?? defaultSubscript.value + return defaultSubscript.value + } + set { + defaultSubscript.value = newValue + if self != nil { self = newValue } + } + } + } + + private final class DefaultSubscript: Hashable { + var value: Value + init(_ value: Value) { + self.value = value + } + static func == (lhs: DefaultSubscript, rhs: DefaultSubscript) -> Bool { + lhs === rhs + } + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + } + + extension CasePathable { + fileprivate subscript( + keyPath: KeyPath> + ) -> Member? { + get { + Self.allCasePaths[keyPath: keyPath].extract(from: self) + } + set { + guard let newValue else { return } + self = Self.allCasePaths[keyPath: keyPath].embed(newValue) + } + } + } + + extension Optional where Wrapped: CasePathable { + fileprivate subscript( + keyPath: KeyPath> + ) -> Member? { + get { + self.flatMap(Wrapped.allCasePaths[keyPath: keyPath].extract(from:)) + } + set { + let casePath = Wrapped.allCasePaths[keyPath: keyPath] + guard self.flatMap(casePath.extract(from:)) != nil + else { return } + self = newValue.map(casePath.embed) + } + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/ConfirmationDialog.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/ConfirmationDialog.swift new file mode 100644 index 00000000..6543e2b9 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/ConfirmationDialog.swift @@ -0,0 +1,70 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension View { + /// Presents a confirmation dialog from a binding to optional confirmation dialog state. + /// + /// See for more information on how to use this API. + /// + /// - Parameters: + /// - state: A binding to optional state that determines whether a confirmation dialog should + /// be presented. When the binding is updated with non-`nil` value, it is unwrapped and used + /// to populate the fields of a dialog that the system displays to the user. When the user + /// presses or taps one of the dialog's actions, the system sets this value to `nil` and + /// dismisses the dialog, and the action is fed to the `action` closure. + /// - handler: A closure that is called with an action from a particular dialog button when + /// tapped. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func confirmationDialog( + _ state: Binding?>, + action handler: @escaping (Value?) -> Void = { (_: Never?) in } + ) -> some View { + self.confirmationDialog( + state.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), + isPresented: state.isPresent(), + titleVisibility: state.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, + presenting: state.wrappedValue, + actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + }, + message: { $0.message.map { Text($0) } } + ) + } + + /// Presents a confirmation dialog from a binding to optional confirmation dialog state. + /// + /// See for more information on how to use this API. + /// + /// > Warning: Async closures cannot be performed with animation. If the underlying action is + /// > animated, a runtime warning will be emitted. + /// + /// - Parameters: + /// - state: A binding to optional state that determines whether a confirmation dialog should + /// be presented. When the binding is updated with non-`nil` value, it is unwrapped and used + /// to populate the fields of a dialog that the system displays to the user. When the user + /// presses or taps one of the dialog's actions, the system sets this value to `nil` and + /// dismisses the dialog, and the action is fed to the `action` closure. + /// - handler: A closure that is called with an action from a particular dialog button when + /// tapped. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func confirmationDialog( + _ state: Binding?>, + action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + ) -> some View { + self.confirmationDialog( + state.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), + isPresented: state.isPresent(), + titleVisibility: state.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, + presenting: state.wrappedValue, + actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + }, + message: { $0.message.map { Text($0) } } + ) + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/AlertsDialogs.md b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/AlertsDialogs.md new file mode 100644 index 00000000..25d07b3c --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/AlertsDialogs.md @@ -0,0 +1,213 @@ +# Alerts and dialogs + +Learn how to present alerts and confirmation dialogs in a concise and testable manner. + +## Overview + +The library comes with new tools for driving alerts and confirmation dialogs from optional and enum +state, and makes them more testable. + +### Alerts + +Suppose you have a feature for deleting something in your application and you want to show an alert +for the user to confirm the deletion. You can do this by holding onto an optional `AlertState` in +your model, as well as an enum that describes every action that can happen in the alert: + + +```swift +@Observable +class FeatureModel { + var alert: AlertState? + enum AlertAction { + case deletionConfirmed + } + + // ... +} +``` + +Then, when you need to show an alert you can update the alert state with a title, message and +buttons: + +```swift +func deleteButtonTapped() { + self.alert = AlertState { + TextState("Are you sure?") + } actions: { + ButtonState("Delete", action: .send(.delete)) + ButtonState("Nevermind", role: .cancel) + } message: { + TextState("Deleting this item cannot be undone.") + } +} +``` + +The type `TextState` is closely related to `Text` from SwiftUI, but plays more nicely with +equatability. This makes it possible to write tests against these values. + +> Tip: The `actions` closure is a result builder, which allows you to insert small bits of logic: +> ```swift +> } actions: { +> if item.isLocked { +> ButtonState("Unlock and delete", action: .send(.unlockAndDelete)) +> } else { +> ButtonState("Delete", action: .send(.delete)) +> } +> ButtonState("Nevermind", role: .cancel) +> } +> ``` + +Next you can provide an endpoint that will be called when the alert is interacted with: + +```swift +func alertButtonTapped(_ action: AlertAction?) { + switch action { + case .deletionConfirmed: + // NB: Perform deletion logic here + case nil: + // NB: Perform cancel button logic here + } +} +``` + +Finally, you can use a new, overloaded `.alert` view modifier for showing the alert when this state +becomes non-`nil`: + +```swift +struct ContentView: View { + @ObservedObject var model: FeatureModel + + var body: some View { + List { + // ... + } + .alert(self.$model.alert) { action in + self.model.alertButtonTapped(action) + } + } +} +``` + +By having all of the alert's state in your feature's model, you instantly unlock the ability to test +it: + +```swift +func testDelete() { + let model = FeatureModel(/* ... */) + + model.deleteButtonTapped() + XCTAssertEqual(model.alert?.title, TextState("Are you sure?")) + + model.alertButtonTapped(.deletionConfirmation) + // NB: Assert that deletion actually occurred. +} +``` + +This works because all of the types for describing an alert are `Equatable`, including `AlertState`, +`TextState`, and even the buttons. + +Sometimes it is not optimal to model the alert as an optional. In particular, if a feature can +navigate to multiple, mutually exclusive screens, then a "case-pathable" enum is more appropriate. + +In such a case: + +```swift +@Observable +class FeatureModel { + var destination: Destination? + + @CasePathable + enum Destination { + case alert(AlertState) + // NB: Other destinations + } + + enum AlertAction { + case deletionConfirmed + } + + // ... +} +``` + +With this kind of set up you can use an alternative `alert` view modifier that takes an additional +argument for specifying which case of the enum drives the presentation of the alert: + +```swift +.alert(self.$model.destination.alert) { action in + self.model.alertButtonTapped(action) +} +``` + +Note that the `case` argument is specified via a concept known as "case paths", which are like +key paths except tuned specifically for enums and cases rather than structs and properties. See + for more information. + +### Confirmation dialogs + +The APIs for driving confirmation dialogs from optional and enum state look nearly identical to that +of alerts. + +For example, the model for a delete confirmation could look like this: + +```swift +@Observable +class FeatureModel { + var dialog: ConfirmationDialogState? + enum DialogAction { + case deletionConfirmed + } + + func deleteButtonTapped() { + self.dialog = ConfirmationDialogState( + title: TextState("Are you sure?"), + titleVisibility: .visible, + message: TextState("Deleting this item cannot be undone."), + buttons: [ + .destructive(TextState("Delete"), action: .send(.delete)), + .cancel(TextState("Nevermind")), + ] + ) + } + + func dialogButtonTapped(_ action: DialogAction?) { + switch action { + case .deletionConfirmed: + // NB: Perform deletion logic here + case nil: + // NB: Perform cancel button logic here + } + } +} +``` + +And then the view would look like this: + +```swift +struct ContentView: View { + @ObservedObject var model: FeatureModel + + var body: some View { + List { + // ... + } + .confirmationDialog(self.$model.dialog) { action in + self.dialogButtonTapped(action) + } + } +} +``` + +## Topics + +### Alert and dialog modifiers + +- ``SwiftUI/View/alert(title:unwrapping:actions:message:)`` +- ``SwiftUI/View/confirmationDialog(title:titleVisibility:unwrapping:actions:message:)`` + +### Alert state and dialog state + +- ``SwiftUI/View/alert(_:action:)-sgyk`` +- ``SwiftUI/View/alert(_:action:)-1gtsa`` +- ``SwiftUI/View/confirmationDialog(_:action:)-9alh7`` +- ``SwiftUI/View/confirmationDialog(_:action:)-7mxx7`` diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md new file mode 100644 index 00000000..07dd305f --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/Bindings.md @@ -0,0 +1,86 @@ +# Bindings + +Learn how to manage certain view state, such as `@FocusState` directly in your observable classes. + +## Overview + +SwiftUI comes with many property wrappers that can be used in views to drive view state, such as + +`@FocusState`. Unfortunately, these property wrappers _must_ be used in views. It's not possible to +extract this logic to an `@Observable` class and integrate it with the rest of the model's business +logic, and be in a better position to test this state. + +We can work around these limitations by introducing a published field to your observable object and +synchronizing it to view state with the `bind` view modifier that ships with this library. + +For example, suppose you have a sign in flow where if the API request to sign in fails, you want +to refocus the email field. The model can be implemented like so: + +```swift +@Observable +class SignInModel { + var email: String + var password: String + var focus: Field? + enum Field { case email, password } + + func signInButtonTapped() async throws { + do { + try await self.apiClient.signIn(self.email, self.password) + } catch { + self.focus = .email + } + } +} +``` + +Notice that we store the focus as a regular `var` property in the model rather than `@FocusState`. +This is because `@FocusState` only works when installed directly in a view. It cannot be used in +an observable class. + +You can implement the view as you would normally, except you must also use `@FocusState` for the +focus _and_ use the `bind` helper to make sure that changes to the model's focus are replayed to +the view, and vice versa. + +```swift +struct SignInView: View { + @FocusState var focus: SignInModel.Field? + @ObservedObject var model: SignInModel + + var body: some View { + Form { + TextField("Email", text: self.$model.email) + .focused(self.$focus, equals: .email) + TextField("Password", text: self.$model.password) + .focused(self.$focus, equals: .password) + Button("Sign in") { + Task { + await self.model.signInButtonTapped() + } + } + } + // ⬇️ Replays changes of `model.focus` to `focus` and vice-versa. + .bind(self.$model.focus, to: self.$focus) + } +} +``` + +## Topics + +### Dynamic case lookup + +- ``SwiftUI/Binding/subscript(dynamicMember:)-9abgy`` +- ``SwiftUI/Binding/subscript(dynamicMember:)-8vc80`` + +### Unwrapping bindings + +- ``SwiftUI/Binding/init(unwrapping:)`` + +### Binding transformations + +- ``SwiftUI/Binding/removeDuplicates()`` +- ``SwiftUI/Binding/removeDuplicates(by:)`` + +### Supporting views + +- ``WithState`` diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md new file mode 100644 index 00000000..14bf6a48 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/Navigation.md @@ -0,0 +1,122 @@ +# Navigation links and destinations + +Learn how to drive navigation in `NavigationView` and `NavigationStack` in a concise and testable +manner. + +## Overview + +The library comes with new tools for driving drill-down navigation with optional and enum values. +This includes new initializers on `NavigationLink` and new overloads of the `navigationDestination` +view modifier. + +Suppose your view or model holds a piece of optional state that represents whether or not a +drill-down should occur: + +```swift +struct ContentView: View { + @State var destination: Int? + + // ... +} +``` + +Further suppose that the screen being navigated to wants a binding to the integer when it is +non-`nil`. You can construct a `NavigationLink` that will activate when that state becomes +non-`nil`, and will deactivate when the state becomes `nil`: + +```swift +NavigationLink(unwrapping: self.$destination) { isActive in + self.destination = isActive ? 42 : nil +} destination: { $number in + CounterView(number: $number) +} label: { + Text("Go to counter") +} +``` + +The first trailing closure is the "action" of the navigation link. It is invoked with `true` when +the user taps on the link, and it is invoked with `false` when the user taps the back button or +swipes on the left edge of the screen. It is your job to hydrate the state in the action closure. + +The second trailing closure, labeled `destination`, takes an argument that is the binding of the +unwrapped state. This binding can be handed to the child view, and any changes made by the parent +will be reflected in the child, and vice-versa. + +For iOS 16+ you can use the `navigationDestination` overload: + +```swift +Button { + self.destination = 42 +} label: { + Text("Go to counter") +} +.navigationDestination( + unwrapping: self.$model.destination +) { $item in + CounterView(number: $number) +} +``` + +Sometimes it is not optimal to model navigation destinations as optionals. In particular, if a +feature can navigate to multiple, mutually exclusive screens, then an enum is more appropriate. + +Suppose that in addition to be able to drill down to a counter view that one can also open a +sheet with some text. We can model those destinations as an enum: + +```swift +@CasePathable +enum Destination { + case counter(Int) + case text(String) +} +``` + +> Note: We have applied the `@CasePathable` macro from +> [CasePaths](https://github.com/pointfreeco.swift-case-paths), which allows the navigation binding +> to use "dynamic case lookup" to a particular enum case. + +And we can hold an optional destination in state to represent whether or not we are navigated to +one of these destinations: + +```swift +@State var destination: Destination? +``` + +With this set up you can make use of the +``SwiftUI/NavigationLink/init(unwrapping:onNavigate:destination:label:)`` initializer on +`NavigationLink` in order to specify a binding to the optional destination, and further specify +which case of the enum you want driving navigation: + +```swift +NavigationLink(unwrapping: self.$destination.counter) { isActive in + self.destination = isActive ? .counter(42) : nil +} destination: { $number in + CounterView(number: $number) +} label: { + Text("Go to counter") +} +``` + +And similarly for ``SwiftUI/View/navigationDestination(unwrapping:destination:)``: + +```swift +Button { + self.destination = .counter(42) +} label: { + Text("Go to counter") +} +.navigationDestination(unwrapping: self.$model.destination.counter) { $number in + CounterView(number: $number) +} +``` + +## Topics + +### Navigation views and modifiers + +- ``SwiftUI/View/navigationDestination(unwrapping:destination:)`` +- ``SwiftUI/NavigationLink/init(unwrapping:onNavigate:destination:label:)`` + +### Supporting types + +- ``HashableObject`` diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md new file mode 100644 index 00000000..7e03c029 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/SheetsPopoversCovers.md @@ -0,0 +1,166 @@ +# Sheets, popovers, and covers + +Learn how to present sheets, popovers and covers in a concise and testable manner. + +## Overview + +The library comes with new tools for driving sheets, popovers and covers from optional and enum +state. + +* [Sheets](#Sheets) +* [Popovers](#Popovers) +* [Covers](#Covers) + +### Sheets + +Suppose your view or model holds a piece of optional state that represents whether or not a modal +sheet is presented: + +```swift +struct ContentView: View { + @State var destination: Int? + + // ... +} +``` + +Further suppose that the screen being presented wants a binding to the integer when it is non-`nil`. +You can use the `sheet(unwrapping:)` view modifier that comes with the library: + +```swift +var body: some View { + List { + // ... + } + .sheet(unwrapping: self.$destination) { $number in + CounterView(number: $number) + } +} +``` + +Notice that the trailing closure is handed a binding to the unwrapped state. This binding can be +handed to the child view, and any changes made by the parent will be reflected in the child, and +vice-versa. + +Sometimes it is not optimal to model presentation destinations as optionals. In particular, if a +feature can navigate to multiple, mutually exclusive screens, then an enum is more appropriate. + +There is an additional overload of the `sheet` for this situation. If you model your destinations +as a "case-pathable" enum: + +```swift +@State var destination: Destination? + +@CasePathable +enum Destination { + case counter(Int) + // More destinations +} +``` + +Then you can show a sheet from the `counter` case with the following: + +```swift +var body: some View { + List { + // ... + } + .sheet(unwrapping: self.$destination.counter) { $number in + CounterView(number: $number) + } +} +``` + +### Popovers + +Popovers work similarly to covers. If the popover's state is represented as an optional you can do +the following: + +```swift +struct ContentView: View { + @State var destination: Int? + + var body: some View { + List { + // ... + } + .popover(unwrapping: self.$destination) { $number in + CounterView(number: $number) + } + } +} +``` + +And if the popover state is represented as a "case-pathable" enum, then you can do the following: + +```swift +struct ContentView: View { + @State var destination: Destination? + + @CasePathable + enum Destination { + case counter(Int) + // More destinations + } + + var body: some View { + List { + // ... + } + .popover(unwrapping: self.$destination.counter) { $number in + CounterView(number: $number) + } + } +} +``` + +### Covers + +Full screen covers work similarly to covers and sheets. If the cover's state is represented as an +optional you can do the following: + +```swift +struct ContentView: View { + @State var destination: Int? + + var body: some View { + List { + // ... + } + .fullscreenCover(unwrapping: self.$destination) { $number in + CounterView(number: $number) + } + } +} +``` + +And if the covers' state is represented as a "case-pathable" enum, then you can do the following: + +```swift +struct ContentView: View { + @State var destination: Destination? + + @CasePathable + enum Destination { + case counter(Int) + // More destinations + } + + var body: some View { + List { + // ... + } + .fullscreenCover(unwrapping: self.$destination.counter) { $number in + CounterView(number: $number) + } + } +} +``` + +## Topics + +### Presentation modifiers + +- ``SwiftUI/View/fullScreenCover(unwrapping:onDismiss:content:)`` +- ``SwiftUI/View/popover(unwrapping:attachmentAnchor:arrowEdge:content:)`` +- ``SwiftUI/View/sheet(unwrapping:onDismiss:content:)`` diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md new file mode 100644 index 00000000..3eea4ab2 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Articles/WhatIsNavigation.md @@ -0,0 +1,293 @@ +# What is navigation? + +Learn how one can think of navigation as a domain modeling problem, and how that leads to the +creation of concise and testable APIs for navigation. + +## Overview + +We will define navigation as a "mode" change in an application. The most prototypical example of +this in SwiftUI are navigation stacks and links. A user taps a button, and a right-to-left +animation transitions you from the current screen to the next screen. + +But there are more examples of navigation beyond that one example. Modal sheets can be thought of +as navigation too. They slide from bottom-to-top and transition you from the current screen to a +new screen. Full screen covers and popovers are also an example of navigation, as they are very +similar to sheets except they either take over the full screen (i.e. covers) or only partially +take over the screen (i.e. popovers). + +Even alerts and confirmation dialogs can be thought of navigation as they take full control over +the interface and force you to make a selection. It's also possible for you to define your own +notions of navigation, such as bottom sheets, toasts, and more. + +## State-driven navigation + +All of these seemingly disparate examples of navigation can be unified under a single API. The +presentation and dismissal of a screen can be described with an optional piece of state. When the +state changes from `nil` to non-`nil` the screen will be presented, whether that be via a +drill-down, modal, popover, etc. And when the state changes from non-`nil` to `nil` the screen will +be dismissed. + +Driving navigation from state like this can be incredibly powerful: + + * It guarantees that your model will always be in sync with the visual representation of the UI. + It shouldn't be possible for a piece of state to be non-`nil` and not have the corresponding + view present. + * It easily enables deep linking capabilities. If all forms of navigation in your application are + driven off of state, then you can instantly open your application into any state imaginable by + simply constructing a piece of state, handing it to SwiftUI, and letting it do its thing. + * It also allows you to write unit tests for navigation logic without resorting to UI tests, which + can be slow, flakey and introduce instability into your test suite. If you write a unit test + that shows when a user performs an action that a piece of state went from `nil` to non-`nil`, + then you can be assured that the user would be navigated to the next screen. + +So, this is why state-driven navigation is so great. So, what tools does SwiftUI gives us to embrace +this pattern? + +## SwiftUI's tools for navigation + +Many of SwiftUI's navigation tools are driven off of optional state, but sadly not all. + +The simplest example is modal sheets. A simple API is provided that takes a binding of an optional +item, and when that item flips to a non-`nil` value it is handed to a content closure to produce +a view, and that view is what is animated from bottom-to-top: + +```swift +func sheet( + item: Binding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content +) -> some View +``` + +When SwiftUI detects the binding flips back to `nil`, the sheet will automatically be dismissed. + +For example, suppose you have a list of items, and when one is tapped you want to bring up a modal +sheet for editing the item: + +```swift +@Observable +class FeatureModel { + var editingItem: Item? + func tapped(item: Item) { + self.editingItem = item + } + // ... +} + +struct FeatureView: View { + @ObservedObject var model: FeatureModel + + var body: some View { + List { + ForEach(self.model.items) { item in + Button(item.name) { + self.model.tapped(item: item) + } + } + } + .sheet(item: self.$model.editingItem) { item in + EditItemView(item: item) + } + } +} +``` + +This works really great. When the button is tapped, the `tapped(item:)` method is called on the +model causing the `editingItem` state to be hydrated, and then SwiftUI sees that value is no longer +`nil` and so causes the sheet to be presented. + +A lot of SwiftUI's navigation APIs follow this pattern. For example, here's the signatures for +showing popovers and full screen covers: + +```swift +func popover( + item: Binding, + attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), + arrowEdge: Edge = .top, + content: @escaping (Item) -> Content +) -> some View where Item : Identifiable, Content : View + +func fullScreenCover( + item: Binding, + onDismiss: (() -> Void)? = nil, + content: @escaping (Item) -> Content +) -> some View where Item : Identifiable, Content : View +``` + +Both take a binding of an optional and a content closure for transforming the non-`nil` state into +a view that is presented in the popover or cover. + +There are, however, two potential problems with these APIs. + +First, the argument passed to the `content` closure is the plain, non-`nil` value. This means the +sheet view presented is handed a plain, inert value, and if that view wants to make mutations it +will need to find a way to communicate that back to the parent. However, two-way communication +is already a solved problem in SwiftUI with bindings. + +So, it might be better if the `sheet(item:content:)` API handed a binding to the unwrapped item so +that any mutations in the sheet would be instantly observable by the parent: + +```swift +.sheet(item: self.$model.editingItem) { $item in + EditItemView(item: $item) +} +``` + +However, this is not the API exposed to us from SwiftUI. + +The second problem is that while optional state is a great way to drive navigation, it doesn't +scale to multiple navigation destinations. + +For example, suppose that in addition to being able to edit an item, the feature can also add an +item and duplicate an item, and you can navigate to a help screen. That can technically be +represented as four optionals: + +```swift +@Observable +class FeatureModel { + var addItem: Item? + var duplicateItem: Item? + var editingItem: Item? + var help: Help? + // ... +} +``` + +But this is not the most concise way to model this domain. Four optional values means there are +`2⁴=16` different states this feature can be in, but only 5 of those states are valid. Either all +can be `nil`, representing we are not navigated anywhere, or at most one can be non-`nil`, +representing navigation to a single screen. + +But it is not valid to have 2, 3 or 4 non-`nil` values. That would represent multiple screens +being simultaneously navigated to, such as two sheets being presented, which is invalid in SwiftUI +and can even cause crashes. + +This is showing that four optional values is not the best way to represent 4 navigation +destinations. Instead, it is more concise to model the 4 destinations as an enum with a case for +each destination, and then hold onto a single optional value to represent which destination +is currently active: + +```swift +@Observable +class FeatureModel { + var destination: Destination? + // ... + + enum Destination { + case add(Item) + case duplicate(Item) + case edit(Item) + case help(Help) + } +} +``` + +This allows you to prove that at most one destination can be active at a time. It is impossible +to have both an "add" and "duplicate" screen presented at the same time. + +But sadly SwiftUI does not come with the tools necessary to drive navigation off of an optional +enum. This is what motivated the creation of this library. It should be possible to represent +all of the screens a feature can navigate to as an enum, and then drive sheets, popovers, covers +and more from a particular case of that enum. + +## SwiftUINavigation's tools + +The tools that ship with this library aim to solve the problems discussed above, and more. There are +new APIs for sheets, popovers, covers, alerts, confirmation dialogs _and_ navigation links that +allow you to model destinations as an enum and drive navigation by a particular case of the enum. + +All of the APIs for these seemingly disparate forms of navigation are unified by a single pattern. +You first specify a binding to an optional value driving navigation, and then you specify some +content that takes a binding to a non-optional value. + +For example, the new sheet API now takes a binding to an optional: + +```swift +func sheet( + unwrapping: Binding, + content: @escaping (Binding) -> Content +) -> some View +``` + +This single API allows you to not only drive the presentation and dismiss of a sheet from an +optional value, but also from a particular case of an enum. + +In order to isolate a specific case of an enum we make use of our [CasePaths][case-paths-gh] +library. A case path is like a key path, except it is specifically tuned for abstracting over the +shape of enums rather than structs. A key path abstractly bundles up the functionality of getting +and setting a property on a struct, whereas a case path bundles up the functionality of "extracting" +a value from an enum and "embedding" a value into an enum. They are an indispensable tool for +transforming bindings. + +Similar APIs are defined for popovers, covers, and more. + +For example, consider a feature model that has 3 different destinations that can be navigated to: + +```swift +@Observable +class FeatureModel { + var destination: Destination? + // ... + + @CasePathable + enum Destination { + case add(Item) + case duplicate(Item) + case edit(Item) + } +} +``` + +We apply that `@CasePathable` macro to the enum in order to enable "dynamic case lookup" for SwiftUI +bindings, which will allow an optional binding to an enum chain into a particular case. + +Suppose we want the `add` destination to be shown in a sheet, the `duplicate` destination to be +shown in a popover, and the `edit` destination in a drill-down. We can do so easily using the APIs +that ship with this library: + +```swift +.popover(unwrapping: self.$model.destination.duplicate) { $item in + DuplicateItemView(item: $item) +} +.sheet(unwrapping: self.$model.destination.add) { $item in + AddItemView(item: $item) +} +.navigationDestination(unwrapping: self.$model.destination.edit) { $item in + EditItemView(item: $item) +} +``` + +Even though all 3 forms of navigation are visually quite different, describing how to present them +is very consistent. You simply provide the binding to the optional enum held in the model, and then +you dot-chain into a particular case. + +The above code uses the `navigationDestination` view modifier, which is only available in iOS 16 and +later. If you must support iOS 15 and earlier, you can use the following initializer on +`NavigationLink`, which also has a very similar API to the above: + +```swift +NavigationLink(unwrapping: self.$model.destination.edit) { isActive in + self.model.setEditIsActive(isActive) +} destination: { $item in + EditItemView(item: $item) +} label: { + Text("\(item.name)") +} +``` + +That is the basics of using this library's APIs for driving navigation off of state. Learn more by +reading the articles below. + +## Topics + +### Tools + +Read the following articles to learn more about the tools that ship with this library for presenting +alerts, dialogs, sheets, popovers, covers, and navigation links all from bindings of enum state. + +- +- +- +- + +[case-paths-gh]: http://github.com/pointfreeco/swift-case-paths diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md new file mode 100644 index 00000000..ed5b5696 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Extensions/Deprecations.md @@ -0,0 +1,47 @@ +# Deprecations + +Review unsupported SwiftUI Navigation APIs and their replacements. + +## Overview + +Avoid using deprecated APIs in your app. Select a method to see the replacement that you should use +instead. + +## Topics + +### Views + +- ``IfLet`` +- ``IfCaseLet`` +- ``SwiftUI/NavigationLink/init(unwrapping:case:onNavigate:destination:label:)`` +- ``SwiftUI/NavigationLink/init(unwrapping:destination:onNavigate:label:)`` +- ``SwiftUI/NavigationLink/init(unwrapping:case:destination:onNavigate:label:)`` +- ``Switch`` + +### View modifiers + +- ``SwiftUI/View/alert(title:unwrapping:case:actions:message:)`` +- ``SwiftUI/View/alert(title:unwrapping:actions:message:)`` +- ``SwiftUI/View/alert(unwrapping:action:)-7da26`` +- ``SwiftUI/View/alert(unwrapping:action:)-6y2fk`` +- ``SwiftUI/View/alert(unwrapping:action:)-867h5`` +- ``SwiftUI/View/alert(unwrapping:case:action:)-14fwn`` +- ``SwiftUI/View/alert(unwrapping:case:action:)-3yw6u`` +- ``SwiftUI/View/alert(unwrapping:case:action:)-4w3oq`` +- ``SwiftUI/View/confirmationDialog(title:titleVisibility:unwrapping:case:actions:message:)`` +- ``SwiftUI/View/confirmationDialog(unwrapping:action:)-9465l`` +- ``SwiftUI/View/confirmationDialog(unwrapping:action:)-4f8ze`` +- ``SwiftUI/View/confirmationDialog(unwrapping:action:)-29s77`` +- ``SwiftUI/View/confirmationDialog(unwrapping:case:action:)-uncl`` +- ``SwiftUI/View/confirmationDialog(unwrapping:case:action:)-2ddxv`` +- ``SwiftUI/View/confirmationDialog(unwrapping:case:action:)-7oi9`` +- ``SwiftUI/View/fullScreenCover(unwrapping:case:onDismiss:content:)`` +- ``SwiftUI/View/navigationDestination(unwrapping:case:destination:)`` +- ``SwiftUI/View/popover(unwrapping:case:attachmentAnchor:arrowEdge:content:)`` +- ``SwiftUI/View/sheet(unwrapping:case:onDismiss:content:)`` + +### Bindings + +- ``SwiftUI/Binding/init(unwrapping:case:)`` +- ``SwiftUI/Binding/case(_:)`` +- ``SwiftUI/Binding/isPresent(_:)`` diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Extensions/Switch.md b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Extensions/Switch.md new file mode 100644 index 00000000..fa849e2b --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/Extensions/Switch.md @@ -0,0 +1,8 @@ +# ``Switch`` + +## Topics + +### Supporting views + +- ``CaseLet`` +- ``Default`` diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/SwiftUINavigation.md b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/SwiftUINavigation.md new file mode 100644 index 00000000..77cbfdfc --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Documentation.docc/SwiftUINavigation.md @@ -0,0 +1,63 @@ +# ``SwiftUINavigation`` + +Tools for making SwiftUI navigation simpler, more ergonomic and more precise. + +## Additional Resources + +- [GitHub Repo](https://github.com/pointfreeco/swiftui-navigation) +- [Discussions](https://github.com/pointfreeco/swiftui-navigation/discussions) +- [Point-Free Videos](https://www.pointfree.co/collections/swiftui/navigation) + +## Overview + +SwiftUI comes with many forms of navigation (tabs, alerts, dialogs, modal sheets, popovers, +navigation links, and more), and each comes with a few ways to construct them. These ways roughly +fall in two categories: + + * "Fire-and-forget": These are initializers and methods that do not take binding arguments, which + means SwiftUI fully manages navigation state internally. This makes it is easy to get something + on the screen quickly, but you also have no programmatic control over the navigation. Examples + of this are the initializers on [`TabView`][TabView.init] and + [`NavigationLink`][NavigationLink.init] that do not take a binding. + + * "State-driven": Most other initializers and methods do take a binding, which means you can + mutate state in your domain to tell SwiftUI when it should activate or deactivate navigation. + Using these APIs is more complicated than the "fire-and-forget" style, but doing so instantly + gives you the ability to deep-link into any state of your application by just constructing a + piece of data, handing it to a SwiftUI view, and letting SwiftUI handle the rest. + +Navigation that is "state-driven" is the more powerful form of navigation, albeit slightly more +complicated. To wield it correctly you must be able to model your domain as concisely as possible, +and this usually means using enums. + +Unfortunately, SwiftUI does not ship with all of the tools necessary to model our domains with +enums and make use of navigation APIs. This library bridges that gap by providing APIs that allow +you to model your navigation destinations as an enum, and then drive navigation by a binding +to that enum. + +## Topics + +### Essentials + +- + +### Tools + +- +- +- +- + +### Deprecated interfaces + +- + +## See Also + +The collection of videos from [Point-Free](https://www.pointfree.co) that dive deep into the +development of the library. + +* [Point-Free Videos](https://www.pointfree.co/collections/swiftui/navigation) + +[NavigationLink.init]: https://developer.apple.com/documentation/swiftui/navigationlink/init(destination:label:)-27n7s +[TabView.init]: https://developer.apple.com/documentation/swiftui/tabview/init(content:) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/FullScreenCover.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/FullScreenCover.swift new file mode 100644 index 00000000..317b40e7 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/FullScreenCover.swift @@ -0,0 +1,62 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension View { + /// Presents a full-screen cover using a binding as a data source for the sheet's content. + /// + /// SwiftUI comes with a `fullScreenCover(item:)` view modifier that is powered by a binding to + /// some hashable state. When this state becomes non-`nil`, it passes an unwrapped value to the + /// content closure. This value, however, is completely static, which prevents the sheet from + /// modifying it. + /// + /// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This + /// gives the sheet the ability to write changes back to its source of truth. + /// + /// Also unlike `fullScreenCover(item:)`, the binding's value does _not_ need to be hashable. + /// + /// ```swift + /// struct TimelineView: View { + /// @State var draft: Post? + /// + /// var body: Body { + /// Button("Compose") { + /// self.draft = Post() + /// } + /// .fullScreenCover(unwrapping: self.$draft) { $draft in + /// ComposeView(post: $draft, onSubmit: { ... }) + /// } + /// } + /// } + /// + /// struct ComposeView: View { + /// @Binding var post: Post + /// var body: some View { ... } + /// } + /// ``` + /// + /// - Parameters: + /// - value: A binding to a source of truth for the sheet. When `value` is non-`nil`, a + /// non-optional binding to the value is passed to the `content` closure. You use this binding + /// to produce content that the system presents to the user in a sheet. Changes made to the + /// sheet's binding will be reflected back in the source of truth. Likewise, changes to + /// `value` are instantly reflected in the sheet. If `value` becomes `nil`, the sheet is + /// dismissed. + /// - onDismiss: The closure to execute when dismissing the sheet. + /// - content: A closure returning the content of the sheet. + @available(iOS 14, tvOS 14, watchOS 7, *) + @available(macOS, unavailable) + public func fullScreenCover( + unwrapping value: Binding, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View + where Content: View { + self.fullScreenCover( + isPresented: value.isPresent(), + onDismiss: onDismiss + ) { + Binding(unwrapping: value).map(content) + } + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/HashableObject.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/HashableObject.swift new file mode 100644 index 00000000..29a08ced --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/HashableObject.swift @@ -0,0 +1,20 @@ +/// A protocol that adds a default implementation of `Hashable` to an object based off its object +/// identity. +/// +/// SwiftUI's navigation tools requires `Identifiable` and `Hashable` conformances throughout its +/// APIs, for example `sheet(item:)` requires `Identifiable`, while `navigationDestination(item:)` +/// and `NavigationLink.init(value:)` require `Hashable`. While `Identifiable` conformances come for +/// free on objects based on object identity, there is no such mechanism for `Hashable`. This +/// protocol addresses this shortcoming by providing default implementations of `==` and +/// `hash(into:)`. +public protocol HashableObject: AnyObject, Hashable {} + +extension HashableObject { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs === rhs + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Internal/Binding+Internal.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Internal/Binding+Internal.swift new file mode 100644 index 00000000..670a5b13 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Internal/Binding+Internal.swift @@ -0,0 +1,15 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension Binding { + func didSet(_ perform: @escaping (Value) -> Void) -> Self { + .init( + get: { self.wrappedValue }, + set: { newValue, transaction in + self.transaction(transaction).wrappedValue = newValue + perform(newValue) + } + ) + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Internal/Deprecations.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Internal/Deprecations.swift new file mode 100644 index 00000000..cb417f79 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Internal/Deprecations.swift @@ -0,0 +1,1955 @@ +#if canImport(SwiftUI) + import SwiftUI + @_spi(RuntimeWarn) import SwiftUINavigationCore + + // NB: Deprecated after 1.2.1 + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension View { + @available(*, deprecated, renamed: "alert(item:title:actions:message:)") + public func alert( + title: (Value) -> Text, + unwrapping value: Binding, + @ViewBuilder actions: (Value) -> A, + @ViewBuilder message: (Value) -> M + ) -> some View { + alert(item: value, title: title, actions: actions, message: message) + } + + @available( + *, deprecated, renamed: "confirmationDialog(item:titleVisibility:title:actions:message:)" + ) + public func confirmationDialog( + title: (Value) -> Text, + titleVisibility: Visibility = .automatic, + unwrapping value: Binding, + @ViewBuilder actions: (Value) -> A, + @ViewBuilder message: (Value) -> M + ) -> some View { + self.confirmationDialog( + value.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: value.isPresent(), + titleVisibility: titleVisibility, + presenting: value.wrappedValue, + actions: actions, + message: message + ) + } + } + + // NB: Deprecated after 1.0.2 + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension View { + @available(*, deprecated, renamed: "alert(_:action:)") + public func alert( + unwrapping value: Binding?>, + action handler: @escaping (Value?) -> Void = { (_: Never?) in } + ) -> some View { + alert( + (value.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: value.isPresent(), + presenting: value.wrappedValue, + actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + }, + message: { $0.message.map { Text($0) } } + ) + } + + @available(*, deprecated, renamed: "alert(_:action:)") + public func alert( + unwrapping value: Binding?>, + action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + ) -> some View { + alert( + (value.wrappedValue?.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: value.isPresent(), + presenting: value.wrappedValue, + actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + }, + message: { $0.message.map { Text($0) } } + ) + } + + @available(*, deprecated, renamed: "confirmationDialog(_:action:)") + public func confirmationDialog( + unwrapping value: Binding?>, + action handler: @escaping (Value?) -> Void = { (_: Never?) in } + ) -> some View { + confirmationDialog( + value.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), + isPresented: value.isPresent(), + titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, + presenting: value.wrappedValue, + actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + }, + message: { $0.message.map { Text($0) } } + ) + } + + @available(*, deprecated, renamed: "confirmationDialog(_:action:)") + public func confirmationDialog( + unwrapping value: Binding?>, + action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + ) -> some View { + confirmationDialog( + value.wrappedValue.flatMap { Text($0.title) } ?? Text(verbatim: ""), + isPresented: value.isPresent(), + titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic, + presenting: value.wrappedValue, + actions: { + ForEach($0.buttons) { + Button($0, action: handler) + } + }, + message: { $0.message.map { Text($0) } } + ) + } + } + + extension View { + @available( + iOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, introduced: 12, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, introduced: 8, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func alert( + title: (Case) -> Text, + unwrapping enum: Binding, + case casePath: AnyCasePath, + @ViewBuilder actions: (Case) -> A, + @ViewBuilder message: (Case) -> M + ) -> some View { + alert( + item: `enum`.case(casePath), + title: title, + actions: actions, + message: message + ) + } + + @available( + iOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, introduced: 12, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, introduced: 8, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func alert( + unwrapping enum: Binding, + case casePath: AnyCasePath>, + action handler: @escaping (Value?) -> Void = { (_: Never?) in } + ) -> some View { + alert(`enum`.case(casePath), action: handler) + } + + @available( + iOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, introduced: 12, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, introduced: 8, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func alert( + unwrapping enum: Binding, + case casePath: AnyCasePath>, + action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + ) -> some View { + alert(`enum`.case(casePath), action: handler) + } + + @available( + iOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, introduced: 12, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, introduced: 8, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func confirmationDialog( + title: (Case) -> Text, + titleVisibility: Visibility = .automatic, + unwrapping enum: Binding, + case casePath: AnyCasePath, + @ViewBuilder actions: (Case) -> A, + @ViewBuilder message: (Case) -> M + ) -> some View { + confirmationDialog( + item: `enum`.case(casePath), + titleVisibility: titleVisibility, + title: title, + actions: actions, + message: message + ) + } + + @available( + iOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, introduced: 12, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, introduced: 8, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func confirmationDialog( + unwrapping enum: Binding, + case casePath: AnyCasePath>, + action handler: @escaping (Value?) -> Void = { (_: Never?) in } + ) -> some View { + confirmationDialog( + `enum`.case(casePath), + action: handler + ) + } + + @available( + iOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, introduced: 12, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, introduced: 15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, introduced: 8, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func confirmationDialog( + unwrapping enum: Binding, + case casePath: AnyCasePath>, + action handler: @escaping (Value?) async -> Void = { (_: Never?) async in } + ) -> some View { + confirmationDialog( + `enum`.case(casePath), + action: handler + ) + } + + @available( + iOS, introduced: 14, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available(macOS, unavailable) + @available( + tvOS, introduced: 14, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, introduced: 7, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func fullScreenCover( + unwrapping enum: Binding, + case casePath: AnyCasePath, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View + where Content: View { + fullScreenCover( + unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) + } + + @available( + iOS, introduced: 16, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, introduced: 13, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, introduced: 16, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, introduced: 9, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func navigationDestination( + unwrapping enum: Binding, + case casePath: AnyCasePath, + @ViewBuilder destination: (Binding) -> Destination + ) -> some View { + navigationDestination(unwrapping: `enum`.case(casePath), destination: destination) + } + + @available( + iOS, introduced: 13, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, introduced: 10.15, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public func popover( + unwrapping enum: Binding, + case casePath: AnyCasePath, + attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), + arrowEdge: Edge = .top, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Content: View { + popover( + unwrapping: `enum`.case(casePath), + attachmentAnchor: attachmentAnchor, + arrowEdge: arrowEdge, + content: content + ) + } + + @available( + iOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @MainActor + public func sheet( + unwrapping enum: Binding, + case casePath: AnyCasePath, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View + where Content: View { + sheet(unwrapping: `enum`.case(casePath), onDismiss: onDismiss, content: content) + } + } + + extension Binding { + @available( + iOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public init?(unwrapping enum: Binding, case casePath: AnyCasePath) { + guard var `case` = casePath.extract(from: `enum`.wrappedValue) + else { return nil } + + self.init( + get: { + `case` = casePath.extract(from: `enum`.wrappedValue) ?? `case` + return `case` + }, + set: { + guard casePath.extract(from: `enum`.wrappedValue) != nil else { return } + `case` = $0 + `enum`.transaction($1).wrappedValue = casePath.embed($0) + } + ) + } + + @available( + iOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func `case`(_ casePath: AnyCasePath) -> Binding + where Value == Enum? { + .init( + get: { self.wrappedValue.flatMap(casePath.extract(from:)) }, + set: { newValue, transaction in + self.transaction(transaction).wrappedValue = newValue.map(casePath.embed) + } + ) + } + + @available( + iOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, deprecated: 9999, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public func isPresent(_ casePath: AnyCasePath) -> Binding + where Value == Enum? { + self.case(casePath).isPresent() + } + } + + public struct IfCaseLet: View + where IfContent: View, ElseContent: View { + public let `enum`: Binding + public let casePath: AnyCasePath + public let ifContent: (Binding) -> IfContent + public let elseContent: ElseContent + + @available( + iOS, deprecated: 9999, + message: + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + ) + @available( + macOS, deprecated: 9999, + message: + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + ) + @available( + tvOS, deprecated: 9999, + message: + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + ) + @available( + watchOS, deprecated: 9999, + message: + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + ) + public init( + _ enum: Binding, + pattern casePath: AnyCasePath, + @ViewBuilder then ifContent: @escaping (Binding) -> IfContent, + @ViewBuilder else elseContent: () -> ElseContent + ) { + self.casePath = casePath + self.elseContent = elseContent() + self.enum = `enum` + self.ifContent = ifContent + } + + public var body: some View { + if let $case = Binding(unwrapping: self.enum, case: self.casePath) { + self.ifContent($case) + } else { + self.elseContent + } + } + } + + @available( + iOS, deprecated: 9999, + message: "Use '$enum.case.map { $case in … }' with a '@CasePathable' enum, instead." + ) + @available( + macOS, deprecated: 9999, + message: "Use '$enum.case.map { $case in … }' with a '@CasePathable' enum, instead." + ) + @available( + tvOS, deprecated: 9999, + message: "Use '$enum.case.map { $case in … }' with a '@CasePathable' enum, instead." + ) + @available( + watchOS, deprecated: 9999, + message: "Use '$enum.case.map { $case in … }' with a '@CasePathable' enum, instead." + ) + extension IfCaseLet where ElseContent == EmptyView { + public init( + _ enum: Binding, + pattern casePath: AnyCasePath, + @ViewBuilder ifContent: @escaping (Binding) -> IfContent + ) { + self.casePath = casePath + elseContent = EmptyView() + self.enum = `enum` + self.ifContent = ifContent + } + } + + public struct IfLet: View + where IfContent: View, ElseContent: View { + public let value: Binding + public let ifContent: (Binding) -> IfContent + public let elseContent: ElseContent + + @available( + iOS, deprecated: 9999, + message: + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + ) + @available( + macOS, deprecated: 9999, + message: + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + ) + @available( + tvOS, deprecated: 9999, + message: + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + ) + @available( + watchOS, deprecated: 9999, + message: + "Use '$enum.case.map { $case in … }' (and 'if !enum.is(\\.case) { … }' if you have an 'else' branch) with a '@CasePathable' enum, instead." + ) + public init( + _ value: Binding, + @ViewBuilder then ifContent: @escaping (Binding) -> IfContent, + @ViewBuilder else elseContent: () -> ElseContent + ) { + self.value = value + self.ifContent = ifContent + self.elseContent = elseContent() + } + + public var body: some View { + if let $value = Binding(unwrapping: self.value) { + self.ifContent($value) + } else { + self.elseContent + } + } + } + + @available( + iOS, deprecated: 9999, + message: "Use '$enum.case.map { $case in … }' with a '@CasePathable' enum, instead." + ) + @available( + macOS, deprecated: 9999, + message: "Use '$enum.case.map { $case in … }' with a '@CasePathable' enum, instead." + ) + @available( + tvOS, deprecated: 9999, + message: "Use '$enum.case.map { $case in … }' with a '@CasePathable' enum, instead." + ) + @available( + watchOS, deprecated: 9999, + message: "Use '$enum.case.map { $case in … }' with a '@CasePathable' enum, instead." + ) + extension IfLet where ElseContent == EmptyView { + public init( + _ value: Binding, + @ViewBuilder then ifContent: @escaping (Binding) -> IfContent + ) { + self.init(value, then: ifContent, else: { EmptyView() }) + } + } + + extension NavigationLink { + @available( + iOS, introduced: 13, deprecated: 16, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + macOS, introduced: 10.15, deprecated: 13, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + tvOS, introduced: 13, deprecated: 16, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + @available( + watchOS, introduced: 6, deprecated: 9, + message: + "Chain a '@CasePathable' enum binding into a case directly instead of specifying a case path." + ) + public init( + unwrapping enum: Binding, + case casePath: AnyCasePath, + onNavigate: @escaping (Bool) -> Void, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + @ViewBuilder label: () -> Label + ) where Destination == WrappedDestination? { + self.init( + unwrapping: `enum`.case(casePath), + onNavigate: onNavigate, + destination: destination, + label: label + ) + } + } + + @available( + iOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + macOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + tvOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + watchOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + public struct Switch: View { + public let `enum`: Binding + public let content: Content + + private init( + enum: Binding, + @ViewBuilder content: () -> Content + ) { + self.enum = `enum` + self.content = content() + } + + public var body: some View { + self.content + .environmentObject(BindingObject(binding: self.enum)) + } + } + + @available( + iOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + macOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + tvOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + watchOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + public struct CaseLet: View + where Content: View { + @EnvironmentObject private var `enum`: BindingObject + public let casePath: AnyCasePath + public let content: (Binding) -> Content + + public init( + _ casePath: AnyCasePath, + @ViewBuilder then content: @escaping (Binding) -> Content + ) { + self.casePath = casePath + self.content = content + } + + public var body: some View { + Binding(unwrapping: self.enum.wrappedValue, case: self.casePath).map(self.content) + } + } + + @available( + iOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + macOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + tvOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + watchOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + public struct Default: View { + private let content: Content + + public init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + public var body: some View { + self.content + } + } + + @available( + iOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + macOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + tvOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + @available( + watchOS, deprecated: 9999, + message: + "Switch over a '@CasePathable' enum and derive bindings from each case using '$enum.case.map { $case in … }', instead." + ) + extension Switch { + public init( + _ enum: Binding, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + Default + ) + > + ) + where + Content == _ConditionalContent< + CaseLet, + Default + > + { + self.init(enum: `enum`) { + let content = content().value + if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { + content.0 + } else { + content.1 + } + } + } + + public init( + _ enum: Binding, + file: StaticString = #fileID, + line: UInt = #line, + @ViewBuilder content: () -> CaseLet + ) + where + Content == _ConditionalContent< + CaseLet, + Default<_ExhaustivityCheckView> + > + { + self.init(`enum`) { + content() + Default { _ExhaustivityCheckView(file: file, line: line) } + } + } + + public init( + _ enum: Binding, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + Default + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + Default + > + { + self.init(enum: `enum`) { + let content = content().value + if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { + content.0 + } else if content.1.casePath.extract(from: `enum`.wrappedValue) != nil { + content.1 + } else { + content.2 + } + } + } + + public init( + _ enum: Binding, + file: StaticString = #fileID, + line: UInt = #line, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + Default<_ExhaustivityCheckView> + > + { + let content = content() + self.init(`enum`) { + content.value.0 + content.value.1 + Default { _ExhaustivityCheckView(file: file, line: line) } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + DefaultContent + >( + _ enum: Binding, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + Default + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + Default + > + > + { + self.init(enum: `enum`) { + let content = content().value + if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { + content.0 + } else if content.1.casePath.extract(from: `enum`.wrappedValue) != nil { + content.1 + } else if content.2.casePath.extract(from: `enum`.wrappedValue) != nil { + content.2 + } else { + content.3 + } + } + } + + public init( + _ enum: Binding, + file: StaticString = #fileID, + line: UInt = #line, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + Default<_ExhaustivityCheckView> + > + > + { + let content = content() + self.init(`enum`) { + content.value.0 + content.value.1 + content.value.2 + Default { _ExhaustivityCheckView(file: file, line: line) } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + DefaultContent + >( + _ enum: Binding, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + Default + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + Default + > + { + self.init(enum: `enum`) { + let content = content().value + if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { + content.0 + } else if content.1.casePath.extract(from: `enum`.wrappedValue) != nil { + content.1 + } else if content.2.casePath.extract(from: `enum`.wrappedValue) != nil { + content.2 + } else if content.3.casePath.extract(from: `enum`.wrappedValue) != nil { + content.3 + } else { + content.4 + } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4 + >( + _ enum: Binding, + file: StaticString = #fileID, + line: UInt = #line, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + Default<_ExhaustivityCheckView> + > + { + let content = content() + self.init(`enum`) { + content.value.0 + content.value.1 + content.value.2 + content.value.3 + Default { _ExhaustivityCheckView(file: file, line: line) } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5, + DefaultContent + >( + _ enum: Binding, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + Default + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + CaseLet, + Default + > + > + { + self.init(enum: `enum`) { + let content = content().value + if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { + content.0 + } else if content.1.casePath.extract(from: `enum`.wrappedValue) != nil { + content.1 + } else if content.2.casePath.extract(from: `enum`.wrappedValue) != nil { + content.2 + } else if content.3.casePath.extract(from: `enum`.wrappedValue) != nil { + content.3 + } else if content.4.casePath.extract(from: `enum`.wrappedValue) != nil { + content.4 + } else { + content.5 + } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5 + >( + _ enum: Binding, + file: StaticString = #fileID, + line: UInt = #line, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + CaseLet, + Default<_ExhaustivityCheckView> + > + > + { + let content = content() + self.init(`enum`) { + content.value.0 + content.value.1 + content.value.2 + content.value.3 + content.value.4 + Default { _ExhaustivityCheckView(file: file, line: line) } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5, + Case6, Content6, + DefaultContent + >( + _ enum: Binding, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + Default + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + Default + > + > + { + self.init(enum: `enum`) { + let content = content().value + if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { + content.0 + } else if content.1.casePath.extract(from: `enum`.wrappedValue) != nil { + content.1 + } else if content.2.casePath.extract(from: `enum`.wrappedValue) != nil { + content.2 + } else if content.3.casePath.extract(from: `enum`.wrappedValue) != nil { + content.3 + } else if content.4.casePath.extract(from: `enum`.wrappedValue) != nil { + content.4 + } else if content.5.casePath.extract(from: `enum`.wrappedValue) != nil { + content.5 + } else { + content.6 + } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5, + Case6, Content6 + >( + _ enum: Binding, + file: StaticString = #fileID, + line: UInt = #line, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + Default<_ExhaustivityCheckView> + > + > + { + let content = content() + self.init(`enum`) { + content.value.0 + content.value.1 + content.value.2 + content.value.3 + content.value.4 + content.value.5 + Default { _ExhaustivityCheckView(file: file, line: line) } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5, + Case6, Content6, + Case7, Content7, + DefaultContent + >( + _ enum: Binding, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + Default + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + Default + > + > + > + { + self.init(enum: `enum`) { + let content = content().value + if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { + content.0 + } else if content.1.casePath.extract(from: `enum`.wrappedValue) != nil { + content.1 + } else if content.2.casePath.extract(from: `enum`.wrappedValue) != nil { + content.2 + } else if content.3.casePath.extract(from: `enum`.wrappedValue) != nil { + content.3 + } else if content.4.casePath.extract(from: `enum`.wrappedValue) != nil { + content.4 + } else if content.5.casePath.extract(from: `enum`.wrappedValue) != nil { + content.5 + } else if content.6.casePath.extract(from: `enum`.wrappedValue) != nil { + content.6 + } else { + content.7 + } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5, + Case6, Content6, + Case7, Content7 + >( + _ enum: Binding, + file: StaticString = #fileID, + line: UInt = #line, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + Default<_ExhaustivityCheckView> + > + > + > + { + let content = content() + self.init(`enum`) { + content.value.0 + content.value.1 + content.value.2 + content.value.3 + content.value.4 + content.value.5 + content.value.6 + Default { _ExhaustivityCheckView(file: file, line: line) } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5, + Case6, Content6, + Case7, Content7, + Case8, Content8, + DefaultContent + >( + _ enum: Binding, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + Default + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + > + >, + Default + > + { + self.init(enum: `enum`) { + let content = content().value + if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { + content.0 + } else if content.1.casePath.extract(from: `enum`.wrappedValue) != nil { + content.1 + } else if content.2.casePath.extract(from: `enum`.wrappedValue) != nil { + content.2 + } else if content.3.casePath.extract(from: `enum`.wrappedValue) != nil { + content.3 + } else if content.4.casePath.extract(from: `enum`.wrappedValue) != nil { + content.4 + } else if content.5.casePath.extract(from: `enum`.wrappedValue) != nil { + content.5 + } else if content.6.casePath.extract(from: `enum`.wrappedValue) != nil { + content.6 + } else if content.7.casePath.extract(from: `enum`.wrappedValue) != nil { + content.7 + } else { + content.8 + } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5, + Case6, Content6, + Case7, Content7, + Case8, Content8 + >( + _ enum: Binding, + file: StaticString = #fileID, + line: UInt = #line, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + > + >, + Default<_ExhaustivityCheckView> + > + { + let content = content() + self.init(`enum`) { + content.value.0 + content.value.1 + content.value.2 + content.value.3 + content.value.4 + content.value.5 + content.value.6 + content.value.7 + Default { _ExhaustivityCheckView(file: file, line: line) } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5, + Case6, Content6, + Case7, Content7, + Case8, Content8, + Case9, Content9, + DefaultContent + >( + _ enum: Binding, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + Default + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + > + >, + _ConditionalContent< + CaseLet, + Default + > + > + { + self.init(enum: `enum`) { + let content = content().value + if content.0.casePath.extract(from: `enum`.wrappedValue) != nil { + content.0 + } else if content.1.casePath.extract(from: `enum`.wrappedValue) != nil { + content.1 + } else if content.2.casePath.extract(from: `enum`.wrappedValue) != nil { + content.2 + } else if content.3.casePath.extract(from: `enum`.wrappedValue) != nil { + content.3 + } else if content.4.casePath.extract(from: `enum`.wrappedValue) != nil { + content.4 + } else if content.5.casePath.extract(from: `enum`.wrappedValue) != nil { + content.5 + } else if content.6.casePath.extract(from: `enum`.wrappedValue) != nil { + content.6 + } else if content.7.casePath.extract(from: `enum`.wrappedValue) != nil { + content.7 + } else if content.8.casePath.extract(from: `enum`.wrappedValue) != nil { + content.8 + } else { + content.9 + } + } + } + + public init< + Case1, Content1, + Case2, Content2, + Case3, Content3, + Case4, Content4, + Case5, Content5, + Case6, Content6, + Case7, Content7, + Case8, Content8, + Case9, Content9 + >( + _ enum: Binding, + file: StaticString = #fileID, + line: UInt = #line, + @ViewBuilder content: () -> TupleView< + ( + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet, + CaseLet + ) + > + ) + where + Content == _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + >, + _ConditionalContent< + _ConditionalContent< + CaseLet, + CaseLet + >, + _ConditionalContent< + CaseLet, + CaseLet + > + > + >, + _ConditionalContent< + CaseLet, + Default<_ExhaustivityCheckView> + > + > + { + let content = content() + self.init(`enum`) { + content.value.0 + content.value.1 + content.value.2 + content.value.3 + content.value.4 + content.value.5 + content.value.6 + content.value.7 + content.value.8 + Default { _ExhaustivityCheckView(file: file, line: line) } + } + } + } + + public struct _ExhaustivityCheckView: View { + @EnvironmentObject private var `enum`: BindingObject + let file: StaticString + let line: UInt + + public var body: some View { + #if DEBUG + let message = """ + Warning: Switch.body@\(self.file):\(self.line) + + "Switch" did not handle "\(describeCase(self.enum.wrappedValue.wrappedValue))" + + Make sure that you exhaustively provide a "CaseLet" view for each case in "\(Enum.self)", \ + provide a "Default" view at the end of the "Switch", or use an "IfCaseLet" view instead. + """ + VStack(spacing: 17) { + self.exclamation() + .font(.largeTitle) + + Text(message) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.white) + .padding() + .background(Color.red.edgesIgnoringSafeArea(.all)) + .onAppear { runtimeWarn(message, file: self.file, line: self.line) } + #else + EmptyView() + #endif + } + + func exclamation() -> some View { + #if os(macOS) + return Text("⚠️") + #else + return Image(systemName: "exclamationmark.triangle.fill") + #endif + } + } + + private class BindingObject: ObservableObject { + let wrappedValue: Binding + + init(binding: Binding) { + wrappedValue = binding + } + } + + private func describeCase(_ enum: Enum) -> String { + let mirror = Mirror(reflecting: `enum`) + let `case`: String + if mirror.displayStyle == .enum, let child = mirror.children.first, let label = child.label { + let childMirror = Mirror(reflecting: child.value) + let associatedValuesMirror = + childMirror.displayStyle == .tuple + ? childMirror + : Mirror(`enum`, unlabeledChildren: [child.value], displayStyle: .tuple) + `case` = """ + \(label)(\ + \(associatedValuesMirror.children.map { "\($0.label ?? "_"):" }.joined())\ + ) + """ + } else { + `case` = "\(`enum`)" + } + var type = String(reflecting: Enum.self) + if let index = type.firstIndex(of: ".") { + type.removeSubrange(...index) + } + return "\(type).\(`case`)" + } + + // NB: Deprecated after 0.5.0 + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension View { + @_disfavoredOverload + @available( + *, + deprecated, + message: + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + ) + public func alert( + unwrapping value: Binding?>, + action handler: @escaping (Value) async -> Void = { (_: Void) async in } + ) -> some View { + alert(value) { (value: Value?) in + if let value = value { + await handler(value) + } + } + } + + @_disfavoredOverload + @available( + *, + deprecated, + message: + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + ) + public func alert( + unwrapping enum: Binding, + case casePath: CasePath>, + action handler: @escaping (Value) async -> Void = { (_: Void) async in } + ) -> some View { + alert(unwrapping: `enum`, case: casePath) { (value: Value?) async in + if let value = value { + await handler(value) + } + } + } + + @_disfavoredOverload + @available( + *, + deprecated, + message: + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + ) + public func confirmationDialog( + unwrapping value: Binding?>, + action handler: @escaping (Value) async -> Void = { (_: Void) async in } + ) -> some View { + confirmationDialog(unwrapping: value) { (value: Value?) in + if let value = value { + await handler(value) + } + } + } + + @_disfavoredOverload + @available( + *, + deprecated, + message: + "'View.alert' now passes an optional action to its handler to allow you to handle action-less dismissals." + ) + public func confirmationDialog( + unwrapping enum: Binding, + case casePath: CasePath>, + action handler: @escaping (Value) async -> Void = { (_: Void) async in } + ) -> some View { + confirmationDialog(unwrapping: `enum`, case: casePath) { (value: Value?) async in + if let value = value { + await handler(value) + } + } + } + } + + // NB: Deprecated after 0.3.0 + + @available(*, deprecated, renamed: "init(_:pattern:then:else:)") + extension IfCaseLet { + public init( + _ enum: Binding, + pattern casePath: CasePath, + @ViewBuilder ifContent: @escaping (Binding) -> IfContent, + @ViewBuilder elseContent: () -> ElseContent + ) { + self.init(`enum`, pattern: casePath, then: ifContent, else: elseContent) + } + } + + // NB: Deprecated after 0.2.0 + + extension NavigationLink { + @available(*, deprecated, renamed: "init(unwrapping:onNavigate:destination:label:)") + public init( + unwrapping value: Binding, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + onNavigate: @escaping (_ isActive: Bool) -> Void, + @ViewBuilder label: () -> Label + ) where Destination == WrappedDestination? { + self.init( + destination: Binding(unwrapping: value).map(destination), + isActive: value.isPresent().didSet(onNavigate), + label: label + ) + } + + @available(*, deprecated, renamed: "init(unwrapping:case:onNavigate:destination:label:)") + public init( + unwrapping enum: Binding, + case casePath: CasePath, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + onNavigate: @escaping (Bool) -> Void, + @ViewBuilder label: () -> Label + ) where Destination == WrappedDestination? { + self.init( + unwrapping: `enum`.case(casePath), + onNavigate: onNavigate, + destination: destination, + label: label + ) + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Internal/Exports.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Internal/Exports.swift new file mode 100644 index 00000000..377bbf31 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Internal/Exports.swift @@ -0,0 +1,5 @@ +#if canImport(SwiftUI) + @_exported import CasePaths + @_exported import SwiftUINavigationCore + @_exported import UIKitNavigation +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/NavigationDestination.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/NavigationDestination.swift new file mode 100644 index 00000000..56e53406 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/NavigationDestination.swift @@ -0,0 +1,85 @@ +#if canImport(SwiftUI) + import SwiftUI + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension View { + /// Pushes a view onto a `NavigationStack` using a binding as a data source for the + /// destination's content. + /// + /// This is a version of SwiftUI's `navigationDestination(isPresented:)` modifier, but powered + /// by a binding to optional state instead of a binding to a boolean. When state becomes + /// non-`nil`, a _binding_ to the unwrapped value is passed to the destination closure. + /// + /// ```swift + /// struct TimelineView: View { + /// @State var draft: Post? + /// + /// var body: Body { + /// Button("Compose") { + /// self.draft = Post() + /// } + /// .navigationDestination(unwrapping: self.$draft) { $draft in + /// ComposeView(post: $draft, onSubmit: { ... }) + /// } + /// } + /// } + /// + /// struct ComposeView: View { + /// @Binding var post: Post + /// var body: some View { ... } + /// } + /// ``` + /// + /// - Parameters: + /// - value: A binding to an optional source of truth for the destination. When `value` is + /// non-`nil`, a non-optional binding to the value is passed to the `destination` closure. + /// You use this binding to produce content that the system pushes to the user in a + /// navigation stack. Changes made to the destination's binding will be reflected back in + /// the source of truth. Likewise, changes to `value` are instantly reflected in the + /// destination. If `value` becomes `nil`, the destination is popped. + /// - destination: A closure returning the content of the destination. + @ViewBuilder + public func navigationDestination( + unwrapping value: Binding, + @ViewBuilder destination: (Binding) -> Destination + ) -> some View { + if requiresBindWorkaround { + self.modifier( + _NavigationDestinationBindWorkaround( + isPresented: value.isPresent(), + destination: Binding(unwrapping: value).map(destination) + ) + ) + } else { + self.navigationDestination(isPresented: value.isPresent()) { + Binding(unwrapping: value).map(destination) + } + } + } + } + + // NB: This view modifier works around a bug in SwiftUI's built-in modifier: + // https://gist.github.com/mbrandonw/f8b94957031160336cac6898a919cbb7#file-fb11056434-md + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + private struct _NavigationDestinationBindWorkaround: ViewModifier { + @Binding var isPresented: Bool + let destination: Destination + + @State private var isPresentedState = false + + public func body(content: Content) -> some View { + content + .navigationDestination(isPresented: self.$isPresentedState) { self.destination } + .bind(self.$isPresented, to: self.$isPresentedState) + } + } + + private let requiresBindWorkaround = { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + return true + } + guard #available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *) + else { return true } + return false + }() +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/NavigationLink.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/NavigationLink.swift new file mode 100644 index 00000000..b604a791 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/NavigationLink.swift @@ -0,0 +1,70 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension NavigationLink { + /// Creates a navigation link that presents the destination view when a bound value is + /// non-`nil`. + /// + /// > Note: This interface is deprecated to match the availability of the corresponding SwiftUI + /// > API. If you are targeting iOS 16 or later, use + /// > ``SwiftUI/View/navigationDestination(unwrapping:destination:)``, instead. + /// + /// This allows you to drive navigation to a destination from an optional value. When the + /// optional value becomes non-`nil` a binding to an honest value is derived and passed to the + /// destination. Any edits made to the binding in the destination are automatically reflected + /// in the parent. + /// + /// ```swift + /// struct ContentView: View { + /// @State var postToEdit: Post? + /// @State var posts: [Post] + /// + /// var body: some View { + /// ForEach(self.posts) { post in + /// NavigationLink(unwrapping: self.$postToEdit) { isActive in + /// self.postToEdit = isActive ? post : nil + /// } destination: { $draft in + /// EditPostView(post: $draft) + /// } label: { + /// Text(post.title) + /// } + /// } + /// } + /// } + /// + /// struct EditPostView: View { + /// @Binding var post: Post + /// var body: some View { ... } + /// } + /// ``` + /// + /// - Parameters: + /// - value: A binding to an optional source of truth for the destination. When `value` is + /// non-`nil`, a non-optional binding to the value is passed to the `destination` closure. + /// The destination can use this binding to produce its content and write changes back to + /// the source of truth. Upstream changes to `value` will also be instantly reflected in the + /// destination. If `value` becomes `nil`, the destination is dismissed. + /// - onNavigate: A closure that executes when the link becomes active or inactive with a + /// boolean that describes if the link was activated or not. Use this closure to populate + /// the source of truth when it is passed a value of `true`. When passed `false`, the system + /// will automatically write `nil` to `value`. + /// - destination: A view for the navigation link to present. + /// - label: A view builder to produce a label describing the `destination` to present. + @available(iOS, introduced: 13, deprecated: 16) + @available(macOS, introduced: 10.15, deprecated: 13) + @available(tvOS, introduced: 13, deprecated: 16) + @available(watchOS, introduced: 6, deprecated: 9) + public init( + unwrapping value: Binding, + onNavigate: @escaping (_ isActive: Bool) -> Void, + @ViewBuilder destination: @escaping (Binding) -> WrappedDestination, + @ViewBuilder label: () -> Label + ) where Destination == WrappedDestination? { + self.init( + destination: Binding(unwrapping: value).map(destination), + isActive: value.isPresent().didSet(onNavigate), + label: label + ) + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Popover.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Popover.swift new file mode 100644 index 00000000..43ce6238 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Popover.swift @@ -0,0 +1,66 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension View { + /// Presents a popover using a binding as a data source for the popover's content. + /// + /// SwiftUI comes with a `popover(item:)` view modifier that is powered by a binding to some + /// hashable state. When this state becomes non-`nil`, it passes an unwrapped value to the + /// content closure. This value, however, is completely static, which prevents the popover from + /// modifying it. + /// + /// This overload differs in that it passes a _binding_ to the unwrapped value, instead. This + /// gives the popover the ability to write changes back to its source of truth. + /// + /// Also unlike `popover(item:)`, the binding's value does _not_ need to be hashable. + /// + /// ```swift + /// struct TimelineView: View { + /// @State var draft: Post? + /// + /// var body: Body { + /// Button("Compose") { + /// self.draft = Post() + /// } + /// .popover(unwrapping: self.$draft) { $draft in + /// ComposeView(post: $draft, onSubmit: { ... }) + /// } + /// } + /// } + /// + /// struct ComposeView: View { + /// @Binding var post: Post + /// var body: some View { ... } + /// } + /// ``` + /// + /// - Parameters: + /// - value: A binding to an optional source of truth for the popover. When `value` is + /// non-`nil`, a non-optional binding to the value is passed to the `content` closure. You + /// use this binding to produce content that the system presents to the user in a popover. + /// Changes made to the popover's binding will be reflected back in the source of truth. + /// Likewise, changes to `value` are instantly reflected in the popover. If `value` becomes + /// `nil`, the popover is dismissed. + /// - attachmentAnchor: The positioning anchor that defines the attachment point of the + /// popover. + /// - arrowEdge: The edge of the `attachmentAnchor` that defines the location of the popover's + /// arrow. + /// - content: A closure returning the content of the popover. + @available(tvOS, unavailable) + @available(watchOS, unavailable) + public func popover( + unwrapping value: Binding, + attachmentAnchor: PopoverAttachmentAnchor = .rect(.bounds), + arrowEdge: Edge = .top, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View where Content: View { + self.popover( + isPresented: value.isPresent(), + attachmentAnchor: attachmentAnchor, + arrowEdge: arrowEdge + ) { + Binding(unwrapping: value).map(content) + } + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Sheet.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Sheet.swift new file mode 100644 index 00000000..6823b78e --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/Sheet.swift @@ -0,0 +1,64 @@ +#if canImport(SwiftUI) + import SwiftUI + + #if canImport(UIKit) + import UIKit + #elseif canImport(AppKit) + import AppKit + #endif + + extension View { + /// Presents a sheet using a binding as a data source for the sheet's content. + /// + /// SwiftUI comes with a `sheet(item:)` view modifier that is powered by a binding to some + /// hashable state. When this state becomes non-`nil`, it passes an unwrapped value to the + /// content closure. This value, however, is completely static, which prevents the sheet from + /// modifying it. + /// + /// This overload differs in that it passes a _binding_ to the content closure, instead. This + /// gives the sheet the ability to write changes back to its source of truth. + /// + /// Also unlike `sheet(item:)`, the binding's value does _not_ need to be hashable. + /// + /// ```swift + /// struct TimelineView: View { + /// @State var draft: Post? + /// + /// var body: Body { + /// Button("Compose") { + /// self.draft = Post() + /// } + /// .sheet(unwrapping: self.$draft) { $draft in + /// ComposeView(post: $draft, onSubmit: { ... }) + /// } + /// } + /// } + /// + /// struct ComposeView: View { + /// @Binding var post: Post + /// var body: some View { ... } + /// } + /// ``` + /// + /// - Parameters: + /// - value: A binding to an optional source of truth for the sheet. When `value` is + /// non-`nil`, a non-optional binding to the value is passed to the `content` closure. You + /// use this binding to produce content that the system presents to the user in a sheet. + /// Changes made to the sheet's binding will be reflected back in the source of truth. + /// Likewise, changes to `value` are instantly reflected in the sheet. If `value` becomes + /// `nil`, the sheet is dismissed. + /// - onDismiss: The closure to execute when dismissing the sheet. + /// - content: A closure returning the content of the sheet. + @MainActor + public func sheet( + unwrapping value: Binding, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Binding) -> Content + ) -> some View + where Content: View { + self.sheet(isPresented: value.isPresent(), onDismiss: onDismiss) { + Binding(unwrapping: value).map(content) + } + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/WithState.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/WithState.swift new file mode 100644 index 00000000..5d7b3397 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigation/WithState.swift @@ -0,0 +1,48 @@ +#if canImport(SwiftUI) + import SwiftUI + + /// A container view that provides a binding to another view. + /// + /// This view is most helpful for creating Xcode previews of views that require bindings. + /// + /// For example, if you wanted to create a preview for a text field, you cannot simply introduce + /// some `@State` to the preview since `previews` is static: + /// + /// ```swift + /// struct TextField_Previews: PreviewProvider { + /// @State static var text = "" // ⚠️ @State static does not work. + /// + /// static var previews: some View { + /// TextField("Test", text: self.$text) + /// } + /// } + /// ``` + /// + /// So, instead you can use ``WithState``: + /// + /// ```swift + /// struct TextField_Previews: PreviewProvider { + /// static var previews: some View { + /// WithState(initialValue: "") { $text in + /// TextField("Test", text: $text) + /// } + /// } + /// } + /// ``` + public struct WithState: View { + @State var value: Value + @ViewBuilder let content: (Binding) -> Content + + public init( + initialValue value: Value, + @ViewBuilder content: @escaping (Binding) -> Content + ) { + self._value = State(wrappedValue: value) + self.content = content + } + + public var body: some View { + self.content(self.$value) + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Alert.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Alert.swift new file mode 100644 index 00000000..448cda9f --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Alert.swift @@ -0,0 +1,141 @@ +#if canImport(SwiftUI) + import SwiftUI + + // MARK: - Alert with dynamic title + extension View { + /// Presents an alert from a binding to an optional value. + /// + /// SwiftUI's `alert` view modifiers are driven by two disconnected pieces of state: an + /// `isPresented` binding to a boolean that determines if the alert should be presented, and + /// optional alert `data` that is used to customize its actions and message. + /// + /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: + /// + /// * `isPresented` can be `true`, but `data` can be `nil`. + /// * `isPresented` can be `false`, but `data` can be non-`nil`. + /// + /// On top of that, SwiftUI's `alert` modifiers take static titles, which means the title cannot + /// be dynamically computed from the alert data. + /// + /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the + /// invalid runtime states at compile time by driving the alert's presentation from a single, + /// optional binding. When this binding is non-`nil`, the alert will be presented. Further, the + /// title can be customized from the alert data. + /// + /// ```swift + /// struct AlertDemo: View { + /// @State var randomMovie: Movie? + /// + /// var body: some View { + /// Button("Pick a random movie", action: self.getRandomMovie) + /// .alert(item: self.$randomMovie) { + /// Text($0.title) + /// } actions: { _ in + /// Button("Pick another", action: self.getRandomMovie) + /// Button("I'm done", action: self.clearRandomMovie) + /// } message: { + /// Text($0.summary) + /// } + /// } + /// + /// func getRandomMovie() { + /// self.randomMovie = Movie.allCases.randomElement() + /// } + /// + /// func clearRandomMovie() { + /// self.randomMovie = nil + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - item: A binding to an optional value that determines whether an alert should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed + /// to the modifier's closures. You can use this data to populate the fields of an alert + /// that the system displays to the user. When the user presses or taps one of the alert's + /// actions, the system sets this value to `nil` and dismisses the alert. + /// - title: A closure returning the alert's title given the current alert state. + /// - actions: A view builder returning the alert's actions given the current alert state. + /// - message: A view builder returning the message for the alert given the current alert + /// state. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func alert( + item: Binding, + title: (Item) -> Text, + @ViewBuilder actions: (Item) -> A, + @ViewBuilder message: (Item) -> M + ) -> some View { + alert( + item.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + presenting: item.wrappedValue, + actions: actions, + message: message + ) + } + + /// Presents an alert from a binding to an optional value. + /// + /// SwiftUI's `alert` view modifiers are driven by two disconnected pieces of state: an + /// `isPresented` binding to a boolean that determines if the alert should be presented, and + /// optional alert `data` that is used to customize its actions and message. + /// + /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: + /// * `isPresented` can be `true`, but `data` can be `nil`. + /// * `isPresented` can be `false`, but `data` can be non-`nil`. + /// + /// On top of that, SwiftUI's `alert` modifiers take static titles, which means the title cannot + /// be dynamically computed from the alert data. + /// + /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the + /// invalid runtime states at compile time by driving the alert's presentation from a single, + /// optional binding. When this binding is non-`nil`, the alert will be presented. Further, the + /// title can be customized from the alert data. + /// + /// ```swift + /// struct AlertDemo: View { + /// @State var randomMovie: Movie? + /// + /// var body: some View { + /// Button("Pick a random movie", action: self.getRandomMovie) + /// .alert(item: self.$randomMovie) { + /// Text($0.title) + /// } actions: { _ in + /// Button("Pick another", action: self.getRandomMovie) + /// Button("I'm done", action: self.clearRandomMovie) + /// } + /// } + /// + /// func getRandomMovie() { + /// self.randomMovie = Movie.allCases.randomElement() + /// } + /// + /// func clearRandomMovie() { + /// self.randomMovie = nil + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - item: A binding to an optional value that determines whether an alert should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed + /// to the modifier's closures. You can use this data to populate the fields of an alert + /// that the system displays to the user. When the user presses or taps one of the alert's + /// actions, the system sets this value to `nil` and dismisses the alert. + /// - title: A closure returning the alert's title given the current alert state. + /// - actions: A view builder returning the alert's actions given the current alert state. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func alert( + item: Binding, + title: (Item) -> Text, + @ViewBuilder actions: (Item) -> A + ) -> some View { + alert( + item.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + presenting: item.wrappedValue, + actions: actions + ) + } + } +#endif diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/AlertState.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/AlertState.swift new file mode 100644 index 00000000..aff3f6ed --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/AlertState.swift @@ -0,0 +1,269 @@ +#if canImport(SwiftUI) + import CustomDump + import SwiftUI + + /// A data type that describes the state of an alert that can be shown to the user. The `Action` + /// generic is the type of actions that can be sent from tapping on a button in the alert. + /// + /// This type can be used in your application's state in order to control the presentation and + /// actions of alerts. This API can be used to push the logic of alert presentation and actions into + /// your model, making it easier to test, and simplifying your view layer. + /// + /// To use this API, you first describe all of the actions that can take place in all of your + /// alerts as an enum: + /// + /// ```swift + /// @Observable + /// class HomeScreenModel { + /// enum AlertAction { + /// case delete + /// case removeFromHomeScreen + /// } + /// // ... + /// } + /// ``` + /// + /// Then you hold onto optional `AlertState` as a field in your model, which can + /// start off as `nil`: + /// + /// ```swift + /// @Observable + /// class HomeScreenModel { + /// var alert: AlertState? + /// // ... + /// } + /// ``` + /// + /// And you define an endpoint for handling each alert action: + /// + /// ```swift + /// @Observable + /// class HomeScreenModel { + /// // ... + /// func alertButtonTapped(_ action: AlertAction?) { + /// switch action { + /// case .delete: + /// // ... + /// case .removeFromHomeScreen: + /// // ... + /// case .none: + /// // ... + /// } + /// } + /// } + /// ``` + /// + /// Then, whenever you need to show an alert you can simply construct an `AlertState` value to + /// represent the alert: + /// + /// ```swift + /// @Observable + /// class HomeScreenModel { + /// // ... + /// func deleteAppButtonTapped() { + /// self.alert = AlertState { + /// TextState(#"Remove "Twitter"?"#) + /// } actions: { + /// ButtonState(role: .destructive, action: .send(.delete)) { + /// TextState("Delete App") + /// } + /// ButtonState(action: .send(.removeFromHomeScreen)) { + /// TextState("Remove from Home Screen") + /// } + /// } message: { + /// TextState( + /// "Removing from Home Screen will keep the app in your App Library." + /// ) + /// } + /// } + /// } + /// ``` + /// + /// And in your view you can use the `.alert(_:action:)` view modifier to present the alert: + /// + /// ```swift + /// struct FeatureView: View { + /// @ObservedObject var model: HomeScreenModel + /// + /// var body: some View { + /// VStack { + /// Button("Delete") { + /// self.model.deleteAppButtonTapped() + /// } + /// } + /// .alert(self.$model.alert) { action in + /// self.model.alertButtonTapped(action) + /// } + /// } + /// } + /// ``` + /// + /// This makes your model in complete control of when the alert is shown or dismissed, and makes it + /// so that any choice made in the alert is automatically fed back into the model so that you can + /// handle its logic. + /// + /// Even better, because `AlertState` is equatable (when `Action` is equatable), you can instantly + /// write tests that your alert behavior works as expected: + /// + /// ```swift + /// let model = HomeScreenModel() + /// + /// model.deleteAppButtonTapped() + /// XCTAssertEqual( + /// model.alert, + /// AlertState { + /// TextState(#"Remove "Twitter"?"#) + /// } actions: { + /// ButtonState(role: .destructive, action: .deleteButtonTapped) { + /// TextState("Delete App"), + /// }, + /// ButtonState(action: .removeFromHomeScreenButtonTapped) { + /// TextState("Remove from Home Screen"), + /// } + /// } message: { + /// TextState( + /// "Removing from Home Screen will keep the app in your App Library." + /// ) + /// } + /// ) + /// + /// model.alertButtonTapped(.delete) { + /// // Also verify that delete logic executed correctly + /// } + /// model.alert = nil + /// ``` + public struct AlertState: Identifiable { + public let id: UUID + public var buttons: [ButtonState] + public var message: TextState? + public var title: TextState + + init( + id: UUID, + buttons: [ButtonState], + message: TextState?, + title: TextState + ) { + self.id = id + self.buttons = buttons + self.message = message + self.title = title + } + + /// Creates alert state. + /// + /// - Parameters: + /// - title: The title of the alert. + /// - actions: A ``ButtonStateBuilder`` returning the alert's actions. + /// - message: The message for the alert. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public init( + title: () -> TextState, + @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, + message: (() -> TextState)? = nil + ) { + self.init( + id: UUID(), + buttons: actions(), + message: message?(), + title: title() + ) + } + + public func map(_ transform: (Action?) -> NewAction?) -> AlertState { + AlertState( + id: self.id, + buttons: self.buttons.map { $0.map(transform) }, + message: self.message, + title: self.title + ) + } + } + + extension AlertState: CustomDumpReflectable { + public var customDumpMirror: Mirror { + var children: [(label: String?, value: Any)] = [ + ("title", self.title) + ] + if !self.buttons.isEmpty { + children.append(("actions", self.buttons)) + } + if let message = self.message { + children.append(("message", message)) + } + return Mirror( + self, + children: children, + displayStyle: .struct + ) + } + } + + extension AlertState: Equatable where Action: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + && lhs.message == rhs.message + && lhs.buttons == rhs.buttons + } + } + + extension AlertState: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.title) + hasher.combine(self.message) + hasher.combine(self.buttons) + } + } + + extension AlertState: Sendable where Action: Sendable {} + + // MARK: - SwiftUI bridging + + extension Alert { + /// Creates an alert from alert state. + /// + /// - Parameters: + /// - state: Alert state used to populate the alert. + /// - action: An action handler, called when a button with an action is tapped, by passing the + /// action to the closure. + public init(_ state: AlertState, action: @escaping (Action?) -> Void) { + if state.buttons.count == 2 { + self.init( + title: Text(state.title), + message: state.message.map { Text($0) }, + primaryButton: .init(state.buttons[0], action: action), + secondaryButton: .init(state.buttons[1], action: action) + ) + } else { + self.init( + title: Text(state.title), + message: state.message.map { Text($0) }, + dismissButton: state.buttons.first.map { .init($0, action: action) } + ) + } + } + + /// Creates an alert from alert state. + /// + /// - Parameters: + /// - state: Alert state used to populate the alert. + /// - action: An action handler, called when a button with an action is tapped, by passing the + /// action to the closure. + public init(_ state: AlertState, action: @escaping (Action?) async -> Void) { + if state.buttons.count == 2 { + self.init( + title: Text(state.title), + message: state.message.map { Text($0) }, + primaryButton: .init(state.buttons[0], action: action), + secondaryButton: .init(state.buttons[1], action: action) + ) + } else { + self.init( + title: Text(state.title), + message: state.message.map { Text($0) }, + dismissButton: state.buttons.first.map { .init($0, action: action) } + ) + } + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Bind.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Bind.swift new file mode 100644 index 00000000..68da3157 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Bind.swift @@ -0,0 +1,86 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension View { + /// Synchronizes model state to view state via two-way bindings. + /// + /// SwiftUI comes with many property wrappers that can be used in views to drive view state, + /// like field focus. Unfortunately, these property wrappers _must_ be used in views. It's not + /// possible to extract this logic to an `@Observable` class and integrate it with the rest of + /// the model's business logic, and be in a better position to test this state. + /// + /// We can work around these limitations by introducing a published field to your observable + /// object and synchronizing it to view state with this view modifier. + /// + /// - Parameters: + /// - modelValue: A binding from model state. _E.g._, a binding derived from a field + /// on an observable class. + /// - viewValue: A binding from view state. _E.g._, a focus binding. + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + public func bind( + _ modelValue: ModelValue, to viewValue: ViewValue + ) -> some View + where ModelValue.Value == ViewValue.Value, ModelValue.Value: Equatable { + self.modifier(_Bind(modelValue: modelValue, viewValue: viewValue)) + } + } + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + private struct _Bind: ViewModifier + where ModelValue.Value == ViewValue.Value, ModelValue.Value: Equatable { + let modelValue: ModelValue + let viewValue: ViewValue + + @State var hasAppeared = false + + func body(content: Content) -> some View { + content + .onAppear { + guard !self.hasAppeared else { return } + self.hasAppeared = true + guard self.viewValue.wrappedValue != self.modelValue.wrappedValue else { return } + self.viewValue.wrappedValue = self.modelValue.wrappedValue + } + .onChange(of: self.modelValue.wrappedValue) { + guard self.viewValue.wrappedValue != $0 + else { return } + self.viewValue.wrappedValue = $0 + } + .onChange(of: self.viewValue.wrappedValue) { + guard self.modelValue.wrappedValue != $0 + else { return } + self.modelValue.wrappedValue = $0 + } + } + } + + public protocol _Bindable { + associatedtype Value + var wrappedValue: Value { get nonmutating set } + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension AccessibilityFocusState: _Bindable {} + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension AccessibilityFocusState.Binding: _Bindable {} + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension AppStorage: _Bindable {} + + extension Binding: _Bindable {} + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension FocusedBinding: _Bindable {} + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension FocusState: _Bindable {} + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension FocusState.Binding: _Bindable {} + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension SceneStorage: _Bindable {} + + extension State: _Bindable {} +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Binding.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Binding.swift new file mode 100644 index 00000000..c1c4c89f --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Binding.swift @@ -0,0 +1,27 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension Binding { + /// Creates a binding by projecting the current optional value to a boolean describing if it's + /// non-`nil`. + /// + /// Writing `false` to the binding will `nil` out the base value. Writing `true` does nothing. + /// + /// - Returns: A binding to a boolean. Returns `true` if non-`nil`, otherwise `false`. + public func isPresent() -> Binding + where Value == Wrapped? { + self._isPresent + } + } + + extension Optional { + fileprivate var _isPresent: Bool { + get { self != nil } + set { + guard !newValue else { return } + self = nil + } + } + } + +#endif diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ButtonState.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ButtonState.swift new file mode 100644 index 00000000..91f062a7 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ButtonState.swift @@ -0,0 +1,370 @@ +#if canImport(SwiftUI) + import CustomDump + import SwiftUI + + public struct ButtonState: Identifiable { + public let id: UUID + public let action: ButtonStateAction + public let label: TextState + public let role: ButtonStateRole? + + init( + id: UUID, + action: ButtonStateAction, + label: TextState, + role: ButtonStateRole? + ) { + self.id = id + self.action = action + self.label = label + self.role = role + } + + /// Creates button state. + /// + /// - Parameters: + /// - role: An optional semantic role that describes the button. A value of `nil` means that the + /// button doesn't have an assigned role. + /// - action: The action to send when the user interacts with the button. + /// - label: A view that describes the purpose of the button's `action`. + public init( + role: ButtonStateRole? = nil, + action: ButtonStateAction = .send(nil), + label: () -> TextState + ) { + self.init(id: UUID(), action: action, label: label(), role: role) + } + + /// Creates button state. + /// + /// - Parameters: + /// - role: An optional semantic role that describes the button. A value of `nil` means that the + /// button doesn't have an assigned role. + /// - action: The action to send when the user interacts with the button. + /// - label: A view that describes the purpose of the button's `action`. + public init( + role: ButtonStateRole? = nil, + action: Action, + label: () -> TextState + ) { + self.init(id: UUID(), action: .send(action), label: label(), role: role) + } + + /// Handle the button's action in a closure. + /// + /// - Parameter perform: Unwraps and passes a button's action to a closure to be performed. If the + /// action has an associated animation, the context will be wrapped using SwiftUI's + /// `withAnimation`. + public func withAction(_ perform: (Action?) -> Void) { + switch self.action.type { + case let .send(action): + perform(action) + case let .animatedSend(action, animation): + withAnimation(animation) { + perform(action) + } + } + } + + /// Handle the button's action in an async closure. + /// + /// > Warning: Async closures cannot be performed with animation. If the underlying action is + /// > animated, a runtime warning will be emitted. + /// + /// - Parameter perform: Unwraps and passes a button's action to a closure to be performed. + public func withAction(_ perform: (Action?) async -> Void) async { + switch self.action.type { + case let .send(action): + await perform(action) + case let .animatedSend(action, _): + var output = "" + customDump(self.action, to: &output, indent: 4) + runtimeWarn( + """ + An animated action was performed asynchronously: … + + Action: + \((output)) + + Asynchronous actions cannot be animated. Evaluate this action in a synchronous closure, or \ + use 'SwiftUI.withAnimation' explicitly. + """ + ) + await perform(action) + } + } + + /// Transforms a button state's action into a new action. + /// + /// - Parameter transform: A closure that transforms an optional action into a new optional + /// action. + /// - Returns: Button state over a new action. + public func map(_ transform: (Action?) -> NewAction?) -> ButtonState { + ButtonState( + id: self.id, + action: self.action.map(transform), + label: self.label, + role: self.role + ) + } + } + + /// A type that wraps an action with additional context, _e.g._ for animation. + public struct ButtonStateAction { + public let type: _ActionType + + public static func send(_ action: Action?) -> Self { + .init(type: .send(action)) + } + + public static func send(_ action: Action?, animation: Animation?) -> Self { + .init(type: .animatedSend(action, animation: animation)) + } + + public var action: Action? { + switch self.type { + case let .animatedSend(action, animation: _), let .send(action): + return action + } + } + + public func map( + _ transform: (Action?) -> NewAction? + ) -> ButtonStateAction { + switch self.type { + case let .animatedSend(action, animation: animation): + return .send(transform(action), animation: animation) + case let .send(action): + return .send(transform(action)) + } + } + + public enum _ActionType { + case send(Action?) + case animatedSend(Action?, animation: Animation?) + } + } + + /// A value that describes the purpose of a button. + /// + /// See `SwiftUI.ButtonRole` for more information. + public enum ButtonStateRole: Sendable { + /// A role that indicates a cancel button. + /// + /// See `SwiftUI.ButtonRole.cancel` for more information. + case cancel + + /// A role that indicates a destructive button. + /// + /// See `SwiftUI.ButtonRole.destructive` for more information. + case destructive + } + + extension ButtonState: CustomDumpReflectable { + public var customDumpMirror: Mirror { + var children: [(label: String?, value: Any)] = [] + if let role = self.role { + children.append(("role", role)) + } + children.append(("action", self.action)) + children.append(("label", self.label)) + return Mirror( + self, + children: children, + displayStyle: .struct + ) + } + } + + extension ButtonStateAction: CustomDumpReflectable { + public var customDumpMirror: Mirror { + switch self.type { + case let .send(action): + return Mirror( + self, + children: [ + "send": action as Any + ], + displayStyle: .enum + ) + case let .animatedSend(action, animation): + return Mirror( + self, + children: [ + "send": (action, animation: animation) + ], + displayStyle: .enum + ) + } + } + } + + extension ButtonStateAction: Equatable where Action: Equatable {} + extension ButtonStateAction._ActionType: Equatable where Action: Equatable {} + extension ButtonStateRole: Equatable {} + extension ButtonState: Equatable where Action: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.action == rhs.action + && lhs.label == rhs.label + && lhs.role == rhs.role + } + } + + extension ButtonStateAction: Hashable where Action: Hashable {} + extension ButtonStateAction._ActionType: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case let .send(action), let .animatedSend(action, animation: _): + hasher.combine(action) + } + } + } + extension ButtonStateRole: Hashable {} + extension ButtonState: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.action) + hasher.combine(self.label) + hasher.combine(self.role) + } + } + + extension ButtonStateAction: Sendable where Action: Sendable {} + extension ButtonStateAction._ActionType: Sendable where Action: Sendable {} + extension ButtonState: Sendable where Action: Sendable {} + + // MARK: - SwiftUI bridging + + extension Alert.Button { + /// Initializes a `SwiftUI.Alert.Button` from `ButtonState` and an action handler. + /// + /// - Parameters: + /// - button: Button state. + /// - action: An action closure that is invoked when the button is tapped. + public init(_ button: ButtonState, action: @escaping (Action?) -> Void) { + let action = { button.withAction(action) } + switch button.role { + case .cancel: + self = .cancel(Text(button.label), action: action) + case .destructive: + self = .destructive(Text(button.label), action: action) + case .none: + self = .default(Text(button.label), action: action) + } + } + + /// Initializes a `SwiftUI.Alert.Button` from `ButtonState` and an async action handler. + /// + /// > Warning: Async closures cannot be performed with animation. If the underlying action is + /// > animated, a runtime warning will be emitted. + /// + /// - Parameters: + /// - button: Button state. + /// - action: An action closure that is invoked when the button is tapped. + public init(_ button: ButtonState, action: @escaping (Action?) async -> Void) { + let action = { _ = Task { await button.withAction(action) } } + switch button.role { + case .cancel: + self = .cancel(Text(button.label), action: action) + case .destructive: + self = .destructive(Text(button.label), action: action) + case .none: + self = .default(Text(button.label), action: action) + } + } + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension ButtonRole { + public init(_ role: ButtonStateRole) { + switch role { + case .cancel: + self = .cancel + case .destructive: + self = .destructive + } + } + } + + extension Button where Label == Text { + /// Initializes a `SwiftUI.Button` from `ButtonState` and an async action handler. + /// + /// - Parameters: + /// - button: Button state. + /// - action: An action closure that is invoked when the button is tapped. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public init(_ button: ButtonState, action: @escaping (Action?) -> Void) { + self.init( + role: button.role.map(ButtonRole.init), + action: { button.withAction(action) } + ) { + Text(button.label) + } + } + + /// Initializes a `SwiftUI.Button` from `ButtonState` and an action handler. + /// + /// > Warning: Async closures cannot be performed with animation. If the underlying action is + /// > animated, a runtime warning will be emitted. + /// + /// - Parameters: + /// - button: Button state. + /// - action: An action closure that is invoked when the button is tapped. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public init(_ button: ButtonState, action: @escaping (Action?) async -> Void) { + self.init( + role: button.role.map(ButtonRole.init), + action: { Task { await button.withAction(action) } } + ) { + Text(button.label) + } + } + } + + @usableFromInline + func debugCaseOutput(_ value: Any) -> String { + func debugCaseOutputHelp(_ value: Any) -> String { + let mirror = Mirror(reflecting: value) + switch mirror.displayStyle { + case .enum: + guard let child = mirror.children.first else { + let childOutput = "\(value)" + return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)" + } + let childOutput = debugCaseOutputHelp(child.value) + return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")" + case .tuple: + return mirror.children.map { label, value in + let childOutput = debugCaseOutputHelp(value) + return + "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")" + } + .joined(separator: ", ") + default: + return "" + } + } + + return (value as? CustomDebugStringConvertible)?.debugDescription + ?? "\(typeName(type(of: value)))\(debugCaseOutputHelp(value))" + } + + private func isUnlabeledArgument(_ label: String) -> Bool { + label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil + } + + @usableFromInline + func typeName(_ type: Any.Type) -> String { + var name = _typeName(type, qualified: true) + if let index = name.firstIndex(of: ".") { + name.removeSubrange(...index) + } + let sanitizedName = + name + .replacingOccurrences( + of: #"<.+>|\(unknown context at \$[[:xdigit:]]+\)\."#, + with: "", + options: .regularExpression + ) + return sanitizedName + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ButtonStateBuilder.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ButtonStateBuilder.swift new file mode 100644 index 00000000..84222ee6 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ButtonStateBuilder.swift @@ -0,0 +1,36 @@ +#if canImport(SwiftUI) + @resultBuilder + public enum ButtonStateBuilder { + public static func buildArray(_ components: [[ButtonState]]) -> [ButtonState] { + components.flatMap { $0 } + } + + public static func buildBlock(_ components: [ButtonState]...) -> [ButtonState] { + components.flatMap { $0 } + } + + public static func buildLimitedAvailability( + _ component: [ButtonState] + ) -> [ButtonState] { + component + } + + public static func buildEither(first component: [ButtonState]) -> [ButtonState] + { + component + } + + public static func buildEither(second component: [ButtonState]) -> [ButtonState] + { + component + } + + public static func buildExpression(_ expression: ButtonState) -> [ButtonState] { + [expression] + } + + public static func buildOptional(_ component: [ButtonState]?) -> [ButtonState] { + component ?? [] + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ConfirmationDialog.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ConfirmationDialog.swift new file mode 100644 index 00000000..f84f1392 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ConfirmationDialog.swift @@ -0,0 +1,150 @@ +#if canImport(SwiftUI) + import SwiftUI + + // MARK: - ConfirmationDialog with dynamic title + + extension View { + /// Presents a confirmation dialog from a binding to an optional value. + /// + /// SwiftUI's `confirmationDialog` view modifiers are driven by two disconnected pieces of + /// state: an `isPresented` binding to a boolean that determines if the dialog should be + /// presented, and optional dialog `data` that is used to customize its actions and message. + /// + /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: + /// + /// * `isPresented` can be `true`, but `data` can be `nil`. + /// * `isPresented` can be `false`, but `data` can be non-`nil`. + /// + /// On top of that, SwiftUI's `confirmationDialog` modifiers take static titles, which means the + /// title cannot be dynamically computed from the dialog data. + /// + /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the + /// invalid runtime states at compile time by driving the dialog's presentation from a single, + /// optional binding. When this binding is non-`nil`, the dialog will be presented. Further, the + /// title can be customized from the dialog data. + /// + /// ```swift + /// struct DialogDemo: View { + /// @State var randomMovie: Movie? + /// + /// var body: some View { + /// Button("Pick a random movie", action: self.getRandomMovie) + /// .confirmationDialog(item: self.$randomMovie, titleVisibility: .always) { + /// Text($0.title) + /// } actions: { _ in + /// Button("Pick another", action: self.getRandomMovie) + /// Button("I'm done", action: self.clearRandomMovie) + /// } message: { + /// Text($0.summary) + /// } + /// } + /// + /// func getRandomMovie() { + /// self.randomMovie = Movie.allCases.randomElement() + /// } + /// + /// func clearRandomMovie() { + /// self.randomMovie = nil + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - item: A binding to an optional value that determines whether a dialog should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed + /// to the modifier's closures. You can use this data to populate the fields of a dialog + /// that the system displays to the user. When the user presses or taps one of the dialog's + /// actions, the system sets this value to `nil` and dismisses the dialog. + /// - title: A closure returning the dialog's title given the current dialog state. + /// - titleVisibility: The visibility of the dialog's title. (default: .automatic) + /// - actions: A view builder returning the dialog's actions given the current dialog state. + /// - message: A view builder returning the message for the dialog given the current dialog + /// state. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func confirmationDialog( + item: Binding, + titleVisibility: Visibility = .automatic, + title: (Item) -> Text, + @ViewBuilder actions: (Item) -> A, + @ViewBuilder message: (Item) -> M + ) -> some View { + confirmationDialog( + item.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + titleVisibility: titleVisibility, + presenting: item.wrappedValue, + actions: actions, + message: message + ) + } + + /// Presents a confirmation dialog from a binding to an optional value. + /// + /// SwiftUI's `confirmationDialog` view modifiers are driven by two disconnected pieces of + /// state: an `isPresented` binding to a boolean that determines if the dialog should be + /// presented, and optional dialog `data` that is used to customize its actions and message. + /// + /// Modeling the domain in this way unfortunately introduces a couple invalid runtime states: + /// + /// * `isPresented` can be `true`, but `data` can be `nil`. + /// * `isPresented` can be `false`, but `data` can be non-`nil`. + /// + /// On top of that, SwiftUI's `confirmationDialog` modifiers take static titles, which means the + /// title cannot be dynamically computed from the dialog data. + /// + /// This overload addresses these shortcomings with a streamlined API. First, it eliminates the + /// invalid runtime states at compile time by driving the dialog's presentation from a single, + /// optional binding. When this binding is non-`nil`, the dialog will be presented. Further, the + /// title can be customized from the dialog data. + /// + /// struct DialogDemo: View { + /// @State var randomMovie: Movie? + /// + /// var body: some View { + /// Button("Pick a random movie", action: self.getRandomMovie) + /// .confirmationDialog(item: self.$randomMovie, titleVisibility: .always) { + /// Text($0.title) + /// } actions: { _ in + /// Button("Pick another", action: self.getRandomMovie) + /// Button("I'm done", action: self.clearRandomMovie) + /// } + /// } + /// + /// func getRandomMovie() { + /// self.randomMovie = Movie.allCases.randomElement() + /// } + /// + /// func clearRandomMovie() { + /// self.randomMovie = nil + /// } + /// } + /// ``` + /// + /// See for more information on how to use this API. + /// + /// - Parameters: + /// - item: A binding to an optional value that determines whether a dialog should be + /// presented. When the binding is updated with non-`nil` value, it is unwrapped and passed + /// to the modifier's closures. You can use this data to populate the fields of a dialog + /// that the system displays to the user. When the user presses or taps one of the dialog's + /// actions, the system sets this value to `nil` and dismisses the dialog. + /// - title: A closure returning the dialog's title given the current dialog state. + /// - titleVisibility: The visibility of the dialog's title. (default: .automatic) + /// - actions: A view builder returning the dialog's actions given the current dialog state. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func confirmationDialog( + item: Binding, + titleVisibility: Visibility = .automatic, + title: (Item) -> Text, + @ViewBuilder actions: (Item) -> A + ) -> some View { + confirmationDialog( + item.wrappedValue.map(title) ?? Text(verbatim: ""), + isPresented: item.isPresent(), + titleVisibility: titleVisibility, + presenting: item.wrappedValue, + actions: actions + ) + } + } +#endif diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift new file mode 100644 index 00000000..ef9f86a1 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/ConfirmationDialogState.swift @@ -0,0 +1,294 @@ +#if canImport(SwiftUI) + import CustomDump + import SwiftUI + + /// A data type that describes the state of a confirmation dialog that can be shown to the user. The + /// `Action` generic is the type of actions that can be sent from tapping on a button in the sheet. + /// + /// This type can be used in your application's state in order to control the presentation and + /// actions of dialogs. This API can be used to push the logic of alert presentation and action into + /// your model, making it easier to test, and simplifying your view layer. + /// + /// To use this API, you describe all of a dialog's actions as cases in an enum: + /// + /// ```swift + /// @Observable + /// class FeatureModel { + /// enum ConfirmationDialogAction { + /// case delete + /// case favorite + /// } + /// // ... + /// } + /// ``` + /// + /// You model the state for showing the alert in as a published field, which can start off `nil`: + /// + /// ```swift + /// @Observable + /// class FeatureModel { + /// // ... + /// var dialog: ConfirmationDialogState? + /// // ... + /// } + /// ``` + /// + /// And you define an endpoint for handling each alert action: + /// + /// ```swift + /// @Observable + /// class FeatureModel { + /// // ... + /// func dialogButtonTapped(_ action: ConfirmationDialogAction) { + /// switch action { + /// case .delete: + /// // ... + /// case .favorite: + /// // ... + /// } + /// } + /// } + /// ``` + /// + /// Then, in an endpoint that should display an alert, you can construct a + /// ``ConfirmationDialogState`` value to represent it: + /// + /// ```swift + /// @Observable + /// class FeatureModel { + /// // ... + /// func infoButtonTapped() { + /// self.dialog = ConfirmationDialogState( + /// title: "What would you like to do?", + /// buttons: [ + /// .default(TextState("Favorite"), action: .send(.favorite)), + /// .destructive(TextState("Delete"), action: .send(.delete)), + /// .cancel(TextState("Cancel")), + /// ] + /// ) + /// } + /// } + /// ``` + /// + /// And in your view you can use the `.confirmationDialog(unwrapping:action:)` view modifier to + /// present the dialog: + /// + /// ```swift + /// struct ItemView: View { + /// @ObservedObject var model: FeatureModel + /// + /// var body: some View { + /// VStack { + /// Button("Info") { + /// self.model.infoButtonTapped() + /// } + /// } + /// .confirmationDialog(unwrapping: self.$model.dialog) { action in + /// self.model.dialogButtonTapped(action) + /// } + /// } + /// } + /// ``` + /// + /// This makes your model in complete control of when the alert is shown or dismissed, and makes it + /// so that any choice made in the alert is automatically fed back into the model so that you can + /// handle its logic. + /// + /// Even better, you can instantly write tests that your alert behavior works as expected: + /// + /// ```swift + /// let model = FeatureModel() + /// + /// model.infoButtonTapped() + /// XCTAssertEqual( + /// model.dialog, + /// ConfirmationDialogState( + /// title: "What would you like to do?", + /// buttons: [ + /// .default(TextState("Favorite"), action: .send(.favorite)), + /// .destructive(TextState("Delete"), action: .send(.delete)), + /// .cancel(TextState("Cancel")), + /// ] + /// ) + /// ) + /// + /// model.dialogButtonTapped(.favorite) + /// // Verify that favorite logic executed correctly + /// model.dialog = nil + /// ``` + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + public struct ConfirmationDialogState: Identifiable { + public let id: UUID + public var buttons: [ButtonState] + public var message: TextState? + public var title: TextState + public var titleVisibility: ConfirmationDialogStateTitleVisibility + + init( + id: UUID, + buttons: [ButtonState], + message: TextState?, + title: TextState, + titleVisibility: ConfirmationDialogStateTitleVisibility + ) { + self.id = id + self.buttons = buttons + self.message = message + self.title = title + self.titleVisibility = titleVisibility + } + + /// Creates confirmation dialog state. + /// + /// - Parameters: + /// - titleVisibility: The visibility of the dialog's title. + /// - title: The title of the dialog. + /// - actions: A ``ButtonStateBuilder`` returning the dialog's actions. + /// - message: The message for the dialog. + @available(iOS 15, *) + @available(macOS 12, *) + @available(tvOS 15, *) + @available(watchOS 8, *) + public init( + titleVisibility: ConfirmationDialogStateTitleVisibility, + title: () -> TextState, + @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, + message: (() -> TextState)? = nil + ) { + self.init( + id: UUID(), + buttons: actions(), + message: message?(), + title: title(), + titleVisibility: titleVisibility + ) + } + + /// Creates confirmation dialog state. + /// + /// - Parameters: + /// - title: The title of the dialog. + /// - actions: A ``ButtonStateBuilder`` returning the dialog's actions. + /// - message: The message for the dialog. + public init( + title: () -> TextState, + @ButtonStateBuilder actions: () -> [ButtonState] = { [] }, + message: (() -> TextState)? = nil + ) { + self.init( + id: UUID(), + buttons: actions(), + message: message?(), + title: title(), + titleVisibility: .automatic + ) + } + + public func map( + _ transform: (Action?) -> NewAction? + ) -> ConfirmationDialogState { + ConfirmationDialogState( + id: self.id, + buttons: self.buttons.map { $0.map(transform) }, + message: self.message, + title: self.title, + titleVisibility: self.titleVisibility + ) + } + } + + /// The visibility of a confirmation dialog title element, chosen automatically based on the + /// platform, current context, and other factors. + /// + /// See `SwiftUI.Visibility` for more information. + public enum ConfirmationDialogStateTitleVisibility: Sendable { + /// The element may be visible or hidden depending on the policies of the component accepting the + /// visibility configuration. + /// + /// See `SwiftUI.Visibility.automatic` for more information. + case automatic + + /// The element may be hidden. + /// + /// See `SwiftUI.Visibility.hidden` for more information. + case hidden + /// The element may be visible. + /// + /// See `SwiftUI.Visibility.visible` for more information. + case visible + } + + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + extension ConfirmationDialogState: CustomDumpReflectable { + public var customDumpMirror: Mirror { + var children: [(label: String?, value: Any)] = [] + if self.titleVisibility != .automatic { + children.append(("titleVisibility", self.titleVisibility)) + } + children.append(("title", self.title)) + if !self.buttons.isEmpty { + children.append(("actions", self.buttons)) + } + if let message = self.message { + children.append(("message", message)) + } + return Mirror( + self, + children: children, + displayStyle: .struct + ) + } + } + + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + extension ConfirmationDialogState: Equatable where Action: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + && lhs.message == rhs.message + && lhs.buttons == rhs.buttons + } + } + + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + extension ConfirmationDialogState: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.title) + hasher.combine(self.message) + hasher.combine(self.buttons) + } + } + + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + extension ConfirmationDialogState: Sendable where Action: Sendable {} + + // MARK: - SwiftUI bridging + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension Visibility { + public init(_ visibility: ConfirmationDialogStateTitleVisibility) { + switch visibility { + case .automatic: + self = .automatic + case .hidden: + self = .hidden + case .visible: + self = .visible + } + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md new file mode 100644 index 00000000..ae7be6d2 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Documentation.docc/SwiftUINavigationCore.md @@ -0,0 +1,28 @@ +# ``SwiftUINavigationCore`` + +A few core types and modifiers included in SwiftUI Navigation. + +## Topics + +### State + +- ``TextState`` +- ``AlertState`` +- ``ConfirmationDialogState`` +- ``ButtonState`` + +### Alert and dialog modifiers + +- ``SwiftUI/View/alert(item:title:actions:message:)`` +- ``SwiftUI/View/alert(item:title:actions:)`` +- ``SwiftUI/View/confirmationDialog(item:titleVisibility:title:actions:message:)`` +- ``SwiftUI/View/confirmationDialog(item:titleVisibility:title:actions:)`` + +### Bindings + +- ``SwiftUI/Binding/isPresent()`` +- ``SwiftUI/View/bind(_:to:)`` + +### Navigation + +- ``SwiftUI/View/navigationDestination(item:destination:)`` diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Internal/Deprecations.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Internal/Deprecations.swift new file mode 100644 index 00000000..b473de04 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Internal/Deprecations.swift @@ -0,0 +1,318 @@ +#if canImport(SwiftUI) + import SwiftUI + + // NB: Deprecated after 0.5.0 + + extension ButtonState { + @available(*, deprecated, message: "Use 'ButtonStateAction' instead.") + public typealias Handler = ButtonStateAction + + @available(*, deprecated, message: "Use 'ButtonStateAction' instead.") + public typealias ButtonAction = ButtonStateAction + + @available(*, deprecated, message: "Use 'ButtonStateRole' instead.") + public typealias Role = ButtonStateRole + } + + extension ButtonStateAction { + @available(*, deprecated, message: "Use 'ButtonState.withAction' instead.") + public typealias ActionType = _ActionType + } + + // NB: Deprecated after 0.3.0 + + extension AlertState { + @available(*, deprecated, message: "Use 'ButtonState' instead.") + public typealias Button = ButtonState + + @available(*, deprecated, message: "Use 'ButtonStateAction' instead.") + public typealias ButtonAction = ButtonStateAction + + @available(*, deprecated, message: "Use 'ButtonStateRole' instead.") + public typealias ButtonRole = ButtonStateRole + + @available( + iOS, introduced: 15, deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + macOS, + introduced: 12, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + tvOS, introduced: 15, deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + watchOS, + introduced: 8, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + public init( + title: TextState, + message: TextState? = nil, + buttons: [ButtonState] + ) { + self.init( + id: UUID(), + buttons: buttons, + message: message, + title: title + ) + } + + @available( + iOS, introduced: 13, deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + macOS, + introduced: 10.15, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + tvOS, introduced: 13, deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + watchOS, + introduced: 6, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + public init( + title: TextState, + message: TextState? = nil, + dismissButton: ButtonState? = nil + ) { + self.init( + id: UUID(), + buttons: dismissButton.map { [$0] } ?? [], + message: message, + title: title + ) + } + + @available( + iOS, introduced: 13, deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + macOS, + introduced: 10.15, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + tvOS, introduced: 13, deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + watchOS, + introduced: 6, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + public init( + title: TextState, + message: TextState? = nil, + primaryButton: ButtonState, + secondaryButton: ButtonState + ) { + self.init( + id: UUID(), + buttons: [primaryButton, secondaryButton], + message: message, + title: title + ) + } + } + + @available( + iOS, + introduced: 13, + deprecated: 100000, + message: "Use 'ButtonState.init(role:action:label:)' instead." + ) + @available( + macOS, introduced: 10.15, + deprecated: 100000, + message: "Use 'ButtonState.init(role:action:label:)' instead." + ) + @available( + tvOS, + introduced: 13, + deprecated: 100000, + message: "Use 'ButtonState.init(role:action:label:)' instead." + ) + @available( + watchOS, + introduced: 6, + deprecated: 100000, + message: "Use 'ButtonState.init(role:action:label:)' instead." + ) + extension ButtonState { + public static func cancel( + _ label: TextState, action: ButtonStateAction = .send(nil) + ) -> Self { + Self(role: .cancel, action: action) { + label + } + } + + public static func `default`( + _ label: TextState, action: ButtonStateAction = .send(nil) + ) -> Self { + Self(action: action) { + label + } + } + + public static func destructive( + _ label: TextState, action: ButtonStateAction = .send(nil) + ) -> Self { + Self(role: .destructive, action: action) { + label + } + } + } + + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + extension ConfirmationDialogState { + @available(*, deprecated, message: "Use 'ButtonState' instead.") + public typealias Button = ButtonState + + @available(*, deprecated, renamed: "ConfirmationDialogStateTitleVisibility") + public typealias Visibility = ConfirmationDialogStateTitleVisibility + + @available( + iOS, + introduced: 13, + deprecated: 100000, + message: "Use 'init(titleVisibility:title:actions:message:)' instead." + ) + @available( + macOS, + introduced: 12, + deprecated: 100000, + message: "Use 'init(titleVisibility:title:actions:message:)' instead." + ) + @available( + tvOS, + introduced: 13, + deprecated: 100000, + message: "Use 'init(titleVisibility:title:actions:message:)' instead." + ) + @available( + watchOS, + introduced: 6, + deprecated: 100000, + message: "Use 'init(titleVisibility:title:actions:message:)' instead." + ) + public init( + title: TextState, + titleVisibility: ConfirmationDialogStateTitleVisibility, + message: TextState? = nil, + buttons: [ButtonState] = [] + ) { + self.init( + id: UUID(), + buttons: buttons, + message: message, + title: title, + titleVisibility: titleVisibility + ) + } + + @available( + iOS, + introduced: 13, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + macOS, + introduced: 12, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + tvOS, + introduced: 13, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + @available( + watchOS, + introduced: 6, + deprecated: 100000, + message: "Use 'init(title:actions:message:)' instead." + ) + public init( + title: TextState, + message: TextState? = nil, + buttons: [ButtonState] = [] + ) { + self.init( + id: UUID(), + buttons: buttons, + message: message, + title: title, + titleVisibility: .automatic + ) + } + } + + @available(iOS, introduced: 13, deprecated: 100000, renamed: "ConfirmationDialogState") + @available(macOS, introduced: 12, unavailable) + @available(tvOS, introduced: 13, deprecated: 100000, renamed: "ConfirmationDialogState") + @available(watchOS, introduced: 6, deprecated: 100000, renamed: "ConfirmationDialogState") + public typealias ActionSheetState = ConfirmationDialogState + + @available( + iOS, + introduced: 13, + deprecated: 100000, + message: + "use 'View.confirmationDialog(title:isPresented:titleVisibility:presenting::actions:)' instead." + ) + @available( + macOS, + introduced: 12, + unavailable + ) + @available( + tvOS, + introduced: 13, + deprecated: 100000, + message: + "use 'View.confirmationDialog(title:isPresented:titleVisibility:presenting::actions:)' instead." + ) + @available( + watchOS, + introduced: 6, + deprecated: 100000, + message: + "use 'View.confirmationDialog(title:isPresented:titleVisibility:presenting::actions:)' instead." + ) + extension ActionSheet { + public init( + _ state: ConfirmationDialogState, + action: @escaping (Action?) -> Void + ) { + self.init( + title: Text(state.title), + message: state.message.map { Text($0) }, + buttons: state.buttons.map { .init($0, action: action) } + ) + } + } + +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift new file mode 100644 index 00000000..5d188c1e --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/Internal/RuntimeWarnings.swift @@ -0,0 +1,74 @@ +#if canImport(SwiftUI) + @_spi(RuntimeWarn) + @_transparent + @inline(__always) + public func runtimeWarn( + _ message: @autoclosure () -> String, + category: String? = "SwiftUINavigation", + file: StaticString? = nil, + line: UInt? = nil + ) { + #if DEBUG + let message = message() + let category = category ?? "Runtime Warning" + if _XCTIsTesting { + if let file = file, let line = line { + XCTFail(message, file: file, line: line) + } else { + XCTFail(message) + } + } else { + #if canImport(os) + os_log( + .fault, + dso: dso, + log: OSLog(subsystem: "com.apple.runtime-issues", category: category), + "%@", + message + ) + #else + fputs("\(formatter.string(from: Date())) [\(category)] \(message)\n", stderr) + #endif + } + #endif + } + + #if DEBUG + import XCTestDynamicOverlay + + #if canImport(os) + import os + import Foundation + + // NB: Xcode runtime warnings offer a much better experience than traditional assertions and + // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. + // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. + // + // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc + @usableFromInline + let dso = { () -> UnsafeMutableRawPointer in + let count = _dyld_image_count() + for i in 0..( + item: Binding, + @ViewBuilder destination: @escaping (D) -> C + ) -> some View { + navigationDestination(isPresented: item.isPresent()) { + if let item = item.wrappedValue { + destination(item) + } + } + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/TextState.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/TextState.swift new file mode 100644 index 00000000..09ac2ee9 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/SwiftUINavigationCore/TextState.swift @@ -0,0 +1,704 @@ +#if canImport(SwiftUI) + import CustomDump + import SwiftUI + + /// An equatable description of SwiftUI `Text`. Useful for storing rich text in feature models + /// that can still be tested for equality. + /// + /// Although `SwiftUI.Text` and `SwiftUI.LocalizedStringKey` are value types that conform to + /// `Equatable`, their `==` do not return `true` when used with seemingly equal values. If we were + /// to naively store these values in state, our tests may begin to fail. + /// + /// ``TextState`` solves this problem by providing an interface similar to `SwiftUI.Text` that can + /// be held in state and asserted against. + /// + /// Let's say you wanted to hold some dynamic, styled text content in your app state. You could use + /// ``TextState``: + /// + /// ```swift + /// @Observable + /// class Model { + /// var label = TextState("") + /// } + /// ``` + /// + /// Your model can then assign a value to this state using an API similar to that of `SwiftUI.Text`. + /// + /// ```swift + /// self.label = TextState("Hello, ") + TextState(name).bold() + TextState("!") + /// ``` + /// + /// And your view can render it by passing it to a `SwiftUI.Text` initializer: + /// + /// ```swift + /// var body: some View { + /// Text(self.model.label) + /// } + /// ``` + /// + /// SwiftUI Navigation comes with a few convenience APIs for alerts and dialogs that wrap + /// ``TextState`` under the hood. See ``AlertState`` and ``ConfirmationDialogState`` accordingly. + /// + /// In the future, should `SwiftUI.Text` and `SwiftUI.LocalizedStringKey` reliably conform to + /// `Equatable`, ``TextState`` may be deprecated. + /// + /// - Note: ``TextState`` does not support _all_ `LocalizedStringKey` permutations at this time + /// (interpolated `SwiftUI.Image`s, for example). ``TextState`` also uses reflection to determine + /// `LocalizedStringKey` equatability, so be mindful of edge cases. + public struct TextState: Equatable, Hashable, Sendable { + fileprivate var modifiers: [Modifier] = [] + fileprivate let storage: Storage + + fileprivate enum Modifier: Equatable, Hashable, Sendable { + case accessibilityHeading(AccessibilityHeadingLevel) + case accessibilityLabel(TextState) + case accessibilityTextContentType(AccessibilityTextContentType) + case baselineOffset(CGFloat) + case bold(isActive: Bool) + case font(Font?) + case fontDesign(Font.Design?) + case fontWeight(Font.Weight?) + case fontWidth(FontWidth?) + case foregroundColor(Color?) + case italic(isActive: Bool) + case kerning(CGFloat) + case monospacedDigit + case speechAdjustedPitch(Double) + case speechAlwaysIncludesPunctuation(Bool) + case speechAnnouncementsQueued(Bool) + case speechSpellsOutCharacters(Bool) + case strikethrough(isActive: Bool, pattern: LineStylePattern?, color: Color?) + case tracking(CGFloat) + case underline(isActive: Bool, pattern: LineStylePattern?, color: Color?) + } + + public enum FontWidth: String, Equatable, Hashable, Sendable { + case compressed + case condensed + case expanded + case standard + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + var toSwiftUI: SwiftUI.Font.Width { + switch self { + case .compressed: return .compressed + case .condensed: return .condensed + case .expanded: return .expanded + case .standard: return .standard + } + } + } + + public enum LineStylePattern: String, Equatable, Hashable, Sendable { + case dash + case dashDot + case dashDotDot + case dot + case solid + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + var toSwiftUI: SwiftUI.Text.LineStyle.Pattern { + switch self { + case .dash: return .dash + case .dashDot: return .dashDot + case .dashDotDot: return .dashDotDot + case .dot: return .dot + case .solid: return .solid + } + } + } + + // NB: LocalizedStringKey is documented as being Sendable, but its conformance appears to be + // unavailable. + fileprivate enum Storage: Equatable, Hashable, @unchecked Sendable { + indirect case concatenated(TextState, TextState) + case localized( + LocalizedStringKey, tableName: String?, bundle: Bundle?, comment: StaticString?) + case verbatim(String) + + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.concatenated(l1, l2), .concatenated(r1, r2)): + return l1 == r1 && l2 == r2 + + case let (.localized(lk, lt, lb, lc), .localized(rk, rt, rb, rc)): + return lk.formatted(tableName: lt, bundle: lb, comment: lc) + == rk.formatted(tableName: rt, bundle: rb, comment: rc) + + case let (.verbatim(lhs), .verbatim(rhs)): + return lhs == rhs + + case let (.localized(key, tableName, bundle, comment), .verbatim(string)), + let (.verbatim(string), .localized(key, tableName, bundle, comment)): + return key.formatted(tableName: tableName, bundle: bundle, comment: comment) == string + + // NB: We do not attempt to equate concatenated cases. + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + enum Key { + case concatenated + case localized + case verbatim + } + + switch self { + case let (.concatenated(first, second)): + hasher.combine(Key.concatenated) + hasher.combine(first) + hasher.combine(second) + + case let .localized(key, tableName, bundle, comment): + hasher.combine(Key.localized) + hasher.combine(key.formatted(tableName: tableName, bundle: bundle, comment: comment)) + + case let .verbatim(string): + hasher.combine(Key.verbatim) + hasher.combine(string) + } + } + } + } + + // MARK: - API + + extension TextState { + public init(verbatim content: String) { + self.storage = .verbatim(content) + } + + @_disfavoredOverload + public init(_ content: S) { + self.init(verbatim: String(content)) + } + + public init( + _ key: LocalizedStringKey, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil + ) { + self.storage = .localized(key, tableName: tableName, bundle: bundle, comment: comment) + } + + public static func + (lhs: Self, rhs: Self) -> Self { + .init(storage: .concatenated(lhs, rhs)) + } + + public func baselineOffset(_ baselineOffset: CGFloat) -> Self { + var `self` = self + `self`.modifiers.append(.baselineOffset(baselineOffset)) + return `self` + } + + public func bold() -> Self { + var `self` = self + `self`.modifiers.append(.bold(isActive: true)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func bold(isActive: Bool) -> Self { + var `self` = self + `self`.modifiers.append(.bold(isActive: isActive)) + return `self` + } + + public func font(_ font: Font?) -> Self { + var `self` = self + `self`.modifiers.append(.font(font)) + return `self` + } + + public func fontDesign(_ design: Font.Design?) -> Self { + var `self` = self + `self`.modifiers.append(.fontDesign(design)) + return `self` + } + + public func fontWeight(_ weight: Font.Weight?) -> Self { + var `self` = self + `self`.modifiers.append(.fontWeight(weight)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func fontWidth(_ width: FontWidth?) -> Self { + var `self` = self + `self`.modifiers.append(.fontWidth(width)) + return `self` + } + + public func foregroundColor(_ color: Color?) -> Self { + var `self` = self + `self`.modifiers.append(.foregroundColor(color)) + return `self` + } + + public func italic() -> Self { + var `self` = self + `self`.modifiers.append(.italic(isActive: true)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func italic(isActive: Bool) -> Self { + var `self` = self + `self`.modifiers.append(.italic(isActive: isActive)) + return `self` + } + + public func kerning(_ kerning: CGFloat) -> Self { + var `self` = self + `self`.modifiers.append(.kerning(kerning)) + return `self` + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public func monospacedDigit() -> Self { + var `self` = self + `self`.modifiers.append(.monospacedDigit) + return `self` + } + + public func strikethrough(_ isActive: Bool = true, color: Color? = nil) -> Self { + var `self` = self + `self`.modifiers.append(.strikethrough(isActive: isActive, pattern: .solid, color: color)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func strikethrough( + _ isActive: Bool = true, + pattern: LineStylePattern, + color: Color? = nil + ) -> Self { + var `self` = self + `self`.modifiers.append(.strikethrough(isActive: isActive, pattern: pattern, color: color)) + return `self` + } + + public func tracking(_ tracking: CGFloat) -> Self { + var `self` = self + `self`.modifiers.append(.tracking(tracking)) + return `self` + } + + public func underline(_ isActive: Bool = true, color: Color? = nil) -> Self { + var `self` = self + `self`.modifiers.append(.underline(isActive: isActive, pattern: .solid, color: color)) + return `self` + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func underline( + _ isActive: Bool = true, + pattern: LineStylePattern, + color: Color? = nil + ) -> Self { + var `self` = self + `self`.modifiers.append(.underline(isActive: isActive, pattern: pattern, color: color)) + return `self` + } + } + + // MARK: Accessibility + + extension TextState { + public enum AccessibilityTextContentType: String, Equatable, Hashable, Sendable { + case console, fileSystem, messaging, narrative, plain, sourceCode, spreadsheet, wordProcessing + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + var toSwiftUI: SwiftUI.AccessibilityTextContentType { + switch self { + case .console: return .console + case .fileSystem: return .fileSystem + case .messaging: return .messaging + case .narrative: return .narrative + case .plain: return .plain + case .sourceCode: return .sourceCode + case .spreadsheet: return .spreadsheet + case .wordProcessing: return .wordProcessing + } + } + } + + public enum AccessibilityHeadingLevel: String, Equatable, Hashable, Sendable { + case h1, h2, h3, h4, h5, h6, unspecified + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + var toSwiftUI: SwiftUI.AccessibilityHeadingLevel { + switch self { + case .h1: return .h1 + case .h2: return .h2 + case .h3: return .h3 + case .h4: return .h4 + case .h5: return .h5 + case .h6: return .h6 + case .unspecified: return .unspecified + } + } + } + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension TextState { + public func accessibilityHeading(_ headingLevel: AccessibilityHeadingLevel) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityHeading(headingLevel)) + return `self` + } + + public func accessibilityLabel(_ label: Self) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityLabel(label)) + return `self` + } + + public func accessibilityLabel(_ string: String) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityLabel(.init(string))) + return `self` + } + + public func accessibilityLabel(_ string: S) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityLabel(.init(string))) + return `self` + } + + public func accessibilityLabel( + _ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, + comment: StaticString? = nil + ) -> Self { + var `self` = self + `self`.modifiers.append( + .accessibilityLabel(.init(key, tableName: tableName, bundle: bundle, comment: comment))) + return `self` + } + + public func accessibilityTextContentType(_ type: AccessibilityTextContentType) -> Self { + var `self` = self + `self`.modifiers.append(.accessibilityTextContentType(type)) + return `self` + } + + public func speechAdjustedPitch(_ value: Double) -> Self { + var `self` = self + `self`.modifiers.append(.speechAdjustedPitch(value)) + return `self` + } + + public func speechAlwaysIncludesPunctuation(_ value: Bool = true) -> Self { + var `self` = self + `self`.modifiers.append(.speechAlwaysIncludesPunctuation(value)) + return `self` + } + + public func speechAnnouncementsQueued(_ value: Bool = true) -> Self { + var `self` = self + `self`.modifiers.append(.speechAnnouncementsQueued(value)) + return `self` + } + + public func speechSpellsOutCharacters(_ value: Bool = true) -> Self { + var `self` = self + `self`.modifiers.append(.speechSpellsOutCharacters(value)) + return `self` + } + } + + extension Text { + public init(_ state: TextState) { + let text: Text + switch state.storage { + case let .concatenated(first, second): + text = Text(first) + Text(second) + case let .localized(content, tableName, bundle, comment): + text = .init(content, tableName: tableName, bundle: bundle, comment: comment) + case let .verbatim(content): + text = .init(verbatim: content) + } + self = state.modifiers.reduce(text) { text, modifier in + switch modifier { + case let .accessibilityHeading(level): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.accessibilityHeading(level.toSwiftUI) + } else { + return text + } + case let .accessibilityLabel(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + switch value.storage { + case let .verbatim(string): + return text.accessibilityLabel(string) + case let .localized(key, tableName, bundle, comment): + return text.accessibilityLabel( + Text(key, tableName: tableName, bundle: bundle, comment: comment)) + case .concatenated(_, _): + assertionFailure("`.accessibilityLabel` does not support concatenated `TextState`") + return text + } + } else { + return text + } + case let .accessibilityTextContentType(type): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.accessibilityTextContentType(type.toSwiftUI) + } else { + return text + } + case let .baselineOffset(baselineOffset): + return text.baselineOffset(baselineOffset) + case let .bold(isActive): + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return text.bold(isActive) + } else { + return text.bold() + } + case let .font(font): + return text.font(font) + case let .fontDesign(design): + if #available(iOS 16.1, macOS 13, tvOS 16.1, watchOS 9.1, *) { + return text.fontDesign(design) + } else { + return text + } + case let .fontWeight(weight): + return text.fontWeight(weight) + case let .fontWidth(width): + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return text.fontWidth(width?.toSwiftUI) + } else { + return text + } + case let .foregroundColor(color): + return text.foregroundColor(color) + case let .italic(isActive): + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return text.italic(isActive) + } else { + return text.italic() + } + case let .kerning(kerning): + return text.kerning(kerning) + case .monospacedDigit: + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.monospacedDigit() + } else { + return text + } + case let .speechAdjustedPitch(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.speechAdjustedPitch(value) + } else { + return text + } + case let .speechAlwaysIncludesPunctuation(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.speechAlwaysIncludesPunctuation(value) + } else { + return text + } + case let .speechAnnouncementsQueued(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.speechAnnouncementsQueued(value) + } else { + return text + } + case let .speechSpellsOutCharacters(value): + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + return text.speechSpellsOutCharacters(value) + } else { + return text + } + case let .strikethrough(isActive, pattern, color): + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *), let pattern = pattern { + return text.strikethrough(isActive, pattern: pattern.toSwiftUI, color: color) + } else { + return text.strikethrough(isActive, color: color) + } + case let .tracking(tracking): + return text.tracking(tracking) + case let .underline(isActive, pattern, color): + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *), let pattern = pattern { + return text.underline(isActive, pattern: pattern.toSwiftUI, color: color) + } else { + return text.underline(isActive, color: color) + } + } + } + } + } + + extension String { + public init(state: TextState, locale: Locale? = nil) { + switch state.storage { + case let .concatenated(lhs, rhs): + self = String(state: lhs, locale: locale) + String(state: rhs, locale: locale) + + case let .localized(key, tableName, bundle, comment): + self = key.formatted( + locale: locale, + tableName: tableName, + bundle: bundle, + comment: comment + ) + + case let .verbatim(string): + self = string + } + } + } + + extension LocalizedStringKey { + // NB: `LocalizedStringKey` conforms to `Equatable` but returns false for equivalent format + // strings. To account for this we reflect on it to extract and string-format its storage. + fileprivate func formatted( + locale: Locale? = nil, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil + ) -> String { + let children = Array(Mirror(reflecting: self).children) + let key = children[0].value as! String + let arguments: [CVarArg] = Array(Mirror(reflecting: children[2].value).children) + .compactMap { + let children = Array(Mirror(reflecting: $0.value).children) + let value: Any + let formatter: Formatter? + // `LocalizedStringKey.FormatArgument` differs depending on OS/platform. + if children[0].label == "storage" { + (value, formatter) = + Array(Mirror(reflecting: children[0].value).children)[0].value as! (Any, Formatter?) + } else { + value = children[0].value + formatter = children[1].value as? Formatter + } + return formatter?.string(for: value) ?? value as! CVarArg + } + + let format = NSLocalizedString( + key, + tableName: tableName, + bundle: bundle ?? .main, + value: "", + comment: comment.map(String.init) ?? "" + ) + return String(format: format, locale: locale, arguments: arguments) + } + } + + // MARK: - CustomDumpRepresentable + + extension TextState: CustomDumpRepresentable { + public var customDumpValue: Any { + func dumpHelp(_ textState: Self) -> String { + var output: String + switch textState.storage { + case let .concatenated(lhs, rhs): + output = dumpHelp(lhs) + dumpHelp(rhs) + case let .localized(key, tableName, bundle, comment): + output = key.formatted(tableName: tableName, bundle: bundle, comment: comment) + case let .verbatim(string): + output = string + } + func tag(_ name: String, attribute: String? = nil, _ value: String? = nil) { + output = """ + <\(name)\(attribute.map { " \($0)" } ?? "")\(value.map { "=\($0)" } ?? "")>\ + \(output)\ + + """ + } + for modifier in textState.modifiers { + switch modifier { + case let .accessibilityHeading(headingLevel): + tag("accessibility-heading-level", headingLevel.rawValue) + case let .accessibilityLabel(value): + tag("accessibility-label", dumpHelp(value)) + case let .accessibilityTextContentType(type): + tag("accessibility-text-content-type", type.rawValue) + case let .baselineOffset(baselineOffset): + tag("baseline-offset", "\(baselineOffset)") + case .bold(isActive: true), .fontWeight(.some(.bold)): + output = "**\(output)**" + case .font(.some): + break // TODO: capture Font description using DSL similar to TextState and print here + case let .fontDesign(.some(design)): + func describe(design: Font.Design) -> String { + switch design { + case .default: return "default" + case .serif: return "serif" + case .rounded: return "rounded" + case .monospaced: return "monospaced" + @unknown default: return "\(design)" + } + } + tag("font-design", describe(design: design)) + case let .fontWeight(.some(weight)): + func describe(weight: Font.Weight) -> String { + switch weight { + case .black: return "black" + case .bold: return "bold" + case .heavy: return "heavy" + case .light: return "light" + case .medium: return "medium" + case .regular: return "regular" + case .semibold: return "semibold" + case .thin: return "thin" + default: return "\(weight)" + } + } + tag("font-weight", describe(weight: weight)) + case let .fontWidth(.some(width)): + tag("font-width", width.rawValue) + case let .foregroundColor(.some(color)): + tag("foreground-color", "\(color)") + case .italic(isActive: true): + output = "_\(output)_" + case let .kerning(kerning): + tag("kerning", "\(kerning)") + case let .speechAdjustedPitch(value): + tag("speech-adjusted-pitch", "\(value)") + case .speechAlwaysIncludesPunctuation(true): + tag("speech-always-includes-punctuation") + case .speechAnnouncementsQueued(true): + tag("speech-announcements-queued") + case .speechSpellsOutCharacters(true): + tag("speech-spells-out-characters") + case let .strikethrough(isActive: true, pattern: _, color: .some(color)): + tag("s", attribute: "color", "\(color)") + case .strikethrough(isActive: true, pattern: _, color: .none): + output = "~~\(output)~~" + case let .tracking(tracking): + tag("tracking", "\(tracking)") + case let .underline(isActive: true, pattern: _, .some(color)): + tag("u", attribute: "color", "\(color)") + case .underline(isActive: true, pattern: _, color: .none): + tag("u") + case .bold(isActive: false), + .font(.none), + .fontDesign(.none), + .fontWeight(.none), + .fontWidth(.none), + .foregroundColor(.none), + .italic(isActive: false), + .monospacedDigit, + .speechAlwaysIncludesPunctuation(false), + .speechAnnouncementsQueued(false), + .speechSpellsOutCharacters(false), + .strikethrough(isActive: false, pattern: _, color: _), + .underline(isActive: false, pattern: _, color: _): + break + } + } + return output + } + + return dumpHelp(self) + } + } +#endif // canImport(SwiftUI) diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/ErrorMechanism.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/ErrorMechanism.swift new file mode 100644 index 00000000..1f2c9e31 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/ErrorMechanism.swift @@ -0,0 +1,18 @@ +@rethrows +protocol _ErrorMechanism { + associatedtype Output + func get() throws -> Output +} + +extension _ErrorMechanism { + func _rethrowError() rethrows -> Never { + _ = try _rethrowGet() + fatalError() + } + + func _rethrowGet() rethrows -> Output { + return try get() + } +} + +extension Result: _ErrorMechanism {} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/Exports.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/Exports.swift new file mode 100644 index 00000000..d7028408 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/Exports.swift @@ -0,0 +1,7 @@ +@_exported import CasePaths +@_exported import Perception +@_exported import SwiftUINavigationCore + +#if canImport(Observation) + @_exported import Observation +#endif diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/Observe.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/Observe.swift new file mode 100644 index 00000000..838a1921 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/Observe.swift @@ -0,0 +1,25 @@ +import Foundation + +@MainActor +public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) { + onChange(apply) +} + +extension NSObject { + @MainActor + public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) { + onChange(apply) + } +} + +fileprivate func onChange(_ apply: @escaping @MainActor @Sendable () -> Void) { + withPerceptionTracking { + MainActor.assumeIsolated { apply() } + } onChange: { + Task { @MainActor in + withUIAnimation(UIAnimation.current) { + onChange(apply) + } + } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/OnDeinit.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/OnDeinit.swift new file mode 100644 index 00000000..0c2e9006 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Internal/OnDeinit.swift @@ -0,0 +1,24 @@ +import UIKit + +final class OnDeinit { + let onDismiss: () -> Void + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + } + deinit { + onDismiss() + } +} + +extension UIViewController { + var onDeinit: OnDeinit? { + get { + objc_getAssociatedObject(self, onDeinitKey) as? OnDeinit + } + set { + objc_setAssociatedObject(self, onDeinitKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +private let onDeinitKey = malloc(1)! diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/Dismiss.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/Dismiss.swift new file mode 100644 index 00000000..7fac0c27 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/Dismiss.swift @@ -0,0 +1,30 @@ +import UIKit + +@available(iOS 17.0, *) +@MainActor +public struct UIDismissAction: Sendable { + let run: @MainActor @Sendable () -> Void + public func callAsFunction() { + self.run() + } +} + +@available(iOS 17.0, *) +private enum DismissActionTrait: UITraitDefinition { + static let defaultValue = UIDismissAction { + // Runtime warn that there is no presentation context + } +} + +@available(iOS 17.0, *) +extension UITraitCollection { + public var dismiss: UIDismissAction { self[DismissActionTrait.self] } +} + +@available(iOS 17.0, *) +extension UIMutableTraits { + var dismiss: UIDismissAction { + get { self[DismissActionTrait.self] } + set { self[DismissActionTrait.self] = newValue } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/Presentation.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/Presentation.swift new file mode 100644 index 00000000..a9260335 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/Presentation.swift @@ -0,0 +1,164 @@ +import SwiftUI +import UIKit + +// TODO: Move Alert APIs from TCA to this repo +// TODO: inherit animation from surrounding context? +// TODO: Add `sheet(item:)` and other APIs as helpers? + +extension UIViewController { + public func present( + isPresented: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping @MainActor @Sendable () -> UIViewController + ) { + present(item: isPresented.toOptionalVoid, onDismiss: onDismiss, content: content) + } + + public func present( + item: UIBinding, + onDismiss: (() -> Void)? = nil, + content: @escaping @MainActor @Sendable (Item) -> UIViewController + ) { + // TODO: Should we skip this `observe` if we detect that we are already in one? + observe { [weak self] in + guard let self else { return } + let key = item.id + if let unwrappedItem = item.wrappedValue { + @MainActor + func presentController(_ controller: UIViewController) { + controller.onDeinit = OnDeinit { [weak self, presentationID = item.presentationID] in + guard let self else { return } + if presentationID == item.presentationID { + item.wrappedValue = nil + presented[key] = nil // TODO: i think we should be cleaning this up here + } + } + if #available(iOS 17.0, *) { + controller.traitOverrides.dismiss = UIDismissAction { [weak self] in + guard let self else { return } + // TODO: Do presentationID check here like above? + item.wrappedValue = nil + presented[key] = nil // TODO: i think we should be cleaning this up here + } + } + presented[key] = Presented(controller, id: item.presentationID) + // NB: Thread hop is unfortunately necessary since UIKit does not allow presenting + // controllers from viewDidLoad. + DispatchQueue.main.async { + self.present(controller, animated: true) + } + } + let controller = content(unwrappedItem) + if let presented = presented[key] { + if presented.presentationID != item.presentationID { + dismiss(animated: true) { + onDismiss?() + presentController(controller) + } + } else { + // TODO: if we get here something went wrong and i think it's our fault. should we precondition? + } + } else { + presentController(controller) + } + } else if let controller = presented[key]?.controller { + controller.dismiss(animated: true, completion: onDismiss) + presented[key] = nil + } + } + } + + fileprivate var presented: [AnyHashable: Presented] { + get { + (objc_getAssociatedObject(self, presentedKey) + as? [AnyHashable: Presented]) + ?? [:] + } + set { + objc_setAssociatedObject(self, presentedKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +private let presentedKey = malloc(1)! + +private extension UIBinding { + var presentationID: AnyHashable? { + guard let id = (self.wrappedValue as? any Identifiable)?.id else { return nil } + return AnyHashable(id) + } +} + +extension UINavigationController { + public func pushViewController( + isPresented: UIBinding, + content: @escaping @MainActor @Sendable () -> UIViewController + ) { + pushViewController(item: isPresented.toOptionalVoid, content: content) + } + + public func pushViewController( + item: UIBinding, + content: @escaping @MainActor @Sendable (Item) -> UIViewController + ) { + // TODO: Should we skip this `observe` if we detect that we are already in one? + observe { [weak self] in + guard let self else { return } + let key = item.id + if let unwrappedItem = item.wrappedValue, presented[key] == nil { + let controller = content(unwrappedItem) + controller.onDeinit = OnDeinit { [weak self] in + guard let self else { return } + item.wrappedValue = nil + presented[key] = nil // TODO: i think we should be cleaning this up here + } + if #available(iOS 17.0, *) { + controller.traitOverrides.dismiss = UIDismissAction { [weak self] in + guard let self else { return } + item.wrappedValue = nil + presented[key] = nil // TODO: i think we should be cleaning this up here + } + } + + presented[key] = Presented(controller) + // NB: Thread hop is necessary since UIKit does not allow presenting controllers from + // viewDidLoad. + DispatchQueue.main.async { + self.pushViewController(controller, animated: true) + } + } else if item.wrappedValue == nil, + let controller = presented[key]?.controller + { + popFromViewController(controller, animated: true) + presented[key] = nil + } + } + } + + func popFromViewController(_ controller: UIViewController, animated: Bool) { + guard + let index = viewControllers.firstIndex(of: controller), + index != 0 + else { + // TODO: setViewControllers([]) or runtimeWarn or precondition? + return + } + popToViewController(viewControllers[index - 1], animated: true) + } +} + +private class Presented { + weak var controller: UIViewController? + let presentationID: AnyHashable? + init(_ controller: UIViewController, id presentationID: AnyHashable? = nil) { + self.controller = controller + self.presentationID = presentationID + } +} + +extension Bool { + fileprivate var toOptionalVoid: Void? { + get { self ? () : nil } + set { self = newValue != nil } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/UINavigationController.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/UINavigationController.swift new file mode 100644 index 00000000..3375dde6 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/UINavigationController.swift @@ -0,0 +1,256 @@ +import ObjectiveC +import UIKit + +public class UINavigationStackController: UINavigationController { + private var destinations: [DestinationType: (Any) -> UIViewController] = [:] + fileprivate var path = UIBinding( + UIBindable(DefaultPath()).elements + ) + private let pathDelegate = PathDelegate() + private var root: UIViewController? + + public override weak var delegate: (any UINavigationControllerDelegate)? { + get { pathDelegate.base } + set { pathDelegate.base = newValue } + } + + public convenience init( + navigationBarClass: AnyClass? = nil, + toolbarClass: AnyClass? = nil, + path: UIBinding, + // TODO: Should this be `rootViewController`? + root: () -> UIViewController + ) where Data.Element: Hashable { + self.init(navigationBarClass: navigationBarClass, toolbarClass: toolbarClass) + self.path = UIBinding(path) + self.root = root() + } + + public convenience init( + navigationBarClass: AnyClass? = nil, + toolbarClass: AnyClass? = nil, + path: UIBinding, + // TODO: Should this be `rootViewController`? + root: () -> UIViewController + ) { + self.init(navigationBarClass: navigationBarClass, toolbarClass: toolbarClass) + self.path = UIBinding(path.elements) + self.root = root() + } + + public func navigationDestination( + for data: D.Type, + destination: @escaping (D) -> UIViewController + ) { + destinations[DestinationType(data)] = { destination($0 as! D) } + if path.wrappedValue.contains(where: { $0 is D }) { + path.wrappedValue = path.wrappedValue + } + } + + public override func viewDidLoad() { + super.viewDidLoad() + + super.delegate = pathDelegate + + observe { [weak self] in + guard let self else { return } + + let newPath = path.wrappedValue + + let difference = newPath.map { $0 as! AnyHashable } + .difference(from: self.viewControllers.compactMap(\.navigationID)) + + if difference.isEmpty { + return + } else if difference.count == 1, + case let .insert(newPath.count, navigationID, nil) = difference.first, + let viewController = self.viewController(for: navigationID) + { + pushViewController(viewController, animated: UIView.areAnimationsEnabled) + } else if difference.count == 1, + case .remove(newPath.count, _, nil) = difference.first + { + popViewController(animated: UIView.areAnimationsEnabled) + } else if difference.insertions.isEmpty, newPath.isEmpty { + popToRootViewController(animated: UIView.areAnimationsEnabled) + } else if difference.insertions.isEmpty, + case let offsets = difference.removals.map(\.offset), + let first = offsets.first, + let last = offsets.last, + offsets.elementsEqual(first...last), + first == newPath.count + { + popToViewController( + viewControllers[first - 1], animated: UIView.areAnimationsEnabled + ) + } else { + var newPath = newPath + let oldViewControllers = + viewControllers.isEmpty + ? root.map { [$0] } ?? [] + : viewControllers + var newViewControllers: [UIViewController] = [] + newViewControllers.reserveCapacity(max(viewControllers.count, newPath.count)) + + loop: for viewController in oldViewControllers { + if let navigationID = viewController.navigationID { + guard navigationID == newPath.first as! AnyHashable + else { break loop } + newPath.removeFirst() + } else { + newViewControllers.append(viewController) + } + } + for navigationID in newPath { + let navigationID = navigationID as! AnyHashable + if let viewController = viewControllers.first(where: { $0.navigationID == navigationID }) + { + newViewControllers.append(viewController) + } else if let viewController = viewController(for: navigationID) { + newViewControllers.append(viewController) + } else { + // TODO: runtimeWarn + } + } + setViewControllers(newViewControllers, animated: UIView.areAnimationsEnabled) + } + } + } + + fileprivate func viewController(for navigationID: AnyHashable) -> UIViewController? { + guard let destination = destinations[DestinationType(type(of: navigationID.base))] else { + // TODO: runtimeWarn + return nil + } + let viewController = destination(navigationID) + viewController.navigationID = navigationID + if #available(iOS 17, *) { + viewController.traitOverrides.dismiss = UIDismissAction { [weak self, weak viewController] in + guard let self, let viewController else { return } + self.popFromViewController(viewController, animated: UIView.areAnimationsEnabled) + } + } + return viewController + } + + private struct DestinationType: Hashable { + let rawValue: Any.Type + init(_ rawValue: Any.Type) { + self.rawValue = rawValue + } + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue == rhs.rawValue + } + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(rawValue)) + } + } + + private final class PathDelegate: NSObject, UINavigationControllerDelegate { + weak var base: (any UINavigationControllerDelegate)? + let viewController = UIViewController() + + func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + base?.navigationController?( + navigationController, willShow: viewController, animated: animated + ) + } + + func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + let navigationController = navigationController as! UINavigationStackController + let oldPath = navigationController.path.wrappedValue + let newPath = navigationController.viewControllers.map(\.navigationID) + if oldPath.count > newPath.count { + navigationController.path.wrappedValue = newPath + } + base?.navigationController?(navigationController, didShow: viewController, animated: animated) + } + + func navigationControllerSupportedInterfaceOrientations( + _ navigationController: UINavigationController + ) -> UIInterfaceOrientationMask { + base?.navigationControllerSupportedInterfaceOrientations?(navigationController) + ?? viewController.supportedInterfaceOrientations + } + + func navigationControllerPreferredInterfaceOrientationForPresentation( + _ navigationController: UINavigationController + ) -> UIInterfaceOrientation { + base?.navigationControllerPreferredInterfaceOrientationForPresentation?(navigationController) + ?? viewController.preferredInterfaceOrientationForPresentation + } + + func navigationController( + _ navigationController: UINavigationController, + interactionControllerFor animationController: any UIViewControllerAnimatedTransitioning + ) -> (any UIViewControllerInteractiveTransitioning)? { + base?.navigationController?( + navigationController, interactionControllerFor: animationController + ) + } + + func navigationController( + _ navigationController: UINavigationController, + animationControllerFor operation: UINavigationController.Operation, + from fromVC: UIViewController, + to toVC: UIViewController + ) -> (any UIViewControllerAnimatedTransitioning)? { + base?.navigationController?( + navigationController, animationControllerFor: operation, from: fromVC, to: toVC + ) + } + } +} + +extension UINavigationController { + // TODO: Should this be `pushValue(_:)`? + public func push(value: Element) { + guard let stackController = self as? UINavigationStackController + else { + // TODO: runtimeWarn? + return + } + func open(_ path: inout P) { + path.append(value as! P.Element) + } + open(&stackController.path.wrappedValue) + } +} + +@Perceptible +private final class DefaultPath { + var elements: [AnyHashable] = [] +} + +extension UIViewController { + fileprivate var navigationID: AnyHashable? { + get { + objc_getAssociatedObject(self, navigationIDKey) as? AnyHashable + } + set { + objc_setAssociatedObject(self, navigationIDKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +private let navigationIDKey = malloc(1)! + +extension CollectionDifference.Change { + fileprivate var offset: Int { + switch self { + case let .insert(offset, _, _): + return offset + case let .remove(offset, _, _): + return offset + } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/UINavigationPath.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/UINavigationPath.swift new file mode 100644 index 00000000..2613e8cb --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/Navigation/UINavigationPath.swift @@ -0,0 +1,127 @@ +import Foundation + +public struct UINavigationPath: Equatable { + var elements: [AnyHashable] + + public var count: Int { + elements.count + } + + public var isEmpty: Bool { + elements.isEmpty + } + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + public var codable: CodableRepresentation? { + CodableRepresentation(self) + } + + public init() { + self.elements = [] + } + + public init(_ elements: S) where S.Element: Hashable { + self.elements = elements.map(AnyHashable.init) + } + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + public init(_ codable: CodableRepresentation) { + self.elements = codable.elements.map(\.value) + } + + public mutating func append(_ value: V) { + elements.append(value) + } + + public mutating func removeLast(_ k: Int = 1) { + elements.removeLast(k) + } + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + public struct CodableRepresentation: Codable, Equatable { + fileprivate struct Element: Equatable { + let type: Any.Type + let value: AnyHashable + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.type == rhs.type && lhs.value == rhs.value + } + } + + fileprivate var elements: [Element] = [] + + fileprivate init?(_ path: UINavigationPath) { + elements.reserveCapacity(path.elements.count) + for value in path.elements.reversed() { + guard value.base is Encodable else { return nil } + elements.insert(Element(type: type(of: value.base), value: value), at: 0) + } + } + + public init(from decoder: any Decoder) throws { + var container = try decoder.unkeyedContainer() + if let count = container.count { + elements.reserveCapacity(count) + } + while !container.isAtEnd { + let typeName = try container.decode(String.self) + // TODO: Only allow types that have been used with navigationDestination? + guard let type = _typeByName(typeName) as? any Decodable.Type + else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "\(typeName) is not decodable." + ) + } + let encodedValue = try container.decode(String.self) + #if swift(<5.7) + func decode(_: A.Type) throws -> A { + try JSONDecoder().decode(A.self, from: Data(encodedValue.utf8)) + } + let value = try _openExistential(type, do: decode) + #else + let value = try JSONDecoder().decode(type, from: Data(encodedValue.utf8)) + #endif + guard let value = value as? AnyHashable + else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "\(typeName) is not hashable." + ) + } + elements.insert(Element(type: type, value: value), at: 0) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.unkeyedContainer() + for element in elements.reversed() { + try container.encode(_mangledTypeName(element.type)) + guard let value = element.value as? any Encodable + else { + throw EncodingError.invalidValue( + element, + .init( + codingPath: container.codingPath, + debugDescription: "\(type(of: element)) is not encodable." + ) + ) + } + + #if swift(<5.7) + func open(_: A.Type) throws -> Data { + try JSONEncoder().encode(element as! A) + } + let string = try String( + decoding: _openExistential(type(of: element), do: open), + as: UTF8.self + ) + try container.encode(string) + #else + let string = try String(decoding: JSONEncoder().encode(value), as: UTF8.self) + try container.encode(string) + #endif + } + } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/SwiftUI/UIViewControllerRepresenting.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/SwiftUI/UIViewControllerRepresenting.swift new file mode 100644 index 00000000..16117ccd --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/SwiftUI/UIViewControllerRepresenting.swift @@ -0,0 +1,21 @@ +import SwiftUI + +public struct UIViewControllerRepresenting< + UIViewControllerType: UIViewController +>: UIViewControllerRepresentable { + private let base: UIViewControllerType + public init(_ base: () -> UIViewControllerType) { + self.base = base() + } + public func makeUIViewController(context _: Context) -> UIViewControllerType { self.base } + public func updateUIViewController(_: UIViewControllerType, context _: Context) {} +} + +public struct UIViewRepresenting: UIViewRepresentable { + private let base: UIViewType + public init(_ base: () -> UIViewType) { + self.base = base() + } + public func makeUIView(context _: Context) -> UIViewType { self.base } + public func updateUIView(_: UIViewType, context _: Context) {} +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIAnimation.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIAnimation.swift new file mode 100644 index 00000000..995eaaf9 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIAnimation.swift @@ -0,0 +1,109 @@ +import UIKit + +// TODO: Support arbitrary body closures, `CASpringAnimation`? + +public struct UIAnimation: Sendable { + @TaskLocal static var current: Self? + + public static var `default`: Self { + if #available(iOS 17, *) { + return Self(storage: .animate_iOS_17()) + } else { + return Self(storage: .animate_iOS_4(withDuration: 0.2)) + } + } + + fileprivate let storage: Storage + + fileprivate enum Storage { + case animate_iOS_4( + withDuration: TimeInterval, + delay: CGFloat = 0, + options: UIView.AnimationOptions = [] + ) + case animate_iOS_7( + withDuration: TimeInterval, + delay: TimeInterval, + usingSpringWithDamping: CGFloat, + initialSpringVelocity: CGFloat, + options: UIView.AnimationOptions = [] + ) + case animate_iOS_17( + springDuration: TimeInterval = 0.5, + bounce: CGFloat = 0, + initialSpringVelocity: CGFloat = 0, + delay: TimeInterval = 0, + options: UIView.AnimationOptions = [] + ) + } +} + +@MainActor +public func withUIAnimation( + _ animation: UIAnimation? = .default, + @_implicitSelfCapture body: () throws -> Result, + completion: ((Bool) -> Void)? = nil +) rethrows -> Result { + switch animation?.storage { + case let .animate_iOS_4(duration, delay, options): + var result: Swift.Result? + withoutActuallyEscaping(body) { body in + UIView.animate( + withDuration: duration, + delay: delay, + options: options, + animations: { + result = Swift.Result { + try UIAnimation.$current.withValue(animation) { + try body() + } + } + }, + completion: completion + ) + } + return try result!._rethrowGet() + case let .animate_iOS_7(duration, delay, damping, initialSpringVelocity, options): + var result: Swift.Result? + withoutActuallyEscaping(body) { body in + UIView.animate( + withDuration: duration, + delay: delay, + usingSpringWithDamping: damping, + initialSpringVelocity: initialSpringVelocity, + options: options, + animations: { + result = Swift.Result { + try UIAnimation.$current.withValue(animation) { + try body() + } + } + }, + completion: completion + ) + } + return try result!._rethrowGet() + case let .animate_iOS_17(springDuration, bounce, initialSpringVelocity, delay, options): + if #available(iOS 17, *) { + var result: Swift.Result? + UIView.animate( + springDuration: springDuration, + bounce: bounce, + initialSpringVelocity: initialSpringVelocity, + delay: delay, + options: options, + animations: { result = Swift.Result { try body() } }, + completion: completion + ) + return try result!._rethrowGet() + } else { + return try UIAnimation.$current.withValue(animation) { + try body() + } + } + case nil: + return try UIAnimation.$current.withValue(animation) { + try body() + } + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIBindable.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIBindable.swift new file mode 100644 index 00000000..644e4fa7 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIBindable.swift @@ -0,0 +1,83 @@ +import Perception + +#if canImport(Observation) + import Observation +#endif + +@dynamicMemberLookup +@propertyWrapper +@MainActor +public struct UIBindable: Sendable { + private let objectIdentifier: ObjectIdentifier + + public var wrappedValue: Value + + init(objectIdentifier: ObjectIdentifier, wrappedValue: Value) { + self.objectIdentifier = objectIdentifier + self.wrappedValue = wrappedValue + } + + @_disfavoredOverload + public init(_ wrappedValue: Value) where Value: AnyObject & Perceptible { + self.init(objectIdentifier: ObjectIdentifier(wrappedValue), wrappedValue: wrappedValue) + } + + @_disfavoredOverload + public init(wrappedValue: Value) where Value: AnyObject & Perceptible { + self.init(objectIdentifier: ObjectIdentifier(wrappedValue), wrappedValue: wrappedValue) + } + + @_disfavoredOverload + public init(projectedValue: UIBindable) where Value: AnyObject & Perceptible { + self = projectedValue + } + + public var projectedValue: Self { + self + } + + public subscript( + dynamicMember keyPath: ReferenceWritableKeyPath + ) -> UIBinding where Value: AnyObject { + UIBinding(root: self.wrappedValue, keyPath: keyPath, animation: nil) + } +} + +#if canImport(Observation) + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + extension UIBindable where Value: AnyObject & Observable { + public init(_ wrappedValue: Value) { + self.init(objectIdentifier: ObjectIdentifier(wrappedValue), wrappedValue: wrappedValue) + } + + public init(wrappedValue: Value) { + self.init(objectIdentifier: ObjectIdentifier(wrappedValue), wrappedValue: wrappedValue) + } + + public init(projectedValue: UIBindable) { + self = projectedValue + } + } +#endif + +extension UIBindable: Equatable { + nonisolated public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.objectIdentifier == rhs.objectIdentifier + } +} + +extension UIBindable: Hashable { + nonisolated public func hash(into hasher: inout Hasher) { + hasher.combine(self.objectIdentifier) + } +} + +extension UIBindable: Identifiable { + public struct ID: Hashable { + fileprivate let rawValue: ObjectIdentifier + } + + nonisolated public var id: ID { + ID(rawValue: self.objectIdentifier) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIBinding.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIBinding.swift new file mode 100644 index 00000000..7abb66ab --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIBinding.swift @@ -0,0 +1,411 @@ +import SwiftUI + +@dynamicMemberLookup +@propertyWrapper +@MainActor +public struct UIBinding: Sendable { + private let location: any _UIBinding + var animation: UIAnimation? + + init(location: any _UIBinding, animation: UIAnimation?) { + self.location = location + self.animation = animation + } + + init( + root: Root, + keyPath: ReferenceWritableKeyPath, + animation: UIAnimation? + ) { + self.init( + location: _UIBindingAppendKeyPath( + base: _UIBindingRoot(wrappedValue: root), + keyPath: keyPath + ), + animation: animation + ) + } + + public init?(_ base: UIBinding) { + guard let initialValue = base.wrappedValue + else { return nil } + func open(_ location: some _UIBinding) -> any _UIBinding { + _UIBindingFromOptional(initialValue: initialValue, base: location) + } + self.init(location: open(base.location), animation: base.animation) + } + + public init(_ base: UIBinding) where Value == V? { + func open(_ location: some _UIBinding) -> any _UIBinding { + _UIBindingToOptional(base: location) + } + self.init(location: open(base.location), animation: base.animation) + } + + // TODO: How is this used in SwiftUI? Is this useful in UIKit? Remove? +// public init(_ base: UIBinding) where Value == AnyHashable { +// func open(_ location: some _UIBinding) -> any _UIBinding { +// _UIBindingToAnyHashable(base: location) +// } +// self.init(location: open(base.location), animation: base.animation) +// } + + public init(projectedValue: UIBinding) { + self = projectedValue + } + + public static func constant(_ value: Value) -> Self { + Self(location: _UIBindingConstant(value), animation: nil) + } + + public var wrappedValue: Value { + get { + self.location.wrappedValue + } + nonmutating set { + self.location.wrappedValue = newValue + } + } + + public var projectedValue: Self { + self + } + + // TODO: Motivate? + public func isPresent() -> UIBinding where Value == Wrapped? { + func open(_ location: some _UIBinding) -> UIBinding { + UIBinding( + location: _UIBindingIsPresent(base: location), + animation: animation + ) + } + return open(self.location) + } + + public subscript( + dynamicMember keyPath: WritableKeyPath + ) -> UIBinding { + func open(_ location: some _UIBinding) -> UIBinding { + UIBinding( + location: _UIBindingAppendKeyPath(base: location, keyPath: keyPath), + animation: self.animation + ) + } + return open(self.location) + } + + public subscript( + dynamicMember keyPath: KeyPath> + ) -> UIBinding? + where Value: CasePathable { + func open(_ location: some _UIBinding) -> UIBinding { + UIBinding( + location: _UIBindingEnumToOptionalCase(base: location, keyPath: keyPath), + animation: self.animation + ) + } + return UIBinding(open(self.location)) + } + + public subscript( + dynamicMember keyPath: KeyPath> + ) -> UIBinding + where Value == V? { + func open(_ location: some _UIBinding) -> UIBinding { + UIBinding( + location: _UIBindingOptionalEnumToCase(base: location, keyPath: keyPath), + animation: self.animation + ) + } + return open(self.location) + } + + public func animation(_ animation: UIAnimation? = .default) -> Self { + var binding = self + binding.animation = animation + return binding + } + + public var isAnimated: Bool { + self.animation != nil + } +} + +extension UIBinding: Equatable { + nonisolated public static func == (lhs: Self, rhs: Self) -> Bool { + func openLHS>(_ lhs: B) -> Bool { + func openRHS(_ rhs: some _UIBinding) -> Bool { + lhs == rhs as? B + } + return openRHS(rhs.location) + } + return openLHS(lhs.location) + } +} + +extension UIBinding: Hashable { + nonisolated public func hash(into hasher: inout Hasher) { + hasher.combine(self.location) + } +} + +// TODO: Given `Equatable` and `Hashable` conformances, should `Identifiable` be unconditional? +extension UIBinding: Identifiable { + nonisolated public var id: AnyHashable { + AnyHashable(self.location) + } +} + +// TODO: Conform to BidirectionalCollection/Collection/RandomAccessCollection/Sequence? +// TODO: Conform to DynamicProperty? + +protocol _UIBinding: AnyObject, Hashable, Sendable { + associatedtype Value + var wrappedValue: Value { get set } +} + +private final class _UIBindingRoot: _UIBinding, @unchecked Sendable { + var wrappedValue: Value + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + static func == (lhs: _UIBindingRoot, rhs: _UIBindingRoot) -> Bool { + lhs.wrappedValue === rhs.wrappedValue + } + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self.wrappedValue)) + } +} + +private final class _UIBindingConstant: _UIBinding, @unchecked Sendable { + let value: Value + init(_ value: Value) { + self.value = value + } + var wrappedValue: Value { + get { self.value } + set {} + } + static func == (lhs: _UIBindingConstant, rhs: _UIBindingConstant) -> Bool { + lhs === rhs + } + func hash(into hasher: inout Hasher) { + if let value = value as? any Hashable { + hasher.combine(AnyHashable(value)) + } else { + hasher.combine(ObjectIdentifier(self)) + } + } +} + +private final class _UIBindingIsPresent: _UIBinding +where Base.Value == Wrapped? +{ + let base: Base + init(base: Base) { + self.base = base + } + var wrappedValue: Bool { + get { + self.base.wrappedValue != nil + } + set { + if !newValue { + self.base.wrappedValue = nil + } + } + } + func hash(into hasher: inout Hasher) { + hasher.combine(base) + } + static func == (lhs: _UIBindingIsPresent, rhs: _UIBindingIsPresent) -> Bool { + lhs.base == rhs.base + } +} + +private final class _UIBindingAppendKeyPath: _UIBinding, @unchecked Sendable { + let base: Base + let keyPath: WritableKeyPath + init(base: Base, keyPath: WritableKeyPath) { + self.base = base + self.keyPath = keyPath + } + var wrappedValue: Value { + get { self.base.wrappedValue[keyPath: self.keyPath] } + set { self.base.wrappedValue[keyPath: self.keyPath] = newValue } + } + static func == (lhs: _UIBindingAppendKeyPath, rhs: _UIBindingAppendKeyPath) -> Bool { + lhs.base == rhs.base && lhs.keyPath == rhs.keyPath + } + func hash(into hasher: inout Hasher) { + hasher.combine(self.base) + hasher.combine(self.keyPath) + } +} + +private final class _UIBindingFromOptional, Value>: _UIBinding, @unchecked Sendable { + var value: Value + let base: Base + init(initialValue: Value, base: Base) { + self.value = initialValue + self.base = base + } + var wrappedValue: Value { + get { + if let value = self.base.wrappedValue { + self.value = value + } + return self.value + } + set { + self.value = newValue + if self.base.wrappedValue != nil { + self.base.wrappedValue = newValue + } + } + } + static func == (lhs: _UIBindingFromOptional, rhs: _UIBindingFromOptional) -> Bool { + lhs.base == rhs.base + } + func hash(into hasher: inout Hasher) { + hasher.combine(self.base) + } +} + +private final class _UIBindingToOptional: _UIBinding { + let base: Base + init(base: Base) { + self.base = base + } + var wrappedValue: Base.Value? { + get { self.base.wrappedValue } + set { + guard let newValue else { return } + self.base.wrappedValue = newValue + } + } + static func == (lhs: _UIBindingToOptional, rhs: _UIBindingToOptional) -> Bool { + lhs.base == rhs.base + } + func hash(into hasher: inout Hasher) { + hasher.combine(self.base) + } +} + +//private final class _UIBindingToAnyHashable: _UIBinding +//where Base.Value: Hashable { +// let base: Base +// init(base: Base) { +// self.base = base +// } +// var wrappedValue: AnyHashable { +// get { self.base.wrappedValue } +// set { +// // TODO: Use swift-dependencies to make this precondition testable? +// self.base.wrappedValue = newValue.base as! Base.Value +// } +// } +// static func == (lhs: _UIBindingToAnyHashable, rhs: _UIBindingToAnyHashable) -> Bool { +// lhs.base == rhs.base +// } +// func hash(into hasher: inout Hasher) { +// hasher.combine(self.base) +// } +//} + +private final class _UIBindingEnumToOptionalCase: _UIBinding, @unchecked Sendable +where Base.Value: CasePathable { + let base: Base + let keyPath: KeyPath> + let casePath: AnyCasePath + init(base: Base, keyPath: KeyPath>) { + self.base = base + self.keyPath = keyPath + self.casePath = Base.Value.allCasePaths[keyPath: keyPath] + } + var wrappedValue: Case? { + get { + self.casePath.extract(from: self.base.wrappedValue) + } + set { + guard let newValue, self.casePath.extract(from: self.base.wrappedValue) != nil + else { return } + self.base.wrappedValue = self.casePath.embed(newValue) + } + } + static func == (lhs: _UIBindingEnumToOptionalCase, rhs: _UIBindingEnumToOptionalCase) -> Bool { + lhs.base == rhs.base && lhs.keyPath == rhs.keyPath + } + func hash(into hasher: inout Hasher) { + hasher.combine(self.base) + hasher.combine(self.keyPath) + } +} + +private final class _UIBindingOptionalEnumToCase< + Base: _UIBinding, Enum: CasePathable, Case +>: _UIBinding, @unchecked Sendable { + let base: Base + let keyPath: KeyPath> + let casePath: AnyCasePath + init(base: Base, keyPath: KeyPath>) { + self.base = base + self.keyPath = keyPath + self.casePath = Enum.allCasePaths[keyPath: keyPath] + } + var wrappedValue: Case? { + get { + self.base.wrappedValue.flatMap(self.casePath.extract(from:)) + } + set { + guard self.base.wrappedValue.flatMap(self.casePath.extract(from:)) != nil + else { return } + self.base.wrappedValue = newValue.map(self.casePath.embed) + } + } + static func == (lhs: _UIBindingOptionalEnumToCase, rhs: _UIBindingOptionalEnumToCase) -> Bool { + lhs.base == rhs.base && lhs.keyPath == rhs.keyPath + } + func hash(into hasher: inout Hasher) { + hasher.combine(self.base) + hasher.combine(self.keyPath) + } +} + +extension UIBinding { + init(_ base: UIBinding) + where Value == any RandomAccessCollection & RangeReplaceableCollection { + func open(_ location: some _UIBinding) -> any _UIBinding { + _UIBindingToAnyRangeReplaceableCollection(base: location) + } + self.init(location: open(base.location), animation: base.animation) + } +} + +private final class _UIBindingToAnyRangeReplaceableCollection: _UIBinding +where Base.Value: RandomAccessCollection & RangeReplaceableCollection { + let base: Base + init(base: Base) { + self.base = base + } + var wrappedValue: any RandomAccessCollection & RangeReplaceableCollection { + // _read { yield self.base.wrappedValue } + // _modify { + // var wrappedValue = self.base.wrappedValue as any RangeReplaceableCollection & RandomAccessCollection + // yield &wrappedValue + // self.base.wrappedValue = wrappedValue as! Base.Value + // } + get { self.base.wrappedValue } + set { self.base.wrappedValue = newValue as! Base.Value } + } + static func == ( + lhs: _UIBindingToAnyRangeReplaceableCollection, + rhs: _UIBindingToAnyRangeReplaceableCollection + ) -> Bool { + lhs.base == rhs.base + } + func hash(into hasher: inout Hasher) { + hasher.combine(self.base) + } +} + diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UICollectionView.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UICollectionView.swift new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UICollectionView.swift @@ -0,0 +1 @@ + diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIColorWell.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIColorWell.swift new file mode 100644 index 00000000..ebd29687 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIColorWell.swift @@ -0,0 +1,13 @@ +import UIKit + +@available(iOS 14, *) +extension UIColorWell { + public convenience init(frame: CGRect = .zero, selectedColor: UIBinding) { + self.init(frame: frame) + self.bind(selectedColor: selectedColor) + } + + public func bind(selectedColor: UIBinding) { + self.bind(selectedColor, to: \.selectedColor, for: .valueChanged) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIControl.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIControl.swift new file mode 100644 index 00000000..20f0038d --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIControl.swift @@ -0,0 +1,67 @@ +import ConcurrencyExtras +import UIKit + +@MainActor +protocol _UIControl: UIControl {} + +extension UIControl: _UIControl {} + +extension _UIControl { + @available(iOS 14, *) + public func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath, + for event: UIControl.Event + ) { + self.bind(binding, to: keyPath, for: event, setAnimated: nil) + } + + @available(iOS 14, *) + func bind( + _ binding: UIBinding, + to keyPath: ReferenceWritableKeyPath, + for event: UIControl.Event, + setAnimated: ((Bool) -> Void)? + ) { + self.addAction( + UIAction { [weak self] _ in + guard let self else { return } + binding.wrappedValue = self[keyPath: keyPath] + }, + for: event + ) + // TODO: Should we vendor LockIsolated? + let isSetting = LockIsolated(false) + observe { [weak self] in + guard let self else { return } + isSetting.setValue(true) + defer { isSetting.setValue(false) } + if let setAnimated { + setAnimated(binding.isAnimated || UIAnimation.current != nil) + } else { + self[keyPath: keyPath] = binding.wrappedValue + } + } + self._observations[keyPath] = self.observe( + keyPath + ) { [weak self] _, _ in + guard let self else { return } + if !isSetting.value { + MainActor.assumeIsolated { + binding.wrappedValue = self[keyPath: keyPath] + } + } + } + } + + private var _observations: [AnyKeyPath: NSKeyValueObservation] { + get { + objc_getAssociatedObject(self, observationsKey) as? [AnyKeyPath: NSKeyValueObservation] ?? [:] + } + set { + objc_setAssociatedObject(self, observationsKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +private let observationsKey = malloc(1)! diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIDatePicker.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIDatePicker.swift new file mode 100644 index 00000000..fd890216 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIDatePicker.swift @@ -0,0 +1,13 @@ +import UIKit + +@available(iOS 14, *) +extension UIDatePicker { + public convenience init(frame: CGRect = .zero, date: UIBinding) { + self.init(frame: frame) + self.bind(date: date) + } + + public func bind(date: UIBinding) { + self.bind(date, to: \.date, for: .valueChanged) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIPageControl.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIPageControl.swift new file mode 100644 index 00000000..4434b28b --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIPageControl.swift @@ -0,0 +1,13 @@ +import UIKit + +@available(iOS 14, *) +extension UIPageControl { + public convenience init(frame: CGRect = .zero, currentPage: UIBinding) { + self.init(frame: frame) + self.bind(currentPage: currentPage) + } + + public func bind(currentPage: UIBinding) { + self.bind(currentPage, to: \.currentPage, for: .valueChanged) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UISlider.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UISlider.swift new file mode 100644 index 00000000..0d3048e7 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UISlider.swift @@ -0,0 +1,13 @@ +import UIKit + +@available(iOS 14, *) +extension UISlider { + public convenience init(frame: CGRect = .zero, value: UIBinding) { + self.init(frame: frame) + self.bind(value: value) + } + + public func bind(value: UIBinding) { + self.bind(value, to: \.value, for: .valueChanged) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIStepper.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIStepper.swift new file mode 100644 index 00000000..54382136 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UIStepper.swift @@ -0,0 +1,13 @@ +import UIKit + +@available(iOS 14, *) +extension UIStepper { + public convenience init(frame: CGRect = .zero, value: UIBinding) { + self.init(frame: frame) + self.bind(value: value) + } + + public func bind(value: UIBinding) { + self.bind(value, to: \.value, for: .valueChanged) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UISwitch.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UISwitch.swift new file mode 100644 index 00000000..214da337 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UISwitch.swift @@ -0,0 +1,22 @@ +import UIKit + +@available(iOS 14, *) +extension UISwitch { + public convenience init(frame: CGRect = .zero, isOn: UIBinding) { + self.init(frame: frame) + self.bind(isOn: isOn) + } + + public func bind(isOn: UIBinding) { + self.bind(isOn, to: \.isOn, for: .valueChanged) { [weak self] isAnimated in + self?.setOn(isOn.wrappedValue, animated: isAnimated) + } + self.addAction( + UIAction { [weak self] _ in + guard let self else { return } + isOn.wrappedValue = self.isOn + }, + for: .valueChanged + ) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UITextField.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UITextField.swift new file mode 100644 index 00000000..dfb5e5b7 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Sources/UIKitNavigation/UIView/UIControl/UITextField.swift @@ -0,0 +1,22 @@ +import UIKit + +@available(iOS 14, *) +extension UITextField { +// public convenience init(frame: CGRect = .zero, text: UIBinding) { +// self.init(frame: frame) +// self.bind(text: text) +// } +// +// public func bind(text: UIBinding) { +// self.bind(text, to: \.text, for: .editingChanged) +// } + + public convenience init(frame: CGRect = .zero, text: UIBinding) { + self.init(frame: frame) + self.bind(text: text) + } + + public func bind(text: UIBinding) { + self.bind(UIBinding(text), to: \.text, for: .editingChanged) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/Support.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/Support.swift new file mode 100644 index 00000000..4ea92640 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/Support.swift @@ -0,0 +1,17 @@ +import UIKitNavigation + +extension UIBinding { + @MainActor + init(wrappedValue: Value) { + @UIBindable var wrapper = Wrapper(wrappedValue) + self = $wrapper.value + } +} + +@Perceptible +private final class Wrapper { + var value: Value + init(_ value: Value) { + self.value = value + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UIBindableTests.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UIBindableTests.swift new file mode 100644 index 00000000..cebf6632 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UIBindableTests.swift @@ -0,0 +1,43 @@ +import UIKitNavigation +import XCTest + +@Perceptible +private final class Model { + var text = "" + var id: String { text } +} + +final class UIBindableTests: XCTestCase { + @MainActor + func testDynamicMemberLookupBindable() throws { + @UIBindable var model = Model() + let textBinding = $model.text + XCTAssert(type(of: textBinding) == UIBinding.self) + + model.text = "Blob" + XCTAssertEqual(model.text, "Blob") + XCTAssertEqual(textBinding.wrappedValue, "Blob") + + textBinding.wrappedValue += ", Jr." + XCTAssertEqual(model.text, "Blob, Jr.") + XCTAssertEqual(textBinding.wrappedValue, "Blob, Jr.") + } + + @MainActor + func testEquatable() throws { + let model = Model() + @UIBindable var model1 = model + @UIBindable var model2 = model + XCTAssertEqual($model1, $model2) + XCTAssertEqual($model1.text, $model2.text) + } + + @MainActor + func testEquatableHashable() throws { + let model = Model() + @UIBindable var model1 = model + @UIBindable var model2 = model + XCTAssertEqual($model1.hashValue, $model2.hashValue) + XCTAssertEqual($model1.text.hashValue, $model2.text.hashValue) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UIBindingTests.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UIBindingTests.swift new file mode 100644 index 00000000..42bb6aed --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UIBindingTests.swift @@ -0,0 +1,187 @@ +import UIKitNavigation +import XCTest + +final class UIBindingTests: XCTestCase { + @MainActor + func testInitProjectedValue() throws { + @UIBinding var text = "" + let textBinding = UIBinding(projectedValue: $text) + + text = "Blob" + XCTAssertEqual(text, "Blob") + XCTAssertEqual(textBinding.wrappedValue, "Blob") + + textBinding.wrappedValue += ", Jr." + XCTAssertEqual(text, "Blob, Jr.") + XCTAssertEqual(textBinding.wrappedValue, "Blob, Jr.") + } + + @MainActor + func testOperationFromOptional() throws { + @UIBinding var count: Int? = nil + + XCTAssertNil(UIBinding($count)) + + count = 42 + let unwrappedCountBinding = try XCTUnwrap(UIBinding($count)) + XCTAssertEqual(count, 42) + XCTAssertEqual(unwrappedCountBinding.wrappedValue, 42) + + count? += 1 + XCTAssertEqual(count, 43) + XCTAssertEqual(unwrappedCountBinding.wrappedValue, 43) + + unwrappedCountBinding.wrappedValue += 1 + XCTAssertEqual(count, 44) + XCTAssertEqual(unwrappedCountBinding.wrappedValue, 44) + + count = nil + XCTAssertEqual(count, nil) + XCTAssertEqual(unwrappedCountBinding.wrappedValue, 44) + + unwrappedCountBinding.wrappedValue += 1 + XCTAssertEqual(count, nil) + XCTAssertEqual(unwrappedCountBinding.wrappedValue, 45) + + count = 1729 + XCTAssertEqual(count, 1729) + XCTAssertEqual(unwrappedCountBinding.wrappedValue, 1729) + } + + @MainActor + func testOperationToOptional() { + @UIBinding var count = 0 + + let optionalCountBinding = UIBinding($count) + + count += 1 + XCTAssertEqual(count, 1) + XCTAssertEqual(optionalCountBinding.wrappedValue, 1) + + optionalCountBinding.wrappedValue? += 1 + XCTAssertEqual(count, 2) + XCTAssertEqual(optionalCountBinding.wrappedValue, 2) + + optionalCountBinding.wrappedValue = nil + XCTAssertEqual(count, 2) + XCTAssertEqual(optionalCountBinding.wrappedValue, 2) + } + +// @MainActor +// func testOperationToAnyHashable() { +// @UIBinding var count = 0 +// +// let optionalCountBinding = UIBinding($count) +// XCTAssertEqual(count, 0) +// XCTAssertEqual(optionalCountBinding.wrappedValue, 0) +// +// count += 1 +// XCTAssertEqual(count, 1) +// XCTAssertEqual(optionalCountBinding.wrappedValue, 1) +// +// optionalCountBinding.wrappedValue = 2 +// XCTAssertEqual(count, 2) +// XCTAssertEqual(optionalCountBinding.wrappedValue, 2) +// } + + @MainActor + func testOperationConstant() { + @UIBinding var count: Int + _count = .constant(0) + + count += 1 + XCTAssertEqual(count, 0) + } + + @MainActor + func testDynamicMemberLookupProperty() { + struct User { + var name = "" + } + @UIBinding var user = User() + + let nameBinding = $user.name + + user.name = "Blob" + XCTAssertEqual(user.name, "Blob") + XCTAssertEqual(nameBinding.wrappedValue, "Blob") + + nameBinding.wrappedValue += ", Jr." + XCTAssertEqual(user.name, "Blob, Jr.") + XCTAssertEqual(nameBinding.wrappedValue, "Blob, Jr.") + } + + @MainActor + func testDynamicMemberLookupCase() throws { + struct Failure: Error, Equatable {} + + @UIBinding var result: Result = .success(0) + + XCTAssertNil($result.failure) + + let countBinding = try XCTUnwrap($result.success) + XCTAssertEqual(result, .success(0)) + XCTAssertEqual(countBinding.wrappedValue, 0) + + result = .success(42) + XCTAssertEqual(result, .success(42)) + XCTAssertEqual(countBinding.wrappedValue, 42) + + countBinding.wrappedValue += 1 + XCTAssertEqual(result, .success(43)) + XCTAssertEqual(countBinding.wrappedValue, 43) + + result = .failure(Failure()) + XCTAssertEqual(result, .failure(Failure())) + XCTAssertEqual(countBinding.wrappedValue, 43) + + countBinding.wrappedValue += 1 + XCTAssertEqual(result, .failure(Failure())) + XCTAssertEqual(countBinding.wrappedValue, 44) + + result = .success(1729) + XCTAssertEqual(result, .success(1729)) + XCTAssertEqual(countBinding.wrappedValue, 1729) + } + + @MainActor + func testDynamicMemberLookupOptionalEnumCase() throws { + struct Failure: Error, Equatable {} + + @UIBinding var result: Result? = .success(0) + + XCTAssertNil($result.failure.wrappedValue) + + let countBinding = try XCTUnwrap($result.success) + XCTAssertEqual(result, .success(0)) + XCTAssertEqual(countBinding.wrappedValue, 0) + + result = .success(42) + XCTAssertEqual(result, .success(42)) + XCTAssertEqual(countBinding.wrappedValue, 42) + + countBinding.wrappedValue? += 1 + XCTAssertEqual(result, .success(43)) + XCTAssertEqual(countBinding.wrappedValue, 43) + + countBinding.wrappedValue = nil + XCTAssertNil(result) + XCTAssertNil(countBinding.wrappedValue) + + result = .failure(Failure()) + XCTAssertEqual(result, .failure(Failure())) + XCTAssertNil(countBinding.wrappedValue) + + countBinding.wrappedValue? += 1 + XCTAssertEqual(result, .failure(Failure())) + XCTAssertNil(countBinding.wrappedValue) + + countBinding.wrappedValue = nil + XCTAssertEqual(result, .failure(Failure())) + XCTAssertNil(countBinding.wrappedValue) + + result = .success(1729) + XCTAssertEqual(result, .success(1729)) + XCTAssertEqual(countBinding.wrappedValue, 1729) + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UIControlTests.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UIControlTests.swift new file mode 100644 index 00000000..bda705d7 --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UIControlTests.swift @@ -0,0 +1,125 @@ +import UIKitNavigation +import XCTest + +@available(iOS 16, *) +final class UIControlTests: XCTestCase { + @MainActor + func testColorWell() async throws { + @UIBinding var color: UIColor? = .red + let colorWell = UIColorWell(selectedColor: $color) + XCTAssertEqual(color, .red) + XCTAssertEqual(colorWell.selectedColor, .red) + + color = nil + await Task.yield() + XCTAssertEqual(color, nil) + XCTAssertEqual(colorWell.selectedColor, nil) + + colorWell.selectedColor = .green + XCTAssertEqual(color, .green) + XCTAssertEqual(colorWell.selectedColor, .green) + } + + @MainActor + func testDatePicker() async throws { + @UIBinding var date = Date(timeIntervalSinceReferenceDate: 0) + let datePicker = UIDatePicker(date: $date) + XCTAssertEqual(date, Date(timeIntervalSinceReferenceDate: 0)) + XCTAssertEqual(datePicker.date, Date(timeIntervalSinceReferenceDate: 0)) + + date = Date(timeIntervalSinceReferenceDate: 42) + await Task.yield() + XCTAssertEqual(date, Date(timeIntervalSinceReferenceDate: 42)) + XCTAssertEqual(datePicker.date, Date(timeIntervalSinceReferenceDate: 42)) + + datePicker.date = Date(timeIntervalSince1970: 0) + XCTAssertEqual(date, Date(timeIntervalSince1970: 0)) + XCTAssertEqual(datePicker.date, Date(timeIntervalSince1970: 0)) + } + + @MainActor + func testPageControl() async throws { + @UIBinding var page = 0 + let pageControl = UIPageControl(currentPage: $page) + pageControl.numberOfPages = 3 + XCTAssertEqual(page, 0) + XCTAssertEqual(pageControl.currentPage, 0) + + page += 1 + await Task.yield() + XCTAssertEqual(page, 1) + XCTAssertEqual(pageControl.currentPage, 1) + + pageControl.currentPage += 1 + XCTAssertEqual(page, 2) + XCTAssertEqual(pageControl.currentPage, 2) + } + + @MainActor + func testSlider() async throws { + @UIBinding var value: Float = 0 + let slider = UISlider(value: $value) + XCTAssertEqual(value, 0) + XCTAssertEqual(slider.value, 0) + + value = 0.5 + await Task.yield() + XCTAssertEqual(value, 0.5) + XCTAssertEqual(slider.value, 0.5) + + slider.value = 1 + XCTAssertEqual(value, 1) + XCTAssertEqual(slider.value, 1) + } + + @MainActor + func testStepper() async throws { + @UIBinding var value = 0.0 + let stepper = UIStepper(value: $value) + XCTAssertEqual(value, 0) + XCTAssertEqual(stepper.value, 0) + + value = 0.5 + await Task.yield() + XCTAssertEqual(value, 0.5) + XCTAssertEqual(stepper.value, 0.5) + + stepper.value = 1 + XCTAssertEqual(value, 1) + XCTAssertEqual(stepper.value, 1) + } + + @MainActor + func testSwitch() async throws { + @UIBinding var isOn = false + let `switch` = UISwitch(isOn: $isOn) + XCTAssertFalse(isOn) + XCTAssertFalse(`switch`.isOn) + + isOn = true + await Task.yield() + XCTAssertTrue(isOn) + XCTAssertTrue(`switch`.isOn) + + `switch`.isOn = false + XCTAssertFalse(isOn) + XCTAssertFalse(`switch`.isOn) + } + + @MainActor + func testTextField() async throws { + @UIBinding var text = "" + let textField = UITextField(text: $text) + XCTAssertEqual(text, "") + XCTAssertEqual(textField.text, "") + + text += "Blob" + await Task.yield() + XCTAssertEqual(text, "Blob") + XCTAssertEqual(textField.text, "Blob") + + textField.text? += ", Jr." + XCTAssertEqual(text, "Blob, Jr.") + XCTAssertEqual(textField.text, "Blob, Jr.") + } +} diff --git a/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UINavigationPathTests.swift b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UINavigationPathTests.swift new file mode 100644 index 00000000..d02e977f --- /dev/null +++ b/0289-modern-uikit-pt9/WiFiSettings/swiftui-navigation/Tests/UIKitNavigationTests/UINavigationPathTests.swift @@ -0,0 +1,80 @@ +import UIKitNavigation +import SwiftUI +import XCTest + +final class UINavigationPathTests: XCTestCase { + func testCodable() throws { + guard #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) else { return } + + var path = UINavigationPath() + path.append("hello") + path.append(42) + path.append(true) + path.append(User(id: 42, name: "Blob")) + + let codable = try XCTUnwrap(path.codable) + let data = try JSONEncoder().encode(codable) + let decoded = try JSONDecoder().decode(UINavigationPath.CodableRepresentation.self, from: data) + XCTAssertEqual(path, UINavigationPath(decoded)) + + struct NotCodable: Hashable {} + + path.append(NotCodable()) + XCTAssertNil(path.codable) + + path.removeLast() + XCTAssertNotNil(path.codable) + XCTAssertEqual(codable, path.codable) + } + + public struct User: Codable, Hashable { + let id: Int + var name: String + } +} + +func _typeByName_Env(_ name: String) -> Any.Type? { + let nameUTF8 = Array(name.utf8) + return nameUTF8.withUnsafeBufferPointer { (nameUTF8) in + return _getTypeByMangledNameInEnvironment( + nameUTF8.baseAddress!, + UInt(nameUTF8.endIndex), + genericEnvironment: nil, + genericArguments: nil + ) + } +} + +func _typeByName_Ctx(_ name: String) -> Any.Type? { + let nameUTF8 = Array(name.utf8) + return nameUTF8.withUnsafeBufferPointer { (nameUTF8) in + return _getTypeByMangledNameInContext( + nameUTF8.baseAddress!, + UInt(nameUTF8.endIndex), + genericContext: nil, + genericArguments: nil + ) + } +} +// +//@_silgen_name("swift_stdlib_getTypeByMangledNameUntrusted") +//internal func _getTypeByMangledNameUntrusted( +// _ name: UnsafePointer, +// _ nameLength: UInt) +// -> Any.Type? + +@_silgen_name("swift_getTypeByMangledNameInEnvironment") +public func _getTypeByMangledNameInEnvironment( + _ name: UnsafePointer, + _ nameLength: UInt, + genericEnvironment: UnsafeRawPointer?, + genericArguments: UnsafeRawPointer?) + -> Any.Type? + +@_silgen_name("swift_getTypeByMangledNameInContext") +public func _getTypeByMangledNameInContext( + _ name: UnsafePointer, + _ nameLength: UInt, + genericContext: UnsafeRawPointer?, + genericArguments: UnsafeRawPointer?) + -> Any.Type? diff --git a/README.md b/README.md index e8b01d1b..5817ed82 100644 --- a/README.md +++ b/README.md @@ -289,3 +289,5 @@ This repository is the home of code written on episodes of [Point-Free](https:// 1. [Modern UIKit: Unified Navigation](0285-modern-uikit-pt5) 1. [Modern UIKit: Tree-based Navigation](0286-modern-uikit-pt6) 1. [Modern UIKit: Stack Navigation, Part 1](0287-modern-uikit-pt7) +1. [Modern UIKit: Stack Navigation, Part 2](0288-modern-uikit-pt8) +1. [Modern UIKit: UIControl Bindings](0289-modern-uikit-pt9)