diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..34a9fdd9 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: ra1028 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..86524e5a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: File a bug report +title: "[Bug]: " +labels: ["bug"] +body: + - type: checkboxes + attributes: + label: Checklist + options: + - label: This is not a bug caused by platform. + required: true + - label: Reviewed the README and documentation. + required: true + - label: Checked existing issues & PRs to ensure not duplicated. + required: true + + - type: textarea + attributes: + label: What happened? + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + validations: + required: true + + - type: textarea + attributes: + label: Reproduction Steps + value: | + 1. + 2. + 3. + validations: + required: true + + - type: input + attributes: + label: Swift Version + validations: + required: true + + - type: input + attributes: + label: Library Version + validations: + required: true + + - type: dropdown + attributes: + label: Platform + multiple: true + options: + - iOS + - tvOS + - macOS + - watchOS + + - type: textarea + attributes: + label: Scrrenshot/Video/Gif + placeholder: | + Drag and drop screenshot, video, or gif here if you have. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3ba13e0c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/documentation_request.yml b/.github/ISSUE_TEMPLATE/documentation_request.yml new file mode 100644 index 00000000..b0e37797 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation_request.yml @@ -0,0 +1,27 @@ +name: Documentation Request +description: Suggest a new doc/example or ask a question about an existing one +title: "[Doc Request]: " +labels: ["documentation"] +body: + - type: checkboxes + attributes: + label: Checklist + options: + - label: Reviewed the README and documentation. + required: true + - label: Confirmed that this is uncovered by existing docs or examples. + required: true + - label: Checked existing issues & PRs to ensure not duplicated. + required: true + + - type: textarea + attributes: + label: Description + placeholder: Describe what the scenario you think is uncovered by the existing ones and why you think it should be covered. + validations: + required: true + + - type: textarea + attributes: + label: Motivation & Context + placeholder: Feel free to describe any additional context, such as why you thought the scenario should be covered. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..53ae7e93 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,42 @@ +name: Feature Request +description: Suggest a new idea of feature +title: "[Feat Request]: " +labels: ["enhancement"] +body: + - type: checkboxes + attributes: + label: Checklist + options: + - label: Reviewed the README and documentation. + required: true + - label: Checked existing issues & PRs to ensure not duplicated. + required: true + + - type: textarea + attributes: + label: Description + placeholder: Describe the feature that you want to propose. + validations: + required: true + + - type: textarea + attributes: + label: Example Use Case + placeholder: Describe an example use case that the feature is useful. + validations: + required: true + + - type: textarea + attributes: + label: Alternative Solution + placeholder: Describe alternatives solutions that you've considered. + + - type: textarea + attributes: + label: Proposed Solution + placeholder: Describe how we can achieve the feature you'd like to suggest. + + - type: textarea + attributes: + label: Motivation & Context + placeholder: Feel free to describe any additional context, such as why you want to suggest this feature. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..946ebb3f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Pull Request Type + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactoring +- [ ] Documentation update +- [ ] Chore + +## Issue for this PR + +Link: + +## Description + +## Motivation and Context + +## Impact on Existing Code + +## Screenshot/Video/Gif diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..5e129abe --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,28 @@ +# https://github.com/actions/virtual-environments + +name: docs + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + test: + name: Test + runs-on: macos-12 + strategy: + matrix: + xcode_version: + - 13.3 + env: + DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode_version }}.app + steps: + - uses: actions/checkout@v2 + - name: Build docs + run: make docs + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..5d294eb9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +# https://github.com/actions/virtual-environments + +name: test + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + test: + name: Test + runs-on: macos-12 + strategy: + matrix: + xcode_version: + - 13.3 + env: + DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode_version }}.app + steps: + - uses: actions/checkout@v2 + - name: Show environments + run: | + swift --version + xcodebuild -version + - name: Test library + run: make test-library + - name: Test examples + run: make test-examples + + validation: + name: Validation + runs-on: macos-12 + env: + DEVELOPER_DIR: /Applications/Xcode_13.3.app + steps: + - uses: actions/checkout@v2 + - name: Validate lint + run: make lint + - name: Validate format + run: | + make format + if [ -n "$(git status --porcelain)" ]; then echo "Make sure that the code is formated by 'make format'."; exit 1; fi + - name: Validate example project + run: | + make proj + if [ -n "$(git status --porcelain)" ]; then echo "Make sure that 'Examples/App.xcodeproj' is formated by 'make proj'."; exit 1; fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..41192cc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +.DS_Store +*/build/* +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +profile +*.moved-aside +DerivedData +.idea/ +*.hmap +*.xccheckout +*.xcuserstate +build/ +archive/ +*.xcframework +.swiftpm +.build +docs diff --git a/.swift-format b/.swift-format new file mode 100644 index 00000000..5f90883c --- /dev/null +++ b/.swift-format @@ -0,0 +1,55 @@ +{ + "version": 1, + "indentation": { + "spaces": 4 + }, + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentConditionalCompilationBlocks": true, + "indentSwitchCaseLabels": false, + "lineBreakAroundMultilineExpressionChainComponents": false, + "lineBreakBeforeControlFlowKeywords": true, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": true, + "lineLength": 150, + "maximumBlankLines": 1, + "prioritizeKeepingFunctionOutputTogether": false, + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": true, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": true, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": false, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": false, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoVoidReturnOnFunctionSignature": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": false, + "OrderedImports": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "UseEarlyExits": false, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": true + } +} diff --git a/Examples/App.xcodeproj/project.pbxproj b/Examples/App.xcodeproj/project.pbxproj new file mode 100644 index 00000000..c51fbe31 --- /dev/null +++ b/Examples/App.xcodeproj/project.pbxproj @@ -0,0 +1,665 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + objects = { + +/* Begin PBXBuildFile section */ + 1005C4AC9120BA9DA0689543 /* CrossPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452C2B75E438AB4621F094DF /* CrossPlatform.swift */; }; + 1107A8B553508AB86E1F5BBB /* CrossPlatformApp in Frameworks */ = {isa = PBXBuildFile; productRef = A708E16BF51AE12E22A59212 /* CrossPlatformApp */; }; + 1459D71BEC087400D12B5A1A /* CrossPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452C2B75E438AB4621F094DF /* CrossPlatform.swift */; }; + 22426BE1FC37FDA1A0F3DDE4 /* iOSApp in Frameworks */ = {isa = PBXBuildFile; productRef = DED302B138966E5FE400892D /* iOSApp */; }; + 5D37D62CADEB9C38C6530412 /* CrossPlatformApp in Frameworks */ = {isa = PBXBuildFile; productRef = 7EB322283CC635090E3D4ABA /* CrossPlatformApp */; }; + 83B07647E2C4BC457F6A4AD4 /* iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA500C4AECAAC64CB3CF447 /* iOS.swift */; }; + D23E13D8439A165AB04F574E /* CrossPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452C2B75E438AB4621F094DF /* CrossPlatform.swift */; }; + FC3C5D2718645D5C1288E80E /* CrossPlatformApp in Frameworks */ = {isa = PBXBuildFile; productRef = E6D0E7940B1382E3FCA5A833 /* CrossPlatformApp */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1A46847C164D474B7B747BC0 /* iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 452C2B75E438AB4621F094DF /* CrossPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossPlatform.swift; sourceTree = ""; }; + 553572CFE97AE83E420D2F2A /* CrossPlatform.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = CrossPlatform.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7700279A8EA052C81A41C049 /* CrossPlatform */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CrossPlatform; path = Packages/CrossPlatform; sourceTree = SOURCE_ROOT; }; + A62E7924E6736516B102BF53 /* iOS */ = {isa = PBXFileReference; lastKnownFileType = folder; name = iOS; path = Packages/iOS; sourceTree = SOURCE_ROOT; }; + B0EB7D420C3AF1A10913B76C /* CrossPlatform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CrossPlatform.app; sourceTree = BUILT_PRODUCTS_DIR; }; + BF4C75EF9327ACB06A605EF0 /* CrossPlatform.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = CrossPlatform.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CDA500C4AECAAC64CB3CF447 /* iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOS.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 396884BFDDC5D978330A4B37 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FC3C5D2718645D5C1288E80E /* CrossPlatformApp in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3EAE4F08F648865C06EDCA89 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5D37D62CADEB9C38C6530412 /* CrossPlatformApp in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 616B90122D0F767296E406C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1107A8B553508AB86E1F5BBB /* CrossPlatformApp in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FC0E41DC167719D0F263590D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 22426BE1FC37FDA1A0F3DDE4 /* iOSApp in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4102E3E068508DD683953C7D = { + isa = PBXGroup; + children = ( + 443E9F32F0EC65EEBF466F3B /* App */, + A09E2501204CB86B4B2B6562 /* Packages */, + E9C3DB3EC6D7655028A9C0D3 /* Products */, + ); + sourceTree = ""; + }; + 443E9F32F0EC65EEBF466F3B /* App */ = { + isa = PBXGroup; + children = ( + 452C2B75E438AB4621F094DF /* CrossPlatform.swift */, + CDA500C4AECAAC64CB3CF447 /* iOS.swift */, + ); + path = App; + sourceTree = ""; + }; + A09E2501204CB86B4B2B6562 /* Packages */ = { + isa = PBXGroup; + children = ( + 7700279A8EA052C81A41C049 /* CrossPlatform */, + A62E7924E6736516B102BF53 /* iOS */, + ); + name = Packages; + sourceTree = SOURCE_ROOT; + }; + E9C3DB3EC6D7655028A9C0D3 /* Products */ = { + isa = PBXGroup; + children = ( + BF4C75EF9327ACB06A605EF0 /* CrossPlatform.app */, + B0EB7D420C3AF1A10913B76C /* CrossPlatform.app */, + 553572CFE97AE83E420D2F2A /* CrossPlatform.app */, + 1A46847C164D474B7B747BC0 /* iOS.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0B9FD0BB6D9928064433FF9B /* CrossPlatform_macOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = D736F6769FE19C126BB0E570 /* Build configuration list for PBXNativeTarget "CrossPlatform_macOS" */; + buildPhases = ( + 99DA4BE5E7DD2B2421648BF9 /* Sources */, + 396884BFDDC5D978330A4B37 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CrossPlatform_macOS; + packageProductDependencies = ( + E6D0E7940B1382E3FCA5A833 /* CrossPlatformApp */, + ); + productName = CrossPlatform_macOS; + productReference = B0EB7D420C3AF1A10913B76C /* CrossPlatform.app */; + productType = "com.apple.product-type.application"; + }; + 2FD64E9E837D5E32A289BD7E /* CrossPlatform_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 57C40505120062DC9D984E56 /* Build configuration list for PBXNativeTarget "CrossPlatform_iOS" */; + buildPhases = ( + 0CA6F0A722C314119B1C42B7 /* Sources */, + 3EAE4F08F648865C06EDCA89 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CrossPlatform_iOS; + packageProductDependencies = ( + 7EB322283CC635090E3D4ABA /* CrossPlatformApp */, + ); + productName = CrossPlatform_iOS; + productReference = BF4C75EF9327ACB06A605EF0 /* CrossPlatform.app */; + productType = "com.apple.product-type.application"; + }; + BCC0364CCD17BA05FD5B35BD /* CrossPlatform_tvOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5692190C256999FACA68657C /* Build configuration list for PBXNativeTarget "CrossPlatform_tvOS" */; + buildPhases = ( + 45830C9A5E17EEEBAD5BB38C /* Sources */, + 616B90122D0F767296E406C2 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CrossPlatform_tvOS; + packageProductDependencies = ( + A708E16BF51AE12E22A59212 /* CrossPlatformApp */, + ); + productName = CrossPlatform_tvOS; + productReference = 553572CFE97AE83E420D2F2A /* CrossPlatform.app */; + productType = "com.apple.product-type.application"; + }; + E002849B581BDA9D70A3A4C9 /* iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = C721B957640FFBE59EA77850 /* Build configuration list for PBXNativeTarget "iOS" */; + buildPhases = ( + 8A0E1982AB0E2C9C11C649C4 /* Sources */, + FC0E41DC167719D0F263590D /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iOS; + packageProductDependencies = ( + DED302B138966E5FE400892D /* iOSApp */, + ); + productName = iOS; + productReference = 1A46847C164D474B7B747BC0 /* iOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 6B590BF25178DC7D824D09CE /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1200; + TargetAttributes = { + 0B9FD0BB6D9928064433FF9B = { + ProvisioningStyle = Manual; + }; + 2FD64E9E837D5E32A289BD7E = { + ProvisioningStyle = Manual; + }; + BCC0364CCD17BA05FD5B35BD = { + ProvisioningStyle = Manual; + }; + E002849B581BDA9D70A3A4C9 = { + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = B3FD05C59F197F398A0B04AB /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 10.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 4102E3E068508DD683953C7D; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2FD64E9E837D5E32A289BD7E /* CrossPlatform_iOS */, + 0B9FD0BB6D9928064433FF9B /* CrossPlatform_macOS */, + BCC0364CCD17BA05FD5B35BD /* CrossPlatform_tvOS */, + E002849B581BDA9D70A3A4C9 /* iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 0CA6F0A722C314119B1C42B7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1459D71BEC087400D12B5A1A /* CrossPlatform.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 45830C9A5E17EEEBAD5BB38C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D23E13D8439A165AB04F574E /* CrossPlatform.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8A0E1982AB0E2C9C11C649C4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 83B07647E2C4BC457F6A4AD4 /* iOS.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 99DA4BE5E7DD2B2421648BF9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1005C4AC9120BA9DA0689543 /* CrossPlatform.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 18D388D490A6478296FDA765 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + "EXCLUDED_ARCHS[sdk=appletv*]" = x86_64; + "EXCLUDED_ARCHS[sdk=appletvsimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + INFOPLIST_FILE = "App/Info-iOS.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_SWIFT_FLAGS = ( + "-Xfrontend", + "-enable-actor-data-race-checks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.ryo.swiftui-atomic-architecture.examples.iOS"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2D6F7CC6515439E3E37A553A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + "EXCLUDED_ARCHS[sdk=appletv*]" = x86_64; + "EXCLUDED_ARCHS[sdk=appletvsimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + INFOPLIST_FILE = "App/Info-CrossPlatform_macOS.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + OTHER_SWIFT_FLAGS = ( + "-Xfrontend", + "-enable-actor-data-race-checks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.ryo.swiftui-atomic-architecture.examples.CrossPlatform-macOS"; + SDKROOT = macosx; + }; + name = Debug; + }; + 506F696D4250374A465EBDA4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + "EXCLUDED_ARCHS[sdk=appletv*]" = x86_64; + "EXCLUDED_ARCHS[sdk=appletvsimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + INFOPLIST_FILE = "App/Info-CrossPlatform_iOS.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_SWIFT_FLAGS = ( + "-Xfrontend", + "-enable-actor-data-race-checks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.ryo.swiftui-atomic-architecture.examples.CrossPlatform-iOS"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 529068892ED8A16BD72F7E92 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + "EXCLUDED_ARCHS[sdk=appletv*]" = x86_64; + "EXCLUDED_ARCHS[sdk=appletvsimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + INFOPLIST_FILE = "App/Info-iOS.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_SWIFT_FLAGS = ( + "-Xfrontend", + "-enable-actor-data-race-checks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.ryo.swiftui-atomic-architecture.examples.iOS"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 5C9EF0E6AF4F9491454DE177 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 96AC891236E557757EB62931 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + "EXCLUDED_ARCHS[sdk=appletv*]" = x86_64; + "EXCLUDED_ARCHS[sdk=appletvsimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + INFOPLIST_FILE = "App/Info-CrossPlatform_macOS.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + OTHER_SWIFT_FLAGS = ( + "-Xfrontend", + "-enable-actor-data-race-checks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.ryo.swiftui-atomic-architecture.examples.CrossPlatform-macOS"; + SDKROOT = macosx; + }; + name = Release; + }; + AA68C62E73C222617BA55DCD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + "EXCLUDED_ARCHS[sdk=appletv*]" = x86_64; + "EXCLUDED_ARCHS[sdk=appletvsimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + INFOPLIST_FILE = "App/Info-CrossPlatform_tvOS.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_SWIFT_FLAGS = ( + "-Xfrontend", + "-enable-actor-data-race-checks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.ryo.swiftui-atomic-architecture.examples.CrossPlatform-tvOS"; + SDKROOT = appletvos; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 14.0; + }; + name = Debug; + }; + EF7DEC50716629B4CAF47EAF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + "EXCLUDED_ARCHS[sdk=appletv*]" = x86_64; + "EXCLUDED_ARCHS[sdk=appletvsimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + INFOPLIST_FILE = "App/Info-CrossPlatform_tvOS.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_SWIFT_FLAGS = ( + "-Xfrontend", + "-enable-actor-data-race-checks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.ryo.swiftui-atomic-architecture.examples.CrossPlatform-tvOS"; + SDKROOT = appletvos; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 14.0; + }; + name = Release; + }; + F61063B78755D98B1B9C3697 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + 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; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + FF655E13C80540E744681F8B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + "EXCLUDED_ARCHS[sdk=appletv*]" = x86_64; + "EXCLUDED_ARCHS[sdk=appletvsimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + INFOPLIST_FILE = "App/Info-CrossPlatform_iOS.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_SWIFT_FLAGS = ( + "-Xfrontend", + "-enable-actor-data-race-checks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.ryo.swiftui-atomic-architecture.examples.CrossPlatform-iOS"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 5692190C256999FACA68657C /* Build configuration list for PBXNativeTarget "CrossPlatform_tvOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA68C62E73C222617BA55DCD /* Debug */, + EF7DEC50716629B4CAF47EAF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 57C40505120062DC9D984E56 /* Build configuration list for PBXNativeTarget "CrossPlatform_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 506F696D4250374A465EBDA4 /* Debug */, + FF655E13C80540E744681F8B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + B3FD05C59F197F398A0B04AB /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F61063B78755D98B1B9C3697 /* Debug */, + 5C9EF0E6AF4F9491454DE177 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C721B957640FFBE59EA77850 /* Build configuration list for PBXNativeTarget "iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 18D388D490A6478296FDA765 /* Debug */, + 529068892ED8A16BD72F7E92 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D736F6769FE19C126BB0E570 /* Build configuration list for PBXNativeTarget "CrossPlatform_macOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2D6F7CC6515439E3E37A553A /* Debug */, + 96AC891236E557757EB62931 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7EB322283CC635090E3D4ABA /* CrossPlatformApp */ = { + isa = XCSwiftPackageProductDependency; + productName = CrossPlatformApp; + }; + A708E16BF51AE12E22A59212 /* CrossPlatformApp */ = { + isa = XCSwiftPackageProductDependency; + productName = CrossPlatformApp; + }; + DED302B138966E5FE400892D /* iOSApp */ = { + isa = XCSwiftPackageProductDependency; + productName = iOSApp; + }; + E6D0E7940B1382E3FCA5A833 /* CrossPlatformApp */ = { + isa = XCSwiftPackageProductDependency; + productName = CrossPlatformApp; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 6B590BF25178DC7D824D09CE /* Project object */; +} diff --git a/Examples/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Examples/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/Examples/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/App.xcodeproj/xcshareddata/xcschemes/iOS.xcscheme b/Examples/App.xcodeproj/xcshareddata/xcschemes/iOS.xcscheme new file mode 100644 index 00000000..49f478cb --- /dev/null +++ b/Examples/App.xcodeproj/xcshareddata/xcschemes/iOS.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/App/CrossPlatform.swift b/Examples/App/CrossPlatform.swift new file mode 100644 index 00000000..39b7babb --- /dev/null +++ b/Examples/App/CrossPlatform.swift @@ -0,0 +1,4 @@ +import CrossPlatformApp + +@main +extension CrossPlatformApp {} diff --git a/Examples/App/Info-CrossPlatform_iOS.plist b/Examples/App/Info-CrossPlatform_iOS.plist new file mode 100644 index 00000000..ae3204f8 --- /dev/null +++ b/Examples/App/Info-CrossPlatform_iOS.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Atoms + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSLocationWhenInUseUsageDescription + Example Usage + NSMicrophoneUsageDescription + Example Usage + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/Examples/App/Info-CrossPlatform_macOS.plist b/Examples/App/Info-CrossPlatform_macOS.plist new file mode 100644 index 00000000..ae3204f8 --- /dev/null +++ b/Examples/App/Info-CrossPlatform_macOS.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Atoms + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSLocationWhenInUseUsageDescription + Example Usage + NSMicrophoneUsageDescription + Example Usage + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/Examples/App/Info-CrossPlatform_tvOS.plist b/Examples/App/Info-CrossPlatform_tvOS.plist new file mode 100644 index 00000000..ae3204f8 --- /dev/null +++ b/Examples/App/Info-CrossPlatform_tvOS.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Atoms + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSLocationWhenInUseUsageDescription + Example Usage + NSMicrophoneUsageDescription + Example Usage + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/Examples/App/Info-iOS.plist b/Examples/App/Info-iOS.plist new file mode 100644 index 00000000..ae3204f8 --- /dev/null +++ b/Examples/App/Info-iOS.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Atoms + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSLocationWhenInUseUsageDescription + Example Usage + NSMicrophoneUsageDescription + Example Usage + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/Examples/App/iOS.swift b/Examples/App/iOS.swift new file mode 100644 index 00000000..e95738b0 --- /dev/null +++ b/Examples/App/iOS.swift @@ -0,0 +1,4 @@ +import iOSApp + +@main +extension iOSApp {} diff --git a/Examples/Packages/CrossPlatform/Package.swift b/Examples/Packages/CrossPlatform/Package.swift new file mode 100644 index 00000000..435e8f8f --- /dev/null +++ b/Examples/Packages/CrossPlatform/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.6 + +import PackageDescription + +let atoms = Target.Dependency.product(name: "Atoms", package: "swiftui-atomic-architecture") + +let package = Package( + name: "CrossPlatformExamples", + platforms: [ + .iOS(.v14), + .macOS(.v11), + .tvOS(.v14), + .watchOS(.v7), + ], + products: [ + .library(name: "CrossPlatformApp", targets: ["CrossPlatformApp"]) + ], + dependencies: [ + .package(path: "../../..") + ], + targets: [ + .target( + name: "CrossPlatformApp", + dependencies: [ + "ExampleCounter", + "ExampleTodo", + ] + ), + .target(name: "ExampleCounter", dependencies: [atoms]), + .testTarget(name: "ExampleCounterTests", dependencies: ["ExampleCounter"]), + .target(name: "ExampleTodo", dependencies: [atoms]), + .testTarget(name: "ExampleTodoTests", dependencies: ["ExampleTodo"]), + ] +) diff --git a/Examples/Packages/CrossPlatform/Sources/CrossPlatformApp/CrossPlatformApp.swift b/Examples/Packages/CrossPlatform/Sources/CrossPlatformApp/CrossPlatformApp.swift new file mode 100644 index 00000000..ea7467ce --- /dev/null +++ b/Examples/Packages/CrossPlatform/Sources/CrossPlatformApp/CrossPlatformApp.swift @@ -0,0 +1,36 @@ +import Atoms +import ExampleCounter +import ExampleTodo +import SwiftUI + +// swift-format-ignore: AllPublicDeclarationsHaveDocumentation +public struct CrossPlatformApp: App { + public init() {} + + public var body: some Scene { + WindowGroup { + AtomRoot { + NavigationView { + List { + NavigationLink("🔢 Counter") { + ExampleCounter() + } + + NavigationLink("📋 Todo") { + ExampleTodo() + } + } + .navigationTitle("Examples") + + #if os(iOS) + .listStyle(.insetGrouped) + #endif + } + + #if os(iOS) + .navigationViewStyle(.stack) + #endif + } + } + } +} diff --git a/Examples/Packages/CrossPlatform/Sources/ExampleCounter/ExampleCounter.swift b/Examples/Packages/CrossPlatform/Sources/ExampleCounter/ExampleCounter.swift new file mode 100644 index 00000000..06cfa34b --- /dev/null +++ b/Examples/Packages/CrossPlatform/Sources/ExampleCounter/ExampleCounter.swift @@ -0,0 +1,56 @@ +import Atoms +import SwiftUI + +struct CounterAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Int { + 0 + } +} + +struct CounterScreen: View { + @Watch(CounterAtom()) + var count + + var body: some View { + VStack { + Text("Count: \(count)").font(.largeTitle) + CountStepper() + } + .fixedSize() + .navigationTitle("Counter") + } +} + +struct CountStepper: View { + @WatchState(CounterAtom()) + var count + + var body: some View { + #if os(tvOS) || os(watchOS) + HStack { + Button("-") { count -= 1 } + Button("+") { count += 1 } + } + #else + Stepper(value: $count) {} + .labelsHidden() + #endif + } +} + +// swift-format-ignore: AllPublicDeclarationsHaveDocumentation +public struct ExampleCounter: View { + public init() {} + + public var body: some View { + CounterScreen() + } +} + +struct CounterScreen_Preview: PreviewProvider { + static var previews: some View { + AtomRoot { + CounterScreen() + } + } +} diff --git a/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Atoms.swift b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Atoms.swift new file mode 100644 index 00000000..e69e90cb --- /dev/null +++ b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Atoms.swift @@ -0,0 +1,53 @@ +import Atoms +import Foundation + +struct TodosAtom: StateAtom, Hashable, KeepAlive { + func defaultValue(context: Context) -> [Todo] { + [ + Todo(id: UUID(), text: "Add a new todo", isCompleted: true), + Todo(id: UUID(), text: "Complete a todo", isCompleted: false), + Todo(id: UUID(), text: "Swipe to delete a todo", isCompleted: false), + ] + } +} + +struct FilterAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Filter { + .all + } +} + +struct FilteredTodosAtom: ValueAtom, Hashable { + func value(context: Context) -> [Todo] { + let filter = context.watch(FilterAtom()) + let todos = context.watch(TodosAtom()) + + switch filter { + case .all: + return todos + + case .completed: + return todos.filter(\.isCompleted) + + case .uncompleted: + return todos.filter { !$0.isCompleted } + } + } +} + +struct StatsAtom: ValueAtom, Hashable { + func value(context: Context) -> Stats { + let todos = context.watch(TodosAtom()) + let total = todos.count + let totalCompleted = todos.filter(\.isCompleted).count + let totalUncompleted = todos.filter { !$0.isCompleted }.count + let percentCompleted = total <= 0 ? 0 : (Double(totalCompleted) / Double(total)) + + return Stats( + total: total, + totalCompleted: totalCompleted, + totalUncompleted: totalUncompleted, + percentCompleted: percentCompleted + ) + } +} diff --git a/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Entities.swift b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Entities.swift new file mode 100644 index 00000000..9e30a51c --- /dev/null +++ b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Entities.swift @@ -0,0 +1,20 @@ +import Foundation + +struct Todo: Hashable { + var id: UUID + var text: String + var isCompleted: Bool +} + +enum Filter: CaseIterable, Hashable { + case all + case completed + case uncompleted +} + +struct Stats: Equatable { + let total: Int + let totalCompleted: Int + let totalUncompleted: Int + let percentCompleted: Double +} diff --git a/Examples/Packages/CrossPlatform/Sources/ExampleTodo/ExampleTodo.swift b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/ExampleTodo.swift new file mode 100644 index 00000000..9d82cb82 --- /dev/null +++ b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/ExampleTodo.swift @@ -0,0 +1,10 @@ +import SwiftUI + +// swift-format-ignore: AllPublicDeclarationsHaveDocumentation +public struct ExampleTodo: View { + public init() {} + + public var body: some View { + TodoListScreen() + } +} diff --git a/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Screens.swift b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Screens.swift new file mode 100644 index 00000000..18ddd063 --- /dev/null +++ b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Screens.swift @@ -0,0 +1,50 @@ +import Atoms +import SwiftUI + +struct TodoListScreen: View { + @Watch(FilteredTodosAtom()) + var filteredTodos + + @ViewContext + var context + + var body: some View { + List { + Section { + TodoStats() + TodoCreator() + } + + Section { + TodoFilters() + + ForEach(filteredTodos, id: \.id) { todo in + TodoItem(todo: todo) + } + .onDelete { indexSet in + let todos = TodosAtom() + let indices = indexSet.compactMap { index in + context[todos].firstIndex(of: filteredTodos[index]) + } + context[todos].remove(atOffsets: IndexSet(indices)) + } + } + } + .navigationTitle("Todo") + + #if os(iOS) + .listStyle(.insetGrouped) + .buttonStyle(.borderless) + #elseif !os(tvOS) + .buttonStyle(.borderless) + #endif + } +} + +struct TodoListScreen_Preview: PreviewProvider { + static var previews: some View { + AtomRoot { + TodoListScreen() + } + } +} diff --git a/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Views.swift b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Views.swift new file mode 100644 index 00000000..f742400b --- /dev/null +++ b/Examples/Packages/CrossPlatform/Sources/ExampleTodo/Views.swift @@ -0,0 +1,119 @@ +import Atoms +import SwiftUI + +struct TodoStats: View { + @Watch(StatsAtom()) + var stats + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + stat("Total", "\(stats.total)") + stat("Completed", "\(stats.totalCompleted)") + stat("Uncompleted", "\(stats.totalUncompleted)") + stat("Percent Completed", "\(Int(stats.percentCompleted * 100))%") + } + .padding(.vertical) + } + + func stat(_ title: String, _ value: String) -> some View { + HStack { + Text(title) + Text(":") + Spacer() + Text(value) + } + } +} + +struct TodoFilters: View { + @WatchState(FilterAtom()) + var filter + + var body: some View { + Picker("Filter", selection: $filter) { + ForEach(Filter.allCases, id: \.self) { filter in + switch filter { + case .all: + Text("All") + + case .completed: + Text("Completed") + + case .uncompleted: + Text("Uncompleted") + } + } + } + .padding(.vertical) + + #if !os(watchOS) + .pickerStyle(.segmented) + #endif + } +} + +struct TodoCreator: View { + @WatchState(TodosAtom()) + var todos + + @State + var text = "" + + var body: some View { + HStack { + TextField("Enter your todo", text: $text) + + #if os(iOS) || os(macOS) + .textFieldStyle(.roundedBorder) + #endif + + Button("Add", action: addTodo) + .disabled(text.isEmpty) + } + .padding(.vertical) + } + + func addTodo() { + let todo = Todo(id: UUID(), text: text, isCompleted: false) + todos.append(todo) + text = "" + } +} + +struct TodoItem: View { + @WatchState(TodosAtom()) + var allTodos + + @State + var text: String + + @State + var isCompleted: Bool + + let todo: Todo + + init(todo: Todo) { + self.todo = todo + self._text = State(initialValue: todo.text) + self._isCompleted = State(initialValue: todo.isCompleted) + } + + var index: Int { + allTodos.firstIndex { $0.id == todo.id }! + } + + var body: some View { + Toggle(isOn: $isCompleted) { + TextField("", text: $text) { + allTodos[index].text = text + } + + #if os(iOS) || os(macOS) + .textFieldStyle(.roundedBorder) + #endif + } + .padding(.vertical, 4) + .onChange(of: isCompleted) { isCompleted in + allTodos[index].isCompleted = isCompleted + } + } +} diff --git a/Examples/Packages/CrossPlatform/Tests/ExampleCounterTests/ExampleCounterTests.swift b/Examples/Packages/CrossPlatform/Tests/ExampleCounterTests/ExampleCounterTests.swift new file mode 100644 index 00000000..83a00633 --- /dev/null +++ b/Examples/Packages/CrossPlatform/Tests/ExampleCounterTests/ExampleCounterTests.swift @@ -0,0 +1,18 @@ +import Atoms +import XCTest + +@testable import ExampleCounter + +@MainActor +final class ExampleCounterTests: XCTestCase { + func testCounterAtom() { + let context = AtomTestContext() + let atom = CounterAtom() + + XCTAssertEqual(context.watch(atom), 0) + + context[atom] = 1 + + XCTAssertEqual(context.watch(atom), 1) + } +} diff --git a/Examples/Packages/CrossPlatform/Tests/ExampleTodoTests/ExampleTodoTests.swift b/Examples/Packages/CrossPlatform/Tests/ExampleTodoTests/ExampleTodoTests.swift new file mode 100644 index 00000000..7493a91a --- /dev/null +++ b/Examples/Packages/CrossPlatform/Tests/ExampleTodoTests/ExampleTodoTests.swift @@ -0,0 +1,110 @@ +import Atoms +import XCTest + +@testable import ExampleTodo + +@MainActor +final class ExampleTodoTests: XCTestCase { + let completedTodos = [ + Todo( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, + text: "Test 0", + isCompleted: true + ) + ] + + let uncompleteTodos = [ + Todo( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + text: "Test 1", + isCompleted: false + ), + Todo( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + text: "Test 2", + isCompleted: false + ), + ] + + var allTodos: [Todo] { + completedTodos + uncompleteTodos + } + + func testFilteredTodosAtom() { + let context = AtomTestContext() + let atom = FilteredTodosAtom() + + context.watch(atom) + + context[TodosAtom()] = [] + + XCTAssertEqual(context.watch(atom), []) + + context[TodosAtom()] = allTodos + + XCTAssertEqual(context.watch(atom), allTodos) + + context[FilterAtom()] = .completed + + XCTAssertEqual(context.watch(atom), completedTodos) + + context[FilterAtom()] = .uncompleted + + XCTAssertEqual(context.watch(atom), uncompleteTodos) + } + + func testStatsAtom() { + let context = AtomTestContext() + let atom = StatsAtom() + + context.watch(atom) + + context[TodosAtom()] = [] + + XCTAssertEqual( + context.watch(atom), + Stats( + total: 0, + totalCompleted: 0, + totalUncompleted: 0, + percentCompleted: 0 + ) + ) + + context[TodosAtom()] = completedTodos + + XCTAssertEqual( + context.watch(atom), + Stats( + total: 1, + totalCompleted: 1, + totalUncompleted: 0, + percentCompleted: 1 + ) + ) + + context[TodosAtom()] = uncompleteTodos + + XCTAssertEqual( + context.watch(atom), + Stats( + total: 2, + totalCompleted: 0, + totalUncompleted: 2, + percentCompleted: 0 + ) + ) + + context[TodosAtom()] = allTodos + + XCTAssertEqual( + context.watch(atom), + Stats( + total: 3, + totalCompleted: 1, + totalUncompleted: 2, + percentCompleted: 1 / 3 + ) + ) + } +} diff --git a/Examples/Packages/iOS/Package.swift b/Examples/Packages/iOS/Package.swift new file mode 100644 index 00000000..7c7e13f9 --- /dev/null +++ b/Examples/Packages/iOS/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version:5.6 + +import PackageDescription + +let atoms = Target.Dependency.product(name: "Atoms", package: "swiftui-atomic-architecture") + +let package = Package( + name: "iOSExamples", + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: "iOSApp", targets: ["iOSApp"]) + ], + dependencies: [ + .package(path: "../../.."), + .package(path: "../CrossPlatform"), + ], + targets: [ + .target( + name: "iOSApp", + dependencies: [ + .product(name: "CrossPlatformApp", package: "CrossPlatform"), + "ExampleMovieDB", + "ExampleMap", + "ExampleVoiceMemo", + "ExampleTimeTravel", + ] + ), + .target(name: "ExampleMovieDB", dependencies: [atoms]), + .testTarget(name: "ExampleMovieDBTests", dependencies: ["ExampleMovieDB"]), + .target(name: "ExampleMap", dependencies: [atoms]), + .testTarget(name: "ExampleMapTests", dependencies: ["ExampleMap"]), + .target(name: "ExampleVoiceMemo", dependencies: [atoms]), + .testTarget(name: "ExampleVoiceMemoTests", dependencies: ["ExampleVoiceMemo"]), + .target(name: "ExampleTimeTravel", dependencies: [atoms]), + .testTarget(name: "ExampleTimeTravelTests", dependencies: ["ExampleTimeTravel"]), + ] +) diff --git a/Examples/Packages/iOS/Sources/ExampleMap/Atoms.swift b/Examples/Packages/iOS/Sources/ExampleMap/Atoms.swift new file mode 100644 index 00000000..4e881faf --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMap/Atoms.swift @@ -0,0 +1,59 @@ +import Atoms +import CoreLocation + +struct LocationManagerAtom: ValueAtom, Hashable { + func value(context: Context) -> LocationManagerProtocol { + let manager = CLLocationManager() + let delegate = LocationManagerDelegate() + + manager.delegate = delegate + manager.desiredAccuracy = kCLLocationAccuracyBest + context.addTermination(manager.stopUpdatingLocation) + context.keepUntilTermination(delegate) + delegate.onChange = { + context.reset(AuthorizationStatusAtom()) + } + + return manager + } +} + +struct CoordinateAtom: ValueAtom, Hashable { + func value(context: Context) -> CLLocationCoordinate2D? { + let manager = context.watch(LocationManagerAtom()) + return manager.location?.coordinate + } +} + +struct AuthorizationStatusAtom: ValueAtom, Hashable { + func value(context: Context) -> CLAuthorizationStatus { + let manager = context.watch(LocationManagerAtom()) + return manager.authorizationStatus + } +} + +private final class LocationManagerDelegate: NSObject, CLLocationManagerDelegate { + var onChange: (() -> Void)? + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + onChange?() + + switch manager.authorizationStatus { + case .authorizedAlways, .authorizedWhenInUse: + manager.startUpdatingLocation() + + case .notDetermined: + manager.requestWhenInUseAuthorization() + + case .restricted, .denied: + break + + @unknown default: + break + } + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print(error.localizedDescription) + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMap/Dependency/LocationManager.swift b/Examples/Packages/iOS/Sources/ExampleMap/Dependency/LocationManager.swift new file mode 100644 index 00000000..4b5a568b --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMap/Dependency/LocationManager.swift @@ -0,0 +1,17 @@ +import CoreLocation + +protocol LocationManagerProtocol { + var delegate: CLLocationManagerDelegate? { get set } + var desiredAccuracy: CLLocationAccuracy { get set } + var location: CLLocation? { get } + var authorizationStatus: CLAuthorizationStatus { get } +} + +extension CLLocationManager: LocationManagerProtocol {} + +final class MockLocationManager: LocationManagerProtocol { + weak var delegate: CLLocationManagerDelegate? + var desiredAccuracy = kCLLocationAccuracyKilometer + var location: CLLocation? = nil + var authorizationStatus = CLAuthorizationStatus.notDetermined +} diff --git a/Examples/Packages/iOS/Sources/ExampleMap/ExampleMap.swift b/Examples/Packages/iOS/Sources/ExampleMap/ExampleMap.swift new file mode 100644 index 00000000..83392546 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMap/ExampleMap.swift @@ -0,0 +1,10 @@ +import SwiftUI + +// swift-format-ignore: AllPublicDeclarationsHaveDocumentation +public struct ExampleMap: View { + public init() {} + + public var body: some View { + MapScreen() + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMap/Screens.swift b/Examples/Packages/iOS/Sources/ExampleMap/Screens.swift new file mode 100644 index 00000000..8a1b002e --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMap/Screens.swift @@ -0,0 +1,63 @@ +import Atoms +import SwiftUI + +struct MapScreen: View { + @Watch(AuthorizationStatusAtom()) + var authorizationStatus + + @ViewContext + var context + + var body: some View { + Group { + switch authorizationStatus { + case .authorizedAlways, .authorizedWhenInUse: + mapContent + + case .notDetermined, .restricted, .denied: + authorizationContent + + @unknown default: + authorizationContent + } + } + .navigationTitle("Map") + } + + var mapContent: some View { + MapView() + .ignoresSafeArea(edges: [.bottom, .leading, .trailing]) + .overlay(alignment: .topTrailing) { + Button { + context.reset(CoordinateAtom()) + } label: { + Image(systemName: "location") + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) + } + .padding() + .shadow(radius: 2) + } + } + + var authorizationContent: some View { + ZStack { + Button("Open Settings") { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + .tint(.blue) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.large) + } + } +} + +struct ExampleScreen_Preview: PreviewProvider { + static var previews: some View { + AtomRoot { + MapScreen() + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMap/Views.swift b/Examples/Packages/iOS/Sources/ExampleMap/Views.swift new file mode 100644 index 00000000..f99ce62c --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMap/Views.swift @@ -0,0 +1,34 @@ +import Atoms +import MapKit +import SwiftUI + +struct MapView: View { + @Watch(CoordinateAtom()) + var coordinate + + var body: some View { + MapViewRepresentable(base: self) + } +} + +private struct MapViewRepresentable: UIViewRepresentable { + let base: MapView + + func makeUIView(context: Context) -> MKMapView { + MKMapView(frame: .zero) + } + + func updateUIView(_ view: MKMapView, context: Context) { + guard let coordinate = base.coordinate else { + return + } + + let span = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + let region = MKCoordinateRegion(center: coordinate, span: span) + let annotation = MKPointAnnotation() + annotation.coordinate = coordinate + + view.addAnnotation(annotation) + view.setRegion(region, animated: true) + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/CommonAtoms.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/CommonAtoms.swift new file mode 100644 index 00000000..e92af8ef --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/CommonAtoms.swift @@ -0,0 +1,18 @@ +import Atoms +import UIKit + +struct APIClientAtom: ValueAtom, Hashable { + func value(context: Context) -> APIClientProtocol { + APIClient() + } +} + +struct ImageAtom: ThrowingTaskAtom, Hashable { + let path: String + let size: ImageSize + + func value(context: Context) async throws -> UIImage { + let api = context.watch(APIClientAtom()) + return try await api.getImage(path: path, size: size) + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/DetailAtoms.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/DetailAtoms.swift new file mode 100644 index 00000000..824adaad --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/DetailAtoms.swift @@ -0,0 +1,19 @@ +import Atoms + +struct IsInMyListAtom: ValueAtom, Hashable { + let movie: Movie + + func value(context: Context) -> Bool { + let myList = context.watch(MyListAtom()) + return myList.contains(movie) + } +} + +struct CastsAtom: ThrowingTaskAtom, Hashable { + let movieID: Int + + func value(context: Context) async throws -> [Credits.Person] { + let api = context.watch(APIClientAtom()) + return try await api.getCredits(movieID: movieID).cast + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/MoviesAtoms.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/MoviesAtoms.swift new file mode 100644 index 00000000..158b878e --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/MoviesAtoms.swift @@ -0,0 +1,93 @@ +import Atoms +import Foundation + +struct FirstPageAtom: ThrowingTaskAtom, Hashable { + func value(context: Context) async throws -> PagedResponse { + let api = context.watch(APIClientAtom()) + let filter = context.watch(FilterAtom()) + + return try await api.getMovies(filter: filter, page: 1) + } +} + +struct NextPagesAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> [PagedResponse] { + // Purges when the first page is updated. + context.watch(FirstPageAtom()) + return [] + } +} + +struct LoadNextAtom: ValueAtom, Hashable { + @MainActor + struct Action { + let context: AtomContext + + func callAsFunction() async { + let api = context.read(APIClientAtom()) + let filter = context.read(FilterAtom()) + let currentPage = context.read(NextPagesAtom()).last?.page ?? 1 + let nextPage = try? await api.getMovies(filter: filter, page: currentPage + 1) + + if let nextPage = nextPage { + context[NextPagesAtom()].append(nextPage) + } + } + } + + func value(context: Context) -> Action { + Action(context: context) + } +} + +struct FilterAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Filter { + .nowPlaying + } +} + +struct MyListAtom: StateAtom, Hashable, KeepAlive { + func defaultValue(context: Context) -> [Movie] { + [] + } +} + +struct MyListInsertAtom: ValueAtom, Hashable { + @MainActor + struct Action { + let context: AtomContext + + func callAsFunction(movie: Movie) { + let myList = MyListAtom() + + if let index = context[myList].firstIndex(of: movie) { + context[myList].remove(at: index) + } + else { + context[myList].append(movie) + } + } + } + + func value(context: Context) -> Action { + Action(context: context) + } +} + +private extension APIClientProtocol { + func getMovies(filter: Filter, page: Int) async throws -> PagedResponse { + switch filter { + case .nowPlaying: + return try await getNowPlaying(page: page) + + case .popular: + return try await getPopular(page: page) + + case .topRated: + return try await getTopRated(page: page) + + case .upcoming: + return try await getUpcoming(page: page) + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/SearchAtoms.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/SearchAtoms.swift new file mode 100644 index 00000000..d9d9c229 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/SearchAtoms.swift @@ -0,0 +1,25 @@ +import Atoms +import Combine + +struct SearchQueryAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> String { + "" + } +} + +struct SearchMoviesAtom: PublisherAtom, Hashable { + func publisher(context: Context) -> AnyPublisher<[Movie], Error> { + let api = context.watch(APIClientAtom()) + let query = context.watch(SearchQueryAtom()) + + if query.isEmpty { + return Just([]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return api.getSearchMovies(query: query) + .map(\.results) + .eraseToAnyPublisher() + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Backend/APIClient.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Backend/APIClient.swift new file mode 100644 index 00000000..bee66fef --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Backend/APIClient.swift @@ -0,0 +1,145 @@ +import Combine +import UIKit + +protocol APIClientProtocol { + func getImage(path: String, size: ImageSize) async throws -> UIImage + func getNowPlaying(page: Int) async throws -> PagedResponse + func getPopular(page: Int) async throws -> PagedResponse + func getTopRated(page: Int) async throws -> PagedResponse + func getUpcoming(page: Int) async throws -> PagedResponse + func getCredits(movieID: Int) async throws -> Credits + func getSearchMovies(query: String) -> Future, Error> // Use Publisher as an example. +} + +struct APIClient: APIClientProtocol { + private let session = URLSession.shared + private let baseURL = URL(string: "https://api.themoviedb.org/3")! + private let imageBaseURL = URL(string: "https://image.tmdb.org/t/p")! + private let apiKey = "3de15b0402484d3d089399ea0b8d98f1" + private let jsonDecoder: JSONDecoder = { + let dateFormatter = DateFormatter() + let decoder = JSONDecoder() + dateFormatter.dateFormat = "yyy-MM-dd" + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .formatted(dateFormatter) + return decoder + }() + + func getImage(path: String, size: ImageSize) async throws -> UIImage { + let url = + imageBaseURL + .appendingPathComponent(size.rawValue) + .appendingPathComponent(path) + + let (data, _) = try await session.data(from: url) + return UIImage(data: data) ?? UIImage() + } + + func getNowPlaying(page: Int) async throws -> PagedResponse { + try await get(path: "movie/now_playing", parameters: ["page": String(page)]) + } + + func getPopular(page: Int) async throws -> PagedResponse { + try await get(path: "movie/popular", parameters: ["page": String(page)]) + } + + func getTopRated(page: Int) async throws -> PagedResponse { + try await get(path: "movie/top_rated", parameters: ["page": String(page)]) + } + + func getUpcoming(page: Int) async throws -> PagedResponse { + try await get(path: "movie/upcoming", parameters: ["page": String(page)]) + } + + func getCredits(movieID: Int) async throws -> Credits { + try await get(path: "movie/\(movieID)/credits", parameters: [:]) + } + + func getSearchMovies(query: String) async throws -> PagedResponse { + try await get(path: "search/movie", parameters: ["query": query]) + } + + func getSearchMovies(query: String) -> Future, Error> { + Future { fulfill in + Task { + do { + let response: PagedResponse = try await get( + path: "search/movie", + parameters: ["query": query] + ) + fulfill(.success(response)) + } + catch { + fulfill(.failure(error)) + } + } + } + } +} + +private extension APIClient { + func get( + _ type: Response.Type = Response.self, + path: String, + parameters: [String: String] + ) async throws -> Response { + let url = baseURL.appendingPathComponent(path) + var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)! + var queryItems = [URLQueryItem(name: "api_key", value: apiKey)] + + for (name, value) in parameters { + queryItems.append(URLQueryItem(name: name, value: value)) + } + + urlComponents.queryItems = queryItems + + var urlRequest = URLRequest(url: urlComponents.url!) + urlRequest.httpMethod = "GET" + + do { + let (data, _) = try await session.data(for: urlRequest) + return try jsonDecoder.decode(Response.self, from: data) + } + catch { + print(error) + throw error + } + } +} + +final class MockAPIClient: APIClientProtocol { + var imageResponse = Result.failure(URLError(.unknown)) + var filteredMovieResponse = Result, Error>.failure(URLError(.unknown)) + var creditsResponse = Result.failure(URLError(.unknown)) + var searchMoviesResponse = Result, Error>.failure(URLError(.unknown)) + + func getImage(path: String, size: ImageSize) async throws -> UIImage { + try imageResponse.get() + } + + func getNowPlaying(page: Int) async throws -> PagedResponse { + try filteredMovieResponse.get() + } + + func getPopular(page: Int) async throws -> PagedResponse { + try filteredMovieResponse.get() + } + + func getTopRated(page: Int) async throws -> PagedResponse { + try filteredMovieResponse.get() + } + + func getUpcoming(page: Int) async throws -> PagedResponse { + try filteredMovieResponse.get() + } + + func getCredits(movieID: Int) async throws -> Credits { + try creditsResponse.get() + } + + func getSearchMovies(query: String) -> Future, Error> { + Future { fulfill in + fulfill(self.searchMoviesResponse) + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Credits.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Credits.swift new file mode 100644 index 00000000..0914f347 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Credits.swift @@ -0,0 +1,10 @@ +struct Credits: Codable, Hashable, Identifiable { + struct Person: Codable, Hashable, Identifiable { + let id: Int + let name: String + let profilePath: String? + } + + let id: Int + let cast: [Person] +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Failable.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Failable.swift new file mode 100644 index 00000000..35ea6008 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Failable.swift @@ -0,0 +1,24 @@ +@propertyWrapper +struct Failable: Decodable { + var wrappedValue: T? + + init(wrappedValue: T?) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let wrappedValue = try? container.decode(T.self) + self.init(wrappedValue: wrappedValue) + } +} + +extension Failable: Encodable where T: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wrappedValue) + } +} + +extension Failable: Equatable where T: Equatable {} +extension Failable: Hashable where T: Hashable {} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Filter.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Filter.swift new file mode 100644 index 00000000..7b125c5f --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Filter.swift @@ -0,0 +1,6 @@ +enum Filter: CaseIterable, Hashable { + case nowPlaying + case popular + case topRated + case upcoming +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/ImageSize.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/ImageSize.swift new file mode 100644 index 00000000..035541c2 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/ImageSize.swift @@ -0,0 +1,6 @@ +enum ImageSize: String, Hashable { + case original + case small = "w154" + case medium = "w500" + case cast = "w185" +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Movie.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Movie.swift new file mode 100644 index 00000000..31e6a32f --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Movie.swift @@ -0,0 +1,12 @@ +import Foundation + +struct Movie: Codable, Hashable, Identifiable { + var id: Int + var title: String + var overview: String? + var posterPath: String? + var backdropPath: String? + var voteAverage: Float + @Failable + var releaseDate: Date? +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/PagedResponse.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/PagedResponse.swift new file mode 100644 index 00000000..513b1c2f --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/PagedResponse.swift @@ -0,0 +1,11 @@ +struct PagedResponse: Decodable { + let page: Int + let totalPages: Int + let results: [T] + + var hasNextPage: Bool { + page < totalPages + } +} + +extension PagedResponse: Equatable where T: Equatable {} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/ExampleMovieDB.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/ExampleMovieDB.swift new file mode 100644 index 00000000..9417c7a7 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/ExampleMovieDB.swift @@ -0,0 +1,10 @@ +import SwiftUI + +// swift-format-ignore: AllPublicDeclarationsHaveDocumentation +public struct ExampleMovieDB: View { + public init() {} + + public var body: some View { + MoviesScreen() + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/DetailScreen.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/DetailScreen.swift new file mode 100644 index 00000000..441d242c --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/DetailScreen.swift @@ -0,0 +1,126 @@ +import Atoms +import SwiftUI + +struct DetailScreen: View { + let movie: Movie + + @Watch(MyListInsertAtom()) + var myListInsert + + @ViewContext + var context + + @Environment(\.dismiss) + var dismiss + + @Environment(\.calendar) + var calendar + + var dateComponents: DateComponents? { + movie.releaseDate.map { calendar.dateComponents([.year], from: $0) } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + coverImage + title + + Group { + GroupBox("Cast") { + CastList(movieID: movie.id) + } + + GroupBox("Overview") { + MovieRow(movie: movie, truncatesOverview: false) + } + } + .padding([.bottom, .horizontal]) + } + } + .background(Color(.systemBackground)) + .overlay(alignment: .topLeading) { + closeButton + } + } + + var coverImage: some View { + Color(.systemGroupedBackground) + .aspectRatio( + CGSize(width: 1, height: 0.6), + contentMode: .fit + ) + .frame(maxWidth: .infinity) + .clipped() + .overlay { + if let path = movie.backdropPath { + NetworkImage(path: path, size: .original) + } + } + } + + var title: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(movie.title) + .font(.title3.bold()) + .foregroundColor(.primary) + + if let year = dateComponents?.year { + Text(verbatim: "(\(year))") + .font(.headline) + .foregroundColor(.secondary) + } + } + + Spacer(minLength: 8) + + myListButton + } + .padding() + } + + @ViewBuilder + var closeButton: some View { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.largeTitle) + .foregroundStyle(Color(.systemGray)) + } + .padding() + .shadow(radius: 2) + } + + @ViewBuilder + var myListButton: some View { + let isOn = context.watch(IsInMyListAtom(movie: movie)) + + Button { + myListInsert(movie: movie) + } label: { + MyListButtonLabel(isOn: isOn) + } + } +} + +struct DetailScreen_Preview: PreviewProvider { + static let movie = Movie( + id: 680, + title: "Pulp Fiction", + overview: """ + A burger-loving hit man, his philosophical partner, a drug-addled gangster\'s moll and a washed-up boxer converge in this sprawling, comedic crime caper. Their adventures unfurl in three stories that ingeniously trip back and forth in time. + """, + posterPath: "/d5iIlFn5s0ImszYzBPb8JPIfbXD.jpg", + backdropPath: "/suaEOtk1N1sgg2MTM7oZd2cfVp3.jpg", + voteAverage: 8.5, + releaseDate: Date(timeIntervalSinceReferenceDate: -199184400.0) + ) + + static var previews: some View { + AtomRoot { + DetailScreen(movie: movie) + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/MoviesScreen.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/MoviesScreen.swift new file mode 100644 index 00000000..42015757 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/MoviesScreen.swift @@ -0,0 +1,112 @@ +import Atoms +import SwiftUI + +struct MoviesScreen: View { + @Watch(FirstPageAtom()) + var firstPage + + @Watch(NextPagesAtom()) + var nextPages + + @WatchState(SearchQueryAtom()) + var searchQuery + + @Watch(LoadNextAtom()) + var loadNext + + @ViewContext + var context + + @State + var isShowingSearchScreen = false + + @State + var selectedMovie: Movie? + + var body: some View { + List { + Section("My List") { + MyMovieList { movie in + selectedMovie = movie + } + } + + Section { + FilterPicker() + + Suspense(firstPage) { firstPage in + let pages = [firstPage] + nextPages + + ForEach(pages, id: \.page) { response in + pageIndex(current: response.page, total: response.totalPages) + + ForEach(response.results, id: \.id) { movie in + movieRow(movie) + } + } + + if let last = pages.last, last.hasNextPage { + ProgressRow().task { + await loadNext() + } + } + } suspending: { + ProgressRow() + } catch: { _ in + CaveatRow(text: "Failed to get the data.") + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Movies") + .toolbar { + ToolbarItem(placement: .principal) { + Text(Image(systemName: "film")) + Text("TMDB") + } + } + .searchable( + text: $searchQuery, + placement: .navigationBarDrawer(displayMode: .always) + ) + .onSubmit(of: .search) { [$isShowingSearchScreen] in + $isShowingSearchScreen.wrappedValue = true + } + .refreshable { [context] in + await context.refresh(FirstPageAtom()) + } + .background { + NavigationLink( + isActive: $isShowingSearchScreen, + destination: SearchScreen.init, + label: EmptyView.init + ) + } + .sheet(item: $selectedMovie) { movie in + DetailScreen(movie: movie) + } + } + + func movieRow(_ movie: Movie) -> some View { + Button { + selectedMovie = movie + } label: { + MovieRow(movie: movie) + } + } + + func pageIndex(current: Int, total: Int) -> some View { + Text("Page: \(current) / \(total)") + .font(.subheadline) + .foregroundColor(.accentColor) + } +} + +struct MoviesScreen_Preview: PreviewProvider { + static var previews: some View { + AtomRoot { + NavigationView { + MoviesScreen() + } + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/SearchScreen.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/SearchScreen.swift new file mode 100644 index 00000000..13f64398 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/SearchScreen.swift @@ -0,0 +1,58 @@ +import Atoms +import SwiftUI + +struct SearchScreen: View { + @Watch(SearchMoviesAtom()) + var movies + + @ViewContext + var context + + @State + var selectedMovie: Movie? + + var body: some View { + List { + switch movies { + case .suspending: + ProgressRow() + + case .failure: + CaveatRow(text: "Failed to get the search results.") + + case .success(let movies) where movies.isEmpty: + CaveatRow(text: "There are no movies that matched your query.") + + case .success(let movies): + ForEach(movies, id: \.id) { movie in + Button { + selectedMovie = movie + } label: { + MovieRow(movie: movie) + } + } + } + } + .navigationTitle("Search Results") + .listStyle(.insetGrouped) + .sheet(item: $selectedMovie) { movie in + DetailScreen(movie: movie) + } + .refreshable { [context] in + await context.refresh(SearchMoviesAtom()) + } + } +} + +struct SearchScreen_Preview: PreviewProvider { + static var previews: some View { + AtomRoot { + NavigationView { + SearchScreen() + } + } + .override(SearchQueryAtom()) { _ in + "Léon" + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/CastList.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/CastList.swift new file mode 100644 index 00000000..d88c6fee --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/CastList.swift @@ -0,0 +1,69 @@ +import Atoms +import SwiftUI + +struct CastList: View { + let movieID: Int + + @ViewContext + var context + + var casts: AsyncPhase<[Credits.Person], Error> { + context.watch(CastsAtom(movieID: movieID).phase) + } + + var body: some View { + switch casts { + case .suspending: + ProgressRow() + + case .failure: + CaveatRow(text: "Failed to get casts data.") + + case .success(let casts) where casts.isEmpty: + CaveatRow(text: "There are no casts.") + + case .success(let casts): + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(casts, id: \.id) { cast in + item(cast: cast) + } + } + } + } + } + + @ViewBuilder + func item(cast: Credits.Person) -> some View { + VStack(spacing: 0) { + ZStack { + if let path = cast.profilePath { + NetworkImage(path: path, size: .cast) + } + else { + Image(systemName: "person.fill") + .font(.largeTitle) + .foregroundStyle(Color(.systemGray)) + } + } + .frame(height: 120) + .frame(maxWidth: .infinity) + .background(Color(.secondarySystemBackground).ignoresSafeArea()) + + Text(cast.name) + .font(.caption2) + .foregroundColor(.primary) + .lineLimit(2) + .padding(4) + .frame(height: 40) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(width: 80) + .background(Color(.systemBackground).ignoresSafeArea()) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(.systemGray3), lineWidth: 0.5) + ) + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/CaveatRow.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/CaveatRow.swift new file mode 100644 index 00000000..4c030856 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/CaveatRow.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct CaveatRow: View { + let text: String + + var body: some View { + Text(text) + .font(.body.bold()) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical) + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/FiltePicker.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/FiltePicker.swift new file mode 100644 index 00000000..dc963acd --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/FiltePicker.swift @@ -0,0 +1,35 @@ +import Atoms +import SwiftUI + +struct FilterPicker: View { + @WatchState(FilterAtom()) + var filter + + var body: some View { + Picker("Filter", selection: $filter) { + ForEach(Filter.allCases, id: \.self) { filter in + Text(filter.title) + } + } + .pickerStyle(.segmented) + .padding(.vertical) + } +} + +private extension Filter { + var title: String { + switch self { + case .nowPlaying: + return "Now" + + case .popular: + return "Popular" + + case .topRated: + return "Top" + + case .upcoming: + return "Upcoming" + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MovieRow.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MovieRow.swift new file mode 100644 index 00000000..24eb9866 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MovieRow.swift @@ -0,0 +1,58 @@ +import Atoms +import SwiftUI + +struct MovieRow: View { + var movie: Movie + var truncatesOverview = true + + var body: some View { + HStack(alignment: .top, spacing: 12) { + ZStack { + if let path = movie.posterPath { + NetworkImage(path: path, size: .medium) + } + } + .frame(width: 100, height: 150) + .background(Color(.systemGroupedBackground)) + .cornerRadius(8) + + VStack(alignment: .leading) { + HStack(alignment: .top) { + PopularityBadge(voteAverage: movie.voteAverage) + + VStack(alignment: .leading) { + Text(movie.title) + .font(.headline.bold()) + .foregroundColor(.accentColor) + .lineLimit(2) + + if let releaseDate = movie.releaseDate { + Text(Self.formatter.string(from: releaseDate)) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + + if let overview = movie.overview { + Text(overview) + .font(.body) + .foregroundColor(.primary) + .lineLimit(truncatesOverview ? 4 : nil) + } + } + + Spacer(minLength: 0) + } + .padding(.vertical) + } +} + +private extension MovieRow { + static private let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter + }() +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MyListButtonLabel.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MyListButtonLabel.swift new file mode 100644 index 00000000..15051cc7 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MyListButtonLabel.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct MyListButtonLabel: View { + let isOn: Bool + + var body: some View { + VStack { + Image(systemName: isOn ? "heart.fill" : "heart") + .font(.title2) + .foregroundStyle(.pink) + + Text("My List") + .font(.system(.caption2)) + .foregroundColor(.pink) + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MyMovieList.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MyMovieList.swift new file mode 100644 index 00000000..383e16cd --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MyMovieList.swift @@ -0,0 +1,52 @@ +import Atoms +import SwiftUI + +struct MyMovieList: View { + @Watch(MyListAtom()) + var myList + + var onSelect: (Movie) -> Void + + var body: some View { + if myList.isEmpty { + emptyContent + } + else { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(myList, id: \.id) { movie in + item(movie: movie) + } + } + .padding(.vertical) + } + } + } + + var emptyContent: some View { + HStack { + Text("Tap") + MyListButtonLabel(isOn: false) + Text("to add movies here.") + } + .font(.body.bold()) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical) + } + + func item(movie: Movie) -> some View { + Button { + onSelect(movie) + } label: { + ZStack { + if let path = movie.posterPath { + NetworkImage(path: path, size: .medium) + } + } + .frame(width: 80, height: 120) + .background(Color(.systemGroupedBackground)) + .cornerRadius(8) + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/NetworkImage.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/NetworkImage.swift new file mode 100644 index 00000000..1677cce7 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/NetworkImage.swift @@ -0,0 +1,25 @@ +import Atoms +import SwiftUI + +struct NetworkImage: View { + let path: String + let size: ImageSize + + @ViewContext + var context + + var image: Task { + context.watch(ImageAtom(path: path, size: size)) + } + + var body: some View { + Suspense(image) { uiImage in + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .clipped() + } suspending: { + ProgressView() + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/PopularityBadge.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/PopularityBadge.swift new file mode 100644 index 00000000..af13cb2d --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/PopularityBadge.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct PopularityBadge: View { + let voteAverage: Float + + var body: some View { + ZStack { + Circle() + .foregroundColor(.primary) + .overlay(overlay) + + Text(Int(score * 100).description + "%") + .font(.caption2.bold()) + .foregroundColor(.primary) + .colorInvert() + } + .frame(width: 36, height: 36) + } + + var overlay: some View { + ZStack { + Circle() + .trim(from: 0, to: CGFloat(score)) + .stroke(style: StrokeStyle(lineWidth: 2)) + .foregroundColor(scoreColor) + } + .rotationEffect(.degrees(-90)) + .padding(2) + } +} + +private extension PopularityBadge { + var score: Float { + voteAverage / 10 + } + + var scoreColor: Color { + switch voteAverage { + case ..<4: + return .red + + case 4..<6: + return .orange + + case 6..<7.5: + return .yellow + + default: + return .green + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/ProgressRow.swift b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/ProgressRow.swift new file mode 100644 index 00000000..9688f1f5 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleMovieDB/Views/ProgressRow.swift @@ -0,0 +1,8 @@ +import SwiftUI + +struct ProgressRow: View { + var body: some View { + ProgressView() + .frame(maxWidth: .infinity, idealHeight: 40) + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleTimeTravel/ExampleTimeTravel.swift b/Examples/Packages/iOS/Sources/ExampleTimeTravel/ExampleTimeTravel.swift new file mode 100644 index 00000000..37d91e6d --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleTimeTravel/ExampleTimeTravel.swift @@ -0,0 +1,177 @@ +import Atoms +import SwiftUI + +struct InputState: Equatable { + var text: String = "" + var latestInput: Int? + + mutating func input(number: Int) { + text += String(number) + latestInput = number + } + + mutating func clear() { + text = "" + latestInput = nil + } +} + +struct InputStateAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> InputState { + InputState() + } +} + +struct NumberInputScreen: View { + let matrix = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ] + + @WatchState(InputStateAtom()) + var inputState + + var body: some View { + VStack { + TextField("Tap numbers", text: .constant(inputState.text)) + .disabled(true) + .textFieldStyle(.roundedBorder) + .padding() + + ForEach(matrix, id: \.first) { row in + HStack { + ForEach(row, id: \.self) { number in + Button("\(number)") { + inputState.input(number: number) + } + .font(.largeTitle) + .frame(width: 80, height: 80) + .background(Color(inputState.latestInput == number ? .systemOrange : .secondarySystemBackground)) + .clipShape(Circle()) + } + } + } + + Button("Clear") { + inputState.clear() + } + .buttonStyle(.borderedProminent) + + Spacer() + } + .frame(maxHeight: .infinity) + .padding() + .navigationTitle("Time Travel") + } +} + +struct TimeTravelDebug: View, AtomObserver { + @ViewBuilder + let content: () -> Content + + @State + var history = [AtomHistory]() + + @State + var position = 0 + + @State + var isTimeTravelMode = false + + var body: some View { + AtomRelay { + ZStack(alignment: .bottom) { + content().disabled(isTimeTravelMode) + + if isTimeTravelMode { + slider + } + else { + startButton + } + } + .padding() + } + .observe(self) // Observes atom changes by this view itself. + } + + var slider: some View { + VStack(alignment: .leading) { + HStack { + Text("History (\(position + 1) / \(history.count))") + Spacer() + doneButton + } + + Slider( + value: Binding( + get: { Double(position) }, + set: { + position = Int($0) + + // Restores the snapshot in the history. + history[position].restore() + } + ), + in: 0...Double(max(0, history.endIndex - 1)) + ) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) + .shadow(radius: 5) + } + + var startButton: some View { + Button("Go to Time Travel ⏳") { + position = history.count - 1 + isTimeTravelMode = true + } + .buttonStyle(.borderedProminent) + } + + var doneButton: some View { + Button("Done") { + // Erases the frame of history that never was. + history = Array(history.prefix(through: position)) + isTimeTravelMode = false + } + .buttonStyle(.borderedProminent) + } + + /// Method of `AtomObserver`, for receiving atom changes. + func atomChanged(snapshot: Snapshot) { + // Collects the snapshots only while `isTimeTravelMode` is off, because + // the store emits change events even by `snapshot.restore()`. + guard !isTimeTravelMode else { + return + } + + // Updates `history` asynchronously to prevent "attempting to update during view update" issue. + Task { + history.append(snapshot) + } + } +} + +// swift-format-ignore: AllPublicDeclarationsHaveDocumentation +public struct ExampleTimeTravel: View { + public init() {} + + public var body: some View { + TimeTravelDebug { + NumberInputScreen() + } + } +} + +struct TimeTravelScreen_Preview: PreviewProvider { + static var previews: some View { + AtomRoot { + TimeTravelDebug { + NumberInputScreen() + } + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/CommonAtoms.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/CommonAtoms.swift new file mode 100644 index 00000000..f8264664 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/CommonAtoms.swift @@ -0,0 +1,37 @@ +import AVFoundation +import Atoms +import Combine +import Foundation + +struct Generator { + var date: () -> Date + var uuid: () -> UUID + var temporaryDirectory: () -> String +} + +struct GeneratorAtom: ValueAtom, Hashable { + func value(context: Context) -> Generator { + Generator( + date: Date.init, + uuid: UUID.init, + temporaryDirectory: NSTemporaryDirectory + ) + } +} + +struct TimerAtom: ValueAtom, Hashable { + var startDate: Date + var timeInterval: TimeInterval + + func value(context: Context) -> AnyPublisher { + Timer.publish( + every: timeInterval, + tolerance: 0, + on: .main, + in: .common + ) + .autoconnect() + .map { $0.timeIntervalSince(startDate) } + .eraseToAnyPublisher() + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/VoiceMemoListAtoms.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/VoiceMemoListAtoms.swift new file mode 100644 index 00000000..4efae435 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/VoiceMemoListAtoms.swift @@ -0,0 +1,176 @@ +import AVFoundation +import Atoms +import Combine +import Foundation + +struct VoiceMemo: Equatable { + var url: URL + var date: Date + var duration: TimeInterval + var title = "" +} + +struct Recording: Equatable { + var url: URL + var date: Date +} + +@MainActor +final class VoiceMemoListViewModel: ObservableObject { + @Published + var voiceMemos = [VoiceMemo]() + + @Published + private(set) var audioRecorderPermission = AVAudioSession.RecordPermission.undetermined + + @Published + private(set) var elapsedTime: TimeInterval = 0 + + @Published + private(set) var currentRecording: Recording? { + willSet { stopRecording() } + didSet { startRecording() } + } + + private let context: AtomContext + private let audioSession: AudioSessionProtocol + private let audioRecorder: AudioRecorderProtocol + private var timerCancellable: Cancellable? + + init( + context: AtomContext, + audioSession: AudioSessionProtocol, + audioRecorder: AudioRecorderProtocol + ) { + self.context = context + self.audioSession = audioSession + self.audioRecorder = audioRecorder + + updateAudioRecorderPermission() + } + + var isRecording: Bool { + currentRecording != nil + } + + func cancelRecording() { + currentRecording = nil + } + + func toggleRecording() { + switch audioSession.recordPermission { + case .undetermined: + audioSession.requestRecordPermissionOnMain { [weak self] _ in + self?.updateAudioRecorderPermission() + } + + case .denied: + context[IsRecordingFailedAtom()] = true + + case .granted: + guard currentRecording == nil else { + return currentRecording = nil + } + + let generator = context.read(GeneratorAtom()) + let date = generator.date() + let url = URL(fileURLWithPath: generator.temporaryDirectory()) + .appendingPathComponent(generator.uuid().uuidString) + .appendingPathExtension("m4a") + + currentRecording = Recording(url: url, date: date) + + @unknown default: + return + } + } + + func delete(_ indexSet: IndexSet) { + for index in indexSet { + let viewModel = context.read(VoiceMemoRowViewModelAtom(voiceMemo: voiceMemos[index])) + viewModel.stopPlaying() + } + + voiceMemos.remove(atOffsets: indexSet) + } + + private func updateAudioRecorderPermission() { + audioRecorderPermission = audioSession.recordPermission + } + + private func startRecording() { + guard let recording = currentRecording else { + timerCancellable = nil + return elapsedTime = 0 + } + + let startDate = context.read(GeneratorAtom()).date() + let timer = context.read(TimerAtom(startDate: startDate, timeInterval: 1)) + + timerCancellable = timer.sink { [weak self] elapsedTime in + self?.elapsedTime = elapsedTime + } + + do { + try audioSession.setCategory(.playAndRecord, mode: .default, options: .defaultToSpeaker) + try audioSession.setActive(true, options: []) + try audioRecorder.record(url: recording.url) + } + catch { + context[IsRecordingFailedAtom()] = true + } + } + + private func stopRecording() { + guard let recording = currentRecording else { + return + } + + let voiceMemo = VoiceMemo( + url: recording.url, + date: recording.date, + duration: audioRecorder.currentTime + ) + + voiceMemos.insert(voiceMemo, at: 0) + audioRecorder.stop() + try? audioSession.setActive(false, options: []) + } +} + +struct VoiceMemoListViewModelAtom: ObservableObjectAtom, Hashable { + func object(context: Context) -> VoiceMemoListViewModel { + VoiceMemoListViewModel( + context: context, + audioSession: context.watch(AudioSessionAtom()), + audioRecorder: context.watch(AudioRecorderAtom()) + ) + } +} + +struct IsPlaybackFailedAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Bool { + false + } +} + +struct IsRecordingFailedAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Bool { + false + } +} + +struct AudioRecorderAtom: ValueAtom, Hashable { + func value(context: Context) -> AudioRecorderProtocol { + AudioRecorder { + context[IsRecordingFailedAtom()] = true + context.read(VoiceMemoListViewModelAtom()).cancelRecording() + } + } +} + +struct AudioSessionAtom: ValueAtom, Hashable { + func value(context: Context) -> AudioSessionProtocol { + AVAudioSession.sharedInstance() + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/VoiceMemoRowAtoms.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/VoiceMemoRowAtoms.swift new file mode 100644 index 00000000..a3c6520e --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/VoiceMemoRowAtoms.swift @@ -0,0 +1,97 @@ +import AVFoundation +import Atoms +import Combine +import Foundation +import SwiftUI + +@MainActor +final class VoiceMemoRowViewModel: ObservableObject { + @Published + private(set) var isPlaying = false { + didSet { togglePlayback() } + } + + @Published + private(set) var elapsedTime: TimeInterval = 0 + + private let context: AtomContext + private let audioPlayer: AudioPlayerProtocol + private let voiceMemo: VoiceMemo + private var timerCancellable: Cancellable? + + init( + context: AtomContext, + audioPlayer: AudioPlayerProtocol, + voiceMemo: VoiceMemo + ) { + self.context = context + self.audioPlayer = audioPlayer + self.voiceMemo = voiceMemo + } + + func stopPlaying() { + isPlaying = false + } + + func togglePaying() { + isPlaying.toggle() + } + + private func togglePlayback() { + guard isPlaying else { + timerCancellable = nil + elapsedTime = 0 + return audioPlayer.stop() + } + + let startDate = context.read(GeneratorAtom()).date() + let timer = context.read(TimerAtom(startDate: startDate, timeInterval: 0.5)) + + timerCancellable = timer.sink { [weak self] elapsedTime in + self?.elapsedTime = elapsedTime + } + + do { + try audioPlayer.play(url: voiceMemo.url) + } + catch { + context[IsPlaybackFailedAtom()] = true + } + } +} + +struct VoiceMemoRowViewModelAtom: ObservableObjectAtom { + let voiceMemo: VoiceMemo + + var key: URL { + voiceMemo.url + } + + func object(context: Context) -> VoiceMemoRowViewModel { + VoiceMemoRowViewModel( + context: context, + audioPlayer: context.watch(AudioPlayerAtom(voiceMemo: voiceMemo)), + voiceMemo: voiceMemo + ) + } +} + +struct AudioPlayerAtom: ValueAtom { + let voiceMemo: VoiceMemo + + var key: URL { + voiceMemo.url + } + + func value(context: Context) -> AudioPlayerProtocol { + AudioPlayer( + onFinish: { + context.read(VoiceMemoRowViewModelAtom(voiceMemo: voiceMemo)).stopPlaying() + }, + onFail: { + context[IsPlaybackFailedAtom()] = false + context.read(VoiceMemoRowViewModelAtom(voiceMemo: voiceMemo)).stopPlaying() + } + ) + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioPlayer.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioPlayer.swift new file mode 100644 index 00000000..b80b1313 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioPlayer.swift @@ -0,0 +1,59 @@ +import AVFoundation + +protocol AudioPlayerProtocol { + func play(url: URL) throws + func stop() +} + +final class AudioPlayer: NSObject, AVAudioPlayerDelegate, AudioPlayerProtocol { + private let onFinish: () -> Void + private let onFail: () -> Void + private var player: AVAudioPlayer? + + init(onFinish: @escaping () -> Void, onFail: @escaping () -> Void) { + self.onFinish = onFinish + self.onFail = onFail + super.init() + } + + func play(url: URL) throws { + player = try AVAudioPlayer(contentsOf: url) + player?.delegate = self + player?.play() + } + + func stop() { + player?.stop() + player = nil + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if flag { + onFinish() + } + else { + onFail() + } + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + onFail() + } +} + +final class MockAudioPlayer: AudioPlayerProtocol { + private(set) var isPlaying = false + var playingError: Error? + + func play(url: URL) throws { + if let playingError = playingError { + throw playingError + } + + isPlaying = true + } + + func stop() { + isPlaying = false + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioRecorder.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioRecorder.swift new file mode 100644 index 00000000..b4fd9329 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioRecorder.swift @@ -0,0 +1,68 @@ +import AVFoundation + +protocol AudioRecorderProtocol { + var currentTime: TimeInterval { get } + + func record(url: URL) throws + func stop() +} + +final class AudioRecorder: NSObject, AVAudioRecorderDelegate, AudioRecorderProtocol { + private var recorder: AVAudioRecorder? + private let onFail: () -> Void + + init(onFail: @escaping () -> Void) { + self.onFail = onFail + super.init() + } + + var currentTime: TimeInterval { + recorder?.currentTime ?? .zero + } + + func record(url: URL) throws { + recorder = try AVAudioRecorder( + url: url, + settings: [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, + ] + ) + recorder?.delegate = self + recorder?.record() + } + + func stop() { + recorder?.stop() + recorder = nil + } + + func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { + if !flag { + onFail() + } + } + + func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + onFail() + } +} + +final class MockAudioRecorder: AudioRecorderProtocol { + var isRecording = false + var recordingError: Error? + var currentTime: TimeInterval = 10 + + func record(url: URL) throws { + if let recordingError = recordingError { + throw recordingError + } + + isRecording = true + } + func stop() { + isRecording = false + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioSession.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioSession.swift new file mode 100644 index 00000000..41425616 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioSession.swift @@ -0,0 +1,47 @@ +import AVFAudio + +protocol AudioSessionProtocol { + var recordPermission: AVAudioSession.RecordPermission { get } + + func requestRecordPermissionOnMain(_ response: @escaping (Bool) -> Void) + func setActive(_ active: Bool, options: AVAudioSession.SetActiveOptions) throws + func setCategory(_ category: AVAudioSession.Category, mode: AVAudioSession.Mode, options: AVAudioSession.CategoryOptions) throws +} + +extension AVAudioSession: AudioSessionProtocol { + func requestRecordPermissionOnMain(_ response: @escaping (Bool) -> Void) { + requestRecordPermission { isGranted in + Task { @MainActor in + response(isGranted) + } + } + } +} + +final class MockAudioSession: AudioSessionProtocol { + var requestRecordPermissionResponse: ((Bool) -> Void)? + var isActive = false + var currentCategory: AVAudioSession.Category? + var currentMode: AVAudioSession.Mode? + var currentOptions: AVAudioSession.CategoryOptions? + + var recordPermission = AVAudioSession.RecordPermission.granted + + func requestRecordPermissionOnMain(_ response: @escaping (Bool) -> Void) { + requestRecordPermissionResponse = response + } + + func setActive(_ active: Bool, options: AVAudioSession.SetActiveOptions) throws { + isActive = active + } + + func setCategory( + _ category: AVAudioSession.Category, + mode: AVAudioSession.Mode, + options: AVAudioSession.CategoryOptions + ) throws { + currentCategory = category + currentMode = mode + currentOptions = options + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/ExampleVoiceMemo.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/ExampleVoiceMemo.swift new file mode 100644 index 00000000..5154aded --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/ExampleVoiceMemo.swift @@ -0,0 +1,10 @@ +import SwiftUI + +// swift-format-ignore: AllPublicDeclarationsHaveDocumentation +public struct ExampleVoiceMemo: View { + public init() {} + + public var body: some View { + VoiceMemoListScreen() + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Helpers.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Helpers.swift new file mode 100644 index 00000000..3e9d0ee9 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/Helpers.swift @@ -0,0 +1,8 @@ +import Foundation + +let dateComponentsFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.zeroFormattingBehavior = .pad + return formatter +}() diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/VoiceMemoListScreen.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/VoiceMemoListScreen.swift new file mode 100644 index 00000000..d23b7650 --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/VoiceMemoListScreen.swift @@ -0,0 +1,98 @@ +import Atoms +import SwiftUI + +struct VoiceMemoListScreen: View { + @WatchStateObject(VoiceMemoListViewModelAtom()) + var viewModel + + @WatchState(IsRecordingFailedAtom()) + var isRecordingFailed + + @WatchState(IsPlaybackFailedAtom()) + var isPlaybackFailed + + var body: some View { + VStack { + List { + ForEach($viewModel.voiceMemos, id: \.url) { $voiceMemo in + VoiceMemoRow(voiceMemo: $voiceMemo) + } + .onDelete { viewModel.delete($0) } + } + + VStack { + ZStack { + switch viewModel.audioRecorderPermission { + case .undetermined, .granted: + Circle() + .foregroundColor(Color(.label)) + .frame(width: 74, height: 74) + + Button { + withAnimation(.spring()) { + viewModel.toggleRecording() + } + } label: { + RoundedRectangle(cornerRadius: viewModel.isRecording ? 4 : 35) + .foregroundColor(Color(.systemRed)) + .padding(viewModel.isRecording ? 18 : 2) + } + .frame(width: 70, height: 70) + + case .denied: + VStack(spacing: 10) { + Text("Recording requires microphone access.") + .multilineTextAlignment(.center) + + Button("Open Settings") { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + } + .frame(maxWidth: .infinity, maxHeight: 74) + + @unknown default: + EmptyView() + } + } + + if viewModel.isRecording, let formattedDuration = dateComponentsFormatter.string(from: viewModel.elapsedTime) { + Text(formattedDuration) + .font(.body.monospacedDigit().bold()) + .foregroundColor(.white) + .colorMultiply(Color(Int(viewModel.elapsedTime).isMultiple(of: 2) ? .systemRed : .label)) + .animation(.easeInOut(duration: 0.5), value: viewModel.elapsedTime) + } + } + .padding() + .alert( + "Voice memo recording failed.", + isPresented: $isRecordingFailed, + actions: {} + ) + .alert( + "Voice memo playback failed.", + isPresented: $isPlaybackFailed, + actions: {} + ) + } + .navigationTitle("Voice Memo") + .background(Color(.tertiarySystemBackground)) + } +} + +struct VoiceMemoListScreen_Preview: PreviewProvider { + static var previews: some View { + AtomRoot { + VoiceMemoListScreen() + } + .override(AudioSessionAtom()) { _ in + MockAudioSession() + } + .override(AudioRecorderAtom()) { _ in + MockAudioRecorder() + } + .override(AudioPlayerAtom.self) { _ in + MockAudioPlayer() + } + } +} diff --git a/Examples/Packages/iOS/Sources/ExampleVoiceMemo/VoiceMemoRow.swift b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/VoiceMemoRow.swift new file mode 100644 index 00000000..6244f29c --- /dev/null +++ b/Examples/Packages/iOS/Sources/ExampleVoiceMemo/VoiceMemoRow.swift @@ -0,0 +1,57 @@ +import Atoms +import SwiftUI + +struct VoiceMemoRow: View { + @Binding + var voiceMemo: VoiceMemo + + @ViewContext + var context + + var viewModel: VoiceMemoRowViewModel { + context.watch(VoiceMemoRowViewModelAtom(voiceMemo: voiceMemo)) + } + + var progress: Double { + max(0, min(1, viewModel.elapsedTime / voiceMemo.duration)) + } + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + if viewModel.isPlaying { + Rectangle() + .foregroundColor(Color(.systemGray5)) + .frame(width: proxy.size.width * CGFloat(progress)) + .animation(.linear(duration: 0.5), value: progress) + } + + HStack { + TextField( + "Untitled, \(voiceMemo.date.formatted(date: .numeric, time: .shortened))", + text: $voiceMemo.title + ) + + Spacer() + + if let time = dateComponentsFormatter.string(from: viewModel.isPlaying ? viewModel.elapsedTime : voiceMemo.duration) { + Text(time) + .font(.footnote.monospacedDigit()) + .foregroundColor(Color(.systemGray)) + } + + Button { + viewModel.togglePaying() + } label: { + Image(systemName: viewModel.isPlaying ? "stop.circle" : "play.circle") + .font(Font.system(size: 22)) + } + } + .frame(maxHeight: .infinity) + .padding([.leading, .trailing]) + } + } + .buttonStyle(.borderless) + .listRowInsets(EdgeInsets()) + } +} diff --git a/Examples/Packages/iOS/Sources/iOSApp/AtomLoggingObserver.swift b/Examples/Packages/iOS/Sources/iOSApp/AtomLoggingObserver.swift new file mode 100644 index 00000000..778c91ec --- /dev/null +++ b/Examples/Packages/iOS/Sources/iOSApp/AtomLoggingObserver.swift @@ -0,0 +1,32 @@ +import Atoms + +final class AtomLoggingObserver: AtomObserver { + private var debugAtomNames = [String]() + + func atomAssigned(atom: Node) { + debugAtomNames.append(Node.debugName) + + print("Assigned \(Node.debugName)") + dump(debugAtomNames, name: "Aliving") + } + + func atomUnassigned(atom: Node) { + if let index = debugAtomNames.firstIndex(of: Node.debugName) { + debugAtomNames.remove(at: index) + } + + print("Unassigned \(Node.debugName)") + dump(debugAtomNames, name: "Aliving") + } + + func atomChanged(snapshot: Snapshot) { + dump(snapshot.value, name: "Changed: \(Node.debugName)") + } +} + +@MainActor +private extension Atom { + static var debugName: String { + String(describing: self) + (shouldKeepAlive ? "[KeepAlive]" : "") + } +} diff --git a/Examples/Packages/iOS/Sources/iOSApp/iOSApp.swift b/Examples/Packages/iOS/Sources/iOSApp/iOSApp.swift new file mode 100644 index 00000000..d9c205cf --- /dev/null +++ b/Examples/Packages/iOS/Sources/iOSApp/iOSApp.swift @@ -0,0 +1,51 @@ +import Atoms +import ExampleCounter +import ExampleMap +import ExampleMovieDB +import ExampleTimeTravel +import ExampleTodo +import ExampleVoiceMemo +import SwiftUI + +// swift-format-ignore: AllPublicDeclarationsHaveDocumentation +public struct iOSApp: App { + public init() {} + + public var body: some Scene { + WindowGroup { + AtomRoot { + NavigationView { + List { + NavigationLink("🔢 Counter") { + ExampleCounter() + } + + NavigationLink("📋 Todo") { + ExampleTodo() + } + + NavigationLink("🎞 The Movie Database") { + ExampleMovieDB() + } + + NavigationLink("🗺 Map") { + ExampleMap() + } + + NavigationLink("🎙️ Voice Memo") { + ExampleVoiceMemo() + } + + NavigationLink("⏳ Time Travel") { + ExampleTimeTravel() + } + } + .navigationTitle("Examples") + .listStyle(.insetGrouped) + } + .navigationViewStyle(.stack) + } + .observe(AtomLoggingObserver()) + } + } +} diff --git a/Examples/Packages/iOS/Tests/ExampleMapTests/ExampleMapTests.swift b/Examples/Packages/iOS/Tests/ExampleMapTests/ExampleMapTests.swift new file mode 100644 index 00000000..b55ce784 --- /dev/null +++ b/Examples/Packages/iOS/Tests/ExampleMapTests/ExampleMapTests.swift @@ -0,0 +1,33 @@ +import Atoms +import CoreLocation +import XCTest + +@testable import ExampleMap + +@MainActor +final class ExampleMapTests: XCTestCase { + func testCoordinateAtom() { + let atom = CoordinateAtom() + let context = AtomTestContext() + let locationManager = MockLocationManager() + + context.override(LocationManagerAtom()) { _ in locationManager } + + locationManager.location = CLLocation(latitude: 1, longitude: 2) + + XCTAssertEqual(context.watch(atom)?.latitude, 1) + XCTAssertEqual(context.watch(atom)?.longitude, 2) + } + + func testAuthorizationStatusAtom() { + let atom = AuthorizationStatusAtom() + let locationManager = MockLocationManager() + let context = AtomTestContext() + + context.override(LocationManagerAtom()) { _ in locationManager } + + locationManager.authorizationStatus = .authorizedWhenInUse + + XCTAssertEqual(context.watch(atom), .authorizedWhenInUse) + } +} diff --git a/Examples/Packages/iOS/Tests/ExampleMovieDBTests/ExampleMovieDBTests.swift b/Examples/Packages/iOS/Tests/ExampleMovieDBTests/ExampleMovieDBTests.swift new file mode 100644 index 00000000..f2f4dbb7 --- /dev/null +++ b/Examples/Packages/iOS/Tests/ExampleMovieDBTests/ExampleMovieDBTests.swift @@ -0,0 +1,207 @@ +import Atoms +import XCTest + +@testable import ExampleMovieDB + +@MainActor +final class ExampleMovieDBTests: XCTestCase { + func testImageAtom() async { + let apiClient = MockAPIClient() + let atom = ImageAtom(path: "", size: .original) + let context = AtomTestContext() + + context.override(APIClientAtom()) { _ in apiClient } + + let image = UIImage() + apiClient.imageResponse = .success(image) + + let successPhase = await AsyncPhase(context.watch(atom).result) + + XCTAssertEqual(successPhase.value, image) + + context.reset(atom) + + let error = URLError(.badURL) + apiClient.imageResponse = .failure(error) + + let failurePhase = await AsyncPhase(context.watch(atom).result) + + XCTAssertEqual(failurePhase.error as? URLError, error) + } + + func testFirstPageAtom() async { + let apiClient = MockAPIClient() + let atom = FirstPageAtom() + let context = AtomTestContext() + + context.override(APIClientAtom()) { _ in apiClient } + + for filter in Filter.allCases { + context[FilterAtom()] = filter + + let expected = PagedResponse.stub() + let error = URLError(.badURL) + + apiClient.filteredMovieResponse = .success(expected) + + let successPhase = await AsyncPhase(context.watch(atom).result) + + XCTAssertEqual(successPhase.value, expected) + + context.reset(atom) + apiClient.filteredMovieResponse = .failure(error) + + let failurePhase = await AsyncPhase(context.watch(atom).result) + + XCTAssertEqual(failurePhase.error as? URLError, error) + } + } + + func testNextPagesAtom() { + let atom = NextPagesAtom() + let context = AtomTestContext() + let pages = [ + PagedResponse(page: 1, totalPages: 100, results: []) + ] + + XCTAssertEqual(context.watch(atom), []) + + context[atom] = pages + + XCTAssertEqual(context.watch(atom), pages) + + context.reset(FirstPageAtom()) + + XCTAssertEqual(context.watch(atom), []) + } + + func testLoadNextAtom() async { + let apiClient = MockAPIClient() + let atom = LoadNextAtom() + let context = AtomTestContext() + + context.override(APIClientAtom()) { _ in apiClient } + + apiClient.filteredMovieResponse = .success(.stub()) + + let loadNext = context.watch(atom) + + XCTAssertEqual(context.watch(NextPagesAtom()), []) + + await loadNext() + + XCTAssertEqual(context.watch(NextPagesAtom()), [.stub()]) + + await loadNext() + + XCTAssertEqual(context.watch(NextPagesAtom()), [.stub(), .stub()]) + } + + func testMyListInsertAtom() { + let atom = MyListInsertAtom() + let context = AtomTestContext() + let action = context.watch(atom) + + XCTAssertEqual(context.watch(MyListAtom()), []) + + action(movie: .stub(id: 0)) + + XCTAssertEqual(context.watch(MyListAtom()), [.stub(id: 0)]) + + action(movie: .stub(id: 1)) + + XCTAssertEqual(context.watch(MyListAtom()), [.stub(id: 0), .stub(id: 1)]) + + action(movie: .stub(id: 0)) + + XCTAssertEqual(context.watch(MyListAtom()), [.stub(id: 1)]) + } + + func testIsInMyListAtom() { + let context = AtomTestContext() + + XCTAssertFalse(context.watch(IsInMyListAtom(movie: .stub(id: 0)))) + + context[MyListAtom()].append(.stub(id: 0)) + + XCTAssertTrue(context.watch(IsInMyListAtom(movie: .stub(id: 0)))) + XCTAssertFalse(context.watch(IsInMyListAtom(movie: .stub(id: 1)))) + } + + func testCastsAtom() async { + let apiClient = MockAPIClient() + let atom = CastsAtom(movieID: 0) + let context = AtomTestContext() + let expected = [Credits.Person(id: 0, name: "test0", profilePath: nil)] + let credits = Credits(id: 0, cast: expected) + let error = URLError(.badURL) + + context.override(APIClientAtom()) { _ in apiClient } + + apiClient.creditsResponse = .success(credits) + + let successPhase = await AsyncPhase(context.watch(atom).result) + + XCTAssertEqual(successPhase.value, expected) + + apiClient.creditsResponse = .failure(error) + context.reset(atom) + + let failurePhase = await AsyncPhase(context.watch(atom).result) + + XCTAssertEqual(failurePhase.error as? URLError, error) + } + + func testSearchMoviesAtom() async { + let apiClient = MockAPIClient() + let atom = SearchMoviesAtom() + let context = AtomTestContext() + let expected = PagedResponse.stub() + let error = URLError(.badURL) + + context.override(APIClientAtom()) { _ in apiClient } + apiClient.searchMoviesResponse = .success(expected) + + context.watch(SearchQueryAtom()) + + let emptyQueryPhase = await context.refresh(atom) + + XCTAssertEqual(emptyQueryPhase.value, []) + + context[SearchQueryAtom()] = "query" + + let successPhase = await context.refresh(atom) + + XCTAssertEqual(successPhase.value, expected.results) + + apiClient.searchMoviesResponse = .failure(error) + + let failurePhase = await context.refresh(atom) + + XCTAssertEqual(failurePhase.error as? URLError, error) + } +} + +private extension PagedResponse where T == Movie { + static func stub() -> Self { + PagedResponse( + page: 0, + totalPages: 100, + results: [.stub()] + ) + } +} + +private extension Movie { + static func stub(id: Int = 0) -> Self { + Movie( + id: id, + title: "title", + overview: nil, + posterPath: nil, + backdropPath: nil, + voteAverage: 0.2, + releaseDate: Date(timeIntervalSince1970: 0) + ) + } +} diff --git a/Examples/Packages/iOS/Tests/ExampleTimeTravelTests/ExampleTimeTravelTests.swift b/Examples/Packages/iOS/Tests/ExampleTimeTravelTests/ExampleTimeTravelTests.swift new file mode 100644 index 00000000..4aa60336 --- /dev/null +++ b/Examples/Packages/iOS/Tests/ExampleTimeTravelTests/ExampleTimeTravelTests.swift @@ -0,0 +1,19 @@ +import Atoms +import XCTest + +@testable import ExampleTimeTravel + +@MainActor +final class ExampleTimeTravelTests: XCTestCase { + func testTextAtom() { + let context = AtomTestContext() + let atom = InputStateAtom() + + XCTAssertEqual(context.watch(atom), InputState()) + + context[atom].text = "modified" + context[atom].latestInput = 1 + + XCTAssertEqual(context.watch(atom), InputState(text: "modified", latestInput: 1)) + } +} diff --git a/Examples/Packages/iOS/Tests/ExampleVoiceMemoTests/ExampleVoiceMemoTests.swift b/Examples/Packages/iOS/Tests/ExampleVoiceMemoTests/ExampleVoiceMemoTests.swift new file mode 100644 index 00000000..8246102b --- /dev/null +++ b/Examples/Packages/iOS/Tests/ExampleVoiceMemoTests/ExampleVoiceMemoTests.swift @@ -0,0 +1,150 @@ +import Atoms +import Combine +import XCTest + +@testable import ExampleVoiceMemo + +@MainActor +final class ExampleVoiceMemoTests: XCTestCase { + func testVoiceMemoListViewModelAtom() { + let audioSession = MockAudioSession() + let audioRecorder = MockAudioRecorder() + let audioPlayer = MockAudioPlayer() + let generator = Generator.stub() + let fileURL = URL(fileURLWithPath: generator.temporaryDirectory()) + .appendingPathComponent(generator.uuid().uuidString) + .appendingPathExtension("m4a") + let timer = PassthroughSubject() + + audioSession.recordPermission = .undetermined + audioRecorder.currentTime = 100 + + let context = AtomTestContext() + + context.override(AudioSessionAtom()) { _ in audioSession } + context.override(AudioRecorderAtom()) { _ in audioRecorder } + context.override(AudioPlayerAtom.self) { _ in audioPlayer } + context.override(GeneratorAtom()) { _ in generator } + context.override(TimerAtom.self) { _ in timer.eraseToAnyPublisher() } + + let viewModel = context.watch(VoiceMemoListViewModelAtom()) + + XCTAssertEqual(viewModel.voiceMemos, []) + XCTAssertEqual(viewModel.audioRecorderPermission, .undetermined) + XCTAssertEqual(viewModel.elapsedTime, 0) + XCTAssertFalse(viewModel.isRecording) + XCTAssertFalse(context.watch(IsRecordingFailedAtom())) + + viewModel.toggleRecording() + + XCTAssertNotNil(audioSession.requestRecordPermissionResponse) + + audioSession.recordPermission = .denied + viewModel.toggleRecording() + + XCTAssertTrue(context.watch(IsRecordingFailedAtom())) + + context[IsRecordingFailedAtom()] = false + audioSession.recordPermission = .granted + audioSession.requestRecordPermissionResponse?(true) + viewModel.toggleRecording() + timer.send(10) + + XCTAssertTrue(viewModel.isRecording) + XCTAssertEqual(viewModel.audioRecorderPermission, .granted) + XCTAssertEqual(viewModel.elapsedTime, 10) + XCTAssertEqual(audioSession.currentCategory, .playAndRecord) + XCTAssertEqual(audioSession.currentMode, .default) + XCTAssertEqual(audioSession.currentOptions, .defaultToSpeaker) + XCTAssertTrue(audioSession.isActive) + XCTAssertTrue(audioRecorder.isRecording) + XCTAssertEqual( + viewModel.currentRecording, + Recording(url: fileURL, date: generator.date()) + ) + + viewModel.toggleRecording() + + let voiceMemo = VoiceMemo( + url: fileURL, + date: generator.date(), + duration: audioRecorder.currentTime + ) + + XCTAssertNil(viewModel.currentRecording) + XCTAssertFalse(viewModel.isRecording) + XCTAssertFalse(audioRecorder.isRecording) + XCTAssertFalse(audioSession.isActive) + XCTAssertEqual(viewModel.voiceMemos, [voiceMemo]) + + let rowViewModel = context.watch(VoiceMemoRowViewModelAtom(voiceMemo: voiceMemo)) + + rowViewModel.togglePaying() + + XCTAssertTrue(rowViewModel.isPlaying) + + viewModel.delete([0]) + + XCTAssertFalse(rowViewModel.isPlaying) + XCTAssertEqual(viewModel.voiceMemos, []) + } + + func testVoiceMemoRowViewModelAtom() { + let audioPlayer = MockAudioPlayer() + let generator = Generator.stub() + let timer = PassthroughSubject() + let voiceMemo = VoiceMemo( + url: URL(fileURLWithPath: "/tmp"), + date: Date(timeIntervalSince1970: 0), + duration: 0 + ) + + let context = AtomTestContext() + + context.override(AudioPlayerAtom.self) { _ in audioPlayer } + context.override(GeneratorAtom()) { _ in generator } + context.override(TimerAtom.self) { _ in timer.eraseToAnyPublisher() } + + let viewModel = context.watch(VoiceMemoRowViewModelAtom(voiceMemo: voiceMemo)) + + XCTAssertFalse(audioPlayer.isPlaying) + XCTAssertFalse(viewModel.isPlaying) + XCTAssertEqual(viewModel.elapsedTime, 0) + XCTAssertFalse(context.watch(IsPlaybackFailedAtom())) + + viewModel.togglePaying() + timer.send(10) + + XCTAssertTrue(audioPlayer.isPlaying) + XCTAssertTrue(viewModel.isPlaying) + XCTAssertEqual(viewModel.elapsedTime, 10) + + viewModel.stopPlaying() + + XCTAssertFalse(audioPlayer.isPlaying) + XCTAssertFalse(viewModel.isPlaying) + XCTAssertEqual(viewModel.elapsedTime, 0) + + audioPlayer.playingError = URLError(.badURL) + viewModel.togglePaying() + + XCTAssertFalse(audioPlayer.isPlaying) + XCTAssertTrue(viewModel.isPlaying) + XCTAssertTrue(context.watch(IsPlaybackFailedAtom())) + + viewModel.togglePaying() + + XCTAssertFalse(audioPlayer.isPlaying) + XCTAssertFalse(viewModel.isPlaying) + } +} + +private extension Generator { + static func stub() -> Self { + Generator( + date: { Date(timeIntervalSince1970: 0) }, + uuid: { UUID(uuidString: "00000000-0000-0000-0000-000000000000")! }, + temporaryDirectory: { "/tmp" } + ) + } +} diff --git a/Examples/project.yml b/Examples/project.yml new file mode 100644 index 00000000..ae9450b4 --- /dev/null +++ b/Examples/project.yml @@ -0,0 +1,73 @@ +name: App +options: + bundleIdPrefix: com.ryo.swiftui-atomic-architecture.examples + createIntermediateGroups: true +settingGroups: + app: + CODE_SIGNING_REQUIRED: NO + CODE_SIGN_IDENTITY: "-" + CODE_SIGN_STYLE: Manual + EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 + EXCLUDED_ARCHS[sdk=iphonesimulator*]: arm64 + EXCLUDED_ARCHS[sdk=appletv*]: x86_64 + EXCLUDED_ARCHS[sdk=appletvsimulator*]: arm64 + OTHER_SWIFT_FLAGS: + - -Xfrontend + - -enable-actor-data-race-checks + +targetTemplates: + App: + type: application + info: + path: App/Info-${target_name}.plist + properties: + UILaunchScreen: + UIRequiresFullScreen: true + CFBundleDisplayName: Atoms + NSLocationWhenInUseUsageDescription: Example Usage + NSMicrophoneUsageDescription: Example Usage + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + settings: + groups: + - app + +schemes: + iOS: + build: + targets: + iOS: all + +packages: + iOSApp: + path: Packages/iOS + + CrossPlatformApp: + path: Packages/CrossPlatform + +targets: + iOS: + templates: + - App + platform: iOS + deploymentTarget: 15.0 + dependencies: + - package: iOSApp + sources: + - App/iOS.swift + + CrossPlatform: + templates: + - App + platform: + - iOS + - macOS + - tvOS + deploymentTarget: + iOS: 14.0 + macOS: 11.0 + tvOS: 14.0 + dependencies: + - package: CrossPlatformApp + sources: + - App/CrossPlatform.swift diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..92d4f9f7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Ryo Aoyama + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..9154b16e --- /dev/null +++ b/Makefile @@ -0,0 +1,55 @@ +TOOL = swift run -c release --package-path Tools +PACKAGE = swift package --package-path Tools +SWIFT_FILE_PATHS = Package.swift Sources Tests Examples +TEST_PLATFORM_IOS = iOS Simulator,name=iPhone 13 Pro +TEST_PLATFORM_MACOS = macOS +TEST_PLATFORM_TVOS = tvOS Simulator,name=Apple TV 4K (at 1080p) (2nd generation) +TEST_PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 7 - 45mm + +.PHONY: proj +proj: + $(TOOL) xcodegen -s Examples/project.yml + +.PHONY: format +format: + $(TOOL) swift-format format -i -p -r $(SWIFT_FILE_PATHS) + +.PHONY: lint +lint: + $(TOOL) swift-format lint -s -p -r $(SWIFT_FILE_PATHS) + +.PHONY: docs +docs: + $(PACKAGE) \ + --allow-writing-to-directory docs \ + generate-documentation \ + --product Atoms \ + --disable-indexing \ + --transform-for-static-hosting \ + --hosting-base-path swiftui-atomic-architecture \ + --output-path docs + +.PHONY: docs-preview +docs-preview: + $(PACKAGE) \ + --disable-sandbox \ + preview-documentation \ + --product Atoms + +.PHONY: test +test: test-library test-examples + +.PHONY: test-library +test-library: + for platform in "$(TEST_PLATFORM_IOS)" "$(TEST_PLATFORM_MACOS)" "$(TEST_PLATFORM_TVOS)" "$(TEST_PLATFORM_WATCHOS)"; do \ + xcodebuild test -scheme swiftui-atomic-architecture -destination platform="$$platform"; \ + done + +.PHONY: test-examples +test-examples: + cd Examples/Packages/iOS && for platform in "$(TEST_PLATFORM_IOS)" ; do \ + xcodebuild test -scheme iOSExamples -destination platform="$$platform"; \ + done + cd Examples/Packages/CrossPlatform && for platform in "$(TEST_PLATFORM_IOS)" "$(TEST_PLATFORM_MACOS)" "$(TEST_PLATFORM_TVOS)" ; do \ + xcodebuild test -scheme CrossPlatformExamples -destination platform="$$platform"; \ + done diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..ce2a209d --- /dev/null +++ b/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.6 + +import PackageDescription + +let package = Package( + name: "swiftui-atomic-architecture", + platforms: [ + .iOS(.v14), + .macOS(.v11), + .tvOS(.v14), + .watchOS(.v7), + ], + products: [ + .library(name: "Atoms", targets: ["Atoms"]) + ], + targets: [ + .target( + name: "Atoms", + swiftSettings: [ + .unsafeFlags([ + "-Xfrontend", + "-enable-actor-data-race-checks", + ]) + ] + ), + .testTarget( + name: "AtomsTests", + dependencies: [ + "Atoms" + ] + ), + ], + swiftLanguageVersions: [.v5] +) diff --git a/README.md b/README.md new file mode 100644 index 00000000..6ba4fde5 --- /dev/null +++ b/README.md @@ -0,0 +1,1508 @@ +

The Atomic Architecture

+

A declarative state management and dependency injection library
for SwiftUI x Concurrency

+

📔 API Reference

+

+ build + release + swift + platform + license +

+ +--- + +- [Introduction](#introduction) +- [Examples](#examples) +- [Getting Started](#getting-started) + - [Installation](#installation) + - [Requirements](#requirements) + - [Documentation](#documentation) + - [Basic Tutorial](#basic-tutorial) +- [Guides](#guides) + - [AtomRoot](#atomroot) + - [Atoms](#atoms) + - [Modifiers](#modifiers) + - [Property Wrappers](#property-wrappers) + - [Contexts](#contexts) + - [KeepAlive](#keepalive) + - [Suspense](#suspense) + - [Testing](#testing) + - [Preview](#preview) + - [Observability](#observability) + - [Advanced Usage](#advanced-usage) + - [Dealing with Known SwiftUI Bugs](#dealing-with-known-swiftui-bugs) +- [Contributing](#contributing) +- [Acknowledgements](#acknowledgements) +- [License](#license) + +--- + +## Introduction + +

+ +|Reactive State Management|Effective Data Caching|Compile Safe
Dependency Injection| +|:------------------------|:----------------|:--------------------------------| +|Piece of state that can be accessed from anywhere propagates changes reactively.|Recompute state and views only when truly need, otherwise it caches state until no longer used.|Successful compilation guarantees that dependency injection is ready.| + +

+ +The Atomic Architecture offers practical capabilities to manage the complexity of modern apps. It effectively integrates the solution for both state management and dependency injection while allowing us to rapidly building an application. + +### Motivation + +SwiftUI offers a simple and understandable state management solution with built-in property wrappers, but is a little uneasiness for building middle to large scale production apps. As a typical example, view state can only be shared by pushing it up to a common ancestor. +Software development is not all set in advance; it evolves over time to meet business and customer needs. Therefore, you may need to radically redesign it so that local state used only in one part of the view-tree can be shared elsewhere, as the app grows. +EnvironmentObject was hoped to be a solution to the problem, but it ended up with let us to create a huge state-holder object - [Big Ball of Mud](https://en.wikipedia.org/wiki/Big_ball_of_mud) being provided from the root, so it could not be an ideal. +Ultimately, pure SwiftUI needs state-drilling from the root to descendants in anyway, which not only makes code-splitting difficult, but also causes gradual performance degradation due to the huge view-tree computation as the app grow up. + +This library solves these problems by defining application state as distributed pieces called atom, allowing state to be shared throughout the app as the source of truth. That said, atom itself doesn't have internal state, but rather retrieves the associated state from the context in which they are used, and ensures that the app is testable. +Furthermore, it manages a directed graph of atoms and propagates state changes transitively from upstream to downstream, such that it updates only the views truly need update while preventing expensive state recomputation, resulting in effortlessly high performance and efficient memory use. + + + +This approach guarantees the following principles: + +- Reactively reflects state changes into views. +- Boilerplate-free interface where shared state has the same simple interface as SwiftUI built-ins. +- Compatible with other architecture libraries of your choice if needed. +- Accelerates code-splitting by distributed & incremental state definition. +- Ensures testable code over time with capabilities of dependency injection. +- Provides simplified interfaces for asynchronous process. +- Swift Concurrency based thread-safety. + +### Quick Overview + +To get a feel for this library, let's first look at the state management for a tiny counter app. + +The `CounterAtom` in the example below represents the shared state of a mutable count value. + +```swift +struct CounterAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Int { + 0 + } +} +``` + +Bind the atom to the view using `@WatchState` property wrapper so that it can obtain the value and write new values. + +```swift +struct CountStepper: View { + @WatchState(CounterAtom()) + var count + + var body: some View { + Stepper(value: $count) {} + } +} +``` + +`@Watch` property wrapper obtains the atom value read-only. +Now that the app can share the state among multiple views without passing it down through initializer. + +```swift +struct CounterView: View { + @Watch(CounterAtom()) + var count + + var body: some View { + VStack { + Text("Count: \(count)") + CountStepper() + } + } +} +``` + +If you like the principles, see the sample apps and the basic tutorial to learn more about this library. + +--- + +## Examples + +| ![Counter](assets/example_counter.png) | ![Todo](assets/example_todo.png) | ![TMDB](assets/example_tmdb.png) | ![Map](assets/example_map.png) | ![Voice Memo](assets/example_voice_memo.png) | ![Time Travel](assets/example_time_travel.png) | +|-|-|-|-|-|-| + +- [Counter](Examples/Packages/CrossPlatform/Sources/ExampleCounter) +Demonstrates the minimum app using this library. +- [Todo](Examples/Packages/CrossPlatform/Sources/ExampleTodo) +A simple todo app that has user interactions, showing how multiple atoms interact with each other. +- [The Movie DB](Examples/Packages/iOS/Sources/ExampleMovieDB) +Demonstrates practical usage which close to a real-world app, using [TMDB](https://www.themoviedb.org/) API for asynchronous networking. +- [Map](Examples/Packages/iOS/Sources/ExampleMap) +A simple but effective app that demonstrates how to wrap a framework in this library. +- [Voice Memo](Examples/Packages/iOS/Sources/ExampleVoiceMemo) +Demonstrates how to manage complex state with multiple dependencies using [MVVM pattern](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) on an atom. Created with imitate [TCA's example](https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples/VoiceMemos). +- [Time Travel](Examples/Packages/iOS/Sources/ExampleTimeTravel) +A simple demo that demonstrates how to do [time travel debugging](https://en.wikipedia.org/wiki/Time_travel_debugging) with this library. + +Each example has test target to show how to test your atoms with dependency injection as well. +Open `Examples/App.xcodeproj` and play around with it! + +--- + +## Getting Started + +### Requirements + +| |Minimum Version| +|------:|--------------:| +|Swift |5.6 | +|Xcode |13.3 | +|iOS |14.0 | +|macOS |11.0 | +|tvOS |14.0 | +|watchOS|7.0 | + +### Installation + +The module name of the package is `Atoms`. Choose one of the instructions below to install and add the following import statement to your source code. + +```swift +import Atoms +``` + +#### [Xcode Package Dependency](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app) + +From Xcode menu: `File` > `Swift Packages` > `Add Package Dependency` + +```text +https://github.com/ra1028/swiftui-atomic-architecture +``` + +#### [Swift Package Manager](https://www.swift.org/package-manager) + +In your `Package.swift` file, first add the following to the package `dependencies`: + +```swift +.package(url: "https://github.com/ra1028/swiftui-atomic-architecture"), +``` + +And then, include "Atoms" as a dependency for your target: + +```swift +.target(name: "", dependencies: [ + .product(name: "Atoms", package: "swiftui-atomic-architecture"), +]), +``` + +### Documentation + +- [API Reference](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms) +- [Example apps](Examples) + +--- + +### Basic Tutorial + +In this tutorial, we will create a simple todo app as an example. This app will support: + +- Create todo items +- Edit todo items +- Filter todo items + +Every view that uses atom must have an `AtomRoot` somewhere in the ancestor. In SwiftUI lifecycle apps, it's recommended to put it right under `WindowGroup`. + +```swift +@main +struct TodoApp: App { + var body: some Scene { + WindowGroup { + AtomRoot { + TodoList() + } + } + } +} +``` + +First, define a todo structure and an enum to filter todo list, and declare state with `StateAtom` that represents a mutable state. + +```swift +struct Todo { + var id: UUID + var text: String + var isCompleted: Bool +} + +enum Filter: CaseIterable, Hashable { + case all, completed, uncompleted +} + +struct TodosAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> [Todo] { + [] + } +} + +struct FilterAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Filter { + .all + } +} +``` + +The `FilteredTodosAtom` below represents the derived state that combines the above two atoms. You can think of derived state as the output of passing values to a pure function that derives a new value from the depending values. + +When dependent data changes, the derived state reactively updates, and the output value is cached until it truly needs to be updated, so don't need to worry about low performance due to the filter function being called each time the view recomputes. + +```swift +struct FilteredTodosAtom: ValueAtom, Hashable { + func value(context: Context) -> [Todo] { + let filter = context.watch(FilterAtom()) + let todos = context.watch(TodosAtom()) + + switch filter { + case .all: return todos + case .completed: return todos.filter(\.isCompleted) + case .uncompleted: return todos.filter { !$0.isCompleted } + } + } +} +``` + +To create a new todo item, you need to access to a writable state that update the value of `TodosAtom` we defined previously. We can use `@WatchState` property wrapper to obtain a read-write access to it. + +```swift +struct TodoCreator: View { + @WatchState(TodosAtom()) + var todos + + @State + var text = "" + + var body: some View { + HStack { + TextField("Enter your todo", text: $text) + Button("Add") { + todos.append(Todo(id: UUID(), text: text, isCompleted: false)) + text = "" + } + } + } +} +``` + +Similarly, build a view to switch the value of `FilterAtom`. Get a `Binding` to the state exposed by `@WatchState` using `$` prefix. + +```swift +struct TodoFilters: View { + @WatchState(FilterAtom()) + var current + + var body: some View { + Picker("Filter", selection: $current) { + ForEach(Filter.allCases, id: \.self) { filter in + switch filter { + case .all: Text("All") + case .completed: Text("Completed") + case .uncompleted: Text("Uncompleted") + } + } + } + .pickerStyle(.segmented) + } +} +``` + +Next, create a view to display and edit individual todo items. + +```swift +struct TodoItem: View { + @WatchState(TodosAtom()) + var allTodos + + @State + var text: String + + @State + var isCompleted: Bool + + let todo: Todo + + init(todo: Todo) { + self.todo = todo + self._text = State(initialValue: todo.text) + self._isCompleted = State(initialValue: todo.isCompleted) + } + + var index: Int { + allTodos.firstIndex { $0.id == todo.id }! + } + + var body: some View { + Toggle(isOn: $isCompleted) { + TextField("Todo", text: $text) { + allTodos[index].text = text + } + } + .onChange(of: isCompleted) { isCompleted in + allTodos[index].isCompleted = isCompleted + } + } +} +``` + +Use `@Watch` to obtain the value of `FilteredTodosAtom` read-only. Updates to any of the dependent states are propagated to this view, and it re-render the todo list. +Finally, assemble the views we've created so far and complete. + +```swift +struct TodoList: View { + @Watch(FilteredTodosAtom()) + var filteredTodos + + var body: some View { + List { + TodoCreator() + TodoFilters() + + ForEach(filteredTodos, id: \.id) { todo in + TodoItem(todo: todo) + } + } + } +} +``` + +That is the basics for building apps using The Atomic Architecture, but even asynchronous processes and more complex state management can be settled according to the same steps. +See [Guides](#guides) section for more detail. Also, the [Examples](Examples) directory has several projects to explore concrete usage. + +--- + +## Guides + +This section introduces the available APIs and their uses. Click on ► `Example` to expand the example code. +To look into the APIs in more detail, visit the [API referrence](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms). + +--- + +### [AtomRoot](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/atomroot) + +Provides the internal store which provides atoms to view-tree through environment values. +It must be the root of any views to manage the state of atoms used throughout the application. + +```swift +@main +struct ExampleApp: App { + var body: some Scene { + WindowGroup { + AtomRoot { + ExampleView() + } + } + } +} +``` + +--- + +### Atoms + +An atom represents a piece of state and is the source of truth for your app. It can also represent a derived state by combining and transforming one or more other atoms. +Each atom does not actually have a global state inside, and retrieve values from the internal store provided by the `AtomRoot`. That's why *they can be accessed from anywhere, but never lose testability.* + +An atom and its value are associated using a unique `key` which is automatically defined if the atom conforms to `Hashable`, but you can also define it explicitly without Hashable. + + ```swift +struct UserNameAtom: StateAtom { + let userID: Int + + var key: Int { + userID + } + + func defaultValue(context: Context) -> String { + "Robert" + } +} +``` + +In order to provide the best interface and effective state management for the type of the resulting values, there are several variants of atoms as following. + +#### [ValueAtom](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/valueatom) + +
Example + +```swift +struct LocaleAtom: ValueAtom, Hashable { + func value(context: Context) -> Locale { + .current + } +} + +struct LocaleView: View { + @Watch(LocaleAtom()) + var locale + + var body: some View { + Text(locale.identifier) + } +} +``` + +
+ +| |Description| +|:----------|:----------| +|Summary |Provides a read-only value.| +|Output |`T`| +|Use Case |Computed property, Derived value, Dependency injection| + +#### [StateAtom](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/stateatom) + +
Example + +```swift +struct CounterAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Int { + 0 + } + + // Does nothing by default. + func willSet(newValue: Int, oldValue: Int, context: Context) { + print("Will change") + } + + // Does nothing by default. + func didSet(newValue: Int, oldValue: Int, context: Context) { + print("Did change") + } +} + +struct CounterView: View { + @WatchState(CounterAtom()) + var count + + var body: some View { + Stepper("Count: \(count)", value: $count) + } +} +``` + +
+ +| |Description| +|:----------|:----------| +|Summary |Provides a read-write state value.| +|Output |`T`| +|Use Case |Mutable state, Derived state| + +#### [TaskAtom](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/taskatom) + +
Example + +```swift +struct FetchUserAtom: TaskAtom, Hashable { + func value(context: Context) async -> User? { + await fetchUser() + } +} + +struct UserView: View { + @Watch(FetchUserAtom()) + var userTask + + var body: some View { + Suspense(userTask) { user in + Text(user?.name ?? "Unknown") + } + } +} +``` + +
+ +| |Description| +|:----------|:----------| +|Summary |Initiates a nonthrowing `Task` from the given `async` function.| +|Output |`Task`| +|Use Case |Non-throwing asynchronous operation e.g. Expensive calculation| + +#### [ThrowingTaskAtom](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/throwingtaskatom) + +
Example + +```swift +struct FetchMoviesAtom: ThrowingTaskAtom, Hashable { + func value(context: Context) async throws -> [Movie] { + try await fetchMovies() + } +} + +struct MoviesView: View { + @Watch(FetchMoviesAtom()) + var moviesTask + + var body: some View { + List { + Suspense(moviesTask) { movies in + ForEach(movies, id: \.id) { movie in + Text(movie.title) + } + } catch: { error in + Text(error.localizedDescription) + } + } + } +} +``` + +
+ +| |Description| +|:----------|:----------| +|Summary |Initiates a throwing `Task` from the given `async throws` function.| +|Output |`Task`| +|Use Case |Throwing asynchronous operation e.g. API call| + +#### [AsyncSequenceAtom](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/asyncsequenceatom) + +
Example + +```swift +struct NotificationAtom: AsyncSequenceAtom, Hashable { + let name: Notification.Name + + func sequence(context: Context) -> NotificationCenter.Notifications { + NotificationCenter.default.notifications(named: name) + } +} + +struct NotificationView: View { + @Watch(NotificationAtom(name: UIApplication.didBecomeActiveNotification)) + var notificationPhase + + var body: some View { + switch notificationPhase { + case .suspending, .failure: + Text("Unknown") + + case .success: + Text("Active") + } + } +} +``` + +
+ +| |Description| +|:----------|:----------| +|Summary |Provides a `AsyncPhase` value that represents asynchronous, sequential elements of the given `AsyncSequence`.| +|Output |`AsyncPhase`| +|Use Case |Handle multiple asynchronous values e.g. web-sockets| + +#### [PublisherAtom](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/publisheratom) + +
Example + +```swift +struct TimerAtom: PublisherAtom, Hashable { + func publisher(context: Context) -> AnyPublisher { + Timer.publish(every: 1, on: .main, in: .default) + .autoconnect() + .eraseToAnyPublisher() + } +} + +struct TimerView: View { + @Watch(TimerAtom()) + var timerPhase + + var body: some View { + if let date = timerPhase.value { + Text(date.formatted(date: .numeric, time: .shortened)) + } + } +} +``` + +
+ +| |Description| +|:------------|:----------| +|Summary |Provides a `AsyncPhase` value that represents sequence of values of the given `Publisher`.| +|Output |`AsyncPhase`| +|Use Case |Handle single or multiple asynchronous value(s) e.g. API call| + +#### [ObservableObjectAtom](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/observableobjectatom) + +
Example + +```swift +class Contact: ObservableObject { + @Published var name = "" + @Published var age = 20 + + func haveBirthday() { + age += 1 + } +} + +struct ContactAtom: ObservableObjectAtom, Hashable { + func object(context: Context) -> Contact { + Contact() + } +} + +struct ContactView: View { + @WatchStateObject(ContactAtom()) + var contact + + var body: some View { + VStack { + TextField("Enter your name", text: $contact.name) + Text("Age: \(contact.age)") + Button("Celebrate your birthday!") { + contact.haveBirthday() + } + } + } +} +``` + +
+ +| |Description| +|:----------|:----------| +|Summary |Instantiates an observable object.| +|Output |`T: ObservableObject`| +|Use Case |Mutable complex state object| + +--- + +### Modifiers + +Modifiers can be applied to an atom to produce a different versions of the original atom to make it more coding friendly or to reduce view re-computation for performance optimization. + +#### [select(_:)](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/atom/select(_:)) + +
Example + +```swift +struct CountAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Int { + 12345 + } +} + +struct CountDisplayView: View { + @Watch(CountAtom().select(\.description)) + var description // : String + + var body: some View { + Text(description) + } +} +``` + +
+ +| |Description| +|:--------------|:----------| +|Summary |Selects a partial property with the specified key path from the original atom. The selected property doesn't notify updates if the new value is equivalent to the old value.| +|Output |`T: Equatable`| +|Compatible |All atoms types. The selected property must be `Equatable` compliant.| +|Use Case |Performance optimization, Property scope restriction| + +#### [phase](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/atom/phase) + +
Example + +```swift +struct FetchWeatherAtom: ThrowingTaskAtom, Hashable { + func value(context: Context) async throws -> Weather { + try await fetchWeather() + } +} + +struct WeatherReportView: View { + @Watch(FetchWeatherAtom().phase) + var weatherPhase // : AsyncPhase + + var body: some View { + switch weatherPhase { + case .suspending: + Text("Loading.") + + case .success(let weather): + Text("It's \(weather.description) now!") + + case .failure: + Text("Failed to get weather data.") + } + } +} +``` + +
+ +| |Description| +|:--------------|:----------| +|Summary |Converts the `Task` that the original atom provides into `AsyncPhase`.| +|Output |`AsyncPhase`| +|Compatible |`TaskAtom`, `ThrowingTaskAtom`| +|Use Case |Consume asynchronous result as `AsyncPhase`| + +--- + +### Property Wrappers + +The following property wrappers are used to bind atoms to view and recompute the view with state changes. +By retrieving the atom through these property wrappers, the internal system marks the atom as in-use and the values are cached until that view is dismantled. + +#### [@Watch](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/watch) + +
Example + +```swift +struct UserNameAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> String { + "John" + } +} + +struct UserNameDisplayView: View { + @Watch(UserNameAtom()) + var name + + var body: some View { + Text("User name: \(name)") + } +} +``` + +
+ +| |Description| +|:--------------|:----------| +|Summary |This property wrapper is similar to `@State` or `@Environment`, but is always read-only. It recomputes the view with value changes.| +|Compatible |All atom types| + +#### [@WatchState](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/watchstate) + +
Example + +```swift +struct UserNameAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> String { + "Jim" + } +} + +struct UserNameInputView: View { + @WatchState(UserNameAtom()) + var name + + var body: some View { + VStack { + TextField("User name", text: $text) + Button("Clear") { + name = "" + } + } + } +} +``` + +
+ +| |Description| +|:--------------|:----------| +|Summary |This property wrapper is read-write as the same interface as `@State`. It recomputes the view with state changes. You can get a `Binding` to the value using `$` prefix.| +|Compatible |`StateAtom`| + +#### [@WatchStateObject](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/watchstateobject) + +
Example + +```swift +class Counter: ObservableObject { + @Published var count = 0 + + func plus(_ value: Int) { + count += value + } +} + +struct CounterAtom: ObservableObjectAtom, Hashable { + func object(context: Context) -> Counter { + Counter() + } +} + +struct CounterView: View { + @WatchStateObject(CounterObjectAtom()) + var counter + + var body: some View { + VStack { + Text("Count: \(counter.count)") + Stepper(value: $counter.count) {} + Button("+100") { + counter.plus(100) + } + } + } +} +``` + +
+ +| |Description| +|:--------------|:----------| +|Summary |This property wrapper has the same interface as `@StateObject` and `@ObservedObject`. It recomputes the view when the observable object updates. You can get a `Binding` to one of the observable object's properties using `$` prefix.| +|Compatible |`ObservableObjectAtom`| + +#### [@ViewContext](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/viewcontext) + +
Example + +```swift +struct FetchBookAtom: ThrowingTaskAtom, Hashable { + let id: Int + + func value(context: Context) async throws -> Book { + try await fetchBook(id: id) + } +} + +struct BookView: View { + @ViewContext + var context + + let id: Int + + var body: some View { + let task = context.watch(FetchBookAtom(id: id)) + + Suspense(task) { book in + Text(book.content) + } suspending: { + ProgressView() + } + } +} +``` + +
+ +Unlike the property wrappers described the above, this property wrapper is not intended to bind single atom. It provides an `AtomViewContext` to the view, allowing for more functional control of atoms. +For instance, the following controls can only be done through the context. + +- `refresh(_:)` operator that to reset an asynchronous atom value and wait for its completion. + +```swift +await context.refresh(FetchMoviesAtom()) +``` + +- `reset(_:)` operator that to clear the current atom value. + +```swift +context.reset(CounterAtom()) +``` + +The context also provides a flexible solution for passing dynamic parameters to atom's initializer. See [Contexts](#contexts) section for more detail. + +--- + +### Contexts + +Contexts are context structure for using and interacting with the state of other atoms from a view or an another atom. The basic API common to all contexts is as follows: + +|API |Use | +|:--------------|:-----------------------------------------------------------------| +|`watch(_:)` |Obtains an atom value and starts watching its update. | +|`read(_:)` |Obtains an atom value but does not watch its update. | +|`set(_:for:)` |Sets a new value to the atom. | +|`subscript(:_)`|Read-write access for applying mutating methods. | +|`state(_:)` |Gets a binding to the atom state. | +|`refresh(_:)` |Reset an atom and await until asynchronous operation is complete. | +|`reset(_:)` |Reset an atom to the default value or a first output. | + +There are the following types context as different contextual environments, and they have some specific APIs for each. + +#### [AtomViewContext](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/atomviewcontext) + +
Example + +```swift +struct SearchQueryAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> String { + "" + } +} + +struct FetchBooksAtom: ThrowingTaskAtom, Hashable { + func value(context: Context) async throws -> [Book] { + let query = context.watch(SearchQueryAtom()) + return try await fetchBooks(query: query) + } +} + +struct BooksView: View { + @ViewContext + var context: AtomViewContext + + var body: some View { + // watch + let booksTask = context.watch(FetchBooksAtom()) // Task<[Book], Error> + // state + let searchQuery = context.state(SearchQueryAtom()) // Binding + + List { + Suspense(booksTask) { books in + ForEach(books, id: \.isbn) { book in + Text("\(book.title): \(book.isbn)") + } + } + } + .searchable(text: searchQuery) + .refreshable { [context] in // NB: Unfortunately, SwiftUI has a memory leak when capturing `self` implicitly inside a `refreshable` modifier. + // refresh + await context.refresh(FetchBooksAtom()) + } + .toolbar { + ToolbarItem(placement: .bottomBar) { + HStack { + Button("Reset") { + // reset + context.reset(SearchQueryAtom()) + } + Button("All") { + // set + context.set("All", for: SearchQueryAtom()) + } + Button("Space") { + // subscript + context[SearchQueryAtom()].append(" ") + } + Button("Print") { + // read + let query = context.read(SearchQueryAtom()) + print(query) + } + } + } + } + } +} +``` + +
+ +Context available through the `@ViewContext` property wrapper when using atoms from a view. There is no specific API for this context. + +#### [AtomRelationContext](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/atomrelationcontext) + +
Example + +```swift +class LocationManagerDelegate: NSObject, CLLocationManagerDelegate { ... } + +struct LocationManagerAtom: ValueAtom, Hashable { + func value(context: Context) -> LocationManagerProtocol { + let manager = CLLocationManager() + let delegate = LocationManagerDelegate() + + manager.delegate = delegate + context.addTermination(manager.stopUpdatingLocation) + context.keepUntilTermination(delegate) + + return manager + } +} + +struct CoordinateAtom: ValueAtom, Hashable { + func value(context: Context) -> CLLocationCoordinate2D? { + let manager = context.watch(LocationManagerAtom()) + return manager.location?.coordinate + } +} +``` + +
+ +Context passed as a parameter to the primary function of each atom type. + +|API |Use | +|:---------------------------|:-------------------------------------------------------------------------------| +|`addTermination(_:)` |Calls the passed closure when the atom is updated or is no longer used. | +|`keepUntilTermination(_:)` |Retains the given object instance until the atom is updated or is no loger used.| + +#### [AtomTestContext](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/atomtestcontext) + +
Example + +```swift +protocol APIClientProtocol { + func fetchMusics() async throws -> [Music] +} + +struct APIClient: APIClientProtocol { ... } +struct MockAPIClient: APIClientProtocol { ... } + +struct APIClientAtom: ValueAtom, Hashable { + func value(context: Context) -> APIClientProtocol { + APIClient() + } +} + +struct FetchMusicsAtom: ThrowingTaskAtom, Hashable { + func value(context: Context) async throws -> [Music] { + let api = context.watch(APIClientAtom()) + return try await api.fetchMusics() + } +} + +@MainActor +class FetchMusicsTests: XCTestCase { + func testFetchMusicsAtom() async throws { + let context = AtomTestContext() + + context.override(APIClientAtom()) { _ in + MockAPIClient() + } + + let musics = try await context.watch(FetchMusicsAtom()).value + + XCTAssertTrue(musics.isEmpty) + } +} +``` + +
+ +Context that can simulate any scenarios in which atoms are used from a view or another atom and provides a comprehensive means of testing. + +|API |Use | +|:------------------|:--------------------------------------------------------------------------------------------| +|`unwatch(_:)` |Simulates a scenario in which the atom is no longer watched. | +|`override(_:with:)`|Overwrites the output of a specific atom or all atoms of the given type with the fixed value.| +|`observe(_:)` |Observes changes in any atom values and its lifecycles. | +|`onUpdate` |Sets a closure that notifies there has been an update to one of the atoms. | + +--- + +### [KeepAlive](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/keepalive) + +`KeepAlive` allows the atom to preserve its state even if it's no longer watched to from anywhere. +In the example case below, once master data is obtained from the server, it can be cached in memory until the app process terminates. + +```swift +struct FetchMasterDataAtom: ThrowingTaskAtom, KeepAlive, Hashable { + func value(context: Context) async throws -> MasterData { + try await fetchMasterData() + } +} +``` + +--- + +### [Suspense](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/suspense) + +`Suspense` awaits the resulting value of the given `Task` and displays the content depending on its phase. +Optionally, you can pass `suspending` content to be displayed until the task completes, and pass `catch` content to be displayed if the task fails. + +```swift +struct NewsView: View { + @Watch(LatestNewsAtom()) + var newsTask: Task + + var body: some View { + Suspense(newsTask) { news in + Text(news.content) + } suspending: { + ProgressView() + } catch: { error in + Text(error.localizedDescription) + } + } +} +``` + +--- + +### Testing + +One important measure of good architecture is how easy testing for middle to large scale production apps is. +The Atomic Architecture naturally integrates dependency injection and state management to provide a comprehensive means of testing. It allows you to test per small atom such that you can keep writing simple test cases per smallest unit of state without compose all states into a huge object and supposing complex integration test scenarios. +In order to fully test your app, this library guarantees the following principles: + +- Hermetic environment that no state is shared between test cases. +- Dependencies are replaceable with any of mock/stub/fake/spy per test case. +- Test cases can reproduce any possible scenarios at the view-layer. + +In the test case, you first create an `AtomTestContext` instance that behaves similarly to other context types. The context allows for flexible reproduction of expected scenarios for testing using the control functions described in the [Contexts](#contexts) section. +In addition, it's able to replace the atom value with test-friendly dependencies with `override` function. It helps you to write a reproducible & stable testing. +Since atom needs to be used from the main actor to guarantee thread-safety, `XCTestCase` class that to test atoms should have `@MainActor` attribute. + +
Click to expand the classes to be tested + +```swift + +struct Book: Equatable { + var title: String + var isbn: String +} + +protocol APIClientProtocol { + func fetchBook(isbn: String) async throws -> Book +} + +struct APIClient: APIClientProtocol { + func fetchBook(isbn: String) async throws -> Book { + ... // Networking logic. + } +} + +class MockAPIClient: APIClientProtocol { + var response: Book? + + func fetchBook(isbn: String) async throws -> Book { + guard let response = response else { + throw URLError(.unknown) + } + return response + } +} + +struct APIClientAtom: ValueAtom, Hashable { + func value(context: Context) -> APIClientProtocol { + APIClient() + } +} + +struct FetchBookAtom: ThrowingTaskAtom, Hashable { + let isbn: String + + func value(context: Context) async throws -> Book { + let api = context.watch(APIClientAtom()) + return try await api.fetchBook(isbn: isbn) + } +} + +``` + +
+ +```swift + +@MainActor +class FetchBookTests: XCTestCase { + func testFetch() async throws { + let context = AtomTestContext() + let api = MockAPIClient() + + // Override the atom value with the mock instance. + context.override(APIClientAtom()) { _ in + api + } + + let expected = Book(title: "A book", isbn: "ISBN000–0–0000–0000–0") + + // Inject the expected response to the mock. + api.response = expected + + let book = try await context.watch(FetchBookAtom(isbn: "ISBN000–0–0000–0000–0")).value + + XCTAssertEqual(book, expected) + } +} +``` + +--- + +### Preview + +Even in SwiftUI previews, the view must have an `AtomRoot` somewhere in the ancestor. However, since The Atomic Architecture offers the new solution for dependency injection, you don't need to do painful DI each time you create previews anymore. You can to override the atoms that you really want to inject substitutions. + +```swift +struct NewsList_Preview: PreviewProvider { + static var previews: some View { + AtomRoot { + NewsList() + } + .override(APIClientAtom()) { _ in + StubAPIClient() + } + } +} +``` + +--- + +### Observability + +
Example + +```swift +struct Logger: AtomObserver { + func atomAssigned(atom: Node) { + print("\(atom) started to be used somewhere.") + } + + func atomUnassigned(atom: Node) { + print("\(atom) is no longer used.") + } + + func atomChanged(snapshot: Snapshot) { + print("The value of `\(snapshot.atom)` is changed to `\(snapshot.value)`.") + } +} + +@main +struct ExampleApp: App { + var body: some Scene { + WindowGroup { + AtomRoot { + VStack { + NavigationLink("Home") { + Home() + } + + NavigationLink("Setting") { + AtomRelay { + Setting() + } + .observe(Logger()) // Observes setting related atoms only. + } + } + } + .observe(Logger()) // Observes all atoms used in the app. + } + } +} +``` + +
+ +You can monitor the updates and lifecycle of atoms used in your app by registering an [AtomObserver](https://ra1028.github.io/swiftui-atomic-architecture/documentation/atoms/atomobserver) compliant instance through the `observe(_:)` function in `AtomRoot` or `AtomRelay`. +Registering an observer in `AtomRoot` observes all atoms used in the app, but in contrast, using `AtomRelay` can observe partial atoms that used in the descendant views. +In addition, this observability can be applied to do [time travel debugging](https://en.wikipedia.org/wiki/Time_travel_debugging) and is demonstrated in one of the [examples](Examples). + +--- + +### Advanced Usage + +#### Obtain an atom value without watching to it + +
Example + +```swift +struct TextAtom: StateAtom, Hashable { + func value(context: Context) -> String { + "" + } +} + +struct TextCopyView: View { + @ViewContext + var context + + var body: some View { + Button("Copy") { + UIPasteboard.general.string = context.read(TextAtom()) + } + } +} +``` + +
+ +The `read(_:)` function is a way to get the state of an atom without having watch to and receiving future updates of it. It's commonly used inside functions triggered by call-to-actions. + +#### Dynamically initiate an atom with external parameters + +
Example + +```swift +struct FetchUserAtom: ThrowingTaskAtom { + let id: Int + + // This atom can also conforms to `Hashable` in this case, + // but this example specifies the key explicitly. + var key: Int { + id + } + + func value(context: Context) async throws -> Value { + try await fetchUser(id: id) + } +} + +struct UserView: View { + let id: Int + + @ViewContext + var context + + var body: some View { + let task = context.watch(FetchUserAtom(id: id)) + + Suspense(task) { user in + VStack { + Text("Name: \(user.name)") + Text("Age: \(user.age)") + } + } + } +} +``` + +
+ +Each atom must have a unique `key` to be uniquely associated with its value. As described in the [Atoms](#atoms) section, it is automatically synthesized by conforming to `Hashable`, but with explicitly specifying a `key` allowing you to pass arbitrary external parameters to the atom. It is commonly used, for example, to retrieve user information associated with a dynamically specified ID from a server. + +#### Pass a context to your object to interact with other atoms + +
Example + +```swift +@MainActor +class MessageLoader: ObservableObject { + let context: AtomContext + + @Published + var phase = AsyncPhase<[Message], Error>.suspending + + init(context: AtomContext) { + self.context = context + } + + func load() async { + do { + let api = context.read(APIClientAtom()) + let messages = try await api.fetchMessages(offset: 0) + phase = .success(messages) + } + catch { + phase = .failure(error) + } + } + + func loadNext() async { + guard let messages = phase.value else { + return + } + + do { + let api = context.read(APIClientAtom()) + let next = try await api.fetchMessages(offset: messages.count) + phase = .success(messages + next) + } + catch { + phase = .failure(error) + } + } +} + +struct MessageLoaderAtom: ObservableObjectAtom, Hashable { + func object(context: Context) -> MessageLoader { + MessageLoader(context: context) + } +} +``` + +
+ +You can pass a context to your object and interact with other atoms at any asynchronous timing. However, in that case, when the `watch` is called, it end up with the object instance itself will be re-created with fresh state. Therefore, you can explicitly prevent the use of the `watch` by passing it as `AtomContext` type. + +--- + +### Dealing with Known SwiftUI Bugs + +#### In iOS14, modal presentation causes assertionFailure when dismissing it + +
Workaround + +```swift +struct RootView: View { + @State + var isPresented = false + + @ViewContext + var context + + var body: some View { + VStack { + Text("Example View") + } + .sheet(isPresented: $isPresented) { + AtomRelay(context) { + MailView() + } + } + } +} +``` + +
+ +Unfortunately, SwiftUI has a bug in iOS14 where the `EnvironmentValue` is removed from a screen presented with `.sheet` just before dismissing it. Since The Atomic Architecture is designed based on `EnvironmentValue`, this bug end up triggering the friendly `assertionFailure` that is added so that developers can easily aware of forgotten `AtomRoot` implementation. +As a workaround, `AtomRelay` has the ability to explicitly inherit the internal store through `AtomViewContext` from the parent view. + +#### Some SwiftUI modifiers cause memory leak + +
Workaround + +```swift +@ViewContext +var context + +... + +.refreshable { [context] in + await context.refresh(FetchDataAtom()) +} +``` + +```swift +@State +var isShowingSearchScreen = false + +... + +.onSubmit { [$isShowingSearchScreen] in + $isShowingSearchScreen.wrappedValue = true +} +``` + +
+ +Some modifiers in SwiftUI seem to cause an internal memory leak if it captures `self` implicitly or explicitly. To avoid that bug, make sure that `self` is not captured when using those modifiers. +Below are the list of modifiers I found that cause memory leaks: + +- [`refreshable(action:)`](https://developer.apple.com/documentation/SwiftUI/View/refreshable(action:)) +- [`onSubmit(of:_:)`](https://developer.apple.com/documentation/swiftui/view/onsubmit(of:_:)) + +--- + +## Contributing + +Any type of contribution is welcome! e.g. + +- Give it star ⭐ & fork this repository. +- Report bugs with reproducible steps. +- Propose new features. +- Add more documentations. +- Provide repos of sample apps using this library. +- Become a maintainer after making multiple contributions. +- Become a [sponsor](https://github.com/sponsors/ra1028). + +--- + +## Acknowledgements + +- [Recoil](https://recoiljs.org) +- [Riverpod](https://riverpod.dev) +- [Jotai](https://github.com/pmndrs/jotai) + +--- + +## License + +[MIT © Ryo Aoyama](LICENSE) + +--- diff --git a/Sources/Atoms/AsyncPhase.swift b/Sources/Atoms/AsyncPhase.swift new file mode 100644 index 00000000..88d03717 --- /dev/null +++ b/Sources/Atoms/AsyncPhase.swift @@ -0,0 +1,135 @@ +/// A value that represents a success, a failure, or a state in which the result of +/// asynchronous process has not yet been determined. +public enum AsyncPhase { + /// A suspending phase in which the result has not yet been determined. + case suspending + + /// A success, storing a `Success` value. + case success(Success) + + /// A failure, storing a `Failure` value. + case failure(Failure) + + /// Creates a new phase with the given result by mapping either of a `success` or + /// a `failure`. + /// + /// - Parameter result: A result value to be mapped. + public init(_ result: Result) { + switch result { + case .success(let value): + self = .success(value) + + case .failure(let error): + self = .failure(error) + } + } + + /// A boolean value indicating whether `self` is ``AsyncPhase/suspending``. + public var isSuspending: Bool { + guard case .suspending = self else { + return false + } + + return true + } + + /// A boolean value indicating whether `self` is ``AsyncPhase/success(_:)``. + public var isSuccess: Bool { + guard case .success = self else { + return false + } + + return true + } + + /// A boolean value indicating whether `self` is ``AsyncPhase/failure(_:)``. + public var isFailure: Bool { + guard case .failure = self else { + return false + } + + return true + } + + /// Returns the success value if `self` is ``AsyncPhase/success(_:)``, otherwise returns `nil`. + public var value: Success? { + guard case .success(let value) = self else { + return nil + } + + return value + } + + /// Returns the error value if `self` is ``AsyncPhase/failure(_:)``, otherwise returns `nil`. + public var error: Failure? { + guard case .failure(let error) = self else { + return nil + } + + return error + } + + /// Returns a new phase, mapping any success value using the given transformation. + /// + /// - Parameter transform: A closure that takes the success value of this instance. + /// + /// - Returns: An ``AsyncPhase`` instance with the result of evaluating `transform` + /// as the new success value if this instance represents a success. + public func map(_ transform: (Success) -> NewSuccess) -> AsyncPhase { + flatMap { .success(transform($0)) } + } + + /// Returns a new phase, mapping any failure value using the given transformation. + /// + /// - Parameter transform: A closure that takes the failure value of the instance. + /// + /// - Returns: An ``AsyncPhase`` instance with the result of evaluating `transform` as + /// the new failure value if this instance represents a failure. + public func mapError(_ transform: (Failure) -> NewFailure) -> AsyncPhase { + flatMapError { .failure(transform($0)) } + } + + /// Returns a new phase, mapping any success value using the given transformation + /// and unwrapping the produced result. + /// + /// - Parameter transform: A closure that takes the success value of the instance. + /// + /// - Returns: An ``AsyncPhase`` instance, either from the closure or the previous + /// ``AsyncPhase/failure(_:)``. + public func flatMap(_ transform: (Success) -> AsyncPhase) -> AsyncPhase { + switch self { + case .suspending: + return .suspending + + case .success(let value): + return transform(value) + + case .failure(let error): + return .failure(error) + } + } + + /// Returns a new phase, mapping any failure value using the given transformation + /// and unwrapping the produced result. + /// + /// - Parameter transform: A closure that takes the failure value of the instance. + /// + /// - Returns: An ``AsyncPhase`` instance, either from the closure or the previous + /// ``AsyncPhase/success(_:)``. + public func flatMapError(_ transform: (Failure) -> AsyncPhase) -> AsyncPhase { + switch self { + case .suspending: + return .suspending + + case .success(let value): + return .success(value) + + case .failure(let error): + return transform(error) + } + } +} + +extension AsyncPhase: Sendable where Success: Sendable {} +extension AsyncPhase: Equatable where Success: Equatable, Failure: Equatable {} +extension AsyncPhase: Hashable where Success: Hashable, Failure: Hashable {} diff --git a/Sources/Atoms/Atom/AsyncSequenceAtom.swift b/Sources/Atoms/Atom/AsyncSequenceAtom.swift new file mode 100644 index 00000000..31c64a3f --- /dev/null +++ b/Sources/Atoms/Atom/AsyncSequenceAtom.swift @@ -0,0 +1,70 @@ +/// An atom type that provides asynchronous, sequential elements of the given `AsyncSequence` +/// as an ``AsyncPhase`` value. +/// +/// The sequential elements emitted by the `AsyncSequence` will be converted into an enum representation +/// ``AsyncPhase`` that changes overtime. When the sequence emits new elements, it notifies changes to +/// downstream atoms and views, so that they can consume it without suspension points which spawn with +/// `await` keyword. +/// +/// ## Output Value +/// +/// ``AsyncPhase`` +/// +/// ## Example +/// +/// ```swift +/// struct QuakeMonitorAtom: AsyncSequenceAtom, Hashable { +/// func sequence(context: Context) -> AsyncStream { +/// AsyncStream { continuation in +/// let monitor = QuakeMonitor() +/// monitor.quakeHandler = { quake in +/// continuation.yield(quake) +/// } +/// continuation.onTermination = { @Sendable _ in +/// monitor.stopMonitoring() +/// } +/// monitor.startMonitoring() +/// } +/// } +/// } +/// +/// struct QuakeMonitorView: View { +/// @Watch(QuakeMonitorAtom()) +/// var quakes +/// +/// var body: some View { +/// switch quakes { +/// case .suspending, .failure: +/// Text("Calm") +/// +/// case .success(let quake): +/// Text("Quake: \(quake.date)") +/// } +/// } +/// } +/// ``` +/// +public protocol AsyncSequenceAtom: Atom where Hook == AsyncSequenceHook { + /// The type of asynchronous sequence that this atom manages. + associatedtype Sequence: AsyncSequence + + /// Creates an asynchronous sequence that to be started when this atom is actually used. + /// + /// The sequence that is produced by this method must be instantiated anew each time this method + /// is called. Otherwise, it could throw a fatal error because Swift Concurrency doesn't allow + /// single `AsyncSequence` instance to be shared between multiple locations. + /// + /// - Parameter context: A context structure that to read, watch, and otherwise + /// interacting with other atoms. + /// + /// - Returns: An asynchronous sequence that produces asynchronous, sequential elements. + @MainActor + func sequence(context: Context) -> Sequence +} + +public extension AsyncSequenceAtom { + @MainActor + var hook: Hook { + Hook(sequence: sequence) + } +} diff --git a/Sources/Atoms/Atom/Atom.swift b/Sources/Atoms/Atom/Atom.swift new file mode 100644 index 00000000..02100fe1 --- /dev/null +++ b/Sources/Atoms/Atom/Atom.swift @@ -0,0 +1,70 @@ +/// Declares that a type can produce a value that can be accessed from everywhere. +/// +/// In summary, this protocol declares a hook that determines the behavioral details +/// of this atom and a key determines the value uniqueness. +/// The value produced by an atom is created only when the atom is watched from somewhere, +/// and is immediately released when no longer watched to. +/// +/// If the atom value needs to be preserved even if no longer watched to, you can consider +/// conform the ``KeepAlive`` protocol to the atom. +public protocol Atom { + /// A type representing the stable identity of this atom. + associatedtype Key: Hashable + + /// A type of the hook that determines behavioral details. + associatedtype Hook: AtomHook + + /// A type of the context structure that to read, watch, and otherwise interacting + /// with other atoms. + typealias Context = AtomRelationContext + + /// A boolean value indicating whether the atom value should be preserved even if + /// no longer watched to. + /// + /// It's recommended to conform the ``KeepAlive`` to this atom, instead of overriding + /// this property to return `true`. + /// The default is `false`. + @MainActor + static var shouldKeepAlive: Bool { get } + + /// A unique value used to identify the atom internally. + /// + /// This key don't have to be unique with respect to other atoms in the entire application + /// because it is identified respecting the metatype of this atom. + /// If this atom conforms to `Hashable`, it will adopt itself as the `key` by default. + var key: Key { get } + + /// Internal use, the hook for managing the state of this atom. + @MainActor + var hook: Hook { get } + + /// Returns a boolean value that determines whether it should notify the value update to + /// watchers with comparing the given old value and the new value. + /// + /// - Parameters: + /// - newValue: The new value after update. + /// - oldValue: The old value before update. + /// + /// - Returns: A boolean value that determines whether it should notify the value update + /// to watchers. + @MainActor + func shouldNotifyUpdate(newValue: Hook.Value, oldValue: Hook.Value) -> Bool +} + +public extension Atom { + @MainActor + func shouldNotifyUpdate(newValue: Hook.Value, oldValue: Hook.Value) -> Bool { + true + } +} + +public extension Atom { + @MainActor + static var shouldKeepAlive: Bool { + false + } +} + +public extension Atom where Self == Key { + var key: Self { self } +} diff --git a/Sources/Atoms/Atom/ObservableObjectAtom.swift b/Sources/Atoms/Atom/ObservableObjectAtom.swift new file mode 100644 index 00000000..84c3570d --- /dev/null +++ b/Sources/Atoms/Atom/ObservableObjectAtom.swift @@ -0,0 +1,73 @@ +import Combine + +/// An atom type that instantiates an observable object. +/// +/// When published properties of the observable object provided through this atom changes, it +/// notifies updates to downstream atoms and views that watches this atom. +/// In case you want to get another atom value from the context later by methods in that +/// observable object, you can pass it as ``AtomContext``. +/// +/// - Note: If you watch other atoms through the context passed as parameter, the observable +/// object itself will be re-created with fresh state when the watching atom is updated. +/// +/// ## Output Value +/// +/// Self.ObjectType +/// +/// ## Example +/// +/// ```swift +/// class Contact: ObservableObject { +/// @Published var name = "" +/// @Published var age = 20 +/// +/// func haveBirthday() { +/// age += 1 +/// } +/// } +/// +/// struct ContactAtom: ObservableObjectAtom, Hashable { +/// func object(context: Context) -> Contact { +/// Contact() +/// } +/// } +/// +/// struct ContactView: View { +/// @WatchStateObject(ContactAtom()) +/// var contact +/// +/// var body: some View { +/// VStack { +/// TextField("Enter your name", text: $contact.name) +/// Text("Age: \(contact.age)") +/// Button("Celebrate your birthday!") { +/// contact.haveBirthday() +/// } +/// } +/// } +/// } +/// ``` +/// +public protocol ObservableObjectAtom: Atom where Hook == ObservableObjectHook { + /// The type of observable object that this atom produces. + associatedtype ObjectType: ObservableObject + + /// Creates an observed object when this atom is actually used. + /// + /// The observable object that returned from this method is managed internally and notifies + /// its updates to downstream atoms and views that watches this atom. + /// + /// - Parameter context: A context structure that to read, watch, and otherwise + /// interacting with other atoms. + /// + /// - Returns: An observable object that notifies its updates over time. + @MainActor + func object(context: Context) -> ObjectType +} + +public extension ObservableObjectAtom { + @MainActor + var hook: Hook { + Hook(object: object) + } +} diff --git a/Sources/Atoms/Atom/PublisherAtom.swift b/Sources/Atoms/Atom/PublisherAtom.swift new file mode 100644 index 00000000..b97bee90 --- /dev/null +++ b/Sources/Atoms/Atom/PublisherAtom.swift @@ -0,0 +1,63 @@ +import Combine + +/// An atom type that provides a sequence of values of the given `Publisher` as an ``AsyncPhase`` value. +/// +/// The sequential values emitted by the `Publisher` will be converted into an enum representation +/// ``AsyncPhase`` that changes overtime. When the publisher emits new results, it notifies changes to +/// downstream atoms and views, so that they can consume it without managing subscription. +/// +/// ## Output Value +/// +/// AsyncPhase +/// +/// ## Example +/// +/// ```swift +/// struct TimerAtom: PublisherAtom, Hashable { +/// func publisher(context: Context) -> AnyPublisher { +/// Timer.publish(every: 1, on: .main, in: .default) +/// .autoconnect() +/// .eraseToAnyPublisher() +/// } +/// } +/// +/// struct TimerView: View { +/// @Watch(TimerAtom()) +/// var timer +/// +/// var body: some View { +/// switch timer { +/// case .suspending: +/// Text("Waiting") +/// +/// case .success(let date): +/// Text("Now: \(date)") +/// } +/// } +/// } +/// ``` +/// +public protocol PublisherAtom: Atom where Hook == PublisherHook { + /// The type of publisher that this atom manages. + associatedtype Publisher: Combine.Publisher + + /// Creates a publisher that to be subscribed when this atom is actually used. + /// + /// The publisher that is produced by this method must be instantiated anew each time this method + /// is called. Otherwise, a cold publisher which has internal state can get result to produce + /// non-reproducible results when it is newly subscribed. + /// + /// - Parameter context: A context structure that to read, watch, and otherwise + /// interacting with other atoms. + /// + /// - Returns: A publisher that produces a sequence of values over time. + @MainActor + func publisher(context: Context) -> Publisher +} + +public extension PublisherAtom { + @MainActor + var hook: Hook { + Hook(publisher: publisher) + } +} diff --git a/Sources/Atoms/Atom/StateAtom.swift b/Sources/Atoms/Atom/StateAtom.swift new file mode 100644 index 00000000..f8c411d6 --- /dev/null +++ b/Sources/Atoms/Atom/StateAtom.swift @@ -0,0 +1,93 @@ +/// An atom type that provides a read-write state value. +/// +/// This atom provides a mutable state value that can be accessed from anywhere, and it notifies changes +/// to downstream atoms and views. +/// In addition, there are `willSet`/`didSet` functions to generate side effects in response before and +/// after state changes. +/// +/// ## Output Value +/// +/// Self.Value +/// +/// ## Example +/// +/// ```swift +/// struct CounterAtom: StateAtom, Hashable { +/// func defaultValue(context: Context) -> Int { +/// 0 +/// } +/// +/// func willSet(newValue: Int, oldValue: Int, , context: Context) { +/// print("Will change - newValue: \(newValue), oldValue: \(oldValue)") +/// } +/// +/// func didSet(newValue: Int, oldValue: Int, context: Context) { +/// print("Did change - newValue: \(newValue), oldValue: \(oldValue)") +/// } +/// } +/// +/// struct CounterView: View { +/// @WatchState(CounterAtom()) +/// var count +/// +/// var body: some View { +/// Stepper("Count: \(count)", value: $count) +/// } +/// } +/// ``` +/// +public protocol StateAtom: Atom where Hook == StateHook { + /// The type of state value that this atom produces. + associatedtype Value + + /// Creates a default value of the state that to be provided via this atom. + /// + /// The value returned from this method will be the default state value. When this atom is reset, + /// the state will revert to this value. + /// + /// - Parameter context: A context structure that to read, watch, and otherwise + /// interacting with other atoms. + /// + /// - Returns: A default value of state. + @MainActor + func defaultValue(context: Context) -> Value + + /// Observes and responds to changes in the state value which is called just before + /// the state is changed. + /// + /// - Parameters + /// - newValue: A new value after update. + /// - oldValue: A old value before update. + /// - context: A context structure that to read, watch, and otherwise + /// interacting with other atoms. + @MainActor + func willSet(newValue: Value, oldValue: Value, context: Context) + + /// Observes and responds to changes in the state value which is called just after + /// the state is changed. + /// + /// - Parameters + /// - newValue: A new value after update. + /// - oldValue: A old value before update. + /// - context: A context structure that to read, watch, and otherwise + /// interacting with other atoms. + @MainActor + func didSet(newValue: Value, oldValue: Value, context: Context) +} + +public extension StateAtom { + @MainActor + var hook: Hook { + Hook( + defaultValue: defaultValue, + willSet: willSet, + didSet: didSet + ) + } + + @MainActor + func willSet(newValue: Value, oldValue: Value, context: Context) {} + + @MainActor + func didSet(newValue: Value, oldValue: Value, context: Context) {} +} diff --git a/Sources/Atoms/Atom/TaskAtom.swift b/Sources/Atoms/Atom/TaskAtom.swift new file mode 100644 index 00000000..c24b08c8 --- /dev/null +++ b/Sources/Atoms/Atom/TaskAtom.swift @@ -0,0 +1,59 @@ +/// An atom type that provides a nonthrowing `Task` from the given asynchronous function. +/// +/// This atom guarantees that the task to be identical instance and its state can be shared +/// at anywhere even when they are accessed simultaneously from multiple locations. +/// +/// - SeeAlso: ``ThrowingTaskAtom`` +/// - SeeAlso: ``Suspense`` +/// +/// ## Output Value +/// +/// Task +/// +/// ## Example +/// +/// ```swift +/// struct AsyncTitleAtom: TaskAtom, Hashable { +/// func value(context: Context) async -> String { +/// try? await Task.sleep(nanoseconds: 1_000_000_000) +/// return "The Atomic Architecture" +/// } +/// } +/// +/// struct DelayedTitleView: View { +/// @Watch(AsyncTitleAtom()) +/// var title +/// +/// var body: some View { +/// Suspense(title) { title in +/// Text(title) +/// } suspending: { +/// Text("Loading...") +/// } +/// } +/// } +/// ``` +/// +public protocol TaskAtom: Atom where Hook == TaskHook { + /// The type of value that this atom produces. + associatedtype Value + + /// Asynchronously produces a value that to be provided via this atom. + /// + /// This asynchronous method is converted to a `Task` internally, and if it will be + /// cancelled by downstream atoms or views, this method will also be cancelled. + /// + /// - Parameter context: A context structure that to read, watch, and otherwise + /// interacting with other atoms. + /// + /// - Returns: A nonthrowing `Task` that produces asynchronous value. + @MainActor + func value(context: Context) async -> Value +} + +public extension TaskAtom { + @MainActor + var hook: Hook { + Hook(value: value) + } +} diff --git a/Sources/Atoms/Atom/ThrowingTaskAtom.swift b/Sources/Atoms/Atom/ThrowingTaskAtom.swift new file mode 100644 index 00000000..1867f22d --- /dev/null +++ b/Sources/Atoms/Atom/ThrowingTaskAtom.swift @@ -0,0 +1,63 @@ +/// An atom type that provides a throwing `Task` from the given asynchronous, throwing function. +/// +/// This atom guarantees that the task to be identical instance and its state can be shared +/// at anywhere even when they are accessed simultaneously from multiple locations. +/// +/// - SeeAlso: ``TaskAtom`` +/// - SeeAlso: ``Suspense`` +/// +/// ## Output Value +/// +/// Task +/// +/// ## Example +/// +/// ```swift +/// struct AsyncTitleAtom: ThrowingTaskAtom, Hashable { +/// func value(context: Context) async throws -> String { +/// try await Task.sleep(nanoseconds: 1_000_000_000) +/// return "The Atomic Architecture" +/// } +/// } +/// +/// struct DelayedTitleView: View { +/// @Watch(AsyncTitleAtom()) +/// var title +/// +/// var body: some View { +/// Suspense(title) { title in +/// Text(title) +/// } suspending: { +/// Text("Loading") +/// } catch: { +/// Text("Failed") +/// } +/// } +/// } +/// ``` +/// +public protocol ThrowingTaskAtom: Atom where Hook == ThrowingTaskHook { + /// The type of value that this atom produces. + associatedtype Value + + /// Asynchronously produces a value that to be provided via this atom. + /// + /// This asynchronous method is converted to a `Task` internally, and if it will be + /// cancelled by downstream atoms or views, this method will also be cancelled. + /// + /// - Parameter context: A context structure that to read, watch, and otherwise + /// interacting with other atoms. + /// + /// - Throws: The error that occurred during the process of creating the resulting value. + /// + /// - Returns: A throwing `Task` that produces asynchronous value. + @MainActor + func value(context: Context) async throws -> Value +} + +public extension ThrowingTaskAtom { + @MainActor + var hook: Hook { + Hook(value: value) + } +} diff --git a/Sources/Atoms/Atom/ValueAtom.swift b/Sources/Atoms/Atom/ValueAtom.swift new file mode 100644 index 00000000..2f341dc3 --- /dev/null +++ b/Sources/Atoms/Atom/ValueAtom.swift @@ -0,0 +1,54 @@ +/// An atom type that provides a read-only value. +/// +/// The value is cached until it will no longer be watched to or any of watching atoms will notify update. +/// This atom can be used to combine one or more other atoms and transform result to another value. +/// Moreover, it can also be used to do dependency injection in compile safe and overridable for testing, +/// by providing a dependency instance required in another atom. +/// +/// ## Output Value +/// +/// Self.Value +/// +/// ## Example +/// +/// ```swift +/// struct CharacterCountAtom: ValueAtom, Hashable { +/// func value(context: Context) -> Int { +/// let text = context.watch(TextAtom()) +/// return text.count +/// } +/// } +/// +/// struct CharacterCountView: View { +/// @Watch(CharacterCountAtom()) +/// var count +/// +/// var body: some View { +/// Text("Character count: \(count)") +/// } +/// } +/// ``` +/// +public protocol ValueAtom: Atom where Hook == ValueHook { + /// The type of value that this atom produces. + associatedtype Value + + /// Creates a constant value that to be provided via this atom. + /// + /// This method is called only when this atom is actually used, and is cached until it will + /// no longer be watched to or any of watching atoms will be updated. + /// + /// - Parameter context: A context structure that to read, watch, and otherwise + /// interacting with other atoms. + /// + /// - Returns: A constant value. + @MainActor + func value(context: Context) -> Value +} + +public extension ValueAtom { + @MainActor + var hook: Hook { + Hook(value: value) + } +} diff --git a/Sources/Atoms/AtomObserver.swift b/Sources/Atoms/AtomObserver.swift new file mode 100644 index 00000000..acdc4581 --- /dev/null +++ b/Sources/Atoms/AtomObserver.swift @@ -0,0 +1,65 @@ +/// An interface you implement to observe changes in atoms. +/// +/// The ``AtomObserver`` protocol provides a comprehensive way to observe changes in atoms +/// such as an atom is assigned, unassigned, or its value is changed. +/// +/// The most typical use of this protocol would be logging. The following example creates +/// a custom logger class and prits messages when the assign, unassign, and value update of atoms. +/// +/// ```swift +/// struct Logger: AtomObserver { +/// func atomAssigned(atom: Node) { +/// print("Assigned: \(atom)") +/// } +/// +/// func atomUnassigned(atom: Node) { +/// print("Unassigned: \(atom)") +/// } +/// +/// func atomChanged(snapshot: Snapshot) { +/// print("Updated: \(snapshot.atom) - value: \(snapshot.value)") +/// } +/// } +/// +/// struct TodoApp: App { +/// var body: some Scene { +/// WindowGroup { +/// AtomRoot { +/// TodoListView() +/// } +/// .observe(Logger()) +/// } +/// } +/// } +/// ``` +/// +@MainActor +public protocol AtomObserver { + /// Tells the observer an atom has been assigned to any of atoms or views. + /// + /// The default implementation does nothing. + /// + /// - Parameter atom: The newly assigned atom. + func atomAssigned(atom: Node) + + /// Tells the observer an atom has been unassigned from all atoms or views. + /// + /// The default implementation does nothing. + /// + /// - Parameter atom: The unassigned atom. + func atomUnassigned(atom: Node) + + /// Tells the observer the value of an atom has been updated. + /// + /// The default implementation does nothing. + /// + /// - Parameter snapshot: A snapshot structure that contains the updated atom + /// instance and its value. + func atomChanged(snapshot: Snapshot) +} + +public extension AtomObserver { + func atomAssigned(atom: Node) {} + func atomUnassigned(atom: Node) {} + func atomChanged(snapshot: Snapshot) {} +} diff --git a/Sources/Atoms/AtomRelay.swift b/Sources/Atoms/AtomRelay.swift new file mode 100644 index 00000000..9efd7c4b --- /dev/null +++ b/Sources/Atoms/AtomRelay.swift @@ -0,0 +1,93 @@ +import SwiftUI + +/// A view that relays an internal store from the passed view context or from environment values. +/// +/// For some reasons, sometimes SwiftUI can fail to pass environment values in the view-tree. +/// The typical example is that, if you use SwiftUI view inside UIKit view, it could fail as +/// SwiftUI can't pass environment values across UIKit. +/// In that case, you can wrap the view with ``AtomRelay`` and pass a view context to it so that +/// the descendant views can explicitly inherit an internal store. +/// +/// ```swift +/// @ViewContext +/// var context +/// +/// var body: some View { +/// MyUIViewWrappingView { +/// AtomRelay(context) { +/// MySwiftUIView() +/// } +/// } +/// } +/// ``` +/// +/// Also, ``AtomRelay`` can be created without passing a view context, and in this case, it relays +/// an internal store from environment values. +/// Relaying from environment values means that does actually nothing and just inherits an internal +/// store from ``AtomRoot`` as same as usual views, but ``AtomRelay`` provides the modifier +/// ``AtomRelay/observe(_:)`` to monitor all changes in atoms used in descendant views. +/// +/// ```swift +/// AtomRelay { +/// MyView() +/// } +/// .observe(Logger()) +/// ``` +/// +public struct AtomRelay: View { + private let context: AtomViewContext? + private let content: Content + private var observers = [AtomObserver]() + + @Environment(\.atomStore) + private var inheritedStore + + /// Creates an atom relay with the specified content that will be allowed to use atoms by + /// passing a view context to explicitly make the descendant views inherit an internal store. + /// + /// - Parameters: + /// - context: The parent view context that for inheriting an internal store explicitly. + /// Default is nil. + /// - content: The view content that inheriting from the parent. + public init( + _ context: AtomViewContext? = nil, + @ViewBuilder content: () -> Content + ) { + self.context = context + self.content = content() + } + + /// The content and behavior of the view. + public var body: some View { + content.environment( + \.atomStore, + Store( + parent: context?._store ?? inheritedStore, + observers: observers + ) + ) + } + + /// Observes changes in any atom values and lifecycles used in descendant views. + /// + /// This method registers the given observer to notify changes of any atom values. + /// It would be useful for monitoring and debugging the atoms and for producing side effects in + /// the changes of particular atom. + /// + /// - SeeAlso: ``AtomObserver`` + /// + /// - Parameter observer: A observer value to observe atom changes. + /// + /// - Returns: The self instance. + public func observe(_ observer: Observer) -> Self { + mutating { $0.observers.append(observer) } + } +} + +private extension AtomRelay { + func `mutating`(_ mutation: (inout Self) -> Void) -> Self { + var view = self + mutation(&view) + return view + } +} diff --git a/Sources/Atoms/AtomRoot.swift b/Sources/Atoms/AtomRoot.swift new file mode 100644 index 00000000..0fd0ecfc --- /dev/null +++ b/Sources/Atoms/AtomRoot.swift @@ -0,0 +1,128 @@ +import SwiftUI + +/// A view that stores the state container of atoms and provides an internal store to view-tree +/// through environment values. +/// +/// It must be the root of any views to manage the state of atoms used throughout the application. +/// +/// ```swift +/// @main +/// struct MyApp: App { +/// var body: some Scene { +/// WindowGroup { +/// AtomRoot { +/// MyView() +/// } +/// } +/// } +/// } +/// ``` +/// +/// It optionally provides the modifier ``AtomRoot/override(_:with:)-20r5z`` to replace the value of +/// the specified atom, which is useful for dependency injection in testing. +/// +/// ```swift +/// AtomRoot { +/// MyView() +/// } +/// .override(RepositoryAtom()) { +/// FakeRepository() +/// } +/// ``` +/// +/// In addition, all changes in atoms managed by ``AtomRoot`` can be monitored by passing an observer +/// to the ``AtomRoot/observe(_:)`` modifier. +/// +/// ```swift +/// AtomRoot { +/// MyView() +/// } +/// .observe(Logger()) +/// ``` +/// +public struct AtomRoot: View { + @StateObject + private var state: State + private var overrides: AtomOverrides + private var observers = [AtomObserver]() + private let content: Content + + /// Creates an atom root with the specified content that will be allowed to use atoms. + /// + /// - Parameter content: The content that uses atoms. + public init(@ViewBuilder content: () -> Content) { + self._state = StateObject(wrappedValue: State()) + self.overrides = AtomOverrides() + self.content = content() + } + + /// The content and behavior of the view. + public var body: some View { + content.environment( + \.atomStore, + Store( + container: state.container, + overrides: overrides, + observers: observers + ) + ) + } + + /// Overrides the atom value with the given value. + /// + /// When accessing the overridden atom, this context will create and return the given value + /// instead of the atom value. + /// + /// - Parameters: + /// - atom: An atom that to be overridden. + /// - value: A value that to be used instead of the atom's value. + /// + /// - Returns: The self instance. + public func override(_ atom: Node, with value: @escaping (Node) -> Node.Hook.Value) -> Self { + mutating { $0.overrides.insert(atom, with: value) } + } + + /// Overrides the atom value with the given value. + /// + /// Instead of overriding the particular instance of atom, this method overrides any atom that + /// has the same metatype. + /// When accessing the overridden atom, this context will create and return the given value + /// instead of the atom value. + /// + /// - Parameters: + /// - atomType: An atom type that to be overridden. + /// - value: A value that to be used instead of the atom's value. + /// + /// - Returns: The self instance. + public func override(_ atomType: Node.Type, with value: @escaping (Node) -> Node.Hook.Value) -> Self { + mutating { $0.overrides.insert(atomType, with: value) } + } + + /// Observes changes in any atom values and its lifecycles. + /// + /// This method registers the given observer to notify changes of any atom values. + /// It would be useful for monitoring and debugging the atoms and for producing side effects in + /// the changes of particular atom. + /// + /// - SeeAlso: ``AtomObserver`` + /// + /// - Parameter observer: A observer value to observe atom changes. + /// + /// - Returns: The self instance. + public func observe(_ observer: Observer) -> Self { + mutating { $0.observers.append(observer) } + } +} + +private extension AtomRoot { + @MainActor + final class State: ObservableObject { + let container = StoreContainer() + } + + func `mutating`(_ mutation: (inout Self) -> Void) -> Self { + var view = self + mutation(&view) + return view + } +} diff --git a/Sources/Atoms/Atoms.docc/Atoms.md b/Sources/Atoms/Atoms.docc/Atoms.md new file mode 100644 index 00000000..29b0299a --- /dev/null +++ b/Sources/Atoms/Atoms.docc/Atoms.md @@ -0,0 +1,83 @@ +# ``Atoms`` + +A declarative state management and dependency injection library for SwiftUI x Concurrency. + +## Overview + +The Atomic Architecture offers practical capabilities to manage the complexity of modern apps. It effectively integrates the solution for both state management and dependency injection while allowing us to rapidly building an application. + +## Source Code + + + +## Topics + +### Atoms + +- ``ValueAtom`` +- ``StateAtom`` +- ``TaskAtom`` +- ``ThrowingTaskAtom`` +- ``AsyncSequenceAtom`` +- ``PublisherAtom`` +- ``ObservableObjectAtom`` + +### Modifiers + +- ``Atom/select(_:)`` +- ``Atom/phase`` + +### Attributes + +- ``KeepAlive`` + +### Property Wrappers + +- ``Watch`` +- ``WatchState`` +- ``WatchStateObject`` +- ``ViewContext`` + +### Contexts + +- ``AtomContext`` +- ``AtomWatchableContext`` +- ``AtomRelationContext`` +- ``AtomViewContext`` +- ``AtomTestContext`` + +### Views + +- ``AtomRoot`` +- ``AtomRelay`` +- ``Suspense`` + +### Values + +- ``AsyncPhase`` + +### Debugging + +- ``AtomObserver`` +- ``AtomHistory`` +- ``Snapshot`` + +### Internal System + +- ``Atom`` +- ``SelectModifierAtom`` +- ``TaskPhaseModifierAtom`` +- ``AtomHook`` +- ``AtomStateHook`` +- ``AtomTaskHook`` +- ``AtomRefreshableHook`` +- ``AtomHookContext`` +- ``ValueHook`` +- ``StateHook`` +- ``TaskHook`` +- ``ThrowingTaskHook`` +- ``AsyncSequenceHook`` +- ``PublisherHook`` +- ``ObservableObjectHook`` +- ``SelectModifierHook`` +- ``TaskPhaseModifierHook`` diff --git a/Sources/Atoms/Context/AtomContext.swift b/Sources/Atoms/Context/AtomContext.swift new file mode 100644 index 00000000..2ee81483 --- /dev/null +++ b/Sources/Atoms/Context/AtomContext.swift @@ -0,0 +1,163 @@ +import SwiftUI + +/// A context structure that to read, write, and otherwise interacting with atoms. +/// +/// - SeeAlso: ``AtomWatchableContext`` +@MainActor +public protocol AtomContext { + /// Accesses the value associated with the given atom without watching to it. + /// + /// This method returns a value for the given atom. Even if you access to a value with this method, + /// it doesn't initiating watch the atom, so if none of other atoms or views is watching as well, + /// the value will not be cached. + /// + /// ```swift + /// let context = ... + /// print(context.read(TextAtom())) // Prints the current value associated with ``TextAtom``. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + func read(_ atom: Node) -> Node.Hook.Value + + /// Sets the new value for the given writable atom. + /// + /// This method only accepts writable atoms such as types conforming to ``StateAtom``, + /// and assign a new value for the atom. + /// When you assign a new value, it notifies update immediately to downstream atoms or views. + /// + /// - SeeAlso: ``AtomContext/subscript`` + /// + /// ```swift + /// let context = ... + /// print(context.watch(TextAtom())) // Prints "Text" + /// context.set("New text", for: TextAtom()) + /// print(context.read(TextAtom())) // Prints "New text" + /// ``` + /// + /// - Parameters + /// - value: A value to be set. + /// - atom: An atom that associates the value. + func set(_ value: Node.Hook.Value, for atom: Node) where Node.Hook: AtomStateHook + + /// Refreshes and then return the value associated with the given refreshable atom. + /// + /// This method only accepts refreshable atoms such as types conforming to: + /// ``TaskAtom``, ``ThrowingTaskAtom``, ``AsyncSequenceAtom``, ``PublisherAtom``. + /// It refreshes the value for the given atom and then return, so the caller can await until + /// the value completes the update. + /// Note that it can be used only in a context that supports concurrency. + /// + /// ```swift + /// let context = ... + /// let image = await context.refresh(AsyncImageDataAtom()).value + /// print(image) // Prints the data obtained through network. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value which completed refreshing associated with the given atom. + @discardableResult + func refresh(_ atom: Node) async -> Node.Hook.Value where Node.Hook: AtomRefreshableHook + + /// Resets the value associated with the given atom, and then notify. + /// + /// This method resets a value for the given atom, and then notify update to the downstream + /// atoms and views. Thereafter, if any of other atoms or views is watching the atom, a newly + /// generated value will be produced. + /// + /// ```swift + /// let context = ... + /// print(context.watch(TextAtom())) // Prints "Text" + /// context[TextAtom()] = "New text" + /// print(context.read(TextAtom())) // Prints "New text" + /// context.reset(TextAtom()) + /// print(context.read(TextAtom())) // Prints "Text" + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + func reset(_ atom: Node) +} + +public extension AtomContext { + /// Accesses the value associated with the given read-write atom for mutating. + /// + /// This subscript only accepts read-write atoms such as types conforming to ``StateAtom``, + /// and returns the value or assign a new value for the atom. + /// When you assign a new value, it notifies update immediately to downstream atoms or views, + /// but it doesn't start watching the given atom only by getting the value. + /// + /// ```swift + /// let context = ... + /// print(context.watch(TextAtom())) // Prints "Text" + /// context[TextAtom()] = "New text" + /// context[TextAtom()].append(" is mutated!") + /// print(context[TextAtom()]) // Prints "New text is mutated!" + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + subscript(_ atom: Node) -> Node.Hook.Value where Node.Hook: AtomStateHook { + get { read(atom) } + nonmutating set { set(newValue, for: atom) } + } +} + +/// A context structure that to read, watch, and otherwise interacting with atoms. +/// +/// - SeeAlso: ``AtomViewContext`` +/// - SeeAlso: ``AtomRelationContext`` +/// - SeeAlso: ``AtomTestContext`` +@MainActor +public protocol AtomWatchableContext: AtomContext { + /// Accesses the value associated with the given atom for reading and initialing watch to + /// receive its updates. + /// + /// This method returns a value for the given atom and initiate watching the atom so that + /// the current context to get updated when the atom notifies updates. + /// The value associated with the atom is cached until it is no longer watched to or until + /// it is updated. + /// + /// ```swift + /// let context = ... + /// print(context.watch(TextAtom())) // Prints the current value associated with `TextAtom`. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + @discardableResult + func watch(_ atom: Node) -> Node.Hook.Value +} + +public extension AtomWatchableContext { + /// Creates a `Binding` that accesses the value associated with the given read-write atom. + /// + /// This method only accepts read-write atoms such as types conforming to ``StateAtom``, + /// and returns a binding that accesses the value or assign a new value for the atom. + /// When you set a new value to the `wrappedValue` property of the binding, it assigns the value + /// to the atom, and then notifies update immediately to downstream atoms or views. + /// Note that the binding initiates wathing the given atom when you get a value through the + /// `wrappedValue` property. + /// + /// ```swift + /// let context = ... + /// let binding = context.state(TextAtom()) + /// binding.wrappedValue = "New text" + /// binding.wrappedValue.append(" is mutated!") + /// print(binding.wrappedValue) // Prints "New text is mutated!" + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + @inlinable + func state(_ atom: Node) -> Binding where Node.Hook: AtomStateHook { + Binding( + get: { watch(atom) }, + set: { self[atom] = $0 } + ) + } +} diff --git a/Sources/Atoms/Context/AtomRelationContext.swift b/Sources/Atoms/Context/AtomRelationContext.swift new file mode 100644 index 00000000..c97eff49 --- /dev/null +++ b/Sources/Atoms/Context/AtomRelationContext.swift @@ -0,0 +1,217 @@ +/// A context structure that to read, watch, and otherwise interacting with atoms. +/// +/// Through this context, watching of an atom is initiated, and when that atom is updated, +/// the value of the atom to which this context is provided will be updated transitively. +@MainActor +public struct AtomRelationContext: AtomWatchableContext { + @usableFromInline + internal let _box: _AnyAtomRelationContextBox + + internal init(atom: Node, store: AtomStore) { + _box = _AtomRelationContextBox(caller: atom, store: store) + } + + /// Accesses the value associated with the given atom without watching to it. + /// + /// This method returns a value for the given atom. Even if you access to a value with this method, + /// it doesn't initiating watch the atom, so if none of other atoms or views is watching as well, + /// the value will not be cached. + /// + /// ```swift + /// let context = ... + /// print(context.read(TextAtom())) // Prints the current value associated with `TextAtom`. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + @inlinable + public func read(_ atom: Node) -> Node.Hook.Value { + _box.store.read(atom) + } + + /// Sets the new value for the given writable atom. + /// + /// This method only accepts writable atoms such as types conforming to ``StateAtom``, + /// and assign a new value for the atom. + /// When you assign a new value, it notifies update immediately to downstream atoms or views. + /// + /// - SeeAlso: ``AtomRelationContext/subscript`` + /// + /// ```swift + /// let context = ... + /// print(context.watch(TextAtom())) // Prints "Text" + /// context.set("New text", for: TextAtom()) + /// print(context.read(TextAtom())) // Prints "New text" + /// ``` + /// + /// - Parameters: + /// - value: A value to be set. + /// - atom: An atom that associates the value. + @inlinable + public func set(_ value: Node.Hook.Value, for atom: Node) where Node.Hook: AtomStateHook { + _box.store.set(value, for: atom) + } + + /// Refreshes and then return the value associated with the given refreshable atom. + /// + /// This method only accepts refreshable atoms such as types conforming to: + /// ``TaskAtom``, ``ThrowingTaskAtom``, ``AsyncSequenceAtom``, ``PublisherAtom``. + /// It refreshes the value for the given atom and then return, so the caller can await until + /// the value completes the update. + /// Note that it can be used only in a context that supports concurrency. + /// + /// ```swift + /// let context = ... + /// let image = await context.refresh(AsyncImageDataAtom()).value + /// print(image) // Prints the data obtained through network. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value which completed refreshing associated with the given atom. + @inlinable + @discardableResult + public func refresh(_ atom: Node) async -> Node.Hook.Value where Node.Hook: AtomRefreshableHook { + await _box.store.refresh(atom) + } + + /// Resets the value associated with the given atom, and then notify. + /// + /// This method resets a value for the given atom, and then notify update to the downstream + /// atoms and views. Thereafter, if any of other atoms or views is watching the atom, a newly + /// generated value will be produced. + /// + /// ```swift + /// let context = ... + /// print(context.watch(TextAtom())) // Prints "Text" + /// context[TextAtom()] = "New text" + /// print(context.read(TextAtom())) // Prints "New text" + /// context.reset(TextAtom()) + /// print(context.read(TextAtom())) // Prints "Text" + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + @inlinable + public func reset(_ atom: Node) { + _box.store.reset(atom) + } + + /// Accesses the value associated with the given atom for reading and initialing watch to + /// receive its updates. + /// + /// This method returns a value for the given atom and initiate watching the atom so that + /// the current context to get updated when the atom notifies updates. + /// The value associated with the atom is cached until it is no longer watched to or until + /// it is updated. + /// + /// ```swift + /// let context = ... + /// let text = context.watch(TextAtom()) + /// print(text) // Prints the current value associated with `TextAtom`. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + @inlinable + @discardableResult + public func watch(_ atom: Node) -> Node.Hook.Value { + _box.watch(atom) + } + + /// Add the termination action that will be performed when the atom will no longer be watched to + /// or upstream atoms are updated. + /// + /// ```swift + /// struct QuakeMonitorAtom: ValueAtom, Hashable { + /// func value(context: Context) -> QuakeMonitor { + /// let monitor = QuakeMonitor() + /// monitor.quakeHandler = { quake in + /// print("Quake: \(quake.date)") + /// } + /// context.addTermination { + /// monitor.stopMonitoring() + /// } + /// monitor.startMonitoring() + /// return monitor + /// } + /// } + /// ``` + /// + /// - Parameter termination: A termination action. + @inlinable + public func addTermination(_ termination: @MainActor @escaping () -> Void) { + _box.addTermination(termination) + } + + /// Add the given object to the storage that to be retained until the atom will no longer be watched + /// to or upstream atoms are updated. + /// + /// ```swift + /// struct LocationManagerAtom: ValueAtom, Hashable { + /// func value(context: Context) -> LocationManagerProtocol { + /// let manager = CLLocationManager() + /// let delegate = LocationManagerDelegate() + /// + /// manager.delegate = delegate + /// context.keepUntilTermination(delegate) + /// context.addTermination(manager.stopUpdatingLocation) + /// + /// return manager + /// } + /// } + /// ``` + /// + /// - Parameter object: An object that to be retained. + @inlinable + public func keepUntilTermination(_ object: Object) { + _box.keepUntilTermination(object) + } +} + +@usableFromInline +@MainActor +internal protocol _AnyAtomRelationContextBox { + var store: AtomStore { get } + + func watch(_ atom: Node) -> Node.Hook.Value + func addTermination(_ termination: @MainActor @escaping () -> Void) + func keepUntilTermination(_ object: Object) +} + +@usableFromInline +internal struct _AtomRelationContextBox: _AnyAtomRelationContextBox { + final class Retainer { + private var object: Object? + + init(_ object: Object) { + self.object = object + } + + func release() { + object = nil + } + } + + let caller: Caller + + @usableFromInline + let store: AtomStore + + @usableFromInline + func watch(_ atom: Node) -> Node.Hook.Value { + store.watch(atom, belongTo: caller) + } + + @usableFromInline + func addTermination(_ termination: @MainActor @escaping () -> Void) { + store.addTermination(caller, termination: termination) + } + + @usableFromInline + func keepUntilTermination(_ object: Object) { + let retainer = Retainer(object) + store.addTermination(caller, termination: retainer.release) + } +} diff --git a/Sources/Atoms/Context/AtomTestContext.swift b/Sources/Atoms/Context/AtomTestContext.swift new file mode 100644 index 00000000..e3bb32b7 --- /dev/null +++ b/Sources/Atoms/Context/AtomTestContext.swift @@ -0,0 +1,203 @@ +/// A context structure that to read, watch, and otherwise interacting with atoms in testing. +/// +/// This context has an internal Store that manages atoms, so it can be used to test individual +/// atoms or their interactions with other atoms without depending on the SwiftUI view tree. +/// Furthermore, unlike other contexts, it is possible to override or observe changes in atoms +/// by this itself. +@MainActor +public struct AtomTestContext: AtomWatchableContext { + private let container: Container + + /// Creates a new test context instance with fresh internal state. + public init() { + container = Container() + } + + /// A callback to perform when any of atoms watched by this context is updated. + public var onUpdate: (() -> Void)? { + get { container.onUpdate } + nonmutating set { container.onUpdate = newValue } + } + + /// Accesses the value associated with the given atom without watching to it. + /// + /// This method returns a value for the given atom. Even if you access to a value with this method, + /// it doesn't initiating watch the atom, so if none of other atoms or views is watching as well, + /// the value will not be cached. + /// + /// ```swift + /// let context = AtomTestContext() + /// print(context.read(TextAtom())) // Prints the current value associated with `TextAtom`. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + public func read(_ atom: Node) -> Node.Hook.Value { + container.store.read(atom) + } + + /// Sets the new value for the given writable atom. + /// + /// This method only accepts writable atoms such as types conforming to ``StateAtom``, + /// and assign a new value for the atom. + /// When you assign a new value, it notifies update immediately to downstream atoms or views. + /// + /// - SeeAlso: ``AtomTestContext/subscript`` + /// + /// ```swift + /// let context = AtomTestContext() + /// print(context.watch(TextAtom())) // Prints "Text" + /// context.set("New text", for: TextAtom()) + /// print(context.read(TextAtom())) // Prints "New text" + /// ``` + /// + /// - Parameters + /// - value: A value to be set. + /// - atom: An atom that associates the value. + public func set(_ value: Node.Hook.Value, for atom: Node) where Node.Hook: AtomStateHook { + container.store.set(value, for: atom) + } + + /// Refreshes and then return the value associated with the given refreshable atom. + /// + /// This method only accepts refreshable atoms such as types conforming to: + /// ``TaskAtom``, ``ThrowingTaskAtom``, ``AsyncSequenceAtom``, ``PublisherAtom``. + /// It refreshes the value for the given atom and then return, so the caller can await until + /// the value completes the update. + /// Note that it can be used only in a context that supports concurrency. + /// + /// ```swift + /// let context = AtomTestContext() + /// let image = await context.refresh(AsyncImageDataAtom()).value + /// print(image) // Prints the data obtained through network. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value which completed refreshing associated with the given atom. + @discardableResult + public func refresh(_ atom: Node) async -> Node.Hook.Value where Node.Hook: AtomRefreshableHook { + await container.store.refresh(atom) + } + + /// Resets the value associated with the given atom, and then notify. + /// + /// This method resets a value for the given atom, and then notify update to the downstream + /// atoms and views. Thereafter, if any of other atoms or views is watching the atom, a newly + /// generated value will be produced. + /// + /// ```swift + /// let context = AtomTestContext() + /// print(context.watch(TextAtom())) // Prints "Text" + /// context[TextAtom()] = "New text" + /// print(context.read(TextAtom())) // Prints "New text" + /// context.reset(TextAtom()) + /// print(context.read(TextAtom())) // Prints "Text" + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + public func reset(_ atom: Node) { + container.store.reset(atom) + } + + /// Accesses the value associated with the given atom for reading and initialing watch to + /// receive its updates. + /// + /// This method returns a value for the given atom and initiate watching the atom so that + /// the current context to get updated when the atom notifies updates. + /// The value associated with the atom is cached until it is no longer watched to or until + /// it is updated. + /// + /// ```swift + /// let context = AtomTestContext() + /// let text = context.watch(TextAtom()) + /// print(text) // Prints the current value associated with `TextAtom`. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + @discardableResult + public func watch(_ atom: Node) -> Node.Hook.Value { + container.store.watch(atom, relationship: container.relationship) { + container.onUpdate?() + } + } + + /// Unwatches the given atom and do not receive any more updates of it. + /// + /// It simulates cases where other atoms or views no longer watches to the atom. + /// + /// - Parameter atom: An atom that associates the value. + public func unwatch(_ atom: Node) { + container.relationship[atom] = nil + } + + /// Overrides the atom value with the given value. + /// + /// When accessing the overridden atom, this context will create and return the given value + /// instead of the atom value. + /// + /// - Parameters: + /// - atom: An atom that to be overridden. + /// - value: A value that to be used instead of the atom's value. + public func override(_ atom: Node, with value: @escaping (Node) -> Node.Hook.Value) { + container.overrides.insert(atom, with: value) + } + + /// Overrides the atom value with the given value. + /// + /// Instead of overriding the particular instance of atom, this method overrides any atom that + /// has the same metatype. + /// When accessing the overridden atom, this context will create and return the given value + /// instead of the atom value. + /// + /// - Parameters: + /// - atomType: An atom type that to be overridden. + /// - value: A value that to be used instead of the atom's value. + public func override(_ atomType: Node.Type, with value: @escaping (Node) -> Node.Hook.Value) { + container.overrides.insert(atomType, with: value) + } + + /// Observes changes of any atom values and its lifecycles. + /// + /// This method registers the given observer to notify changes of any atom values. + /// It would be useful for monitoring and debugging the atoms and for producing side effects in + /// the changes of particular atom. + /// + /// - SeeAlso: ``AtomObserver`` + /// + /// - Parameter observer: A observer value to observe atom changes. + public func observe(_ observer: Observer) { + container.observers.append(observer) + } +} + +private extension AtomTestContext { + @MainActor + final class Container { + private let storeContainer = StoreContainer() + private var relationshipContainer = RelationshipContainer() + + var overrides: AtomOverrides + var observers = [AtomObserver]() + var onUpdate: (() -> Void)? + + init() { + overrides = AtomOverrides() + } + + var store: AtomStore { + Store( + container: storeContainer, + overrides: overrides, + observers: observers + ) + } + + var relationship: Relationship { + Relationship(container: relationshipContainer) + } + } +} diff --git a/Sources/Atoms/Context/AtomViewContext.swift b/Sources/Atoms/Context/AtomViewContext.swift new file mode 100644 index 00000000..b71e073a --- /dev/null +++ b/Sources/Atoms/Context/AtomViewContext.swift @@ -0,0 +1,132 @@ +/// A context structure that to read, watch, and otherwise interacting with atoms. +/// +/// Through this context, watching of an atom is initiated, and when that atom is updated, +/// the view to which this context is used will be rebuilt. +@MainActor +public struct AtomViewContext: AtomWatchableContext { + @usableFromInline + internal let _relationship: Relationship + @usableFromInline + internal let _store: AtomStore + @usableFromInline + internal let _notifyUpdate: @MainActor () -> Void + + internal init( + store: AtomStore, + relationship: Relationship, + notifyUpdate: @MainActor @escaping () -> Void + ) { + _store = store + _relationship = relationship + _notifyUpdate = notifyUpdate + } + + /// Accesses the value associated with the given atom without watching to it. + /// + /// This method returns a value for the given atom. Even if you access to a value with this method, + /// it doesn't initiating watch the atom, so if none of other atoms or views is watching as well, + /// the value will not be cached. + /// + /// ```swift + /// let context = ... + /// print(context.read(TextAtom())) // Prints the current value associated with `TextAtom`. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + @inlinable + public func read(_ atom: Node) -> Node.Hook.Value { + _store.read(atom) + } + + /// Sets the new value for the given writable atom. + /// + /// This method only accepts writable atoms such as types conforming to ``StateAtom``, + /// and assign a new value for the atom. + /// When you assign a new value, it notifies update immediately to downstream atoms or views. + /// + /// - SeeAlso: ``AtomViewContext/subscript`` + /// + /// ```swift + /// let context = ... + /// print(context.watch(TextAtom())) // Prints "Text" + /// context.set("New text", for: TextAtom()) + /// print(context.read(TextAtom())) // Prints "New text" + /// ``` + /// + /// - Parameters + /// - value: A value to be set. + /// - atom: An atom that associates the value. + @inlinable + public func set(_ value: Node.Hook.Value, for atom: Node) where Node.Hook: AtomStateHook { + _store.set(value, for: atom) + } + + /// Refreshes and then return the value associated with the given refreshable atom. + /// + /// This method only accepts refreshable atoms such as types conforming to: + /// ``TaskAtom``, ``ThrowingTaskAtom``, ``AsyncSequenceAtom``, ``PublisherAtom``. + /// It refreshes the value for the given atom and then return, so the caller can await until + /// the value completes the update. + /// Note that it can be used only in a context that supports concurrency. + /// + /// ```swift + /// let context = ... + /// let image = await context.refresh(AsyncImageDataAtom()).value + /// print(image) // Prints the data obtained through network. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value which completed refreshing associated with the given atom. + @discardableResult + @inlinable + public func refresh(_ atom: Node) async -> Node.Hook.Value where Node.Hook: AtomRefreshableHook { + await _store.refresh(atom) + } + + /// Resets the value associated with the given atom, and then notify. + /// + /// This method resets a value for the given atom, and then notify update to the downstream + /// atoms and views. Thereafter, if any of other atoms or views is watching the atom, a newly + /// generated value will be produced. + /// + /// ```swift + /// let context = ... + /// print(context.watch(TextAtom())) // Prints "Text" + /// context[TextAtom()] = "New text" + /// print(context.read(TextAtom())) // Prints "New text" + /// context.reset(TextAtom()) + /// print(context.read(TextAtom())) // Prints "Text" + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + @inlinable + public func reset(_ atom: Node) { + _store.reset(atom) + } + + /// Accesses the value associated with the given atom for reading and initialing watch to + /// receive its updates. + /// + /// This method returns a value for the given atom and initiate watching the atom so that + /// the current context to get updated when the atom notifies updates. + /// The value associated with the atom is cached until it is no longer watched to or until + /// it is updated. + /// + /// ```swift + /// let context = ... + /// let text = context.watch(TextAtom()) + /// print(text) // Prints the current value associated with `TextAtom`. + /// ``` + /// + /// - Parameter atom: An atom that associates the value. + /// + /// - Returns: The value associated with the given atom. + @discardableResult + @inlinable + public func watch(_ atom: Node) -> Node.Hook.Value { + _store.watch(atom, relationship: _relationship, notifyUpdate: _notifyUpdate) + } +} diff --git a/Sources/Atoms/Core/Hook/AsyncSequenceHook.swift b/Sources/Atoms/Core/Hook/AsyncSequenceHook.swift new file mode 100644 index 00000000..af098e51 --- /dev/null +++ b/Sources/Atoms/Core/Hook/AsyncSequenceHook.swift @@ -0,0 +1,82 @@ +/// Internal use, a hook type that determines behavioral details of corresponding atoms. +@MainActor +public struct AsyncSequenceHook: AtomRefreshableHook { + /// A type of value that this hook manages. + public typealias Value = AsyncPhase + + /// A reference type object to manage internal state. + public final class Coordinator { + internal var phase: Value? + } + + private let sequence: @MainActor (AtomRelationContext) -> Sequence + + internal init(sequence: @MainActor @escaping (AtomRelationContext) -> Sequence) { + self.sequence = sequence + } + + /// Creates a coordinator instance. + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /// Gets and returns the value with the given context. + public func value(context: Context) -> Value { + context.coordinator.phase ?? _assertingFallbackValue(context: context) + } + + /// Initiates awaiting for the asynchronous elements of the given async sequence. + public func update(context: Context) { + let sequence = sequence(context.atomContext) + let box = UnsafeUncheckedSendableBox(sequence) + let task = Task { + do { + for try await element in box.unboxed { + if !Task.isCancelled { + context.coordinator.phase = .success(element) + context.notifyUpdate() + } + } + } + catch { + if !Task.isCancelled { + context.coordinator.phase = .failure(error) + context.notifyUpdate() + } + } + } + + context.coordinator.phase = .suspending + context.addTermination(task.cancel) + } + + /// Overrides with the given value. + public func updateOverride(context: Context, with value: Value) { + context.coordinator.phase = value + } + + /// Refreshes and awaits until the given async sequence to be terminated. + public func refresh(context: Context) async -> Value { + let sequence = sequence(context.atomContext) + context.coordinator.phase = .suspending + + do { + for try await element in sequence { + context.coordinator.phase = .success(element) + } + } + catch { + context.coordinator.phase = .failure(error) + } + + context.notifyUpdate() + return context.coordinator.phase ?? .suspending + } + + /// Overrides with the given value and just notify update. + public func refreshOverride(context: Context, with value: Value) async -> Value { + context.coordinator.phase = value + context.notifyUpdate() + return value + } +} diff --git a/Sources/Atoms/Core/Hook/AtomHook.swift b/Sources/Atoms/Core/Hook/AtomHook.swift new file mode 100644 index 00000000..c7b41162 --- /dev/null +++ b/Sources/Atoms/Core/Hook/AtomHook.swift @@ -0,0 +1,67 @@ +/// Internal use, a hook type that determines behavioral details of atoms. +@MainActor +public protocol AtomHook { + associatedtype Coordinator + associatedtype Value + + /// A type of the context structure that to interact with internal store. + typealias Context = AtomHookContext + + /// Creates a coordinator instance. + func makeCoordinator() -> Coordinator + + /// Gets and returns the value with the given context. + func value(context: Context) -> Value + + /// Updates and caches the value. + func update(context: Context) + + /// Overrides with the given value. + func updateOverride(context: Context, with value: Value) +} + +/// Internal use, a hook type that determines behavioral details of read-write atoms. +@MainActor +public protocol AtomStateHook: AtomHook { + /// Writes the given value. + func set(value: Value, context: Context) + + /// Observes to changes in the state which is called just before the state is changed. + func willSet(newValue: Value, oldValue: Value, context: Context) + + /// Observes to changes in the state which is called just after the state is changed. + func didSet(newValue: Value, oldValue: Value, context: Context) +} + +/// Internal use, a hook type that determines behavioral details of refreshable, asynchronous atoms. +@MainActor +public protocol AtomRefreshableHook: AtomHook { + /// Refreshes and awaits until the asynchronous value to be updated. + func refresh(context: Context) async -> Value + + /// Overrides with the given value and awaits until the value to be updated. + func refreshOverride(context: Context, with value: Value) async -> Value +} + +/// Internal use, a hook type that determines behavioral details of atoms which provide `Task`. +@MainActor +public protocol AtomTaskHook: AtomHook where Value == Task { + associatedtype Success + associatedtype Failure: Error + + /// Gets and returns the task with the given context. + func value(context: Context) -> Task +} + +internal extension AtomHook { + func _assertingFallbackValue(context: Context, file: StaticString = #file, line: UInt = #line) -> Value { + assertionFailure( + "[Atoms] Internal Logic Failure: Call `AtomHook/update(context:)` before accessing value.", + file: file, + line: line + ) + + update(context: context) + return value(context: context) + } +} diff --git a/Sources/Atoms/Core/Hook/AtomHookContext.swift b/Sources/Atoms/Core/Hook/AtomHookContext.swift new file mode 100644 index 00000000..616f0400 --- /dev/null +++ b/Sources/Atoms/Core/Hook/AtomHookContext.swift @@ -0,0 +1,67 @@ +/// Internal use, a context structure that to interact with internal store. +@MainActor +public struct AtomHookContext { + @usableFromInline + internal let _box: _AnyAtomHookContextBox + + internal let coordinator: Coordinator + + internal init( + atom: Node, + coordinator: Coordinator, + store: AtomStore + ) { + self._box = _AtomHookContextBox(atom: atom, store: store) + self.coordinator = coordinator + } + + @inlinable + internal var atomContext: AtomRelationContext { + _box.atomContext + } + + @inlinable + internal func notifyUpdate() { + _box.notifyUpdate() + } + + @inlinable + internal func addTermination(_ termination: @MainActor @escaping () -> Void) { + _box.addTermination(termination) + } +} + +@usableFromInline +@MainActor +internal protocol _AnyAtomHookContextBox { + var atomContext: AtomRelationContext { get } + + func notifyUpdate() + func addTermination(_ termination: @MainActor @escaping () -> Void) +} + +@usableFromInline +internal struct _AtomHookContextBox: _AnyAtomHookContextBox { + let atom: Node + let store: AtomStore + + init(atom: Node, store: AtomStore) { + self.atom = atom + self.store = store + } + + @usableFromInline + var atomContext: AtomRelationContext { + AtomRelationContext(atom: atom, store: store) + } + + @usableFromInline + func notifyUpdate() { + store.notifyUpdate(atom) + } + + @usableFromInline + func addTermination(_ termination: @MainActor @escaping () -> Void) { + store.addTermination(atom, termination: termination) + } +} diff --git a/Sources/Atoms/Core/Hook/ObservableObjectHook.swift b/Sources/Atoms/Core/Hook/ObservableObjectHook.swift new file mode 100644 index 00000000..b4a5403c --- /dev/null +++ b/Sources/Atoms/Core/Hook/ObservableObjectHook.swift @@ -0,0 +1,42 @@ +import Combine + +/// Internal use, a hook type that determines behavioral details of corresponding atoms. +@MainActor +public struct ObservableObjectHook: AtomHook { + /// A reference type object to manage internal state. + public final class Coordinator { + internal var object: ObjectType? + } + + private let object: @MainActor (AtomRelationContext) -> ObjectType + + internal init(object: @MainActor @escaping (AtomRelationContext) -> ObjectType) { + self.object = object + } + + /// Creates a coordinator instance. + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /// Gets and returns the observable object with the given context. + public func value(context: Context) -> ObjectType { + context.coordinator.object ?? _assertingFallbackValue(context: context) + } + + /// Instantiates and caches the observable object, and then subscribes to it. + public func update(context: Context) { + let object = object(context.atomContext) + updateOverride(context: context, with: object) + } + + /// Overrides with the given observable object. + public func updateOverride(context: Context, with value: ObjectType) { + let cancellable = value.objectWillChange.sink { _ in + context.notifyUpdate() + } + + context.coordinator.object = value + context.addTermination(cancellable.cancel) + } +} diff --git a/Sources/Atoms/Core/Hook/PublisherHook.swift b/Sources/Atoms/Core/Hook/PublisherHook.swift new file mode 100644 index 00000000..4682a65c --- /dev/null +++ b/Sources/Atoms/Core/Hook/PublisherHook.swift @@ -0,0 +1,102 @@ +import Combine + +/// Internal use, a hook type that determines behavioral details of corresponding atoms. +@MainActor +public struct PublisherHook: AtomRefreshableHook { + /// A type of value that this hook manages. + public typealias Value = AsyncPhase + + /// A reference type object to manage internal state. + public final class Coordinator { + internal var phase: Value? + } + + private let publisher: @MainActor (AtomRelationContext) -> Publisher + + internal init(publisher: @MainActor @escaping (AtomRelationContext) -> Publisher) { + self.publisher = publisher + } + + /// Creates a coordinator instance. + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /// Gets and returns the value with the given context. + public func value(context: Context) -> Value { + context.coordinator.phase ?? _assertingFallbackValue(context: context) + } + + /// Initiates subscribing to the publisher. + public func update(context: Context) { + let results = publisher(context.atomContext).results + let box = UnsafeUncheckedSendableBox(results) + let task = Task { + for await result in box.unboxed { + if !Task.isCancelled { + context.coordinator.phase = AsyncPhase(result) + context.notifyUpdate() + } + } + } + + context.coordinator.phase = .suspending + context.addTermination(task.cancel) + } + + /// Overrides with the given value. + public func updateOverride(context: Context, with value: Value) { + context.coordinator.phase = value + } + + /// Refreshes and awaits until the publisher to be completed. + public func refresh(context: Context) async -> Value { + let results = publisher(context.atomContext).results + context.coordinator.phase = .suspending + + for await result in results { + context.coordinator.phase = AsyncPhase(result) + } + + context.notifyUpdate() + return context.coordinator.phase ?? .suspending + } + + /// Overrides with the given value and just notify update. + public func refreshOverride(context: Context, with value: Value) async -> Value { + context.coordinator.phase = value + context.notifyUpdate() + return value + } +} + +private extension Publisher { + var results: AsyncStream> { + AsyncStream { continuation in + let cancellable = map(Result.success) + .catch { Just(.failure($0)) } + .sink( + receiveCompletion: { _ in + continuation.finish() + }, + receiveValue: { result in + continuation.yield(result) + } + ) + + let box = UnsafeUncheckedSendableBox(cancellable) + continuation.onTermination = { termination in + switch termination { + case .cancelled: + box.unboxed.cancel() + + case .finished: + break + + @unknown default: + break + } + } + } + } +} diff --git a/Sources/Atoms/Core/Hook/SelectModifierHook.swift b/Sources/Atoms/Core/Hook/SelectModifierHook.swift new file mode 100644 index 00000000..f75cb8ee --- /dev/null +++ b/Sources/Atoms/Core/Hook/SelectModifierHook.swift @@ -0,0 +1,36 @@ +/// Internal use, a hook type that determines behavioral details of corresponding atoms. +@MainActor +public struct SelectModifierHook: AtomHook { + /// A reference type object to manage internal state. + public final class Coordinator { + internal var value: Value? + } + + private let base: Base + private let keyPath: KeyPath + + internal init(base: Base, keyPath: KeyPath) { + self.base = base + self.keyPath = keyPath + } + + /// Creates a coordinator instance. + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /// Gets and returns the value with the given context. + public func value(context: Context) -> Value { + context.coordinator.value ?? _assertingFallbackValue(context: context) + } + + /// Starts wathing to the base atom. + public func update(context: Context) { + context.coordinator.value = context.atomContext.watch(base)[keyPath: keyPath] + } + + /// Overrides with the given value. + public func updateOverride(context: Context, with value: Value) { + context.coordinator.value = value + } +} diff --git a/Sources/Atoms/Core/Hook/StateHook.swift b/Sources/Atoms/Core/Hook/StateHook.swift new file mode 100644 index 00000000..23b8a454 --- /dev/null +++ b/Sources/Atoms/Core/Hook/StateHook.swift @@ -0,0 +1,64 @@ +/// Internal use, a hook type that determines behavioral details of corresponding atoms. +@MainActor +public struct StateHook: AtomStateHook { + /// A typealias of update observer will-set. + public typealias WillSet = @MainActor (_ newValue: Value, _ oldValue: Value, _ context: AtomRelationContext) -> Void + + /// A typealias of update observer did-set. + public typealias DidSet = @MainActor (_ newValue: Value, _ oldValue: Value, _ context: AtomRelationContext) -> Void + + /// A reference type object to manage internal state. + public final class Coordinator { + internal var value: Value? + } + + private let defaultValue: @MainActor (AtomRelationContext) -> Value + private let willSet: WillSet + private let didSet: DidSet + + internal init( + defaultValue: @MainActor @escaping (AtomRelationContext) -> Value, + willSet: @escaping WillSet, + didSet: @escaping DidSet + ) { + self.defaultValue = defaultValue + self.willSet = willSet + self.didSet = didSet + } + + /// Creates a coordinator instance. + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /// Gets and returns the value with the given context. + public func value(context: Context) -> Value { + context.coordinator.value ?? _assertingFallbackValue(context: context) + } + + /// Update to be the default value. + public func update(context: Context) { + context.coordinator.value = defaultValue(context.atomContext) + } + + /// Overrides with the given value. + public func updateOverride(context: Context, with value: Value) { + context.coordinator.value = value + } + + /// Writes the given value and then notify update. + public func set(value: Value, context: Context) { + context.coordinator.value = value + context.notifyUpdate() + } + + /// Observes to changes in the state which is called just before the state is changed. + public func willSet(newValue: Value, oldValue: Value, context: Context) { + willSet(newValue, oldValue, context.atomContext) + } + + /// Observes to changes in the state which is called just after the state is changed. + public func didSet(newValue: Value, oldValue: Value, context: Context) { + didSet(newValue, oldValue, context.atomContext) + } +} diff --git a/Sources/Atoms/Core/Hook/TaskHook.swift b/Sources/Atoms/Core/Hook/TaskHook.swift new file mode 100644 index 00000000..0bea70b7 --- /dev/null +++ b/Sources/Atoms/Core/Hook/TaskHook.swift @@ -0,0 +1,67 @@ +/// Internal use, a hook type that determines behavioral details of corresponding atoms. +@MainActor +public struct TaskHook: + AtomTaskHook, + AtomRefreshableHook +{ + /// A type of value that this hook manages. + public typealias Value = Task + + /// A reference type object to manage internal state. + public final class Coordinator { + internal var task: Value? + } + + private let value: @MainActor (AtomRelationContext) async -> Success + + internal init(value: @MainActor @escaping (AtomRelationContext) async -> Success) { + self.value = value + } + + /// Creates a coordinator instance. + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /// Gets and returns the value with the given context. + public func value(context: Context) -> Value { + context.coordinator.task ?? _assertingFallbackValue(context: context) + } + + /// Initiates awaiting for the asynchronous value. + public func update(context: Context) { + let task = Task { + await value(context.atomContext) + } + + updateOverride(context: context, with: task) + } + + /// Overrides with the given value. + public func updateOverride(context: Context, with task: Value) { + context.coordinator.task = task + context.addTermination(task.cancel) + } + + /// Refreshes and awaits until the given asynchronous resulting value to be available. + public func refresh(context: Context) async -> Value { + let task = Task { + await value(context.atomContext) + } + + return await refreshOverride(context: context, with: task) + } + + /// Overrides with the given value and just notify update. + public func refreshOverride(context: Context, with task: Value) async -> Value { + context.coordinator.task = task + + return await withTaskCancellationHandler { + task.cancel() + } operation: { + _ = await task.result + context.notifyUpdate() + return task + } + } +} diff --git a/Sources/Atoms/Core/Hook/TaskPhaseModifierHook.swift b/Sources/Atoms/Core/Hook/TaskPhaseModifierHook.swift new file mode 100644 index 00000000..147c022f --- /dev/null +++ b/Sources/Atoms/Core/Hook/TaskPhaseModifierHook.swift @@ -0,0 +1,47 @@ +/// Internal use, a hook type that determines behavioral details of corresponding atoms. +@MainActor +public struct TaskPhaseModifierHook: AtomHook where Base.Hook: AtomTaskHook { + /// A type of value that this hook manages. + public typealias Value = AsyncPhase + + /// A reference type object to manage internal state. + public final class Coordinator { + internal var phase: Value? + } + + private let base: Base + + internal init(base: Base) { + self.base = base + } + + /// Creates a coordinator instance. + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /// Gets and returns the value with the given context. + public func value(context: Context) -> Value { + context.coordinator.phase ?? _assertingFallbackValue(context: context) + } + + /// Starts wathing to the base atom and initiates awaiting for its asynchronous value. + public func update(context: Context) { + let task = Task { + let phase = await AsyncPhase(context.atomContext.watch(base).result) + + if !Task.isCancelled { + context.coordinator.phase = phase + context.notifyUpdate() + } + } + + context.coordinator.phase = .suspending + context.addTermination(task.cancel) + } + + /// Overrides with the given value. + public func updateOverride(context: Context, with value: Value) { + context.coordinator.phase = value + } +} diff --git a/Sources/Atoms/Core/Hook/ThrowingTaskHook.swift b/Sources/Atoms/Core/Hook/ThrowingTaskHook.swift new file mode 100644 index 00000000..f46be60f --- /dev/null +++ b/Sources/Atoms/Core/Hook/ThrowingTaskHook.swift @@ -0,0 +1,67 @@ +/// Internal use, a hook type that determines behavioral details of corresponding atoms. +@MainActor +public struct ThrowingTaskHook: + AtomTaskHook, + AtomRefreshableHook +{ + /// A type of value that this hook manages. + public typealias Value = Task + + /// A reference type object to manage internal state. + public final class Coordinator { + internal var task: Value? + } + + private let value: @MainActor (AtomRelationContext) async throws -> Success + + internal init(value: @MainActor @escaping (AtomRelationContext) async throws -> Success) { + self.value = value + } + + /// Creates a coordinator instance. + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /// Gets and returns the value with the given context. + public func value(context: Context) -> Value { + context.coordinator.task ?? _assertingFallbackValue(context: context) + } + + /// Initiates awaiting for the asynchronous value. + public func update(context: Context) { + let task = Task { + try await value(context.atomContext) + } + + updateOverride(context: context, with: task) + } + + /// Overrides with the given value. + public func updateOverride(context: Context, with task: Value) { + context.coordinator.task = task + context.addTermination(task.cancel) + } + + /// Refreshes and awaits until the given asynchronous resulting value to be available. + public func refresh(context: Context) async -> Value { + let task = Task { + try await value(context.atomContext) + } + + return await refreshOverride(context: context, with: task) + } + + /// Overrides with the given value and just notify update. + public func refreshOverride(context: Context, with task: Value) async -> Value { + context.coordinator.task = task + + return await withTaskCancellationHandler { + task.cancel() + } operation: { + _ = await task.result + context.notifyUpdate() + return task + } + } +} diff --git a/Sources/Atoms/Core/Hook/ValueHook.swift b/Sources/Atoms/Core/Hook/ValueHook.swift new file mode 100644 index 00000000..e4202c55 --- /dev/null +++ b/Sources/Atoms/Core/Hook/ValueHook.swift @@ -0,0 +1,34 @@ +/// Internal use, a hook type that determines behavioral details of corresponding atoms. +@MainActor +public struct ValueHook: AtomHook { + /// A reference type object to manage internal state. + public final class Coordinator { + internal var value: Value? + } + + private let value: @MainActor (AtomRelationContext) -> Value + + internal init(value: @MainActor @escaping (AtomRelationContext) -> Value) { + self.value = value + } + + /// Creates a coordinator instance. + public func makeCoordinator() -> Coordinator { + Coordinator() + } + + /// Gets and returns the value with the given context. + public func value(context: Context) -> Value { + context.coordinator.value ?? _assertingFallbackValue(context: context) + } + + /// Instantiates the value and cache. + public func update(context: Context) { + context.coordinator.value = value(context.atomContext) + } + + /// Overrides with the given value. + public func updateOverride(context: Context, with value: Value) { + context.coordinator.value = value + } +} diff --git a/Sources/Atoms/Core/Internal/AtomHost.swift b/Sources/Atoms/Core/Internal/AtomHost.swift new file mode 100644 index 00000000..3feb329f --- /dev/null +++ b/Sources/Atoms/Core/Internal/AtomHost.swift @@ -0,0 +1,72 @@ +import Combine + +internal final class AtomHost: AtomHostBase { + private let notifier = PassthroughSubject() + private var container = RelationshipContainer() + private var terminations = Set() + + var coordinator: Coordinator? + var onDeinit: (() -> Void)? + var onUpdate: ((Coordinator) -> Void)? + + deinit { + onDeinit?() + } + + var relationship: Relationship { + Relationship(container: container) + } + + func addTermination(_ termination: @MainActor @escaping () -> Void) { + let termination = AnyCancellable { termination() } + terminations.insert(termination) + } + + func notifyUpdate() { + notifier.send() + + if let coordinator = coordinator { + onUpdate?(coordinator) + } + } + + func withTermination(_ body: (AtomHost) -> T) -> T { + // Keep the atom's assignment until the given process is finished. + withExtendedLifetime(container) { + terminate() + return body(self) + } + } + + func withAsyncTermination(_ body: (AtomHost) async -> T) async -> T { + // Keep the atom's assignment until the given async process is finished. + await container.withExtendedLifetime { + terminate() + return await body(self) + } + } + + func observe(_ notifyUpdate: @MainActor @escaping () -> Void) -> Relation { + let cancellable = notifier.sink(receiveValue: { notifyUpdate() }) + return Relation(retaining: self, termination: cancellable.cancel) + } +} + +private extension AtomHost { + func terminate() { + coordinator = nil + container = RelationshipContainer() + terminations.removeAll() + } + +} + +@usableFromInline +@MainActor +internal class AtomHostBase {} + +private extension RelationshipContainer { + func withExtendedLifetime(_ body: () async -> T) async -> T { + await body() + } +} diff --git a/Sources/Atoms/Core/Internal/AtomKey.swift b/Sources/Atoms/Core/Internal/AtomKey.swift new file mode 100644 index 00000000..223eddca --- /dev/null +++ b/Sources/Atoms/Core/Internal/AtomKey.swift @@ -0,0 +1,15 @@ +@usableFromInline +internal struct AtomKey: Hashable { + private let typeIdentifier: ObjectIdentifier + private let instance: AnyHashable + + init(_ atom: Node) { + typeIdentifier = ObjectIdentifier(Node.self) + instance = AnyHashable(atom.key) + } + + init(_: Node.Type) { + typeIdentifier = ObjectIdentifier(Node.self) + instance = AnyHashable(typeIdentifier) + } +} diff --git a/Sources/Atoms/Core/Internal/AtomOverrides.swift b/Sources/Atoms/Core/Internal/AtomOverrides.swift new file mode 100644 index 00000000..9fb374fb --- /dev/null +++ b/Sources/Atoms/Core/Internal/AtomOverrides.swift @@ -0,0 +1,59 @@ +@usableFromInline +@MainActor +internal struct AtomOverrides { + private var entries = [AtomKey: Override]() + + mutating func insert( + _ atom: Node, + with value: @escaping (Node) -> Node.Hook.Value + ) { + let key = AtomKey(atom) + entries[key] = OverrideValue(value) + } + + mutating func insert( + _ atomType: Node.Type, + with value: @escaping (Node) -> Node.Hook.Value + ) { + let key = AtomKey(atomType) + entries[key] = OverrideValue(value) + } + + subscript(atom: Node) -> Node.Hook.Value? { + // Individual atom override takes precedence. + let override = entries[AtomKey(atom)] ?? entries[AtomKey(Node.self)] + return override?.value(of: atom) + } +} + +@MainActor +private protocol Override {} + +private extension Override { + func value(of atom: Node) -> Node.Hook.Value { + guard let value = self as? OverrideValue else { + fatalError( + """ + Detected an illegal override. + There might be duplicate keys or logic failure. + Detected: \(type(of: self)) + Expected: OverrideValue<\(Node.self)> + """ + ) + } + + return value(of: atom) + } +} + +private struct OverrideValue: Override { + private let value: (Node) -> Node.Hook.Value + + init(_ value: @escaping (Node) -> Node.Hook.Value) { + self.value = value + } + + func callAsFunction(of atom: Node) -> Node.Hook.Value { + value(atom) + } +} diff --git a/Sources/Atoms/Core/Internal/AtomStore.swift b/Sources/Atoms/Core/Internal/AtomStore.swift new file mode 100644 index 00000000..8b49c126 --- /dev/null +++ b/Sources/Atoms/Core/Internal/AtomStore.swift @@ -0,0 +1,42 @@ +@usableFromInline +internal protocol AtomStore { + @MainActor + var container: StoreContainer? { get } + + @MainActor + var overrides: AtomOverrides? { get } + + @MainActor + var observers: [AtomObserver] { get } + + @MainActor + func read(_ atom: Node) -> Node.Hook.Value + + @MainActor + func set(_ value: Node.Hook.Value, for atom: Node) where Node.Hook: AtomStateHook + + @MainActor + func refresh(_ atom: Node) async -> Node.Hook.Value where Node.Hook: AtomRefreshableHook + + @MainActor + func reset(_ atom: Node) + + @MainActor + func watch( + _ atom: Node, + relationship: Relationship, + notifyUpdate: @MainActor @escaping () -> Void + ) -> Node.Hook.Value + + @MainActor + func watch(_ atom: Node, belongTo caller: Caller) -> Node.Hook.Value + + @MainActor + func notifyUpdate(_ atom: Node) + + @MainActor + func addTermination(_ atom: Node, termination: @MainActor @escaping () -> Void) + + @MainActor + func restore(snapshot: Snapshot) +} diff --git a/Sources/Atoms/Core/Internal/AtomStoreEnvironment.swift b/Sources/Atoms/Core/Internal/AtomStoreEnvironment.swift new file mode 100644 index 00000000..d9eebf85 --- /dev/null +++ b/Sources/Atoms/Core/Internal/AtomStoreEnvironment.swift @@ -0,0 +1,14 @@ +import SwiftUI + +internal extension EnvironmentValues { + var atomStore: AtomStore { + get { self[StoreEnvironmentKey.self] } + set { self[StoreEnvironmentKey.self] = newValue } + } +} + +private struct StoreEnvironmentKey: EnvironmentKey { + static var defaultValue: AtomStore { + DefaultStore() + } +} diff --git a/Sources/Atoms/Core/Internal/Container.swift b/Sources/Atoms/Core/Internal/Container.swift new file mode 100644 index 00000000..57438381 --- /dev/null +++ b/Sources/Atoms/Core/Internal/Container.swift @@ -0,0 +1,10 @@ +@usableFromInline +internal typealias StoreContainer = Container + +@usableFromInline +internal typealias RelationshipContainer = Container + +@usableFromInline +internal final class Container { + var entries = [Key: Value]() +} diff --git a/Sources/Atoms/Core/Internal/DefaultStore.swift b/Sources/Atoms/Core/Internal/DefaultStore.swift new file mode 100644 index 00000000..03724697 --- /dev/null +++ b/Sources/Atoms/Core/Internal/DefaultStore.swift @@ -0,0 +1,126 @@ +internal struct DefaultStore: AtomStore { + private let fallbackContainer = StoreContainer() + + var container: StoreContainer? { + fallbackContainer + } + + var overrides: AtomOverrides? { + nil + } + + var observers: [AtomObserver] { + [] + } + + func read(_ atom: Node) -> Node.Hook.Value { + assertionFailureStoreNotProvided() + return fallbackStore.read(atom) + } + + func set(_ value: Node.Hook.Value, for atom: Node) where Node.Hook: AtomStateHook { + assertionFailureStoreNotProvided() + fallbackStore.set(value, for: atom) + } + + func refresh(_ atom: Node) async -> Node.Hook.Value where Node.Hook: AtomRefreshableHook { + assertionFailureStoreNotProvided() + return await fallbackStore.refresh(atom) + } + + func reset(_ atom: Node) { + assertionFailureStoreNotProvided() + fallbackStore.reset(atom) + } + + func watch( + _ atom: Node, + relationship: Relationship, + notifyUpdate: @escaping @MainActor () -> Void + ) -> Node.Hook.Value { + assertionFailureStoreNotProvided() + return fallbackStore.watch(atom, relationship: relationship, notifyUpdate: notifyUpdate) + } + + func watch(_ atom: Node, belongTo caller: Caller) -> Node.Hook.Value { + assertionFailureStoreNotProvided() + return fallbackStore.watch(atom, belongTo: caller) + } + + func notifyUpdate(_ atom: Node) { + assertionFailureStoreNotProvided() + fallbackStore.notifyUpdate(atom) + } + + func addTermination(_ atom: Node, termination: @MainActor @escaping () -> Void) { + assertionFailureStoreNotProvided() + fallbackStore.addTermination(atom, termination: termination) + } + + func restore(snapshot: Snapshot) { + assertionFailureStoreNotProvided() + fallbackStore.restore(snapshot: snapshot) + } +} + +private extension DefaultStore { + @MainActor + var fallbackStore: Store { + Store(container: fallbackContainer) + } + + func assertionFailureStoreNotProvided(file: StaticString = #file, line: UInt = #line) { + assertionFailure( + """ + [Atoms] + There is no `Store` provided to hold the Atom on this view tree. + Make sure that this application has an `AtomRoot` as a root ancestor of any view. + + ``` + struct ExampleApp: App { + var body: some Scene { + WindowGroup { + AtomRoot { + ExampleView() + } + } + } + } + ``` + + If for some reason the view tree is formed that does not inherit from `EnvironmentValues`, + consider using `AtomRelay` to pass it. + That happens when using SwiftUI view wrapped with `UIHostingController`. + + ``` + struct ExampleView: View { + @ViewContext + var context + + var body: some View { + UIViewWrappingView { + AtomRelay(context) { + WrappedView() + } + } + } + } + ``` + + The modal screen presented by the `.sheet` modifier or etc, inherits from the environment values, + but only in iOS14, there is a bug where the environment values will be dismantled during it is + dismissing. This also can be avoided by using `AtomRelay` to explicitly inherit from it. + + ``` + .sheet(isPresented: ...) { + AtomRelay(context) { + ExampleView() + } + } + ``` + """, + file: file, + line: line + ) + } +} diff --git a/Sources/Atoms/Core/Internal/Relation.swift b/Sources/Atoms/Core/Internal/Relation.swift new file mode 100644 index 00000000..538457be --- /dev/null +++ b/Sources/Atoms/Core/Internal/Relation.swift @@ -0,0 +1,16 @@ +import Combine + +@usableFromInline +@MainActor +internal final class Relation { + private let host: AtomHostBase + private let termination: AnyCancellable + + init( + retaining host: AtomHostBase, + termination: @escaping () -> Void + ) { + self.host = host + self.termination = AnyCancellable(termination) + } +} diff --git a/Sources/Atoms/Core/Internal/Relationship.swift b/Sources/Atoms/Core/Internal/Relationship.swift new file mode 100644 index 00000000..62fed94b --- /dev/null +++ b/Sources/Atoms/Core/Internal/Relationship.swift @@ -0,0 +1,20 @@ +@usableFromInline +@MainActor +internal struct Relationship { + private weak var container: RelationshipContainer? + + init(container: RelationshipContainer) { + self.container = container + } + + subscript(_ atom: Node) -> Relation? { + get { + let key = AtomKey(atom) + return container?.entries[key] + } + nonmutating set { + let key = AtomKey(atom) + container?.entries[key] = newValue + } + } +} diff --git a/Sources/Atoms/Core/Internal/Store.swift b/Sources/Atoms/Core/Internal/Store.swift new file mode 100644 index 00000000..3a39dd1d --- /dev/null +++ b/Sources/Atoms/Core/Internal/Store.swift @@ -0,0 +1,248 @@ +@MainActor +internal struct Store: AtomStore { + private(set) weak var container: StoreContainer? + let overrides: AtomOverrides? + let observers: [AtomObserver] + + init( + container: StoreContainer, + overrides: AtomOverrides? = nil, + observers: [AtomObserver] = [] + ) { + self.container = container + self.overrides = overrides + self.observers = observers + } + + init( + parent: AtomStore, + observers: [AtomObserver] + ) { + self.container = parent.container + self.overrides = parent.overrides + self.observers = parent.observers + observers + } + + func read(_ atom: Node) -> Node.Hook.Value { + let coordinator = getCoordinator(of: atom) { coordinator in + let context = AtomHookContext(atom: atom, coordinator: coordinator, store: self) + + // Ensure that the atom is setup to have an initial value when it is first created. + if let value = overrides?[atom] { + // Set the override value. + atom.hook.updateOverride(context: context, with: value) + } + else { + // Update the atom to have an initial value. + atom.hook.update(context: context) + } + + // Notify the initial update to observers. + notifyObserversOfUpdate(of: atom, coordinator: coordinator) + } + let context = AtomHookContext(atom: atom, coordinator: coordinator, store: self) + + return atom.hook.value(context: context) + } + + func set(_ value: Node.Hook.Value, for atom: Node) where Node.Hook: AtomStateHook { + // Do nothing if the host is yet to be assigned. + guard let coordinator = existingHost(of: atom)?.coordinator else { + return + } + + let context = AtomHookContext(atom: atom, coordinator: coordinator, store: self) + let oldValue = atom.hook.value(context: context) + + atom.hook.willSet(newValue: value, oldValue: oldValue, context: context) + atom.hook.set(value: value, context: context) + atom.hook.didSet(newValue: value, oldValue: oldValue, context: context) + } + + func refresh(_ atom: Node) async -> Node.Hook.Value where Node.Hook: AtomRefreshableHook { + // Terminate the value & the ongoing task, but keep assignment until finishing refresh. + await host(of: atom).withAsyncTermination { _ in + let coordinator = getCoordinator(of: atom) + let context = AtomHookContext(atom: atom, coordinator: coordinator, store: self) + + if let value = overrides?[atom] { + return await atom.hook.refreshOverride(context: context, with: value) + } + else { + return await atom.hook.refresh(context: context) + } + } + } + + func reset(_ atom: Node) { + // Terminate the value & the ongoing task, but keep assignment until finishing notify update. + // Do nothing if the host is yet to be assigned. + existingHost(of: atom)?.withTermination { + $0.notifyUpdate() + } + } + + func watch( + _ atom: Node, + relationship: Relationship, + notifyUpdate: @MainActor @escaping () -> Void + ) -> Node.Hook.Value { + // Assign the observation to the given relationship. + relationship[atom] = host(of: atom).observe(notifyUpdate) + return read(atom) + } + + func watch(_ atom: Node, belongTo caller: Caller) -> Node.Hook.Value { + watch(atom, relationship: host(of: caller).relationship) { + let oldValue = read(caller) + + // Terminate the value & the ongoing task, but keep assignment until finishing notify update. + host(of: caller).withTermination { host in + let newValue = read(caller) + let shouldNotify = caller.shouldNotifyUpdate(newValue: newValue, oldValue: oldValue) + + if shouldNotify { + host.notifyUpdate() + } + } + } + } + + func notifyUpdate(_ atom: Node) { + // Do nothing if the host is yet to be assigned. + existingHost(of: atom)?.notifyUpdate() + } + + func addTermination(_ atom: Node, termination: @MainActor @escaping () -> Void) { + // Terminate immediately if the host is yet to be assigned. + guard let host = existingHost(of: atom) else { + return termination() + } + + host.addTermination(termination) + } + + func restore(snapshot: Snapshot) { + // Do nothing if the host is yet to be assigned. + guard let host = existingHost(of: snapshot.atom), let coordinator = host.coordinator else { + return + } + + let context = AtomHookContext( + atom: snapshot.atom, + coordinator: coordinator, + store: self + ) + + snapshot.atom.hook.updateOverride(context: context, with: snapshot.value) + host.notifyUpdate() + } +} + +private extension Store { + func getCoordinator( + of atom: Node, + initialize: ((Node.Hook.Coordinator) -> Void)? = nil + ) -> Node.Hook.Coordinator { + let host = host(of: atom) + + if let coordinator = host.coordinator { + return coordinator + } + + let coordinator = atom.hook.makeCoordinator() + host.coordinator = coordinator + initialize?(coordinator) + + return coordinator + } + + func host(of atom: Node) -> AtomHost { + let key = AtomKey(atom) + + // Check if the host already exists. + if let host = existingHost(of: atom) { + return host + } + + let host = AtomHost() + + host.onDeinit = { + // Cleanup the weak entry box. + container?.entries.removeValue(forKey: key) + + // Notify the unassignment to observers. + for observer in observers { + observer.atomUnassigned(atom: atom) + } + } + + host.onUpdate = { coordinator in + // Notify the update to observers. + notifyObserversOfUpdate(of: atom, coordinator: coordinator) + } + + guard let container = container else { + return host + } + + if Node.shouldKeepAlive { + // Insert the host with strong reference to keep it eternally in the current process. + container.entries[key] = KeepAliveStoreEntry(host: host) + } + else { + // Insert the host with weak reference. + container.entries[key] = WeakStoreEntry(host: host) + } + + // Notify the assignment to observers. + for observer in observers { + observer.atomAssigned(atom: atom) + } + + return host + } + + func existingHost(of atom: Node) -> AtomHost? { + let key = AtomKey(atom) + + guard let base = container?.entries[key]?.host else { + return nil + } + + guard let host = base as? AtomHost else { + assertionFailure( + """ + The type of the given atom and the stored host did not match. + There might be duplicate keys, make sure that the keys for all atom types are unique. + + Atom type: \(Node.self) + Key type: \(type(of: atom.key)) + Host type: \(type(of: base)) + """ + ) + + // Remove the existing entry as fallback. + container?.entries.removeValue(forKey: key) + return nil + } + + return host + } + + func notifyObserversOfUpdate( + of atom: Node, + coordinator: Node.Hook.Coordinator + ) { + guard !observers.isEmpty else { + return + } + + let context = AtomHookContext(atom: atom, coordinator: coordinator, store: self) + let snapshot = Snapshot(atom: atom, value: atom.hook.value(context: context), store: self) + + for observer in observers { + observer.atomChanged(snapshot: snapshot) + } + } +} diff --git a/Sources/Atoms/Core/Internal/StoreEntry.swift b/Sources/Atoms/Core/Internal/StoreEntry.swift new file mode 100644 index 00000000..292cf434 --- /dev/null +++ b/Sources/Atoms/Core/Internal/StoreEntry.swift @@ -0,0 +1,12 @@ +@usableFromInline +internal protocol StoreEntry { + var host: AtomHostBase? { get } +} + +internal struct WeakStoreEntry: StoreEntry { + weak var host: AtomHostBase? +} + +internal struct KeepAliveStoreEntry: StoreEntry { + let host: AtomHostBase? +} diff --git a/Sources/Atoms/Core/Internal/UnsafeUncheckedSendableBox.swift b/Sources/Atoms/Core/Internal/UnsafeUncheckedSendableBox.swift new file mode 100644 index 00000000..6c17939e --- /dev/null +++ b/Sources/Atoms/Core/Internal/UnsafeUncheckedSendableBox.swift @@ -0,0 +1,7 @@ +internal struct UnsafeUncheckedSendableBox: @unchecked Sendable { + let unboxed: T + + init(_ unboxed: T) { + self.unboxed = unboxed + } +} diff --git a/Sources/Atoms/KeepAlive.swift b/Sources/Atoms/KeepAlive.swift new file mode 100644 index 00000000..629b215d --- /dev/null +++ b/Sources/Atoms/KeepAlive.swift @@ -0,0 +1,21 @@ +/// A marker protocol that indicates that the value of atoms conform with this protocol +/// will continue to be retained even after they are no longer watched to. +/// +/// ## Example +/// +/// ```swift +/// struct SharedPollingServiceAtom: ValueAtom, KeepAlive, Hashable { +/// func value(context: Context) -> PollingService { +/// PollingService() +/// } +/// } +/// ``` +/// +public protocol KeepAlive where Self: Atom {} + +public extension KeepAlive { + @MainActor + static var shouldKeepAlive: Bool { + true + } +} diff --git a/Sources/Atoms/Modifier/SelectModifier.swift b/Sources/Atoms/Modifier/SelectModifier.swift new file mode 100644 index 00000000..3e57f11b --- /dev/null +++ b/Sources/Atoms/Modifier/SelectModifier.swift @@ -0,0 +1,77 @@ +public extension Atom { + /// Selects a partial property with the specified key path from the original atom. + /// + /// When this modifier is used, the atom provides the partial value which conforms to `Equatable` + /// and prevent the view from updating its child view if the new value is equivalent to old value. + /// + /// ```swift + /// struct IntAtom: ValueAtom, Hashable { + /// func value(context: Context) -> Int { + /// 12345 + /// } + /// } + /// + /// struct ExampleView: View { + /// @Watch(IntAtom().select(\.description)) + /// var description + /// + /// var body: some View { + /// Text(description) + /// } + /// } + /// ``` + /// + /// - Parameter keyPath: A key path for the property of the original atom value. + /// + /// - Returns: An atom that provides the partial property of the original atom value. + @MainActor + func select( + _ keyPath: KeyPath + ) -> SelectModifierAtom { + SelectModifierAtom(base: self, keyPath: keyPath) + } +} + +/// An atom that selects the partial value of the specified key path from the original atom. +/// +/// You can also use ``Atom/select(_:)`` to constract this atom. +public struct SelectModifierAtom: Atom { + /// A type representing the stable identity of this atom associated with an instance. + public struct Key: Hashable { + private let base: Base.Key + private let keyPath: KeyPath + + fileprivate init( + base: Base.Key, + keyPath: KeyPath + ) { + self.base = base + self.keyPath = keyPath + } + } + + private let base: Base + private let keyPath: KeyPath + + /// Creates a new atom instance with given base atom and key path. + public init(base: Base, keyPath: KeyPath) { + self.base = base + self.keyPath = keyPath + } + + /// A unique value used to identify the atom internally. + public var key: Key { + Key(base: base.key, keyPath: keyPath) + } + + /// The hook for managing the state of this atom internally. + public var hook: SelectModifierHook { + Hook(base: base, keyPath: keyPath) + } + + /// Returns a boolean value that determines whether it should notify the value update to + /// watchers with comparing the given old value and the new value. + public func shouldNotifyUpdate(newValue: Value, oldValue: Value) -> Bool { + newValue != oldValue + } +} diff --git a/Sources/Atoms/Modifier/TaskPhaseModifier.swift b/Sources/Atoms/Modifier/TaskPhaseModifier.swift new file mode 100644 index 00000000..ed5b49b7 --- /dev/null +++ b/Sources/Atoms/Modifier/TaskPhaseModifier.swift @@ -0,0 +1,67 @@ +public extension Atom where Hook: AtomTaskHook { + /// Converts the `Task` that the original atom provides into ``AsyncPhase`` that + /// changes overtime. + /// + /// ```swift + /// struct AsyncIntAtom: TaskAtom, Hashable { + /// func value(context: Context) async -> Int { + /// try? await Task.sleep(nanoseconds: 1_000_000_000) + /// return 12345 + /// } + /// } + /// + /// struct ExampleView: View { + /// @Watch(AsyncIntAtom().phase) + /// var intPhase + /// + /// var body: some View { + /// switch intPhase { + /// case .success(let value): + /// Text("Value is \(value)") + /// + /// case .suspending: + /// Text("Loading") + /// } + /// } + /// } + /// ``` + /// + /// This modifier converts the `Task` that the original atom provides into ``AsyncPhase`` + /// and notifies its changes to downstream atoms and views. + @MainActor + var phase: TaskPhaseModifierAtom { + TaskPhaseModifierAtom(base: self) + } +} + +/// An atom that provides a sequential value of the base atom as an enum +/// representation ``AsyncPhase`` that changes overtime. +/// +/// You can also use ``Atom/phase`` to constract this atom. +public struct TaskPhaseModifierAtom: Atom where Base.Hook: AtomTaskHook { + /// A type representing the stable identity of this atom associated with an instance. + public struct Key: Hashable { + private let base: Base.Key + + fileprivate init(_ base: Base.Key) { + self.base = base + } + } + + private let base: Base + + /// Creates a new atom instance with given base atom. + public init(base: Base) { + self.base = base + } + + /// A unique value used to identify the atom internally. + public var key: Key { + Key(base.key) + } + + /// The hook for managing the state of this atom internally. + public var hook: TaskPhaseModifierHook { + Hook(base: base) + } +} diff --git a/Sources/Atoms/PropertyWrapper/ViewContext.swift b/Sources/Atoms/PropertyWrapper/ViewContext.swift new file mode 100644 index 00000000..05263184 --- /dev/null +++ b/Sources/Atoms/PropertyWrapper/ViewContext.swift @@ -0,0 +1,66 @@ +import Combine +import SwiftUI + +/// A property wrapper type that provides a context structure that to read, watch, and otherwise +/// interacting with atoms from views. +/// +/// Through the provided context, the view can read, write, or some other interactions to atoms. +/// If the view watches an atom through the context, the view invalidates its appearance and recompute +/// the body when the atom value updates. +/// +/// - SeeAlso: ``AtomViewContext`` +/// +/// ## Example +/// +/// ```swift +/// struct CounterView: View { +/// @ViewContext +/// var context +/// +/// var body: some View { +/// VStack { +/// Text("Count: \(context.watch(CounterAtom()))") // Read value, and start watching. +/// Button("Increment") { +/// context[CounterAtom()] += 1 // Mutation which means simultaneous read-write access. +/// } +/// Button("Reset") { +/// context.reset(CounterAtom()) // Reset to default value. +/// } +/// } +/// } +/// } +/// ``` +/// +@propertyWrapper +public struct ViewContext: DynamicProperty { + @StateObject + private var state: State + + @Environment(\.atomStore) + private var store + + /// Creates a view context. + public init() { + _state = StateObject(wrappedValue: State()) + } + + /// The underlying view context to interact with atoms. + /// + /// This property provides primary access to the view context. However you don't + /// access ``wrappedValue`` directly. + /// Instead, you use the property variable created with the `@ViewContext` attribute. + public var wrappedValue: AtomViewContext { + AtomViewContext( + store: store, + relationship: Relationship(container: state.container), + notifyUpdate: state.objectWillChange.send + ) + } +} + +private extension ViewContext { + @MainActor + final class State: ObservableObject { + let container = RelationshipContainer() + } +} diff --git a/Sources/Atoms/PropertyWrapper/Watch.swift b/Sources/Atoms/PropertyWrapper/Watch.swift new file mode 100644 index 00000000..5db58ada --- /dev/null +++ b/Sources/Atoms/PropertyWrapper/Watch.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// A property wrapper type that can watch and read-only access to the given atom. +/// +/// When the view accesses ``wrappedValue``, it starts watching to the atom, and when the atom value +/// changes, the view invalidates its appearance and recomputes the body. +/// +/// See also ``WatchState`` to write value of ``StateAtom`` and ``WatchStateObject`` to receive updates of +/// ``ObservableObjectAtom``. +/// +/// ## Example +/// +/// ```swift +/// struct CountDisplay: View { +/// @Watch(CounterAtom()) +/// var count +/// +/// var body: some View { +/// Text("Count: \(count)") // Read value, and start watching. +/// } +/// } +/// ``` +/// +@propertyWrapper +public struct Watch: DynamicProperty { + private let atom: Node + + @ViewContext + private var context + + /// Creates a watch with the atom that to be watched. + public init(_ atom: Node) { + self.atom = atom + } + + /// The underlying value associated with the given atom. + /// + /// This property provides primary access to the value's data. However, you don't + /// access ``wrappedValue`` directly. Instead, you use the property variable created + /// with the `@Watch` attribute. + /// Accessing to this property starts watching to the atom. + public var wrappedValue: Node.Hook.Value { + context.watch(atom) + } +} diff --git a/Sources/Atoms/PropertyWrapper/WatchState.swift b/Sources/Atoms/PropertyWrapper/WatchState.swift new file mode 100644 index 00000000..9b752bee --- /dev/null +++ b/Sources/Atoms/PropertyWrapper/WatchState.swift @@ -0,0 +1,66 @@ +import SwiftUI + +/// A property wrapper type that can watch and read-write access to the given atom conforms +/// to ``StateAtom``. +/// +/// When the view accesses ``wrappedValue``, it starts watching to the atom, and when the atom changes, +/// the view invalidates its appearance and recomputes the body. However, if only write access is +/// performed, it doesn't start watching. +/// +/// See also ``Watch`` to have read-only access and ``WatchStateObject`` to receive updates of +/// ``ObservableObjectAtom``. +/// The interface of this property wrapper follows `@State`. +/// +/// ## Example +/// +/// ```swift +/// struct CounterView: View { +/// @WatchState(CounterAtom()) +/// var count +/// +/// var body: some View { +/// VStack { +/// Text("Count: \(count)") // Read value, and start watching. +/// Stepper(value: $count) {} // Use as a binding +/// Button("+100") { +/// count += 100 // Mutation which means simultaneous read-write access. +/// } +/// } +/// } +/// } +/// ``` +/// +@propertyWrapper +public struct WatchState: DynamicProperty where Node.Hook: AtomStateHook { + private let atom: Node + + @ViewContext + private var context + + /// Creates a watch with the atom that to be watched. + public init(_ atom: Node) { + self.atom = atom + } + + /// The underlying value associated with the given atom. + /// + /// This property provides primary access to the value's data. However, you don't + /// access ``wrappedValue`` directly. Instead, you use the property variable created + /// with the `@WatchState` attribute. + /// Accessing to the getter of this property starts watching to the atom, but doesn't + /// by setting a new value. + public var wrappedValue: Node.Hook.Value { + get { context.watch(atom) } + nonmutating set { context.set(newValue, for: atom) } + } + + /// A binding to the atom value. + /// + /// Use the projected value to pass a binding value down a view hierarchy. + /// To get the ``projectedValue``, prefix the property variable with `$`. + /// Accessing to this property itself doesn't starts watching to the atom, but does when + /// the view accesses to the getter of the binding. + public var projectedValue: Binding { + context.state(atom) + } +} diff --git a/Sources/Atoms/PropertyWrapper/WatchStateObject.swift b/Sources/Atoms/PropertyWrapper/WatchStateObject.swift new file mode 100644 index 00000000..51df248f --- /dev/null +++ b/Sources/Atoms/PropertyWrapper/WatchStateObject.swift @@ -0,0 +1,96 @@ +import SwiftUI + +/// A property wrapper type that can watch the given atom conforms to ``ObservableObjectAtom``. +/// +/// When the view accesses ``wrappedValue``, it starts watching to the atom, and when the atom changes, +/// the view invalidates its appearance and recomputes the body. +/// +/// See also ``Watch`` to have read-only access and ``WatchState`` to write value of ``StateAtom``. +/// The interface of this property wrapper follows `@StateObject`. +/// +/// ## Example +/// +/// ```swift +/// class Counter: ObservableObject { +/// @Published var count = 0 +/// +/// func plus(_ value: Int) { +/// count += value +/// } +/// } +/// +/// struct CounterAtom: ObservableObjectAtom, Hashable { +/// func object(context: Context) -> Counter { +/// Counter() +/// } +/// } +/// +/// struct CounterView: View { +/// @WatchStateObject(CounterAtom()) +/// var counter +/// +/// var body: some View { +/// VStack { +/// Text("Count: \(counter.count)") // Read property, and start watching. +/// Stepper(value: $counter.count) {} // Use the property as a binding +/// Button("+100") { +/// counter.plus(100) // Call the method to update. +/// } +/// } +/// } +/// } +/// ``` +/// +@propertyWrapper +public struct WatchStateObject: DynamicProperty { + /// A wrapper of the underlying observable object that can create bindings to + /// its properties using dynamic member lookup. + @dynamicMemberLookup + public struct Wrapper { + private let object: Node.ObjectType + + /// Returns a binding to the resulting value of the given key path. + /// + /// - Parameter keyPath: A key path to a specific resulting value. + /// + /// - Returns: A new binding. + public subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> Binding { + Binding( + get: { object[keyPath: keyPath] }, + set: { object[keyPath: keyPath] = $0 } + ) + } + + fileprivate init(_ object: Node.ObjectType) { + self.object = object + } + } + + private let atom: Node + + @ViewContext + private var context + + /// Creates a watch with the atom that to be watched. + public init(_ atom: Node) { + self.atom = atom + } + + /// The underlying observable object associated with the given atom. + /// + /// This property provides primary access to the value's data. However, you don't + /// access ``wrappedValue`` directly. Instead, you use the property variable created + /// with the `@WatchStateObject` attribute. + /// Accessing to this property starts watching to the atom. + public var wrappedValue: Node.ObjectType { + context.watch(atom) + } + + /// A projection of the state object that creates bindings to its properties. + /// + /// Use the projected value to pass a binding value down a view hierarchy. + /// To get the projected value, prefix the property variable with `$`. + public var projectedValue: Wrapper { + Wrapper(wrappedValue) + } +} diff --git a/Sources/Atoms/Snapshot.swift b/Sources/Atoms/Snapshot.swift new file mode 100644 index 00000000..24cb819e --- /dev/null +++ b/Sources/Atoms/Snapshot.swift @@ -0,0 +1,33 @@ +/// A protocol that abstracts restorable change history of an atom. +/// +/// This type would be useful, for example, when you want to erase the type of +/// a ``Snapshot`` to add it to single array. +@MainActor +public protocol AtomHistory: Sendable { + /// Restores the change in this history. + func restore() +} + +/// A snapshot that contains the changed atom and its value. +/// +/// - SeeAlso: ``AtomObserver`` +public struct Snapshot: AtomHistory { + /// The snapshot atom instance. + public let atom: Node + + /// The snapshot value of the``atom``. + public let value: Node.Hook.Value + + private let store: AtomStore + + internal init(atom: Node, value: Node.Hook.Value, store: AtomStore) { + self.atom = atom + self.value = value + self.store = store + } + + /// Restores the change in this snapshot. + public func restore() { + store.restore(snapshot: self) + } +} diff --git a/Sources/Atoms/Suspense.swift b/Sources/Atoms/Suspense.swift new file mode 100644 index 00000000..8bb27714 --- /dev/null +++ b/Sources/Atoms/Suspense.swift @@ -0,0 +1,214 @@ +import SwiftUI + +/// A view that lets the content wait for the given task to provide a resulting value +/// or an error. +/// +/// ``Suspense`` manages the given task internally until the task instance is changed. +/// While the specified task is in process to provide a resulting value, it displays the +/// `suspending` content that is empty by default. +/// When the task eventually provides a resulting value, it updates the view to display +/// the given content. If the task fails, it falls back to show the `catch` content that +/// is also empty as default. +/// +/// ## Example +/// +/// ```swift +/// let fetchImageTask: Task = ... +/// +/// Suspense(fetchImageTask) { uiImage in +/// // Displays content when the task successfully provides a value. +/// Image(uiImage: uiImage) +/// } suspending: { +/// // Optionally displays a suspending content. +/// ProgressView() +/// } catch: { error in +/// // Optionally displays a failure content. +/// Text(error.localizedDescription) +/// } +/// ``` +/// +public struct Suspense: View { + private let task: Task + private let content: (Value) -> Content + private let suspending: () -> Suspending + private let failureContent: (Failure) -> FailureContent + + @StateObject + private var state: State + + /// Waits for the given task to provide a resulting value and display the content + /// accordingly. + /// + /// ```swift + /// let fetchImageTask: Task = ... + /// + /// Suspense(fetchImageTask) { uiImage in + /// Image(uiImage: uiImage) + /// } suspending: { + /// ProgressView() + /// } catch: { error in + /// Text(error.localizedDescription) + /// } + /// ``` + /// + /// - Parameters: + /// - task: A task that provides a resulting value to be displayed. + /// - content: A content that displays when the task successfully provides a value. + /// - suspending: A suspending content that displays while the task is in process. + /// - catch: A failure content that displays if the task fails. + public init( + _ task: Task, + @ViewBuilder content: @escaping (Value) -> Content, + @ViewBuilder suspending: @escaping () -> Suspending, + @ViewBuilder catch: @escaping (Failure) -> FailureContent + ) { + self._state = StateObject(wrappedValue: State()) + self.task = task + self.content = content + self.suspending = suspending + self.failureContent = `catch` + } + + /// Waits for the given task to provide a resulting value and display the content + /// accordingly. + /// + /// ```swift + /// let fetchImageTask: Task = ... + /// + /// Suspense(fetchImageTask) { uiImage in + /// Image(uiImage: uiImage) + /// } + /// ``` + /// + /// - Parameters: + /// - task: A task that provides a resulting value to be displayed. + /// - content: A content that displays when the task successfully provides a value. + public init( + _ task: Task, + @ViewBuilder content: @escaping (Value) -> Content + ) where Suspending == EmptyView, FailureContent == EmptyView { + self.init( + task, + content: content, + suspending: EmptyView.init, + catch: { _ in EmptyView() } + ) + } + + /// Waits for the given task to provide a resulting value and display the content + /// accordingly. + /// + /// ```swift + /// let fetchImageTask: Task = ... + /// + /// Suspense(fetchImageTask) { uiImage in + /// Image(uiImage: uiImage) + /// } suspending: { + /// ProgressView() + /// } + /// ``` + /// + /// - Parameters: + /// - task: A task that provides a resulting value to be displayed. + /// - content: A content that displays when the task successfully provides a value. + /// - suspending: A suspending content that displays while the task is in process. + public init( + _ task: Task, + @ViewBuilder content: @escaping (Value) -> Content, + @ViewBuilder suspending: @escaping () -> Suspending + ) where FailureContent == EmptyView { + self.init( + task, + content: content, + suspending: suspending, + catch: { _ in EmptyView() } + ) + } + + /// Waits for the given task to provide a resulting value and display the content + /// accordingly. + /// + /// ```swift + /// let fetchImageTask: Task = ... + /// + /// Suspense(fetchImageTask) { uiImage in + /// Image(uiImage: uiImage) + /// } catch: { error in + /// Text(error.localizedDescription) + /// } + /// ``` + /// + /// - Parameters: + /// - task: A task that provides a resulting value to be displayed. + /// - content: A content that displays when the task successfully provides a value. + /// - catch: A failure content that displays if the task fails. + public init( + _ task: Task, + @ViewBuilder content: @escaping (Value) -> Content, + @ViewBuilder catch: @escaping (Failure) -> FailureContent + ) where Suspending == EmptyView { + self.init( + task, + content: content, + suspending: EmptyView.init, + catch: `catch` + ) + } + + /// The content and behavior of the view. + public var body: some View { + state.task = task + + return Group { + switch state.phase { + case .success(let value): + content(value) + + case .suspending: + suspending() + + case .failure(let error): + failureContent(error) + } + } + } +} + +private extension Suspense { + @MainActor + final class State: ObservableObject { + @Published + private(set) var phase = AsyncPhase.suspending + + private var suspensionTask: Task? { + didSet { oldValue?.cancel() } + } + + var task: Task? { + didSet { + guard task != oldValue else { + return + } + + guard let task = task else { + phase = .suspending + return suspensionTask = nil + } + + suspensionTask = Task { [weak self] in + self?.phase = .suspending + + let result = await task.result + + if !Task.isCancelled { + self?.phase = AsyncPhase(result) + } + } + } + } + + deinit { + suspensionTask?.cancel() + } + } +} diff --git a/Tests/AtomsTests/AsyncPhaseTests.swift b/Tests/AtomsTests/AsyncPhaseTests.swift new file mode 100644 index 00000000..3c8a68f3 --- /dev/null +++ b/Tests/AtomsTests/AsyncPhaseTests.swift @@ -0,0 +1,191 @@ +import XCTest + +@testable import Atoms + +final class AsyncPhaseTests: XCTestCase { + struct TestError: Error, Equatable { + let value: Int + } + + let phases: [AsyncPhase] = [ + .suspending, + .success(0), + .failure(TestError(value: 0)), + ] + + func testIsSuspending() { + let expected = [ + true, + false, + false, + ] + + XCTAssertEqual(phases.map(\.isSuspending), expected) + } + + func testIsSuccess() { + let expected = [ + false, + true, + false, + ] + + XCTAssertEqual(phases.map(\.isSuccess), expected) + } + + func testIsFailure() { + let expected = [ + false, + false, + true, + ] + + XCTAssertEqual(phases.map(\.isFailure), expected) + } + + func testValue() { + let expected = [ + nil, + 0, + nil, + ] + + XCTAssertEqual(phases.map(\.value), expected) + } + + func testError() { + let expected = [ + nil, + nil, + TestError(value: 0), + ] + + XCTAssertEqual(phases.map(\.error), expected) + } + + func testInitWithResult() { + let results: [Result] = [ + .success(0), + .failure(TestError(value: 0)), + ] + + let expected: [AsyncPhase] = [ + .success(0), + .failure(TestError(value: 0)), + ] + + XCTAssertEqual(results.map(AsyncPhase.init), expected) + } + + func testMap() { + let phase = AsyncPhase.success(0) + .map(String.init) + + XCTAssertEqual(phase.value, "0") + } + + func testMapError() { + let phase = AsyncPhase.failure(TestError(value: 0)) + .mapError { _ in URLError(.badURL) } + + XCTAssertEqual(phase.error, URLError(.badURL)) + } + + func testFlatMap() { + XCTContext.runActivity(named: "To suspending") { _ in + let transformed = phases.map { phase in + phase.flatMap { _ -> AsyncPhase in + .suspending + } + } + + let expected: [AsyncPhase] = [ + .suspending, + .suspending, + .failure(TestError(value: 0)), + ] + + XCTAssertEqual(transformed, expected) + } + + XCTContext.runActivity(named: "To success") { _ in + let transformed = phases.map { phase in + phase.flatMap { .success(String($0)) } + } + + let expected: [AsyncPhase] = [ + .suspending, + .success("0"), + .failure(TestError(value: 0)), + ] + + XCTAssertEqual(transformed, expected) + } + + XCTContext.runActivity(named: "To failure") { _ in + let transformed = phases.map { phase in + phase.flatMap { _ -> AsyncPhase in + .failure(TestError(value: 1)) + } + } + + let expected: [AsyncPhase] = [ + .suspending, + .failure(TestError(value: 1)), + .failure(TestError(value: 0)), + ] + + XCTAssertEqual(transformed, expected) + } + } + + func testFlatMapError() { + XCTContext.runActivity(named: "To suspending") { _ in + let transformed = phases.map { phase in + phase.flatMapError { _ -> AsyncPhase in + .suspending + } + } + + let expected: [AsyncPhase] = [ + .suspending, + .success(0), + .suspending, + ] + + XCTAssertEqual(transformed, expected) + } + + XCTContext.runActivity(named: "To success") { _ in + let transformed = phases.map { phase in + phase.flatMapError { _ -> AsyncPhase in + .success(1) + } + } + + let expected: [AsyncPhase] = [ + .suspending, + .success(0), + .success(1), + ] + + XCTAssertEqual(transformed, expected) + } + + XCTContext.runActivity(named: "To failure") { _ in + let transformed = phases.map { phase in + phase.flatMapError { _ -> AsyncPhase in + .failure(URLError(.badURL)) + } + } + + let expected: [AsyncPhase] = [ + .suspending, + .success(0), + .failure(URLError(.badURL)), + ] + + XCTAssertEqual(transformed, expected) + } + } +} diff --git a/Tests/AtomsTests/Atom/AsyncSequenceAtomTests.swift b/Tests/AtomsTests/Atom/AsyncSequenceAtomTests.swift new file mode 100644 index 00000000..429aba16 --- /dev/null +++ b/Tests/AtomsTests/Atom/AsyncSequenceAtomTests.swift @@ -0,0 +1,28 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AsyncSequenceAtomTests: XCTestCase { + struct TestAtom: AsyncSequenceAtom, Hashable { + let value: Int + + func sequence(context: Context) -> AsyncStream { + AsyncStream { continuation in + continuation.yield(value) + continuation.finish() + } + } + } + + func test() async { + let atom = TestAtom(value: 100) + let context = AtomTestContext() + + context.watch(atom) + + let phase = await context.refresh(atom) + + XCTAssertEqual(phase.value, 100) + } +} diff --git a/Tests/AtomsTests/Atom/ObservableObjectAtomTests.swift b/Tests/AtomsTests/Atom/ObservableObjectAtomTests.swift new file mode 100644 index 00000000..b003f55b --- /dev/null +++ b/Tests/AtomsTests/Atom/ObservableObjectAtomTests.swift @@ -0,0 +1,39 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class ObservableObjectAtomTests: XCTestCase { + final class TestObject: ObservableObject { + @Published + var value: Int + + init(value: Int) { + self.value = value + } + } + + struct TestAtom: ObservableObjectAtom, Hashable { + let value: Int + + func object(context: Context) -> TestObject { + TestObject(value: value) + } + } + + func test() { + let atom = TestAtom(value: 100) + let context = AtomTestContext() + let object = context.watch(atom) + var isUpdated = false + + XCTAssertEqual(object.value, 100) + XCTAssertFalse(isUpdated) + + context.onUpdate = { isUpdated = true } + object.value = 200 + + XCTAssertEqual(object.value, 200) + XCTAssertTrue(isUpdated) + } +} diff --git a/Tests/AtomsTests/Atom/PublisherAtomTests.swift b/Tests/AtomsTests/Atom/PublisherAtomTests.swift new file mode 100644 index 00000000..e61422c5 --- /dev/null +++ b/Tests/AtomsTests/Atom/PublisherAtomTests.swift @@ -0,0 +1,23 @@ +import Combine +import XCTest + +@testable import Atoms + +@MainActor +final class PublisherAtomTests: XCTestCase { + struct TestAtom: PublisherAtom, Hashable { + let value: Int + + func publisher(context: Context) -> Just { + Just(value) + } + } + + func test() async { + let atom = TestAtom(value: 100) + let context = AtomTestContext() + let value = await context.refresh(atom).value + + XCTAssertEqual(value, 100) + } +} diff --git a/Tests/AtomsTests/Atom/StateAtomTests.swift b/Tests/AtomsTests/Atom/StateAtomTests.swift new file mode 100644 index 00000000..c83ec67a --- /dev/null +++ b/Tests/AtomsTests/Atom/StateAtomTests.swift @@ -0,0 +1,25 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class StateAtomTests: XCTestCase { + struct TestAtom: StateAtom, Hashable { + let defaultValue: Int + + func defaultValue(context: Context) -> Int { + defaultValue + } + } + + func test() { + let atom = TestAtom(defaultValue: 100) + let context = AtomTestContext() + + XCTAssertEqual(context.watch(atom), 100) + + context[atom] = 200 + + XCTAssertEqual(context.watch(atom), 200) + } +} diff --git a/Tests/AtomsTests/Atom/TaskAtomTests.swift b/Tests/AtomsTests/Atom/TaskAtomTests.swift new file mode 100644 index 00000000..2922558c --- /dev/null +++ b/Tests/AtomsTests/Atom/TaskAtomTests.swift @@ -0,0 +1,22 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class TaskAtomTests: XCTestCase { + struct TestAtom: TaskAtom, Hashable { + let value: Int + + func value(context: Context) async -> Int { + value + } + } + + func test() async { + let atom = TestAtom(value: 100) + let context = AtomTestContext() + let value = await context.watch(atom).value + + XCTAssertEqual(value, 100) + } +} diff --git a/Tests/AtomsTests/Atom/ThrowingTaskAtomTests.swift b/Tests/AtomsTests/Atom/ThrowingTaskAtomTests.swift new file mode 100644 index 00000000..43b9af2a --- /dev/null +++ b/Tests/AtomsTests/Atom/ThrowingTaskAtomTests.swift @@ -0,0 +1,22 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class ThrowingTaskAtomTests: XCTestCase { + struct TestAtom: ThrowingTaskAtom, Hashable { + let value: Int + + func value(context: Context) async throws -> Int { + value + } + } + + func test() async throws { + let atom = TestAtom(value: 100) + let context = AtomTestContext() + let value = try await context.watch(atom).value + + XCTAssertEqual(value, 100) + } +} diff --git a/Tests/AtomsTests/Atom/ValueAtomTests.swift b/Tests/AtomsTests/Atom/ValueAtomTests.swift new file mode 100644 index 00000000..037ace82 --- /dev/null +++ b/Tests/AtomsTests/Atom/ValueAtomTests.swift @@ -0,0 +1,21 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class ValueAtomTests: XCTestCase { + struct TestAtom: ValueAtom, Hashable { + let value: Int + + func value(context: Context) -> Int { + value + } + } + + func test() { + let atom = TestValueAtom(value: 100) + let context = AtomTestContext() + + XCTAssertEqual(context.watch(atom), 100) + } +} diff --git a/Tests/AtomsTests/AtomObserverTests.swift b/Tests/AtomsTests/AtomObserverTests.swift new file mode 100644 index 00000000..f9a7712d --- /dev/null +++ b/Tests/AtomsTests/AtomObserverTests.swift @@ -0,0 +1,18 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AtomObserverTests: XCTestCase { + struct TestEmptyObserver: AtomObserver {} + + func testEmpty() { + let observer = TestEmptyObserver() + let atom = TestValueAtom(value: 0) + let snapshot = Snapshot(atom: atom, value: 0, store: DefaultStore()) + + observer.atomAssigned(atom: atom) + observer.atomUnassigned(atom: atom) + observer.atomChanged(snapshot: snapshot) + } +} diff --git a/Tests/AtomsTests/Context/AtomContextTests.swift b/Tests/AtomsTests/Context/AtomContextTests.swift new file mode 100644 index 00000000..4d17f3eb --- /dev/null +++ b/Tests/AtomsTests/Context/AtomContextTests.swift @@ -0,0 +1,30 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AtomContextTests: XCTestCase { + func testSubscript() { + let atom = TestStateAtom(defaultValue: 0) + let context: AtomWatchableContext = AtomTestContext() + + XCTAssertEqual(context.watch(atom), 0) + + context[atom] = 100 + + XCTAssertEqual(context[atom], 100) + } + + func testState() { + let atom = TestStateAtom(defaultValue: 0) + let context: AtomWatchableContext = AtomTestContext() + let state = context.state(atom) + + XCTAssertEqual(context.read(atom), 0) + + state.wrappedValue = 100 + + XCTAssertEqual(state.wrappedValue, 100) + XCTAssertEqual(context.read(atom), 100) + } +} diff --git a/Tests/AtomsTests/Context/AtomRelationContextTests.swift b/Tests/AtomsTests/Context/AtomRelationContextTests.swift new file mode 100644 index 00000000..6ba510da --- /dev/null +++ b/Tests/AtomsTests/Context/AtomRelationContextTests.swift @@ -0,0 +1,153 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AtomRelationContextTests: XCTestCase { + func testAddTermination() { + let atom = TestValueAtom(value: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let context = AtomRelationContext(atom: atom, store: store) + var terminationCount0 = 0 + var terminationCount1 = 0 + + _ = store.watch(atom, relationship: relationship) {} + + context.addTermination { + terminationCount0 += 1 + } + context.addTermination { + terminationCount1 += 1 + } + + context.reset(atom) + + XCTAssertEqual(terminationCount0, 1) + XCTAssertEqual(terminationCount1, 1) + } + + func testKeepUntilTermination() { + let atom = TestValueAtom(value: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let context = AtomRelationContext(atom: atom, store: store) + var object: Object? = Object() + var isDeinitialized = false + + object?.onDeinit = { + isDeinitialized = true + } + + _ = store.watch(atom, relationship: relationship) {} + + context.keepUntilTermination(object!) + + object = nil + XCTAssertFalse(isDeinitialized) + + context.reset(atom) + XCTAssertTrue(isDeinitialized) + } + + func testSubscript() { + let atom = TestStateAtom(defaultValue: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let context = AtomRelationContext(atom: atom, store: store) + var updateCount = 0 + + _ = store.watch(atom, relationship: relationship) { + updateCount += 1 + } + + XCTAssertEqual(context[atom], 100) + XCTAssertEqual(updateCount, 0) + + context[atom] = 200 + + XCTAssertEqual(context[atom], 200) + XCTAssertEqual(updateCount, 1) + } + + func testRead() { + let atom = TestValueAtom(value: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let context = AtomRelationContext(atom: atom, store: store) + + XCTAssertEqual(context.read(atom), 100) + } + + func testWatch() { + let atom0 = TestValueAtom(value: 100) + let atom1 = TestStateAtom(defaultValue: 200) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let context = AtomRelationContext(atom: atom0, store: store) + var updateCount = 0 + + _ = store.watch(atom0, relationship: relationship) { + updateCount += 1 + } + + XCTAssertEqual(context.watch(atom1), 200) + XCTAssertEqual(updateCount, 0) + + // Resetting atom1 triggers atom0 update. + context.reset(atom1) + + XCTAssertEqual(updateCount, 1) + } + + func testRefresh() async { + let atom = TestTaskAtom(value: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let context = AtomRelationContext(atom: atom, store: store) + var updateCount = 0 + + _ = store.watch(atom, relationship: relationship) { + updateCount += 1 + } + + let value = await context.refresh(atom).value + + XCTAssertEqual(value, 100) + XCTAssertEqual(updateCount, 1) + } + + func testReset() { + let atom = TestStateAtom(defaultValue: 0) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let context = AtomRelationContext(atom: atom, store: store) + var updateCount = 0 + + _ = store.watch(atom, relationship: relationship) { + updateCount += 1 + } + + context[atom] = 100 + + XCTAssertEqual(context.read(atom), 100) + XCTAssertEqual(updateCount, 1) + + context.reset(atom) + + XCTAssertEqual(context.read(atom), 0) + XCTAssertEqual(updateCount, 2) + } +} diff --git a/Tests/AtomsTests/Context/AtomTestContext.swift b/Tests/AtomsTests/Context/AtomTestContext.swift new file mode 100644 index 00000000..331bf751 --- /dev/null +++ b/Tests/AtomsTests/Context/AtomTestContext.swift @@ -0,0 +1,216 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AtomTestContextTests: XCTestCase { + func testOnUpdate() { + let atom = TestValueAtom(value: 100) + let context = AtomTestContext() + var isCalled = false + + context.onUpdate = { + isCalled = false + } + + // Override. + context.onUpdate = { + isCalled = true + } + + context.reset(atom) + + XCTAssertFalse(isCalled) + + context.watch(atom) + + XCTAssertFalse(isCalled) + + context.reset(atom) + + XCTAssertTrue(isCalled) + } + + func testOverride() { + let atom0 = TestValueAtom(value: 100) + let atom1 = TestValueAtom(value: 200) + let context = AtomTestContext() + + XCTAssertEqual(context.read(atom0), 100) + XCTAssertEqual(context.read(atom1), 200) + + context.override(atom0) { _ in 300 } + + XCTAssertEqual(context.read(atom0), 300) + XCTAssertEqual(context.read(atom1), 200) + } + + func testOverrideWithType() { + let atom0 = TestValueAtom(value: 100) + let atom1 = TestValueAtom(value: 200) + let context = AtomTestContext() + + XCTAssertEqual(context.read(atom0), 100) + XCTAssertEqual(context.read(atom1), 200) + + context.override(TestValueAtom.self) { _ in 300 } + + XCTAssertEqual(context.read(atom0), 300) + XCTAssertEqual(context.read(atom1), 300) + } + + func testObserve() { + final class TestObserver: AtomObserver { + var asignedAtomKeys = [AtomKey]() + var unassignedAtomKeys = [AtomKey]() + var changedAtomKeys = [AtomKey]() + + func atomAssigned(atom: Node) { + asignedAtomKeys.append(AtomKey(atom)) + } + + func atomUnassigned(atom: Node) { + unassignedAtomKeys.append(AtomKey(atom)) + } + + func atomChanged(snapshot: Snapshot) { + changedAtomKeys.append(AtomKey(snapshot.atom)) + } + } + + let atom = TestStateAtom(defaultValue: 100) + let key = AtomKey(atom) + let observers = [TestObserver(), TestObserver()] + let context = AtomTestContext() + + for observer in observers { + context.observe(observer) + } + + context.watch(atom) + + for observer in observers { + XCTAssertEqual(observer.asignedAtomKeys, [key]) + XCTAssertEqual(observer.unassignedAtomKeys, []) + XCTAssertEqual(observer.changedAtomKeys, [key]) + } + + context[atom] = 200 + + for observer in observers { + XCTAssertEqual(observer.asignedAtomKeys, [key]) + XCTAssertEqual(observer.unassignedAtomKeys, []) + XCTAssertEqual(observer.changedAtomKeys, [key, key]) + } + + context.unwatch(atom) + + for observer in observers { + XCTAssertEqual(observer.asignedAtomKeys, [key]) + XCTAssertEqual(observer.unassignedAtomKeys, [key]) + XCTAssertEqual(observer.changedAtomKeys, [key, key]) + } + } + + func testTerminate() { + let atom = TestStateAtom(defaultValue: 100) + let context = AtomTestContext() + + XCTAssertEqual(context.watch(atom), 100) + + context[atom] = 200 + + var updateCount = 0 + + context.onUpdate = { + updateCount += 1 + } + + context.unwatch(atom) + + XCTAssertEqual(context.read(atom), 100) + XCTAssertEqual(updateCount, 0) + } + + func testSubscript() { + let atom = TestStateAtom(defaultValue: 100) + let context = AtomTestContext() + var updateCount = 0 + + context.onUpdate = { + updateCount += 1 + } + + context.watch(atom) + + XCTAssertEqual(context[atom], 100) + XCTAssertEqual(updateCount, 0) + + context[atom] = 200 + + XCTAssertEqual(context[atom], 200) + XCTAssertEqual(updateCount, 1) + } + + func testRead() { + let atom = TestValueAtom(value: 100) + let context = AtomTestContext() + + XCTAssertEqual(context.read(atom), 100) + } + + func testWatch() { + let atom = TestStateAtom(defaultValue: 100) + let context = AtomTestContext() + var updateCount = 0 + + context.onUpdate = { + updateCount += 1 + } + + XCTAssertEqual(context.watch(atom), 100) + XCTAssertEqual(updateCount, 0) + + context[atom] = 200 + + XCTAssertEqual(context.watch(atom), 200) + XCTAssertEqual(updateCount, 1) + } + + func testRefresh() async { + let atom = TestTaskAtom(value: 100) + let context = AtomTestContext() + var updateCount = 0 + + context.onUpdate = { + updateCount += 1 + } + + context.watch(atom) + + let value = await context.refresh(atom).value + + XCTAssertEqual(value, 100) + XCTAssertEqual(updateCount, 1) + } + + func testReset() { + let atom = TestStateAtom(defaultValue: 0) + let context = AtomTestContext() + var updateCount = 0 + + context.onUpdate = { + updateCount += 1 + } + + XCTAssertEqual(context.watch(atom), 0) + + context[atom] = 100 + + XCTAssertEqual(context.read(atom), 100) + + context.reset(atom) + + XCTAssertEqual(context.read(atom), 0) + } +} diff --git a/Tests/AtomsTests/Context/AtomViewContextTests.swift b/Tests/AtomsTests/Context/AtomViewContextTests.swift new file mode 100644 index 00000000..1827420e --- /dev/null +++ b/Tests/AtomsTests/Context/AtomViewContextTests.swift @@ -0,0 +1,100 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AtomViewContextTests: XCTestCase { + func testSubscript() { + let atom = TestStateAtom(defaultValue: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + var updateCount = 0 + let context = AtomViewContext(store: store, relationship: relationship) { + updateCount += 1 + } + + context.watch(atom) + + XCTAssertEqual(context[atom], 100) + XCTAssertEqual(updateCount, 0) + + context[atom] = 200 + + XCTAssertEqual(context[atom], 200) + XCTAssertEqual(updateCount, 1) + } + + func testRead() { + let atom = TestValueAtom(value: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let context = AtomViewContext(store: store, relationship: relationship) {} + + XCTAssertEqual(context.read(atom), 100) + } + + func testWatch() { + let atom = TestStateAtom(defaultValue: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + var updateCount = 0 + let context = AtomViewContext(store: store, relationship: relationship) { + updateCount += 1 + } + + XCTAssertEqual(context.watch(atom), 100) + XCTAssertEqual(updateCount, 0) + + context[atom] = 200 + + XCTAssertEqual(context.watch(atom), 200) + XCTAssertEqual(updateCount, 1) + } + + func testRefresh() async { + let atom = TestTaskAtom(value: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + var updateCount = 0 + let context = AtomViewContext(store: store, relationship: relationship) { + updateCount += 1 + } + + context.watch(atom) + + let value = await context.refresh(atom).value + + XCTAssertEqual(value, 100) + XCTAssertEqual(updateCount, 1) + } + + func testReset() { + let atom = TestStateAtom(defaultValue: 0) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + var updateCount = 0 + let context = AtomViewContext(store: store, relationship: relationship) { + updateCount += 1 + } + + XCTAssertEqual(context.watch(atom), 0) + + context[atom] = 100 + + XCTAssertEqual(context.read(atom), 100) + + context.reset(atom) + + XCTAssertEqual(context.read(atom), 0) + } +} diff --git a/Tests/AtomsTests/Core/Hook/AsyncSequenceHookTests.swift b/Tests/AtomsTests/Core/Hook/AsyncSequenceHookTests.swift new file mode 100644 index 00000000..06dd1106 --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/AsyncSequenceHookTests.swift @@ -0,0 +1,178 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AsyncSequenceHookTests: XCTestCase { + final class AsyncThrowingStreamPipe { + var stream: AsyncThrowingStream + var continuation: AsyncThrowingStream.Continuation! + + init() { + (stream, continuation) = Self.pipe() + } + + func reset() { + (stream, continuation) = Self.pipe() + } + + static func pipe() -> ( + AsyncThrowingStream, + AsyncThrowingStream.Continuation + ) { + var continuation: AsyncThrowingStream.Continuation! + let stream = AsyncThrowingStream { continuation = $0 } + return (stream, continuation) + } + } + + func testMakeCoordinator() { + let hook = AsyncSequenceHook { _ in + AsyncStream { _ in } + } + let coordinator = hook.makeCoordinator() + + XCTAssertNil(coordinator.phase) + } + + func testValue() { + let hook = AsyncSequenceHook { _ in + AsyncThrowingStream { _ in } + } + let coordinator = hook.makeCoordinator() + let context = AtomHookContext( + atom: TestAtom(key: 0, hook: hook), + coordinator: coordinator, + store: Store(container: StoreContainer()) + ) + + coordinator.phase = .success(100) + + XCTAssertEqual(hook.value(context: context).value, 100) + } + + func testUpdate() { + let pipe = AsyncThrowingStreamPipe() + let hook = AsyncSequenceHook { _ in pipe.stream } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + + XCTContext.runActivity(named: "Initially suspending") { _ in + XCTAssertTrue(context.watch(atom).isSuspending) + } + + XCTContext.runActivity(named: "Value") { _ in + let expectation = expectation(description: "Update") + context.onUpdate = expectation.fulfill + pipe.continuation.yield(0) + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(context.watch(atom).value, 0) + } + + XCTContext.runActivity(named: "Error") { _ in + let expectation = expectation(description: "Update") + context.onUpdate = expectation.fulfill + pipe.continuation.finish(throwing: URLError(.badURL)) + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(context.watch(atom).error as? URLError, URLError(.badURL)) + } + + XCTContext.runActivity(named: "Value after finished") { _ in + let expectation = expectation(description: "Update") + expectation.isInverted = true + context.onUpdate = expectation.fulfill + pipe.continuation.yield(1) + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(context.watch(atom).error as? URLError, URLError(.badURL)) + } + + XCTContext.runActivity(named: "Value after termination") { _ in + context.unwatch(atom) + pipe.reset() + context.watch(atom) + context.unwatch(atom) + + let expectation = expectation(description: "Update") + expectation.isInverted = true + context.onUpdate = expectation.fulfill + pipe.continuation.yield(0) + + wait(for: [expectation], timeout: 1) + } + + XCTContext.runActivity(named: "Error after termination") { _ in + context.unwatch(atom) + pipe.reset() + context.watch(atom) + context.unwatch(atom) + + let expectation = expectation(description: "Update") + expectation.isInverted = true + context.onUpdate = expectation.fulfill + pipe.continuation.finish(throwing: URLError(.badURL)) + + wait(for: [expectation], timeout: 1) + } + + XCTContext.runActivity(named: "Override") { _ in + context.override(atom) { _ in .success(100) } + + XCTAssertEqual(context.watch(atom).value, 100) + } + } + + func testRefresh() async { + let pipe = AsyncThrowingStreamPipe() + let hook = AsyncSequenceHook { _ in pipe.stream } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + var updateCount = 0 + + context.onUpdate = { updateCount += 1 } + + // Refresh + + XCTAssertTrue(context.watch(atom).isSuspending) + + Task { + pipe.continuation.yield(0) + pipe.continuation.finish(throwing: nil) + } + + pipe.reset() + let phase0 = await context.refresh(atom) + + XCTAssertEqual(phase0.value, 0) + XCTAssertEqual(updateCount, 1) + + // Cancellation + + let refreshTask = Task { + await context.refresh(atom) + } + + Task { + pipe.continuation.yield(1) + refreshTask.cancel() + } + + pipe.reset() + let phase1 = await refreshTask.value + + XCTAssertEqual(phase1.value, 1) + XCTAssertEqual(updateCount, 2) + + // Override + + context.override(atom) { _ in .success(200) } + + pipe.reset() + let phase2 = await context.refresh(atom) + + XCTAssertEqual(phase2.value, 200) + XCTAssertEqual(updateCount, 3) + } +} diff --git a/Tests/AtomsTests/Core/Hook/AtomHookContextTests.swift b/Tests/AtomsTests/Core/Hook/AtomHookContextTests.swift new file mode 100644 index 00000000..e067c430 --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/AtomHookContextTests.swift @@ -0,0 +1,59 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AtomHookContextTests: XCTestCase { + func testAtomContext() { + let atom = TestValueAtom(value: 100) + let store = Store(container: StoreContainer()) + let context = AtomHookContext(atom: atom, coordinator: 0, store: store) + + XCTAssertEqual(context.atomContext.read(atom), 100) + } + + func testNotifyUpdate() { + let atom = TestValueAtom(value: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let context = AtomHookContext(atom: atom, coordinator: 0, store: store) + var updateCount = 0 + + _ = store.watch(atom, relationship: relationship) { + updateCount += 1 + } + + context.notifyUpdate() + context.notifyUpdate() + context.notifyUpdate() + + XCTAssertEqual(updateCount, 3) + } + + func testAddTermination() { + let atom = TestValueAtom(value: 100) + let storeContainer = StoreContainer() + let store = Store(container: storeContainer) + var relationshipContainer: RelationshipContainer? = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer!) + let context = AtomHookContext(atom: atom, coordinator: 0, store: store) + var terminationCount0 = 0 + var terminationCount1 = 0 + + _ = store.watch(atom, relationship: relationship) {} + + context.addTermination { + terminationCount0 += 1 + } + context.addTermination { + terminationCount1 += 1 + } + + relationshipContainer = nil + + XCTAssertEqual(terminationCount0, 1) + XCTAssertEqual(terminationCount1, 1) + } +} diff --git a/Tests/AtomsTests/Core/Hook/ObservableObjectHookTests.swift b/Tests/AtomsTests/Core/Hook/ObservableObjectHookTests.swift new file mode 100644 index 00000000..4a156449 --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/ObservableObjectHookTests.swift @@ -0,0 +1,75 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class ObservableObjectHookTests: XCTestCase { + final class TestObject: ObservableObject {} + + func testMakeCoordinator() { + let object = TestObject() + let hook = ObservableObjectHook { _ in object } + let coordinator = hook.makeCoordinator() + + XCTAssertNil(coordinator.object) + } + + func testValue() { + let object = TestObject() + let hook = ObservableObjectHook { _ in object } + let coordinator = hook.makeCoordinator() + let context = AtomHookContext( + atom: TestAtom(key: 0, hook: hook), + coordinator: coordinator, + store: Store(container: StoreContainer()) + ) + + coordinator.object = object + + XCTAssertTrue(hook.value(context: context) === object) + } + + func testUpdate() { + let hook = ObservableObjectHook { _ in TestObject() } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + var updateCount = 0 + + context.onUpdate = { updateCount += 1 } + + let object0 = context.watch(atom) + + XCTContext.runActivity(named: "Update") { _ in + object0.objectWillChange.send() + XCTAssertEqual(updateCount, 1) + } + + XCTContext.runActivity(named: "Termination") { _ in + context.unwatch(atom) + + object0.objectWillChange.send() + XCTAssertEqual(updateCount, 1) + } + + let overrideObject = TestObject() + context.override(atom) { _ in overrideObject } + + let object1 = context.watch(atom) + + XCTContext.runActivity(named: "Override") { _ in + XCTAssertTrue(object1 === overrideObject) + + object1.objectWillChange.send() + + XCTAssertEqual(updateCount, 2) + } + + XCTContext.runActivity(named: "Override termination") { _ in + context.unwatch(atom) + + object1.objectWillChange.send() + + XCTAssertEqual(updateCount, 2) + } + } +} diff --git a/Tests/AtomsTests/Core/Hook/PublisherHookTests.swift b/Tests/AtomsTests/Core/Hook/PublisherHookTests.swift new file mode 100644 index 00000000..17d11ea6 --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/PublisherHookTests.swift @@ -0,0 +1,177 @@ +import Combine +import XCTest + +@testable import Atoms + +@MainActor +final class PublisherHookTests: XCTestCase { + final class TestSubject: Publisher, Subject { + private var internalSubject = PassthroughSubject() + + func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { + internalSubject.receive(subscriber: subscriber) + } + + func send(_ value: Output) { + internalSubject.send(value) + } + + func send(completion: Subscribers.Completion) { + internalSubject.send(completion: completion) + } + + func send(subscription: Subscription) { + internalSubject.send(subscription: subscription) + } + + func reset() { + internalSubject = PassthroughSubject() + } + } + + func testMakeCoordinator() { + let hook = PublisherHook { _ in Just(0) } + let coordinator = hook.makeCoordinator() + + XCTAssertNil(coordinator.phase) + } + + func testValue() { + let hook = PublisherHook { _ in Just(0) } + let coordinator = hook.makeCoordinator() + let context = AtomHookContext( + atom: TestAtom(key: 0, hook: hook), + coordinator: coordinator, + store: Store(container: StoreContainer()) + ) + + coordinator.phase = .success(100) + + XCTAssertEqual(hook.value(context: context).value, 100) + } + + func testUpdate() { + let subject = TestSubject() + let hook = PublisherHook { _ in subject } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + + XCTContext.runActivity(named: "Initially suspending") { _ in + XCTAssertTrue(context.watch(atom).isSuspending) + } + + XCTContext.runActivity(named: "Value") { _ in + let expectation = expectation(description: "Update") + context.onUpdate = expectation.fulfill + subject.send(0) + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(context.watch(atom), .success(0)) + } + + XCTContext.runActivity(named: "Error") { _ in + let expectation = expectation(description: "Update") + context.onUpdate = expectation.fulfill + subject.send(completion: .failure(URLError(.badURL))) + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(context.watch(atom), .failure(URLError(.badURL))) + } + + XCTContext.runActivity(named: "Value after completion") { _ in + let expectation = expectation(description: "Update") + expectation.isInverted = true + context.onUpdate = expectation.fulfill + subject.send(1) + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(context.watch(atom), .failure(URLError(.badURL))) + } + + XCTContext.runActivity(named: "Value after termination") { _ in + context.unwatch(atom) + subject.reset() + context.watch(atom) + context.unwatch(atom) + + let expectation = expectation(description: "Update") + expectation.isInverted = true + context.onUpdate = expectation.fulfill + subject.send(0) + + wait(for: [expectation], timeout: 1) + } + + XCTContext.runActivity(named: "Error after termination") { _ in + context.unwatch(atom) + subject.reset() + context.watch(atom) + context.unwatch(atom) + + let expectation = expectation(description: "Update") + expectation.isInverted = true + context.onUpdate = expectation.fulfill + subject.send(completion: .failure(URLError(.badURL))) + + wait(for: [expectation], timeout: 1) + } + + XCTContext.runActivity(named: "Override") { _ in + context.override(atom) { _ in .success(100) } + + XCTAssertEqual(context.watch(atom), .success(100)) + } + } + + func testRefresh() async { + let subject = TestSubject() + let hook = PublisherHook { _ in subject } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + var updateCount = 0 + + context.onUpdate = { updateCount += 1 } + + // Refresh + + XCTAssertTrue(context.watch(atom).isSuspending) + + Task { + subject.send(0) + subject.send(completion: .finished) + } + + subject.reset() + let phase0 = await context.refresh(atom) + + XCTAssertEqual(phase0.value, 0) + XCTAssertEqual(updateCount, 1) + + // Cancellation + + let refreshTask = Task { + await context.refresh(atom) + } + + Task { + subject.send(1) + refreshTask.cancel() + } + + subject.reset() + let phase1 = await refreshTask.value + + XCTAssertEqual(phase1, .success(1)) + XCTAssertEqual(updateCount, 2) + + // Override + + context.override(atom) { _ in .success(200) } + + subject.reset() + let phase2 = await context.refresh(atom) + + XCTAssertEqual(phase2.value, 200) + XCTAssertEqual(updateCount, 3) + } +} diff --git a/Tests/AtomsTests/Core/Hook/SelectModifierHookTests.swift b/Tests/AtomsTests/Core/Hook/SelectModifierHookTests.swift new file mode 100644 index 00000000..c684fb5f --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/SelectModifierHookTests.swift @@ -0,0 +1,47 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class SelectModifierHookTests: XCTestCase { + func testMakeCoordinator() { + let base = TestValueAtom(value: 0) + let hook = SelectModifierHook(base: base, keyPath: \.description) + let coordinator = hook.makeCoordinator() + + XCTAssertNil(coordinator.value) + } + + func testValue() { + let base = TestValueAtom(value: 100) + let hook = SelectModifierHook(base: base, keyPath: \.description) + let coordinator = hook.makeCoordinator() + let context = AtomHookContext( + atom: TestAtom(key: 0, hook: hook), + coordinator: coordinator, + store: Store(container: StoreContainer()) + ) + + coordinator.value = "test" + + XCTAssertEqual(hook.value(context: context), "test") + } + + func testUpdate() { + let base = TestValueAtom(value: 100) + let hook = SelectModifierHook(base: base, keyPath: \.description) + let atom = TestAtom(key: 1, hook: hook) + let context = AtomTestContext() + + XCTContext.runActivity(named: "Value") { _ in + XCTAssertEqual(context.watch(atom), "100") + } + + XCTContext.runActivity(named: "Override") { _ in + context.unwatch(atom) + context.override(atom) { _ in "override" } + + XCTAssertEqual(context.watch(atom), "override") + } + } +} diff --git a/Tests/AtomsTests/Core/Hook/StateHookTests.swift b/Tests/AtomsTests/Core/Hook/StateHookTests.swift new file mode 100644 index 00000000..5b0d4c1a --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/StateHookTests.swift @@ -0,0 +1,126 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class StateHookTests: XCTestCase { + func testMakeCoordinator() { + let hook = StateHook( + defaultValue: { _ in 0 }, + willSet: { _, _, _ in }, + didSet: { _, _, _ in } + ) + let coordinator = hook.makeCoordinator() + + XCTAssertNil(coordinator.value) + } + + func testValue() { + let hook = StateHook( + defaultValue: { _ in 0 }, + willSet: { _, _, _ in }, + didSet: { _, _, _ in } + ) + let coordinator = hook.makeCoordinator() + let context = AtomHookContext( + atom: TestAtom(key: 0, hook: hook), + coordinator: coordinator, + store: Store(container: StoreContainer()) + ) + + coordinator.value = 100 + + XCTAssertEqual(hook.value(context: context), 100) + } + + func testUpdate() { + let hook = StateHook( + defaultValue: { _ in 100 }, + willSet: { _, _, _ in }, + didSet: { _, _, _ in } + ) + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + + XCTContext.runActivity(named: "Value") { _ in + XCTAssertEqual(context.watch(atom), 100) + } + + XCTContext.runActivity(named: "Override") { _ in + context.unwatch(atom) + context.override(atom) { _ in 200 } + + XCTAssertEqual(context.watch(atom), 200) + } + } + + func testSet() { + var willSetNewValues = [Int]() + var willSetOldValues = [Int]() + var didSetNewValues = [Int]() + var didSetOldValues = [Int]() + let hook = StateHook( + defaultValue: { _ in 0 }, + willSet: { newValue, oldValue, _ in + willSetNewValues.append(newValue) + willSetOldValues.append(oldValue) + }, + didSet: { newValue, oldValue, _ in + didSetNewValues.append(newValue) + didSetOldValues.append(oldValue) + } + ) + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + + XCTAssertEqual(context.watch(atom), 0) + XCTAssertTrue(willSetNewValues.isEmpty) + XCTAssertTrue(willSetOldValues.isEmpty) + XCTAssertTrue(didSetNewValues.isEmpty) + XCTAssertTrue(didSetOldValues.isEmpty) + + context.onUpdate = { + XCTAssertEqual(willSetNewValues, [100]) + XCTAssertEqual(willSetOldValues, [0]) + XCTAssertTrue(didSetNewValues.isEmpty) + XCTAssertTrue(didSetOldValues.isEmpty) + } + + context[atom] = 100 + + XCTAssertEqual(context.watch(atom), 100) + XCTAssertEqual(willSetNewValues, [100]) + XCTAssertEqual(willSetOldValues, [0]) + XCTAssertEqual(didSetNewValues, [100]) + XCTAssertEqual(didSetOldValues, [0]) + } + + func testSetOverride() { + var willSetCount = 0 + var didSetCount = 0 + let hook = StateHook( + defaultValue: { _ in 0 }, + willSet: { _, _, _ in willSetCount += 1 }, + didSet: { _, _, _ in didSetCount += 1 } + ) + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + + context.override(atom) { _ in 200 } + + XCTAssertEqual(context.watch(atom), 200) + XCTAssertEqual(willSetCount, 0) + XCTAssertEqual(didSetCount, 0) + + context.onUpdate = { + XCTAssertEqual(willSetCount, 1) + XCTAssertEqual(didSetCount, 0) + } + + context[atom] = 100 + + XCTAssertEqual(context.watch(atom), 100) + XCTAssertEqual(willSetCount, 1) + XCTAssertEqual(didSetCount, 1) + } +} diff --git a/Tests/AtomsTests/Core/Hook/TaskHookTests.swift b/Tests/AtomsTests/Core/Hook/TaskHookTests.swift new file mode 100644 index 00000000..87ea23d3 --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/TaskHookTests.swift @@ -0,0 +1,124 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class TaskHookTests: XCTestCase { + func testMakeCoordinator() { + let hook = TaskHook { _ in 0 } + let coordinator = hook.makeCoordinator() + + XCTAssertNil(coordinator.task) + } + + func testValue() async { + let hook = TaskHook { _ in 0 } + let coordinator = hook.makeCoordinator() + let context = AtomHookContext( + atom: TestAtom(key: 0, hook: hook), + coordinator: coordinator, + store: Store(container: StoreContainer()) + ) + + coordinator.task = Task { 100 } + + let value = await hook.value(context: context).value + + XCTAssertEqual(value, 100) + } + + func testUpdate() async { + let hook = TaskHook { _ in 100 } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + + // Value + + let value0 = await context.watch(atom).value + XCTAssertEqual(value0, 100) + + // Termination + + let task0 = context.watch(atom) + context.unwatch(atom) + + XCTAssertTrue(task0.isCancelled) + + // Override + + context.override(atom) { _ in Task { 200 } } + + let value1 = await context.watch(atom).value + XCTAssertEqual(value1, 200) + + // Override termination + + context.override(atom) { _ in Task { 300 } } + + let task1 = context.watch(atom) + context.unwatch(atom) + + XCTAssertTrue(task1.isCancelled) + } + + func testRefresh() async { + var value = 100 + let hook = TaskHook { _ in value } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + var updateCount = 0 + + context.onUpdate = { updateCount += 1 } + + // Refresh + + context.watch(atom) + + let value0 = await context.refresh(atom).value + XCTAssertEqual(value0, 100) + XCTAssertEqual(updateCount, 1) + + value = 200 + + let value1 = await context.refresh(atom).value + XCTAssertEqual(value1, 200) + XCTAssertEqual(updateCount, 2) + + // Cancellation + + let refreshTask0 = Task { + await context.refresh(atom) + } + + Task { + refreshTask0.cancel() + } + + let task0 = await refreshTask0.value + + XCTAssertTrue(task0.isCancelled) + + // Override + + context.override(atom) { _ in Task { 300 } } + + let value2 = await context.refresh(atom).value + XCTAssertEqual(value2, 300) + + // Override cancellation + + context.override(atom) { _ in Task { 400 } } + + let refreshTask1 = Task { + await context.refresh(atom) + } + + Task { + refreshTask1.cancel() + } + + let task1 = await refreshTask1.value + + XCTAssertTrue(task1.isCancelled) + } +} diff --git a/Tests/AtomsTests/Core/Hook/TaskPhaseModifierHookTests.swift b/Tests/AtomsTests/Core/Hook/TaskPhaseModifierHookTests.swift new file mode 100644 index 00000000..6b6f0249 --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/TaskPhaseModifierHookTests.swift @@ -0,0 +1,88 @@ +import Combine +import XCTest + +@testable import Atoms + +@MainActor +final class TaskPhaseModifierHookTests: XCTestCase { + func testMakeCoordinator() { + let base = TestTaskAtom(value: 0) + let hook = TaskPhaseModifierHook(base: base) + let coordinator = hook.makeCoordinator() + + XCTAssertNil(coordinator.phase) + } + + func testValue() { + let base = TestTaskAtom(value: 0) + let hook = TaskPhaseModifierHook(base: base) + let coordinator = hook.makeCoordinator() + let context = AtomHookContext( + atom: TestAtom(key: 0, hook: hook), + coordinator: coordinator, + store: Store(container: StoreContainer()) + ) + + coordinator.phase = .success(100) + + XCTAssertEqual(hook.value(context: context).value, 100) + } + + func testUpdate() { + var getValue: () async throws -> Int = { 0 } + let base = TestAtom( + key: 0, + hook: ThrowingTaskHook { _ in try await getValue() } + ) + let hook = TaskPhaseModifierHook(base: base) + let atom = TestAtom(key: 1, hook: hook) + let context = AtomTestContext() + + XCTContext.runActivity(named: "Initially suspending") { _ in + XCTAssertTrue(context.watch(atom).isSuspending) + } + + XCTContext.runActivity(named: "Value") { _ in + let expectation = expectation(description: "Update") + context.onUpdate = expectation.fulfill + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(context.watch(atom).value, 0) + } + + XCTContext.runActivity(named: "Error") { _ in + getValue = { throw URLError(.badURL) } + context.onUpdate = nil + context.unwatch(atom) + context.watch(atom) + + let expectation = expectation(description: "Update") + context.onUpdate = expectation.fulfill + + wait(for: [expectation], timeout: 1) + XCTAssertEqual( + context.watch(atom).error as? URLError, + URLError(.badURL) + ) + } + + XCTContext.runActivity(named: "Termination") { _ in + getValue = { 0 } + context.unwatch(atom) + context.watch(atom) + context.unwatch(atom) + + let expectation = expectation(description: "Update") + expectation.isInverted = true + context.onUpdate = expectation.fulfill + + wait(for: [expectation], timeout: 1) + } + + XCTContext.runActivity(named: "Override") { _ in + context.override(atom) { _ in .success(100) } + + XCTAssertEqual(context.watch(atom).value, 100) + } + } +} diff --git a/Tests/AtomsTests/Core/Hook/ThrowingTaskHookTests.swift b/Tests/AtomsTests/Core/Hook/ThrowingTaskHookTests.swift new file mode 100644 index 00000000..6579099e --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/ThrowingTaskHookTests.swift @@ -0,0 +1,138 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class ThrowingTaskHookTests: XCTestCase { + func testMakeCoordinator() { + let hook = ThrowingTaskHook { _ in 0 } + let coordinator = hook.makeCoordinator() + + XCTAssertNil(coordinator.task) + } + + func testValue() async throws { + let hook = ThrowingTaskHook { _ in 0 } + let coordinator = hook.makeCoordinator() + let context = AtomHookContext( + atom: TestAtom(key: 0, hook: hook), + coordinator: coordinator, + store: Store(container: StoreContainer()) + ) + + coordinator.task = Task { 100 } + + let value = try await hook.value(context: context).value + + XCTAssertEqual(value, 100) + } + + func testUpdate() async throws { + var makeValue: () throws -> Int = { 100 } + let hook = ThrowingTaskHook { _ in try makeValue() } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + + // Value + + let value0 = try await context.watch(atom).value + XCTAssertEqual(value0, 100) + + // Error + + do { + makeValue = { throw URLError(.badURL) } + context.unwatch(atom) + _ = try await context.watch(atom).value + + XCTFail("Accessing to value should throw an error") + } + catch { + XCTAssertEqual(error as? URLError, URLError(.badURL)) + } + + // Termination + + let task0 = context.watch(atom) + context.unwatch(atom) + + XCTAssertTrue(task0.isCancelled) + + // Override + + context.override(atom) { _ in Task { 200 } } + + let value1 = try await context.watch(atom).value + XCTAssertEqual(value1, 200) + + // Override termination + + context.override(atom) { _ in Task { 300 } } + + let task1 = context.watch(atom) + context.unwatch(atom) + + XCTAssertTrue(task1.isCancelled) + } + + func testRefresh() async throws { + var value = 100 + let hook = ThrowingTaskHook { _ in value } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + var updateCount = 0 + + context.onUpdate = { updateCount += 1 } + + // Refresh + + context.watch(atom) + + let value0 = try await context.refresh(atom).value + XCTAssertEqual(value0, 100) + XCTAssertEqual(updateCount, 1) + + value = 200 + + let value1 = try await context.refresh(atom).value + XCTAssertEqual(value1, 200) + XCTAssertEqual(updateCount, 2) + + // Cancellation + + let refreshTask0 = Task { + await context.refresh(atom) + } + + Task { + refreshTask0.cancel() + } + + let task0 = await refreshTask0.value + + XCTAssertTrue(task0.isCancelled) + + // Override + + context.override(atom) { _ in Task { 300 } } + + let value2 = try await context.refresh(atom).value + XCTAssertEqual(value2, 300) + + // Override cancellation + + context.override(atom) { _ in Task { 400 } } + + let refreshTask1 = Task { + await context.refresh(atom) + } + + Task { + refreshTask1.cancel() + } + + let task1 = await refreshTask1.value + + XCTAssertTrue(task1.isCancelled) + } +} diff --git a/Tests/AtomsTests/Core/Hook/ValueHookTests.swift b/Tests/AtomsTests/Core/Hook/ValueHookTests.swift new file mode 100644 index 00000000..4295f9ae --- /dev/null +++ b/Tests/AtomsTests/Core/Hook/ValueHookTests.swift @@ -0,0 +1,44 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class ValueHookTests: XCTestCase { + func testMakeCoordinator() { + let hook = ValueHook { _ in 0 } + let coordinator = hook.makeCoordinator() + + XCTAssertNil(coordinator.value) + } + + func testValue() { + let hook = ValueHook { _ in 0 } + let coordinator = hook.makeCoordinator() + let context = AtomHookContext( + atom: TestAtom(key: 0, hook: hook), + coordinator: coordinator, + store: Store(container: StoreContainer()) + ) + + coordinator.value = 100 + + XCTAssertEqual(hook.value(context: context), 100) + } + + func testUpdate() { + let hook = ValueHook { _ in 100 } + let atom = TestAtom(key: 0, hook: hook) + let context = AtomTestContext() + + XCTContext.runActivity(named: "Value") { _ in + XCTAssertEqual(context.watch(atom), 100) + } + + XCTContext.runActivity(named: "Override") { _ in + context.unwatch(atom) + context.override(atom) { _ in 200 } + + XCTAssertEqual(context.watch(atom), 200) + } + } +} diff --git a/Tests/AtomsTests/Core/Internal/AtomHostTests.swift b/Tests/AtomsTests/Core/Internal/AtomHostTests.swift new file mode 100644 index 00000000..d5ab66a7 --- /dev/null +++ b/Tests/AtomsTests/Core/Internal/AtomHostTests.swift @@ -0,0 +1,141 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AtomHostTests: XCTestCase { + func testCast() { + let host: AtomHostBase = AtomHost() + XCTAssertNotNil(host as? AtomHost) + } + + func testRelationship() { + let host = AtomHost() + let atom = TestValueAtom(value: 0) + var isTerminated = false + + host.relationship[atom] = Relation(retaining: AtomHostBase()) { + isTerminated = true + } + + XCTAssertNotNil(host.relationship[atom]) + + host.relationship[atom] = nil + + XCTAssertTrue(isTerminated) + XCTAssertNil(host.relationship[atom]) + } + + func testAddTermination() { + var host: AtomHost? = AtomHost() + var isTerminated = false + + host?.addTermination { + isTerminated = true + } + + host = nil + + XCTAssertTrue(isTerminated) + } + + func testNotifyUpdate() { + let host = AtomHost() + var isUpdated = false + + host.coordinator = 0 + host.onUpdate = { _ in + isUpdated = true + } + + host.notifyUpdate() + + XCTAssertTrue(isUpdated) + } + + func testWithTermination() { + var host: AtomHost? = AtomHost() + weak var weakHost = host + let atom = TestValueAtom(value: 0) + var isTerminated = false + var isRelationPurged = false + + host?.addTermination { + isTerminated = true + } + + host?.relationship[atom] = Relation(retaining: AtomHostBase()) { + isRelationPurged = true + } + + host?.withTermination { _ in + host = nil + + XCTAssertNil(weakHost?.coordinator) + XCTAssertNotNil(weakHost) + XCTAssertTrue(isTerminated) + XCTAssertFalse(isRelationPurged) + } + + XCTAssertNil(weakHost) + XCTAssertTrue(isRelationPurged) + } + + func testWithAsyncTermination() async { + var host: AtomHost? = AtomHost() + weak var weakHost = host + let atom = TestValueAtom(value: 0) + var isTerminated = false + var isRelationPurged = false + + host?.addTermination { + isTerminated = true + } + + host?.relationship[atom] = Relation(retaining: AtomHostBase()) { + isRelationPurged = true + } + + await host?.withAsyncTermination { _ in + host = nil + + await Task {}.value + + XCTAssertNil(weakHost?.coordinator) + XCTAssertNotNil(weakHost) + XCTAssertTrue(isTerminated) + XCTAssertFalse(isRelationPurged) + } + + XCTAssertNil(weakHost) + XCTAssertTrue(isRelationPurged) + } + + func testObserve() { + var host: AtomHost? = AtomHost() + weak var weakHost = host + var receiveCount = 0 + + var relation: Relation? = host?.observe { + receiveCount += 1 + } + + host?.notifyUpdate() + + XCTAssertNotNil(weakHost) + XCTAssertEqual(receiveCount, 1) + + host = nil + weakHost?.notifyUpdate() + + XCTAssertNotNil(weakHost) + XCTAssertEqual(receiveCount, 2) + + relation = nil + weakHost?.notifyUpdate() + + XCTAssertNil(relation) + XCTAssertNil(weakHost) + XCTAssertEqual(receiveCount, 2) + } +} diff --git a/Tests/AtomsTests/Core/Internal/AtomKeyTests.swift b/Tests/AtomsTests/Core/Internal/AtomKeyTests.swift new file mode 100644 index 00000000..474d100b --- /dev/null +++ b/Tests/AtomsTests/Core/Internal/AtomKeyTests.swift @@ -0,0 +1,41 @@ +import XCTest + +@testable import Atoms + +final class AtomKeyTests: XCTestCase { + func testKeyHashableForSameAtoms() { + let atom = TestValueAtom(value: 0) + let key0 = AtomKey(atom) + let key1 = AtomKey(atom) + + XCTAssertEqual(key0, key1) + XCTAssertEqual(key0.hashValue, key1.hashValue) + } + + func testKeyHashableForDifferentAtoms() { + let atom0 = TestValueAtom(value: 0) + let atom1 = TestValueAtom(value: 1) + let key0 = AtomKey(atom0) + let key1 = AtomKey(atom1) + + XCTAssertNotEqual(key0, key1) + XCTAssertNotEqual(key0.hashValue, key1.hashValue) + } + + func testDictionaryKey() { + let atom0 = TestValueAtom(value: 0) + let atom1 = TestValueAtom(value: 1) + let key0 = AtomKey(atom0) + let key1 = AtomKey(atom1) + let key2 = AtomKey(atom1) + var dictionary = [AtomKey: Int]() + + dictionary[key0] = 100 + dictionary[key1] = 200 + dictionary[key2] = 300 + + XCTAssertEqual(dictionary[key0], 100) + XCTAssertEqual(dictionary[key1], 300) + XCTAssertEqual(dictionary[key2], 300) + } +} diff --git a/Tests/AtomsTests/Core/Internal/AtomOverridesTests.swift b/Tests/AtomsTests/Core/Internal/AtomOverridesTests.swift new file mode 100644 index 00000000..196565c7 --- /dev/null +++ b/Tests/AtomsTests/Core/Internal/AtomOverridesTests.swift @@ -0,0 +1,33 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class AtomOverridesTests: XCTestCase { + func testIndividualOverride() { + var overrides = AtomOverrides() + let atom = TestValueAtom(value: 0) + + XCTAssertNil(overrides[atom]) + + overrides.insert(atom) { _ in 100 } + + XCTAssertEqual(overrides[atom], 100) + } + + func testTypeOverride() { + var overrides = AtomOverrides() + let atom = TestValueAtom(value: 0) + + XCTAssertNil(overrides[atom]) + + overrides.insert(type(of: atom)) { _ in 200 } + + XCTAssertEqual(overrides[atom], 200) + + overrides.insert(atom) { _ in 100 } + + // Individual override should take precedence. + XCTAssertEqual(overrides[atom], 100) + } +} diff --git a/Tests/AtomsTests/Core/Internal/AtomStoreEnvironmentTests.swift b/Tests/AtomsTests/Core/Internal/AtomStoreEnvironmentTests.swift new file mode 100644 index 00000000..561b286c --- /dev/null +++ b/Tests/AtomsTests/Core/Internal/AtomStoreEnvironmentTests.swift @@ -0,0 +1,17 @@ +import SwiftUI +import XCTest + +@testable import Atoms + +@MainActor +final class AtomStoreEnvironmentTests: XCTestCase { + func testAtomStore() { + var environmentValues = EnvironmentValues() + + XCTAssertTrue(environmentValues.atomStore is DefaultStore) + + environmentValues.atomStore = Store(container: StoreContainer()) + + XCTAssertTrue(environmentValues.atomStore is Store) + } +} diff --git a/Tests/AtomsTests/Core/Internal/RelationTests.swift b/Tests/AtomsTests/Core/Internal/RelationTests.swift new file mode 100644 index 00000000..cdc0bf2c --- /dev/null +++ b/Tests/AtomsTests/Core/Internal/RelationTests.swift @@ -0,0 +1,26 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class RelationTests: XCTestCase { + func testTermination() { + var host: AtomHostBase? = AtomHostBase() + weak var weakHost = host + var isTerminated = false + var relation: Relation? = Relation(retaining: host!) { + isTerminated = true + } + + host = nil + + XCTAssertNotNil(weakHost) + XCTAssertFalse(isTerminated) + + relation = nil + + XCTAssertNil(relation) + XCTAssertNil(weakHost) + XCTAssertTrue(isTerminated) + } +} diff --git a/Tests/AtomsTests/Core/Internal/RelationshipTests.swift b/Tests/AtomsTests/Core/Internal/RelationshipTests.swift new file mode 100644 index 00000000..6327c843 --- /dev/null +++ b/Tests/AtomsTests/Core/Internal/RelationshipTests.swift @@ -0,0 +1,24 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class RelationshipTests: XCTestCase { + func testSubscript() { + let container = RelationshipContainer() + let relationship = Relationship(container: container) + let atom = TestValueAtom(value: 0) + var isTerminated = false + + relationship[atom] = Relation(retaining: AtomHostBase()) { + isTerminated = true + } + + XCTAssertNotNil(relationship[atom]) + + relationship[atom] = nil + + XCTAssertNil(relationship[atom]) + XCTAssertTrue(isTerminated) + } +} diff --git a/Tests/AtomsTests/Core/Internal/StoreEntryTests.swift b/Tests/AtomsTests/Core/Internal/StoreEntryTests.swift new file mode 100644 index 00000000..6f41d1e5 --- /dev/null +++ b/Tests/AtomsTests/Core/Internal/StoreEntryTests.swift @@ -0,0 +1,28 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class StoreEntryTests: XCTestCase { + func testWeakStoreEntry() { + var host: AtomHostBase? = AtomHostBase() + let entry = WeakStoreEntry(host: host) + + XCTAssertNotNil(entry.host) + + host = nil + + XCTAssertNil(entry.host) + } + + func testKeepAliveStoreEntry() { + var host: AtomHostBase? = AtomHostBase() + let entry = KeepAliveStoreEntry(host: host) + + XCTAssertNotNil(entry.host) + + host = nil + + XCTAssertNotNil(entry.host) + } +} diff --git a/Tests/AtomsTests/Core/Internal/StoreTests.swift b/Tests/AtomsTests/Core/Internal/StoreTests.swift new file mode 100644 index 00000000..84b6283e --- /dev/null +++ b/Tests/AtomsTests/Core/Internal/StoreTests.swift @@ -0,0 +1,406 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class StoreTests: XCTestCase { + final class TestObserver: AtomObserver { + var assignedAtomKeys = [AnyHashable]() + var unassignedAtomKeys = [AnyHashable]() + var changedKeys = [AnyHashable]() + + func atomAssigned(atom: Node) { + assignedAtomKeys.append(atom.key) + } + + func atomUnassigned(atom: Node) { + unassignedAtomKeys.append(atom.key) + } + + func atomChanged(snapshot: Snapshot) { + changedKeys.append(snapshot.atom.key) + } + } + + func testRead() { + let container = StoreContainer() + let observer = TestObserver() + let atom = TestValueAtom(value: 0) + let store = Store(container: container, observers: [observer]) + + XCTAssertEqual(store.read(atom), 0) + XCTAssertEqual(observer.assignedAtomKeys, [atom.key]) + XCTAssertEqual(observer.unassignedAtomKeys, [atom.key]) + XCTAssertEqual(observer.changedKeys, [atom.key]) + } + + func testReadOverride() { + let container = StoreContainer() + var overrides = AtomOverrides() + let atom = TestValueAtom(value: 0) + + overrides.insert(atom) { _ in 100 } + + let store = Store(container: container, overrides: overrides) + + XCTAssertEqual(store.read(atom), 100) + } + + func testSet() { + let container = StoreContainer() + let observer = TestObserver() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let atom = TestStateAtom(defaultValue: 0) + let store = Store(container: container, observers: [observer]) + + store.set(1, for: atom) + + XCTAssertTrue(observer.assignedAtomKeys.isEmpty) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertTrue(observer.changedKeys.isEmpty) + + // Emits change event of value initiation. + XCTAssertEqual(store.read(atom), 0) + + // Start watching, emits change event of value initiation. + _ = store.watch(atom, relationship: relationship) {} + + // Emits update. + store.set(1, for: atom) + + XCTAssertEqual(observer.assignedAtomKeys, [atom.key, atom.key]) + XCTAssertEqual(observer.unassignedAtomKeys, [atom.key]) + XCTAssertEqual(observer.changedKeys, [atom.key, atom.key, atom.key]) + XCTAssertEqual(store.read(atom), 1) + } + + func testSetOverride() { + let container = StoreContainer() + var overrides = AtomOverrides() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let atom = TestStateAtom(defaultValue: 0) + + overrides.insert(atom) { _ in 100 } + + let store = Store(container: container, overrides: overrides) + + // Start watching. + _ = store.watch(atom, relationship: relationship) {} + + XCTAssertEqual(store.read(atom), 100) + + store.set(200, for: atom) + + XCTAssertEqual(store.read(atom), 200) + } + + func testRefresh() async { + let container = StoreContainer() + let observer = TestObserver() + let atom = TestTaskAtom(value: 0) + let store = Store(container: container, observers: [observer]) + let value = await store.refresh(atom).value + + XCTAssertEqual(value, 0) + XCTAssertEqual(observer.assignedAtomKeys, [atom.key]) + XCTAssertEqual(observer.unassignedAtomKeys, [atom.key]) + XCTAssertEqual(observer.changedKeys, [atom.key]) + } + + func testRefreshOverride() async { + let container = StoreContainer() + var overrides = AtomOverrides() + let atom = TestTaskAtom(value: 0) + + overrides.insert(atom) { _ in Task { 100 } } + + let store = Store(container: container, overrides: overrides) + let value = await store.refresh(atom).value + + XCTAssertEqual(value, 100) + } + + func testReset() { + let container = StoreContainer() + let observer = TestObserver() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let atom = TestValueAtom(value: 0) + let store = Store(container: container, observers: [observer]) + + // NOP. + store.reset(atom) + + XCTAssertTrue(observer.assignedAtomKeys.isEmpty) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertTrue(observer.changedKeys.isEmpty) + + // Start watching, emits change event of value initiation. + _ = store.watch(atom, relationship: relationship) {} + + // Emits update. + store.reset(atom) + + XCTAssertEqual(observer.assignedAtomKeys, [atom.key]) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertEqual(observer.changedKeys, [atom.key]) + } + + func testWatch() { + let container = StoreContainer() + let observer = TestObserver() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let atom = TestValueAtom(value: 0) + let store = Store(container: container, observers: [observer]) + var updateCount = 0 + + // Start watching, emits change event of value initiation. + let value = store.watch(atom, relationship: relationship) { + updateCount += 1 + } + + XCTAssertEqual(value, 0) + XCTAssertEqual(updateCount, 0) + XCTAssertEqual(observer.assignedAtomKeys, [atom.key]) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertEqual(observer.changedKeys, [atom.key]) + + // Emits change event of value initiation. + store.notifyUpdate(atom) + + XCTAssertEqual(updateCount, 1) + XCTAssertEqual(observer.assignedAtomKeys, [atom.key]) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertEqual(observer.changedKeys, [atom.key, atom.key]) + } + + func testWatchOverride() { + let container = StoreContainer() + var overrides = AtomOverrides() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let atom = TestValueAtom(value: 0) + var updateCount = 0 + + overrides.insert(atom) { _ in 100 } + + let store = Store(container: container, overrides: overrides) + let value = store.watch(atom, relationship: relationship) { + updateCount += 1 + } + + XCTAssertEqual(value, 100) + XCTAssertEqual(updateCount, 0) + + store.notifyUpdate(atom) + + XCTAssertEqual(updateCount, 1) + } + + func testWatchBelongTo() { + let container = StoreContainer() + let observer = TestObserver() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let atom = TestValueAtom(value: 0) + let caller = TestValueAtom(value: 1) + let store = Store(container: container, observers: [observer]) + var callerUpdateCount = 0 + var updateCount = 0 + + // Emits change event of value initiation for caller. + _ = store.watch(caller, relationship: relationship) { + callerUpdateCount += 1 + } + + // Emits change event of value initiation for atom. + _ = store.watch(atom, relationship: relationship) { + updateCount += 1 + } + + let value = store.watch(atom, belongTo: caller) + + XCTAssertEqual(value, 0) + XCTAssertEqual(updateCount, 0) + XCTAssertEqual(callerUpdateCount, 0) + XCTAssertEqual(observer.assignedAtomKeys, [caller.key, atom.key]) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertEqual(observer.changedKeys, [caller.key, atom.key]) + + store.notifyUpdate(atom) + + XCTAssertEqual(updateCount, 1) + XCTAssertEqual(callerUpdateCount, 1) + XCTAssertEqual(observer.assignedAtomKeys, [caller.key, atom.key]) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertEqual(observer.changedKeys, [caller.key, atom.key, caller.key, caller.key, atom.key]) + } + + func testWatchBelongToOverride() { + let container = StoreContainer() + var overrides = AtomOverrides() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let atom = TestValueAtom(value: 0) + let caller = TestValueAtom(value: 1) + var callerUpdateCount = 0 + var updateCount = 0 + + overrides.insert(atom) { _ in 100 } + + let store = Store(container: container, overrides: overrides) + + _ = store.watch(caller, relationship: relationship) { + callerUpdateCount += 1 + } + _ = store.watch(atom, relationship: relationship) { + updateCount += 1 + } + + let value = store.watch(atom, belongTo: caller) + + XCTAssertEqual(value, 100) + XCTAssertEqual(updateCount, 0) + XCTAssertEqual(callerUpdateCount, 0) + + store.notifyUpdate(atom) + + XCTAssertEqual(updateCount, 1) + XCTAssertEqual(callerUpdateCount, 1) + } + + func testNotifyUpdate() { + let container = StoreContainer() + let observer = TestObserver() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let atom = TestValueAtom(value: 0) + let store = Store(container: container, observers: [observer]) + + store.notifyUpdate(atom) + + XCTAssertTrue(observer.assignedAtomKeys.isEmpty) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertTrue(observer.changedKeys.isEmpty) + + // Start watching, emits change event of value initiation. + _ = store.watch(atom, relationship: relationship) {} + + // Emits update. + store.notifyUpdate(atom) + + XCTAssertEqual(observer.assignedAtomKeys, [atom.key]) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertEqual(observer.changedKeys, [atom.key, atom.key]) + } + + func testAddTermination() { + let container = StoreContainer() + var relationshipContainer: RelationshipContainer? = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer!) + let atom = TestValueAtom(value: 0) + let store = Store(container: container) + var terminationCount = 0 + + store.addTermination(atom) { + terminationCount += 1 + } + + // Run termination immediately. + XCTAssertEqual(terminationCount, 1) + + // Start watching. + _ = store.watch(atom, relationship: relationship) {} + + store.addTermination(atom) { + terminationCount += 1 + } + + // Unwatch. + relationshipContainer = nil + + XCTAssertEqual(terminationCount, 2) + } + + func testRestore() { + let container = StoreContainer() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let atom = TestValueAtom(value: 0) + let store = Store(container: container) + var updateCount = 0 + + // Start watching. + let value = store.watch( + atom, + relationship: relationship, + notifyUpdate: { updateCount += 1 } + ) + + XCTAssertEqual(value, 0) + XCTAssertEqual(updateCount, 0) + + let snapshot = Snapshot(atom: atom, value: 100, store: store) + store.restore(snapshot: snapshot) + let newValue = store.read(atom) + + XCTAssertEqual(newValue, 100) + XCTAssertEqual(updateCount, 1) + } + + func testKeepAlive() { + struct TestAtom: ValueAtom, Hashable, KeepAlive { + func value(context: Context) -> Int { + 0 + } + } + + var container: StoreContainer? = StoreContainer() + let observer = TestObserver() + let atom = TestAtom() + let store = Store(container: container!, observers: [observer]) + + XCTAssertEqual(store.read(atom), 0) + XCTAssertEqual(observer.assignedAtomKeys, [atom.key]) + XCTAssertTrue(observer.unassignedAtomKeys.isEmpty) + XCTAssertEqual(observer.changedKeys, [atom.key]) + + container = nil + + XCTAssertEqual(observer.assignedAtomKeys, [atom.key]) + XCTAssertEqual(observer.unassignedAtomKeys, [atom.key]) + XCTAssertEqual(observer.changedKeys, [atom.key]) + } + + func testSnapshots() { + final class TestObserver: AtomObserver { + var snapshots = [Snapshot>]() + + func atomChanged(snapshot: Snapshot) { + if let snapshot = snapshot as? Snapshot> { + snapshots.append(snapshot) + } + } + } + + let observer = TestObserver() + let context = AtomTestContext() + let atom = TestStateAtom(defaultValue: 0) + + context.observe(observer) + + XCTAssertEqual(context.watch(atom), 0) + XCTAssertEqual(observer.snapshots.map(\.value), [0]) + + context[atom] = 1 + + XCTAssertEqual(observer.snapshots.map(\.value), [0, 1]) + + context.unwatch(atom) + + XCTAssertEqual(observer.snapshots.map(\.value), [0, 1]) + } +} diff --git a/Tests/AtomsTests/KeepAliveTests.swift b/Tests/AtomsTests/KeepAliveTests.swift new file mode 100644 index 00000000..374622b1 --- /dev/null +++ b/Tests/AtomsTests/KeepAliveTests.swift @@ -0,0 +1,23 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class KeepAliveTests: XCTestCase { + struct TestNormalAtom: ValueAtom, Hashable { + func value(context: Context) -> Int { + 0 + } + } + + struct TestKeepAliveAtom: ValueAtom, Hashable, KeepAlive { + func value(context: Context) -> Int { + 0 + } + } + + func testShouldKeepAlive() { + XCTAssertFalse(TestNormalAtom.shouldKeepAlive) + XCTAssertTrue(TestKeepAliveAtom.shouldKeepAlive) + } +} diff --git a/Tests/AtomsTests/Modifier/SelectModifierAtomTests.swift b/Tests/AtomsTests/Modifier/SelectModifierAtomTests.swift new file mode 100644 index 00000000..98bc0a87 --- /dev/null +++ b/Tests/AtomsTests/Modifier/SelectModifierAtomTests.swift @@ -0,0 +1,46 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class SelectModifierAtomTests: XCTestCase { + func testKey() { + let base = TestStateAtom(defaultValue: "") + let atom = SelectModifierAtom(base: base, keyPath: \.count) + + XCTAssertNotEqual(ObjectIdentifier(type(of: base.key)), ObjectIdentifier(type(of: atom.key))) + XCTAssertNotEqual(base.key.hashValue, atom.key.hashValue) + } + + func testSelect() { + let atom = TestStateAtom(defaultValue: "") + let context = AtomTestContext() + var updatedCount = 0 + + context.onUpdate = { + updatedCount += 1 + } + + XCTAssertEqual(updatedCount, 0) + XCTAssertEqual(context.watch(atom.select(\.count)), 0) + + context[atom] = "modified" + + XCTAssertEqual(updatedCount, 1) + XCTAssertEqual(context.watch(atom.select(\.count)), 8) + + context[atom] = "modified" + + // Should not be updated with an equivalent value. + XCTAssertEqual(updatedCount, 1) + } + + func testShouldNotifyUpdate() { + let atom = TestStateAtom(defaultValue: "").select(\.count) + let result0 = atom.shouldNotifyUpdate(newValue: 100, oldValue: 100) + let result1 = atom.shouldNotifyUpdate(newValue: 100, oldValue: 200) + + XCTAssertFalse(result0) + XCTAssertTrue(result1) + } +} diff --git a/Tests/AtomsTests/Modifier/TaskPhaseModifierAtomTests.swift b/Tests/AtomsTests/Modifier/TaskPhaseModifierAtomTests.swift new file mode 100644 index 00000000..05ad56ff --- /dev/null +++ b/Tests/AtomsTests/Modifier/TaskPhaseModifierAtomTests.swift @@ -0,0 +1,37 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class TaskPhaseModifierAtomTests: XCTestCase { + struct TestAtom: TaskAtom, Hashable { + func value(context: Context) async -> Int { + 0 + } + } + + func testKey() { + let base = TestAtom() + let atom = TaskPhaseModifierAtom(base: base) + + XCTAssertEqual(base.key.hashValue, atom.key.hashValue) + XCTAssertNotEqual( + ObjectIdentifier(type(of: base.key)), + ObjectIdentifier(type(of: atom.key)) + ) + } + + func testPhase() { + let atom = TestAtom() + let context = AtomTestContext() + + XCTAssertEqual(context.watch(atom.phase), .suspending) + + let expectation = expectation(description: "Update") + context.onUpdate = expectation.fulfill + + wait(for: [expectation], timeout: 1) + + XCTAssertEqual(context.watch(atom.phase), .success(0)) + } +} diff --git a/Tests/AtomsTests/SnapshotTests.swift b/Tests/AtomsTests/SnapshotTests.swift new file mode 100644 index 00000000..937ec13b --- /dev/null +++ b/Tests/AtomsTests/SnapshotTests.swift @@ -0,0 +1,23 @@ +import XCTest + +@testable import Atoms + +@MainActor +final class SnapshotTests: XCTestCase { + func testRestore() { + let container = StoreContainer() + let relationshipContainer = RelationshipContainer() + let relationship = Relationship(container: relationshipContainer) + let store = Store(container: container) + let atom = TestValueAtom(value: 0) + let snapshot = Snapshot(atom: atom, value: 100, store: store) + + let value = store.watch(atom, relationship: relationship) {} + + XCTAssertEqual(value, 0) + + snapshot.restore() + + XCTAssertEqual(store.read(atom), 100) + } +} diff --git a/Tests/AtomsTests/Utilities/Object.swift b/Tests/AtomsTests/Utilities/Object.swift new file mode 100644 index 00000000..a9afb0db --- /dev/null +++ b/Tests/AtomsTests/Utilities/Object.swift @@ -0,0 +1,7 @@ +final class Object { + var onDeinit: (() -> Void)? + + deinit { + onDeinit?() + } +} diff --git a/Tests/AtomsTests/Utilities/TestAtom.swift b/Tests/AtomsTests/Utilities/TestAtom.swift new file mode 100644 index 00000000..c1bf5b83 --- /dev/null +++ b/Tests/AtomsTests/Utilities/TestAtom.swift @@ -0,0 +1,30 @@ +import Atoms + +struct TestAtom: Atom { + var key: Key + var hook: Hook +} + +struct TestValueAtom: ValueAtom, Hashable { + let value: T + + func value(context: Context) -> T { + value + } +} + +struct TestStateAtom: StateAtom, Hashable { + let defaultValue: T + + func defaultValue(context: Context) -> T { + defaultValue + } +} + +struct TestTaskAtom: TaskAtom, Hashable { + let value: T + + func value(context: Context) async -> T { + value + } +} diff --git a/Tools/Package.resolved b/Tools/Package.resolved new file mode 100644 index 00000000..1140a71a --- /dev/null +++ b/Tools/Package.resolved @@ -0,0 +1,158 @@ +{ + "pins" : [ + { + "identity" : "aexml", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tadija/AEXML.git", + "state" : { + "revision" : "38f7d00b23ecd891e1ee656fa6aeebd6ba04ecc3", + "version" : "4.6.1" + } + }, + { + "identity" : "graphviz", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwiftDocOrg/GraphViz.git", + "state" : { + "revision" : "70bebcf4597b9ce33e19816d6bbd4ba9b7bdf038", + "version" : "0.2.0" + } + }, + { + "identity" : "jsonutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/yonaskolb/JSONUtilities.git", + "state" : { + "revision" : "128d2ffc22467f69569ef8ff971683e2393191a0", + "version" : "4.2.0" + } + }, + { + "identity" : "pathkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/PathKit.git", + "state" : { + "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", + "version" : "1.0.1" + } + }, + { + "identity" : "rainbow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Rainbow.git", + "state" : { + "revision" : "626c3d4b6b55354b4af3aa309f998fae9b31a3d9", + "version" : "3.2.0" + } + }, + { + "identity" : "spectre", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kylef/Spectre.git", + "state" : { + "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", + "version" : "0.10.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "82905286cc3f0fa8adc4674bf49437cab65a8373", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-format", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-format.git", + "state" : { + "revision" : "c06258081a3f8703f55ff6e9647b32cf3144e247", + "version" : "0.50600.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "0b6c22b97f8e9320bca62e82cdbee601cf37ad3f", + "version" : "0.50600.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "836bc4557b74fe6d2660218d56e3ce96aff76574", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-tools-support-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-tools-support-core.git", + "state" : { + "revision" : "b7667f3e266af621e5cc9c77e74cacd8e8c00cb4", + "version" : "0.2.5" + } + }, + { + "identity" : "swiftcli", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jakeheis/SwiftCLI.git", + "state" : { + "revision" : "2e949055d9797c1a6bddcda0e58dada16cc8e970", + "version" : "6.0.3" + } + }, + { + "identity" : "version", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mxcl/Version", + "state" : { + "revision" : "1fe824b80d89201652e7eca7c9252269a1d85e25", + "version" : "2.0.1" + } + }, + { + "identity" : "xcodegen", + "kind" : "remoteSourceControl", + "location" : "https://github.com/yonaskolb/XcodeGen.git", + "state" : { + "revision" : "f6cdd090c22622c3e2254da167099e3980e9bd89", + "version" : "2.27.0" + } + }, + { + "identity" : "xcodeproj", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/XcodeProj.git", + "state" : { + "revision" : "c75c3acc25460195cfd203a04dde165395bf00e0", + "version" : "8.7.1" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa", + "version" : "4.0.6" + } + } + ], + "version" : 2 +} diff --git a/Tools/Package.swift b/Tools/Package.swift new file mode 100644 index 00000000..a3575246 --- /dev/null +++ b/Tools/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version:5.6 + +import PackageDescription + +let package = Package( + name: "Tools", + dependencies: [ + .package(name: "swiftui-atomic-architecture", path: ".."), + .package(url: "https://github.com/apple/swift-docc-plugin", exact: "1.0.0"), + .package(url: "https://github.com/apple/swift-format.git", exact: "0.50600.0"), + .package(url: "https://github.com/yonaskolb/XcodeGen.git", exact: "2.27.0"), + ] +) diff --git a/assets/assets.key b/assets/assets.key new file mode 100755 index 00000000..d94e9acd Binary files /dev/null and b/assets/assets.key differ diff --git a/assets/diagram.png b/assets/diagram.png new file mode 100644 index 00000000..bf2f867a Binary files /dev/null and b/assets/diagram.png differ diff --git a/assets/example_counter.png b/assets/example_counter.png new file mode 100644 index 00000000..2d79d905 Binary files /dev/null and b/assets/example_counter.png differ diff --git a/assets/example_map.png b/assets/example_map.png new file mode 100644 index 00000000..683c03b3 Binary files /dev/null and b/assets/example_map.png differ diff --git a/assets/example_time_travel.png b/assets/example_time_travel.png new file mode 100644 index 00000000..3085a174 Binary files /dev/null and b/assets/example_time_travel.png differ diff --git a/assets/example_tmdb.png b/assets/example_tmdb.png new file mode 100644 index 00000000..577829af Binary files /dev/null and b/assets/example_tmdb.png differ diff --git a/assets/example_todo.png b/assets/example_todo.png new file mode 100644 index 00000000..9dc94625 Binary files /dev/null and b/assets/example_todo.png differ diff --git a/assets/example_voice_memo.png b/assets/example_voice_memo.png new file mode 100644 index 00000000..c0fb56b8 Binary files /dev/null and b/assets/example_voice_memo.png differ