├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ ├── documentation_request.yml │ └── feature_request.yml ├── pull_request_template.md └── workflows │ ├── docs.yml │ └── test.yml ├── .gitignore ├── .swift-format ├── Benchmarks ├── App │ ├── Info-iOS.plist │ └── iOS.swift ├── Project.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── BenchmarkTests.xcscheme ├── Tests │ ├── BenchmarkTests.swift │ ├── TestView.swift │ └── ViewTest.swift └── project.yml ├── Examples ├── App │ ├── CrossPlatform.swift │ ├── Info-CrossPlatform_macOS.plist │ ├── Info-CrossPlatform_tvOS.plist │ ├── Info-iOS.plist │ └── iOS.swift ├── Packages │ ├── CrossPlatform │ │ ├── Package.swift │ │ ├── Sources │ │ │ ├── CrossPlatformApp │ │ │ │ └── CrossPlatformRoot.swift │ │ │ ├── ExampleCounter │ │ │ │ └── ExampleCounter.swift │ │ │ └── ExampleTodo │ │ │ │ ├── Atoms.swift │ │ │ │ ├── Entities.swift │ │ │ │ ├── ExampleTodo.swift │ │ │ │ ├── Screens.swift │ │ │ │ └── Views.swift │ │ └── Tests │ │ │ ├── ExampleCounterTests │ │ │ └── ExampleCounterTests.swift │ │ │ └── ExampleTodoTests │ │ │ └── ExampleTodoTests.swift │ └── iOS │ │ ├── Package.swift │ │ ├── Sources │ │ ├── ExampleMap │ │ │ ├── Atoms.swift │ │ │ ├── Dependency │ │ │ │ └── LocationManager.swift │ │ │ ├── ExampleMap.swift │ │ │ ├── Screens.swift │ │ │ └── Views.swift │ │ ├── ExampleMovieDB │ │ │ ├── Atoms │ │ │ │ ├── CommonAtoms.swift │ │ │ │ ├── DetailAtoms.swift │ │ │ │ ├── MoviesAtoms.swift │ │ │ │ └── SearchAtoms.swift │ │ │ ├── Backend │ │ │ │ └── APIClient.swift │ │ │ ├── Entities │ │ │ │ ├── Credits.swift │ │ │ │ ├── Failable.swift │ │ │ │ ├── Filter.swift │ │ │ │ ├── ImageSize.swift │ │ │ │ ├── Movie.swift │ │ │ │ └── PagedResponse.swift │ │ │ ├── ExampleMovieDB.swift │ │ │ ├── Screens │ │ │ │ ├── DetailScreen.swift │ │ │ │ ├── MoviesScreen.swift │ │ │ │ └── SearchScreen.swift │ │ │ └── Views │ │ │ │ ├── CastList.swift │ │ │ │ ├── CaveatRow.swift │ │ │ │ ├── FiltePicker.swift │ │ │ │ ├── MovieRow.swift │ │ │ │ ├── MyListButtonLabel.swift │ │ │ │ ├── MyMovieList.swift │ │ │ │ ├── NetworkImage.swift │ │ │ │ ├── PopularityBadge.swift │ │ │ │ └── ProgressRow.swift │ │ ├── ExampleTimeTravel │ │ │ └── ExampleTimeTravel.swift │ │ ├── ExampleVoiceMemo │ │ │ ├── Atoms │ │ │ │ ├── CommonAtoms.swift │ │ │ │ ├── VoiceMemoListAtoms.swift │ │ │ │ └── VoiceMemoRowAtoms.swift │ │ │ ├── Dependency │ │ │ │ ├── AudioPlayer.swift │ │ │ │ ├── AudioRecorder.swift │ │ │ │ └── AudioSession.swift │ │ │ ├── Effects │ │ │ │ └── RecordingEffect.swift │ │ │ ├── ExampleVoiceMemo.swift │ │ │ ├── Helpers.swift │ │ │ ├── VoiceMemoListScreen.swift │ │ │ └── VoiceMemoRow.swift │ │ └── iOSApp │ │ │ └── iOSRoot.swift │ │ └── Tests │ │ ├── ExampleMapTests │ │ └── ExampleMapTests.swift │ │ ├── ExampleMovieDBTests │ │ └── ExampleMovieDBTests.swift │ │ ├── ExampleTimeTravelTests │ │ └── ExampleTimeTravelTests.swift │ │ └── ExampleVoiceMemoTests │ │ └── ExampleVoiceMemoTests.swift ├── Project.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── project.yml ├── LICENSE ├── Makefile ├── Package.swift ├── README.md ├── Sources └── Atoms │ ├── AsyncPhase.swift │ ├── Atom │ ├── AsyncPhaseAtom.swift │ ├── AsyncSequenceAtom.swift │ ├── ObservableObjectAtom.swift │ ├── PublisherAtom.swift │ ├── StateAtom.swift │ ├── TaskAtom.swift │ ├── ThrowingTaskAtom.swift │ └── ValueAtom.swift │ ├── AtomDerivedScope.swift │ ├── AtomRoot.swift │ ├── AtomScope.swift │ ├── AtomStore.swift │ ├── Atoms.docc │ └── Atoms.md │ ├── Attribute │ ├── KeepAlive.swift │ ├── Refreshable.swift │ ├── Resettable.swift │ └── Scoped.swift │ ├── Context │ ├── AtomContext.swift │ ├── AtomCurrentContext.swift │ ├── AtomTestContext.swift │ ├── AtomTransactionContext.swift │ └── AtomViewContext.swift │ ├── Core │ ├── Atom │ │ ├── AsyncAtom.swift │ │ ├── Atom.swift │ │ └── ModifiedAtom.swift │ ├── AtomCache.swift │ ├── AtomKey.swift │ ├── AtomState.swift │ ├── Effect │ │ ├── EmptyEffect.swift │ │ └── MergedEffect.swift │ ├── Environment.swift │ ├── Modifier │ │ ├── AsyncAtomModifier.swift │ │ └── AtomModifier.swift │ ├── Observer.swift │ ├── Override.swift │ ├── OverrideContainer.swift │ ├── Producer │ │ ├── AtomProducer.swift │ │ ├── AtomProducerContext.swift │ │ └── AtomRefreshProducer.swift │ ├── Scope.swift │ ├── ScopeID.swift │ ├── ScopeKey.swift │ ├── ScopeState.swift │ ├── ScopeValues.swift │ ├── SourceLocation.swift │ ├── StoreContext.swift │ ├── Subscriber.swift │ ├── SubscriberKey.swift │ ├── SubscriberState.swift │ ├── Subscription.swift │ ├── TopologicalSort.swift │ ├── TransactionState.swift │ ├── UnsafeUncheckedSendable.swift │ └── Utilities.swift │ ├── Effect │ ├── AtomEffect.swift │ ├── AtomEffectBuilder.swift │ ├── InitializeEffect.swift │ ├── InitializingEffect.swift │ ├── ReleaseEffect.swift │ └── UpdateEffect.swift │ ├── Modifier │ ├── AnimationModifier.swift │ ├── ChangesModifier.swift │ ├── ChangesOfModifier.swift │ └── TaskPhaseModifier.swift │ ├── PropertyWrapper │ ├── ViewContext.swift │ ├── Watch.swift │ ├── WatchState.swift │ └── WatchStateObject.swift │ ├── Snapshot.swift │ └── Suspense.swift ├── Tests └── AtomsTests │ ├── AsyncPhaseTests.swift │ ├── Atom │ ├── AsyncPhaseAtomTests.swift │ ├── AsyncSequenceAtomTests.swift │ ├── ObservableObjectAtomTests.swift │ ├── PublisherAtomTests.swift │ ├── StateAtomTests.swift │ ├── TaskAtomTests.swift │ ├── ThrowingTaskAtomTests.swift │ └── ValueAtomTests.swift │ ├── Attribute │ ├── KeepAliveTests.swift │ ├── RefreshableTests.swift │ ├── ResettableTests.swift │ └── ScopedTests.swift │ ├── Context │ ├── AtomContextTests.swift │ ├── AtomCurrentContextTests.swift │ ├── AtomTestContextTests.swift │ ├── AtomTransactionContextTests.swift │ └── AtomViewContextTests.swift │ ├── Core │ ├── Atom │ │ └── ModifiedAtomTests.swift │ ├── AtomCacheTests.swift │ ├── AtomKeyTests.swift │ ├── AtomProducerContextTests.swift │ ├── Effect │ │ └── AtomEffectBuilderTests.swift │ ├── EnvironmentTests.swift │ ├── OverrideContainerTests.swift │ ├── ScopeIDTests.swift │ ├── ScopeKeyTests.swift │ ├── StoreContextTests.swift │ ├── SubscriberKeyTests.swift │ ├── SubscriberStateTests.swift │ ├── SubscriberTests.swift │ ├── TopologicalSortTests.swift │ └── TransactionTests.swift │ ├── Effect │ ├── InitializeEffectTests.swift │ ├── InitializingEffectTests.swift │ ├── ReleaseEffectTests.swift │ └── UpdateEffectTests.swift │ ├── Modifier │ ├── ChangesModifierTests.swift │ ├── ChangesOfModifierTests.swift │ └── TaskPhaseModifierTests.swift │ ├── SnapshotTests.swift │ └── Utilities │ ├── TestAtom.swift │ └── Utilities.swift ├── Tools ├── Package.resolved └── Package.swift ├── assets ├── assets.key ├── dependency_graph.png ├── diagram.png ├── example_counter.png ├── example_map.png ├── example_time_travel.png ├── example_tmdb.png ├── example_todo.png └── example_voice_memo.png └── scripts ├── swift-run.sh └── test.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ra1028 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Checklist 9 | options: 10 | - label: This is not a bug caused by platform. 11 | required: true 12 | - label: Reviewed the README and documentation. 13 | required: true 14 | - label: Checked existing issues & PRs to ensure not duplicated. 15 | required: true 16 | 17 | - type: textarea 18 | attributes: 19 | label: What happened? 20 | validations: 21 | required: true 22 | 23 | - type: textarea 24 | id: expected-behavior 25 | attributes: 26 | label: Expected Behavior 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | attributes: 32 | label: Reproduction Steps 33 | value: | 34 | 1. 35 | 2. 36 | 3. 37 | validations: 38 | required: true 39 | 40 | - type: input 41 | attributes: 42 | label: Swift Version 43 | validations: 44 | required: true 45 | 46 | - type: input 47 | attributes: 48 | label: Library Version 49 | validations: 50 | required: true 51 | 52 | - type: dropdown 53 | attributes: 54 | label: Platform 55 | multiple: true 56 | options: 57 | - iOS 58 | - tvOS 59 | - macOS 60 | - watchOS 61 | 62 | - type: textarea 63 | attributes: 64 | label: Scrrenshot/Video/Gif 65 | placeholder: | 66 | Drag and drop screenshot, video, or gif here if you have. 67 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation_request.yml: -------------------------------------------------------------------------------- 1 | name: Documentation Request 2 | description: Suggest a new doc/example or ask a question about an existing one 3 | title: "[Doc Request]: " 4 | labels: ["documentation"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Checklist 9 | options: 10 | - label: Reviewed the README and documentation. 11 | required: true 12 | - label: Confirmed that this is uncovered by existing docs or examples. 13 | required: true 14 | - label: Checked existing issues & PRs to ensure not duplicated. 15 | required: true 16 | 17 | - type: textarea 18 | attributes: 19 | label: Description 20 | placeholder: Describe what the scenario you think is uncovered by the existing ones and why you think it should be covered. 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | attributes: 26 | label: Motivation & Context 27 | placeholder: Feel free to describe any additional context, such as why you thought the scenario should be covered. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new idea of feature 3 | title: "[Feat Request]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Checklist 9 | options: 10 | - label: Reviewed the README and documentation. 11 | required: true 12 | - label: Checked existing issues & PRs to ensure not duplicated. 13 | required: true 14 | 15 | - type: textarea 16 | attributes: 17 | label: Description 18 | placeholder: Describe the feature that you want to propose. 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | attributes: 24 | label: Example Use Case 25 | placeholder: Describe an example use case that the feature is useful. 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | attributes: 31 | label: Alternative Solution 32 | placeholder: Describe alternatives solutions that you've considered. 33 | 34 | - type: textarea 35 | attributes: 36 | label: Proposed Solution 37 | placeholder: Describe how we can achieve the feature you'd like to suggest. 38 | 39 | - type: textarea 40 | attributes: 41 | label: Motivation & Context 42 | placeholder: Feel free to describe any additional context, such as why you want to suggest this feature. 43 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Pull Request Type 2 | 3 | - [ ] Bug fix 4 | - [ ] New feature 5 | - [ ] Refactoring 6 | - [ ] Documentation update 7 | - [ ] Chore 8 | 9 | ## Issue for this PR 10 | 11 | Link: 12 | 13 | ## Description 14 | 15 | ## Motivation and Context 16 | 17 | ## Impact on Existing Code 18 | 19 | ## Screenshot/Video/Gif 20 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/actions/virtual-environments 2 | 3 | name: docs 4 | 5 | on: 6 | release: 7 | types: [published] 8 | workflow_dispatch: 9 | 10 | env: 11 | DEVELOPER_DIR: /Applications/Xcode_16.1.app 12 | 13 | jobs: 14 | publish-docs: 15 | name: Publish Documentation 16 | runs-on: macos-14 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build docs 20 | run: make docs 21 | - name: Deploy 22 | uses: peaceiris/actions-gh-pages@v4 23 | with: 24 | github_token: ${{ secrets.GITHUB_TOKEN }} 25 | publish_dir: docs 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/actions/virtual-environments 2 | 3 | name: test 4 | 5 | on: 6 | pull_request: 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | env: 13 | DEVELOPER_DIR: /Applications/Xcode_16.1.app 14 | IOS_SIMULATOR: '"platform=iOS Simulator,name=iPhone 16 Pro"' 15 | MACOS: '"platform=macOS"' 16 | TVOS_SIMULATOR: '"platform=tvOS Simulator,name=Apple TV 4K (3rd generation)"' 17 | WATCHOS_SIMULATOR: '"platform=watchOS Simulator,name=Apple Watch Ultra 2 (49mm)"' 18 | 19 | jobs: 20 | test: 21 | name: Test 22 | runs-on: macos-14 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Test library iOS 26 | run: scripts/test.sh library -destinations ${{ env.IOS_SIMULATOR }} 27 | - name: Test library macOS 28 | run: scripts/test.sh library -destinations ${{ env.MACOS }} 29 | - name: Test library tvOS 30 | run: scripts/test.sh library -destinations ${{ env.TVOS_SIMULATOR }} 31 | - name: Test library watchOS 32 | run: scripts/test.sh library -destinations ${{ env.WATCHOS_SIMULATOR }} 33 | 34 | test_examples: 35 | name: Test examples 36 | runs-on: macos-14 37 | steps: 38 | - uses: actions/checkout@v4 39 | - name: Test example iOS 40 | run: scripts/test.sh example-ios -destinations ${{ env.IOS_SIMULATOR }} 41 | - name: Test example cross platform 42 | run: | 43 | scripts/test.sh example-cross-platform -destinations \ 44 | ${{ env.MACOS }} \ 45 | ${{ env.TVOS_SIMULATOR }} 46 | 47 | test_language_mode: 48 | name: Test Swift 5 language mode 49 | runs-on: macos-14 50 | strategy: 51 | matrix: 52 | enable_upcoming_features: 53 | - 0 54 | - 1 55 | steps: 56 | - uses: actions/checkout@v4 57 | - name: Test Swift 5 language mode 58 | run: ENABLE_UPCOMING_FEATURES=${{ matrix.enable_upcoming_features }} scripts/test.sh library SWIFT_VERSION=5 -destinations ${{ env.IOS_SIMULATOR }} 59 | 60 | benchmark: 61 | name: Benchmark 62 | runs-on: macos-14 63 | steps: 64 | - uses: actions/checkout@v4 65 | - name: Run benchmark test 66 | run: scripts/test.sh benchmark -destinations ${{ env.IOS_SIMULATOR }} 67 | 68 | validation: 69 | name: Validation 70 | runs-on: macos-14 71 | steps: 72 | - uses: actions/checkout@v4 73 | - name: Show environments 74 | run: | 75 | swift --version 76 | xcodebuild -version 77 | - uses: actions/cache@v4 78 | with: 79 | path: Tools/bin 80 | key: spm-${{ runner.os }}-${{env.DEVELOPER_DIR}}-${{ hashFiles('Tools/Package.swift') }} 81 | - name: Validate lint 82 | run: make lint 83 | - name: Validate format 84 | run: | 85 | make format 86 | if [ -n "$(git status --porcelain)" ]; then git diff && echo "Make sure that the code is formated by 'make format'."; exit 1; fi 87 | - name: Validate example project 88 | run: | 89 | make proj 90 | if [ -n "$(git status --porcelain)" ]; then git diff && echo "Make sure that Xcode projects are formated by 'make proj'."; exit 1; fi 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | */build/* 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | xcuserdata 12 | profile 13 | *.moved-aside 14 | DerivedData 15 | .idea/ 16 | *.hmap 17 | *.xccheckout 18 | *.xcuserstate 19 | build/ 20 | archive/ 21 | *.xcframework 22 | .swiftpm 23 | .build 24 | bin 25 | docs 26 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "indentation": { 4 | "spaces": 4 5 | }, 6 | "fileScopedDeclarationPrivacy": { 7 | "accessLevel": "private" 8 | }, 9 | "indentConditionalCompilationBlocks": true, 10 | "indentSwitchCaseLabels": false, 11 | "lineBreakAroundMultilineExpressionChainComponents": false, 12 | "lineBreakBeforeControlFlowKeywords": true, 13 | "lineBreakBeforeEachArgument": true, 14 | "lineBreakBeforeEachGenericRequirement": true, 15 | "lineLength": 150, 16 | "maximumBlankLines": 1, 17 | "prioritizeKeepingFunctionOutputTogether": false, 18 | "respectsExistingLineBreaks": true, 19 | "rules": { 20 | "AllPublicDeclarationsHaveDocumentation": true, 21 | "AlwaysUseLowerCamelCase": true, 22 | "AmbiguousTrailingClosureOverload": true, 23 | "BeginDocumentationCommentWithOneLineSummary": true, 24 | "DoNotUseSemicolons": true, 25 | "DontRepeatTypeInStaticProperties": false, 26 | "FileScopedDeclarationPrivacy": true, 27 | "FullyIndirectEnum": true, 28 | "GroupNumericLiterals": true, 29 | "IdentifiersMustBeASCII": true, 30 | "NeverForceUnwrap": false, 31 | "NeverUseForceTry": true, 32 | "NeverUseImplicitlyUnwrappedOptionals": false, 33 | "NoAccessLevelOnExtensionDeclaration": false, 34 | "NoBlockComments": true, 35 | "NoCasesWithOnlyFallthrough": true, 36 | "NoEmptyTrailingClosureParentheses": true, 37 | "NoLabelsInCasePatterns": true, 38 | "NoLeadingUnderscores": false, 39 | "NoParensAroundConditions": true, 40 | "NoVoidReturnOnFunctionSignature": true, 41 | "OneCasePerLine": true, 42 | "OneVariableDeclarationPerLine": true, 43 | "OnlyOneTrailingClosureArgument": false, 44 | "OrderedImports": true, 45 | "ReturnVoidInsteadOfEmptyTuple": true, 46 | "UseEarlyExits": false, 47 | "UseLetInEveryBoundCaseVariable": true, 48 | "UseShorthandTypeNames": true, 49 | "UseSingleLinePropertyGetter": true, 50 | "UseSynthesizedInitializer": true, 51 | "UseTripleSlashForDocumentationComments": true, 52 | "UseWhereClausesInForLoops": false, 53 | "ValidateDocumentationComments": true 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Benchmarks/App/Info-iOS.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Benchmarks 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | UILaunchScreen 24 | 25 | UIRequiresFullScreen 26 | 27 | UISupportedInterfaceOrientations 28 | 29 | UIInterfaceOrientationPortrait 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Benchmarks/App/iOS.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct TestHostApp { 5 | static func main() { 6 | _TestApp().run() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Benchmarks/Project.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Benchmarks/Project.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Benchmarks/Tests/BenchmarkTests.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import XCTest 3 | 4 | final class RenderingPerformanceTests: XCTestCase { 5 | func testPerformance() { 6 | let test = ViewTest { 7 | AtomRoot { 8 | TestView() 9 | } 10 | } 11 | 12 | let size = test.initSize() 13 | 14 | test.perform { 15 | measure { 16 | for _ in 0..<100 { 17 | test.sendTouchSequence( 18 | Array( 19 | repeating: (location: CGPoint(x: size.width / 2, y: size.height - 30), globalLocation: nil, timestamp: Date()), 20 | count: 2 21 | ) 22 | ) 23 | test.turnRunloop(times: 1) 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Benchmarks/Tests/TestView.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct Test0Atom: StateAtom, Hashable { 5 | func defaultValue(context: Context) -> Int { 6 | 0 7 | } 8 | } 9 | 10 | struct Test1Atom: ValueAtom, Hashable { 11 | func value(context: Context) -> Int { 12 | context.watch(Test0Atom()) 13 | } 14 | } 15 | 16 | struct Test2Atom: ValueAtom, Hashable { 17 | func value(context: Context) -> Int { 18 | context.watch(Test1Atom()) 19 | } 20 | } 21 | 22 | struct Test3Atom: ValueAtom, Hashable { 23 | func value(context: Context) -> Int { 24 | context.watch(Test2Atom()) 25 | } 26 | } 27 | 28 | struct Test4Atom: ValueAtom, Hashable { 29 | func value(context: Context) -> Int { 30 | context.watch(Test3Atom()) 31 | } 32 | } 33 | 34 | struct Test5Atom: ValueAtom, Hashable { 35 | func value(context: Context) -> Int { 36 | context.watch(Test4Atom()) 37 | } 38 | } 39 | 40 | struct Test6Atom: ValueAtom, Hashable { 41 | func value(context: Context) -> Int { 42 | context.watch(Test5Atom()) 43 | } 44 | } 45 | 46 | struct Test7Atom: ValueAtom, Hashable { 47 | func value(context: Context) -> Int { 48 | context.watch(Test6Atom()) 49 | } 50 | } 51 | 52 | struct Test8Atom: ValueAtom, Hashable { 53 | func value(context: Context) -> Int { 54 | context.watch(Test7Atom()) 55 | } 56 | } 57 | 58 | struct Test9Atom: ValueAtom, Hashable { 59 | func value(context: Context) -> Int { 60 | context.watch(Test8Atom()) 61 | } 62 | } 63 | 64 | struct Test10Atom: ValueAtom, Hashable { 65 | func value(context: Context) -> Int { 66 | context.watch(Test9Atom()) 67 | } 68 | } 69 | 70 | struct TestRowAtom: ValueAtom, Hashable { 71 | let key: Int 72 | 73 | func value(context: Context) -> Int { 74 | context.watch(Test10Atom()) 75 | } 76 | } 77 | 78 | struct TestRow: View { 79 | @Watch 80 | var value: Int 81 | 82 | init(key: Int) { 83 | _value = Watch(TestRowAtom(key: key)) 84 | } 85 | 86 | var body: some View { 87 | Text(value.description) 88 | .frame(height: 30) 89 | .frame(maxWidth: .infinity) 90 | .background(.yellow) 91 | } 92 | } 93 | 94 | struct TestView: View { 95 | @WatchState(Test0Atom()) 96 | var value 97 | 98 | var body: some View { 99 | VStack { 100 | ScrollView { 101 | VStack { 102 | ForEach(0..<200) { i in 103 | TestRow(key: i) 104 | } 105 | } 106 | } 107 | 108 | Button { 109 | value += 1 110 | } label: { 111 | Text("Increment") 112 | .frame(height: 60) 113 | .frame(maxWidth: .infinity) 114 | .background(.red) 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Benchmarks/Tests/ViewTest.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ViewTest: _ViewTest { 4 | let rootView: @MainActor () -> Content 5 | 6 | func initRootView() -> some View { 7 | MainActor.assumeIsolated { 8 | rootView() 9 | } 10 | } 11 | 12 | func initSize() -> CGSize { 13 | MainActor.assumeIsolated { 14 | UIScreen.main.bounds.size 15 | } 16 | } 17 | 18 | func perform(_ body: () -> Void) { 19 | setUpTest() 20 | body() 21 | tearDownTest() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Benchmarks/project.yml: -------------------------------------------------------------------------------- 1 | name: Project 2 | 3 | options: 4 | bundleIdPrefix: com.ryo.swiftui-atom-properties.examples 5 | createIntermediateGroups: true 6 | 7 | settings: 8 | CODE_SIGNING_REQUIRED: NO 9 | CODE_SIGN_IDENTITY: "-" 10 | CODE_SIGN_STYLE: Manual 11 | SWIFT_VERSION: 6 12 | 13 | packages: 14 | swiftui-atom-properties: 15 | path: .. 16 | 17 | schemes: 18 | BenchmarkTests: 19 | build: 20 | targets: 21 | BenchmarkTests: all 22 | test: 23 | targets: 24 | - BenchmarkTests 25 | 26 | targets: 27 | TestHostApp: 28 | type: application 29 | platform: iOS 30 | info: 31 | path: App/Info-iOS.plist 32 | properties: 33 | UILaunchScreen: 34 | UIRequiresFullScreen: true 35 | CFBundleDisplayName: Benchmarks 36 | UISupportedInterfaceOrientations: 37 | - UIInterfaceOrientationPortrait 38 | sources: 39 | - App/iOS.swift 40 | 41 | BenchmarkTests: 42 | type: bundle.unit-test 43 | platform: iOS 44 | settings: 45 | GENERATE_INFOPLIST_FILE: YES 46 | dependencies: 47 | - target: TestHostApp 48 | - package: swiftui-atom-properties 49 | product: Atoms 50 | sources: 51 | - Tests 52 | -------------------------------------------------------------------------------- /Examples/App/CrossPlatform.swift: -------------------------------------------------------------------------------- 1 | import CrossPlatformApp 2 | import SwiftUI 3 | 4 | @main 5 | struct CrossPlatformApp: App { 6 | var body: some Scene { 7 | WindowGroup { 8 | CrossPlatformRoot() 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/App/Info-CrossPlatform_macOS.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Atoms 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | NSLocationWhenInUseUsageDescription 24 | Example Usage 25 | NSMicrophoneUsageDescription 26 | Example Usage 27 | UILaunchScreen 28 | 29 | UIRequiresFullScreen 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Examples/App/Info-CrossPlatform_tvOS.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Atoms 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | NSLocationWhenInUseUsageDescription 24 | Example Usage 25 | NSMicrophoneUsageDescription 26 | Example Usage 27 | UILaunchScreen 28 | 29 | UIRequiresFullScreen 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Examples/App/Info-iOS.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Atoms 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | NSLocationWhenInUseUsageDescription 24 | Example Usage 25 | NSMicrophoneUsageDescription 26 | Example Usage 27 | UILaunchScreen 28 | 29 | UIRequiresFullScreen 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Examples/App/iOS.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import iOSApp 3 | 4 | @main 5 | struct iOSApp: App { 6 | var body: some Scene { 7 | WindowGroup { 8 | iOSRoot() 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let atoms = Target.Dependency.product(name: "Atoms", package: "swiftui-atom-properties") 6 | let swiftSettings: [SwiftSetting] = [ 7 | .enableUpcomingFeature("ExistentialAny") 8 | ] 9 | 10 | let package = Package( 11 | name: "CrossPlatformExamples", 12 | platforms: [ 13 | .iOS(.v16), 14 | .macOS(.v13), 15 | .tvOS(.v16), 16 | .watchOS(.v9), 17 | ], 18 | products: [ 19 | .library(name: "CrossPlatformApp", targets: ["CrossPlatformApp"]) 20 | ], 21 | dependencies: [ 22 | .package(path: "../../..") 23 | ], 24 | targets: [ 25 | .target( 26 | name: "CrossPlatformApp", 27 | dependencies: [ 28 | atoms, 29 | "ExampleCounter", 30 | "ExampleTodo", 31 | ], 32 | swiftSettings: swiftSettings 33 | ), 34 | .target(name: "ExampleCounter", dependencies: [atoms], swiftSettings: swiftSettings), 35 | .testTarget(name: "ExampleCounterTests", dependencies: ["ExampleCounter"], swiftSettings: swiftSettings), 36 | .target(name: "ExampleTodo", dependencies: [atoms], swiftSettings: swiftSettings), 37 | .testTarget(name: "ExampleTodoTests", dependencies: ["ExampleTodo"], swiftSettings: swiftSettings), 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Sources/CrossPlatformApp/CrossPlatformRoot.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import ExampleCounter 3 | import ExampleTodo 4 | import SwiftUI 5 | 6 | // swift-format-ignore: AllPublicDeclarationsHaveDocumentation 7 | public struct CrossPlatformRoot: View { 8 | public init() {} 9 | 10 | public var body: some View { 11 | AtomRoot { 12 | NavigationStack { 13 | List { 14 | NavigationLink("🔢 Counter") { 15 | ExampleCounter() 16 | } 17 | 18 | NavigationLink("📋 Todo") { 19 | ExampleTodo() 20 | } 21 | } 22 | .navigationTitle("Examples") 23 | 24 | #if os(iOS) 25 | .listStyle(.insetGrouped) 26 | #endif 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Sources/ExampleCounter/ExampleCounter.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct CounterAtom: StateAtom, Hashable { 5 | func defaultValue(context: Context) -> Int { 6 | 0 7 | } 8 | } 9 | 10 | struct CounterScreen: View { 11 | @Watch(CounterAtom()) 12 | var count 13 | 14 | var body: some View { 15 | VStack { 16 | Text("Count: \(count)").font(.largeTitle) 17 | CountStepper() 18 | } 19 | .fixedSize() 20 | .navigationTitle("Counter") 21 | } 22 | } 23 | 24 | struct CountStepper: View { 25 | @WatchState(CounterAtom()) 26 | var count 27 | 28 | var body: some View { 29 | #if os(tvOS) || os(watchOS) 30 | HStack { 31 | Button("-") { count -= 1 } 32 | Button("+") { count += 1 } 33 | } 34 | #else 35 | Stepper(value: $count) {} 36 | .labelsHidden() 37 | #endif 38 | } 39 | } 40 | 41 | // swift-format-ignore: AllPublicDeclarationsHaveDocumentation 42 | public struct ExampleCounter: View { 43 | public init() {} 44 | 45 | public var body: some View { 46 | CounterScreen() 47 | } 48 | } 49 | 50 | #Preview { 51 | AtomRoot { 52 | CounterScreen() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Sources/ExampleTodo/Atoms.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import Foundation 3 | 4 | struct TodosAtom: StateAtom, Hashable, KeepAlive { 5 | func defaultValue(context: Context) -> [Todo] { 6 | [ 7 | Todo(id: UUID(), text: "Add a new todo", isCompleted: true), 8 | Todo(id: UUID(), text: "Complete a todo", isCompleted: false), 9 | Todo(id: UUID(), text: "Swipe to delete a todo", isCompleted: false), 10 | ] 11 | } 12 | } 13 | 14 | struct FilterAtom: StateAtom, Hashable { 15 | func defaultValue(context: Context) -> Filter { 16 | .all 17 | } 18 | } 19 | 20 | struct FilteredTodosAtom: ValueAtom, Hashable { 21 | func value(context: Context) -> [Todo] { 22 | let filter = context.watch(FilterAtom()) 23 | let todos = context.watch(TodosAtom()) 24 | 25 | switch filter { 26 | case .all: 27 | return todos 28 | 29 | case .completed: 30 | return todos.filter(\.isCompleted) 31 | 32 | case .uncompleted: 33 | return todos.filter { !$0.isCompleted } 34 | } 35 | } 36 | } 37 | 38 | struct StatsAtom: ValueAtom, Hashable { 39 | func value(context: Context) -> Stats { 40 | let todos = context.watch(TodosAtom()) 41 | let total = todos.count 42 | let totalCompleted = todos.filter(\.isCompleted).count 43 | let totalUncompleted = todos.filter { !$0.isCompleted }.count 44 | let percentCompleted = total <= 0 ? 0 : (Double(totalCompleted) / Double(total)) 45 | 46 | return Stats( 47 | total: total, 48 | totalCompleted: totalCompleted, 49 | totalUncompleted: totalUncompleted, 50 | percentCompleted: percentCompleted 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Sources/ExampleTodo/Entities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Todo: Hashable { 4 | var id: UUID 5 | var text: String 6 | var isCompleted: Bool 7 | } 8 | 9 | enum Filter: CaseIterable, Hashable { 10 | case all 11 | case completed 12 | case uncompleted 13 | } 14 | 15 | struct Stats: Equatable { 16 | let total: Int 17 | let totalCompleted: Int 18 | let totalUncompleted: Int 19 | let percentCompleted: Double 20 | } 21 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Sources/ExampleTodo/ExampleTodo.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // swift-format-ignore: AllPublicDeclarationsHaveDocumentation 4 | public struct ExampleTodo: View { 5 | public init() {} 6 | 7 | public var body: some View { 8 | TodoListScreen() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Sources/ExampleTodo/Screens.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct TodoListScreen: View { 5 | @Watch(FilteredTodosAtom()) 6 | var filteredTodos 7 | 8 | @ViewContext 9 | var context 10 | 11 | var body: some View { 12 | List { 13 | Section { 14 | TodoStats() 15 | TodoCreator() 16 | } 17 | 18 | Section { 19 | TodoFilters() 20 | 21 | ForEach(filteredTodos, id: \.id) { todo in 22 | TodoItem(todo: todo) 23 | } 24 | .onDelete { indexSet in 25 | let filtered = filteredTodos 26 | context.modify(TodosAtom()) { todos in 27 | let indices = indexSet.compactMap { index in 28 | todos.firstIndex(of: filtered[index]) 29 | } 30 | todos.remove(atOffsets: IndexSet(indices)) 31 | } 32 | } 33 | } 34 | } 35 | .navigationTitle("Todo") 36 | 37 | #if os(iOS) 38 | .listStyle(.insetGrouped) 39 | .buttonStyle(.borderless) 40 | #elseif !os(tvOS) 41 | .buttonStyle(.borderless) 42 | #endif 43 | } 44 | } 45 | 46 | #Preview { 47 | AtomRoot { 48 | TodoListScreen() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Sources/ExampleTodo/Views.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct TodoStats: View { 5 | @Watch(StatsAtom()) 6 | var stats 7 | 8 | var body: some View { 9 | VStack(alignment: .leading, spacing: 4) { 10 | stat("Total", "\(stats.total)") 11 | stat("Completed", "\(stats.totalCompleted)") 12 | stat("Uncompleted", "\(stats.totalUncompleted)") 13 | stat("Percent Completed", "\(Int(stats.percentCompleted * 100))%") 14 | } 15 | .padding(.vertical) 16 | } 17 | 18 | func stat(_ title: String, _ value: String) -> some View { 19 | HStack { 20 | Text(title) + Text(":") 21 | Spacer() 22 | Text(value) 23 | } 24 | } 25 | } 26 | 27 | struct TodoFilters: View { 28 | @WatchState(FilterAtom()) 29 | var filter 30 | 31 | var body: some View { 32 | Picker("Filter", selection: $filter) { 33 | ForEach(Filter.allCases, id: \.self) { filter in 34 | switch filter { 35 | case .all: 36 | Text("All") 37 | 38 | case .completed: 39 | Text("Completed") 40 | 41 | case .uncompleted: 42 | Text("Uncompleted") 43 | } 44 | } 45 | } 46 | .padding(.vertical) 47 | 48 | #if !os(watchOS) 49 | .pickerStyle(.segmented) 50 | #endif 51 | } 52 | } 53 | 54 | struct TodoCreator: View { 55 | @WatchState(TodosAtom()) 56 | var todos 57 | 58 | @State 59 | var text = "" 60 | 61 | var body: some View { 62 | HStack { 63 | TextField("Enter your todo", text: $text) 64 | 65 | #if os(iOS) || os(macOS) 66 | .textFieldStyle(.roundedBorder) 67 | #endif 68 | 69 | Button("Add") { 70 | addTodo() 71 | } 72 | .disabled(text.isEmpty) 73 | } 74 | .padding(.vertical) 75 | } 76 | 77 | func addTodo() { 78 | let todo = Todo(id: UUID(), text: text, isCompleted: false) 79 | todos.append(todo) 80 | text = "" 81 | } 82 | } 83 | 84 | struct TodoItem: View { 85 | @WatchState(TodosAtom()) 86 | var allTodos 87 | 88 | @State 89 | var text: String 90 | 91 | @State 92 | var isCompleted: Bool 93 | 94 | let todo: Todo 95 | 96 | init(todo: Todo) { 97 | self.todo = todo 98 | self._text = State(initialValue: todo.text) 99 | self._isCompleted = State(initialValue: todo.isCompleted) 100 | } 101 | 102 | var index: Int { 103 | allTodos.firstIndex { $0.id == todo.id }! 104 | } 105 | 106 | var body: some View { 107 | Toggle(isOn: $isCompleted) { 108 | TextField("", text: $text) { 109 | allTodos[index].text = text 110 | } 111 | 112 | #if os(iOS) || os(macOS) 113 | .textFieldStyle(.roundedBorder) 114 | #endif 115 | } 116 | .padding(.vertical, 4) 117 | .onChange(of: isCompleted) { isCompleted in 118 | allTodos[index].isCompleted = isCompleted 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Tests/ExampleCounterTests/ExampleCounterTests.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import XCTest 3 | 4 | @testable import ExampleCounter 5 | 6 | final class ExampleCounterTests: XCTestCase { 7 | @MainActor 8 | func testCounterAtom() { 9 | let context = AtomTestContext() 10 | let atom = CounterAtom() 11 | 12 | XCTAssertEqual(context.watch(atom), 0) 13 | 14 | context[atom] = 1 15 | 16 | XCTAssertEqual(context.watch(atom), 1) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Examples/Packages/CrossPlatform/Tests/ExampleTodoTests/ExampleTodoTests.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import XCTest 3 | 4 | @testable import ExampleTodo 5 | 6 | final class ExampleTodoTests: XCTestCase { 7 | let completedTodos = [ 8 | Todo( 9 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, 10 | text: "Test 0", 11 | isCompleted: true 12 | ) 13 | ] 14 | 15 | let uncompleteTodos = [ 16 | Todo( 17 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, 18 | text: "Test 1", 19 | isCompleted: false 20 | ), 21 | Todo( 22 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, 23 | text: "Test 2", 24 | isCompleted: false 25 | ), 26 | ] 27 | 28 | var allTodos: [Todo] { 29 | completedTodos + uncompleteTodos 30 | } 31 | 32 | @MainActor 33 | func testFilteredTodosAtom() { 34 | let context = AtomTestContext() 35 | let atom = FilteredTodosAtom() 36 | 37 | context.watch(atom) 38 | 39 | context[TodosAtom()] = [] 40 | 41 | XCTAssertEqual(context.watch(atom), []) 42 | 43 | context[TodosAtom()] = allTodos 44 | 45 | XCTAssertEqual(context.watch(atom), allTodos) 46 | 47 | context[FilterAtom()] = .completed 48 | 49 | XCTAssertEqual(context.watch(atom), completedTodos) 50 | 51 | context[FilterAtom()] = .uncompleted 52 | 53 | XCTAssertEqual(context.watch(atom), uncompleteTodos) 54 | } 55 | 56 | @MainActor 57 | func testStatsAtom() { 58 | let context = AtomTestContext() 59 | let atom = StatsAtom() 60 | 61 | context.watch(atom) 62 | 63 | context[TodosAtom()] = [] 64 | 65 | XCTAssertEqual( 66 | context.watch(atom), 67 | Stats( 68 | total: 0, 69 | totalCompleted: 0, 70 | totalUncompleted: 0, 71 | percentCompleted: 0 72 | ) 73 | ) 74 | 75 | context[TodosAtom()] = completedTodos 76 | 77 | XCTAssertEqual( 78 | context.watch(atom), 79 | Stats( 80 | total: 1, 81 | totalCompleted: 1, 82 | totalUncompleted: 0, 83 | percentCompleted: 1 84 | ) 85 | ) 86 | 87 | context[TodosAtom()] = uncompleteTodos 88 | 89 | XCTAssertEqual( 90 | context.watch(atom), 91 | Stats( 92 | total: 2, 93 | totalCompleted: 0, 94 | totalUncompleted: 2, 95 | percentCompleted: 0 96 | ) 97 | ) 98 | 99 | context[TodosAtom()] = allTodos 100 | 101 | XCTAssertEqual( 102 | context.watch(atom), 103 | Stats( 104 | total: 3, 105 | totalCompleted: 1, 106 | totalUncompleted: 2, 107 | percentCompleted: 1 / 3 108 | ) 109 | ) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let atoms = Target.Dependency.product(name: "Atoms", package: "swiftui-atom-properties") 6 | let swiftSettings: [SwiftSetting] = [ 7 | .enableUpcomingFeature("ExistentialAny") 8 | ] 9 | 10 | let package = Package( 11 | name: "iOSExamples", 12 | platforms: [ 13 | .iOS(.v16) 14 | ], 15 | products: [ 16 | .library(name: "iOSApp", targets: ["iOSApp"]) 17 | ], 18 | dependencies: [ 19 | .package(path: "../../.."), 20 | .package(path: "../CrossPlatform"), 21 | ], 22 | targets: [ 23 | .target( 24 | name: "iOSApp", 25 | dependencies: [ 26 | atoms, 27 | .product(name: "CrossPlatformApp", package: "CrossPlatform"), 28 | "ExampleMovieDB", 29 | "ExampleMap", 30 | "ExampleVoiceMemo", 31 | "ExampleTimeTravel", 32 | ], 33 | swiftSettings: swiftSettings 34 | ), 35 | .target(name: "ExampleMovieDB", dependencies: [atoms], swiftSettings: swiftSettings), 36 | .testTarget(name: "ExampleMovieDBTests", dependencies: ["ExampleMovieDB"], swiftSettings: swiftSettings), 37 | .target(name: "ExampleMap", dependencies: [atoms], swiftSettings: swiftSettings), 38 | .testTarget(name: "ExampleMapTests", dependencies: ["ExampleMap"], swiftSettings: swiftSettings), 39 | .target(name: "ExampleVoiceMemo", dependencies: [atoms], swiftSettings: swiftSettings), 40 | .testTarget(name: "ExampleVoiceMemoTests", dependencies: ["ExampleVoiceMemo"], swiftSettings: swiftSettings), 41 | .target(name: "ExampleTimeTravel", dependencies: [atoms], swiftSettings: swiftSettings), 42 | .testTarget(name: "ExampleTimeTravelTests", dependencies: ["ExampleTimeTravel"], swiftSettings: swiftSettings), 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMap/Atoms.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import CoreLocation 3 | 4 | final class LocationObserver: NSObject, ObservableObject, CLLocationManagerDelegate, @unchecked Sendable { 5 | let manager: any LocationManagerProtocol 6 | 7 | deinit { 8 | manager.stopUpdatingLocation() 9 | } 10 | 11 | init(manager: any LocationManagerProtocol) { 12 | self.manager = manager 13 | super.init() 14 | manager.delegate = self 15 | } 16 | 17 | func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { 18 | objectWillChange.send() 19 | 20 | switch manager.authorizationStatus { 21 | case .authorizedAlways, .authorizedWhenInUse: 22 | manager.startUpdatingLocation() 23 | 24 | case .notDetermined: 25 | manager.requestWhenInUseAuthorization() 26 | 27 | case .restricted, .denied: 28 | break 29 | 30 | @unknown default: 31 | break 32 | } 33 | } 34 | 35 | func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) { 36 | print(error.localizedDescription) 37 | } 38 | } 39 | 40 | struct LocationManagerAtom: ValueAtom, Hashable { 41 | func value(context: Context) -> any LocationManagerProtocol { 42 | let manager = CLLocationManager() 43 | manager.desiredAccuracy = kCLLocationAccuracyBest 44 | return manager 45 | } 46 | } 47 | 48 | struct LocationObserverAtom: ObservableObjectAtom, Hashable { 49 | func object(context: Context) -> LocationObserver { 50 | let manager = context.watch(LocationManagerAtom()) 51 | return LocationObserver(manager: manager) 52 | } 53 | } 54 | 55 | struct CoordinateAtom: ValueAtom, Hashable { 56 | func value(context: Context) -> CLLocationCoordinate2D? { 57 | let observer = context.watch(LocationObserverAtom()) 58 | return observer.manager.location?.coordinate 59 | } 60 | } 61 | 62 | struct AuthorizationStatusAtom: ValueAtom, Hashable { 63 | func value(context: Context) -> CLAuthorizationStatus { 64 | context.watch(LocationObserverAtom().changes(of: \.manager.authorizationStatus)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMap/Dependency/LocationManager.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | 3 | protocol LocationManagerProtocol: AnyObject { 4 | var delegate: (any CLLocationManagerDelegate)? { get set } 5 | var desiredAccuracy: CLLocationAccuracy { get set } 6 | var location: CLLocation? { get } 7 | var authorizationStatus: CLAuthorizationStatus { get } 8 | 9 | func stopUpdatingLocation() 10 | } 11 | 12 | extension CLLocationManager: LocationManagerProtocol {} 13 | 14 | final class MockLocationManager: LocationManagerProtocol, @unchecked Sendable { 15 | weak var delegate: (any CLLocationManagerDelegate)? 16 | var desiredAccuracy = kCLLocationAccuracyKilometer 17 | var location: CLLocation? = nil 18 | var authorizationStatus = CLAuthorizationStatus.notDetermined 19 | var isUpdatingLocation = true 20 | 21 | func stopUpdatingLocation() { 22 | isUpdatingLocation = false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMap/ExampleMap.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // swift-format-ignore: AllPublicDeclarationsHaveDocumentation 4 | public struct ExampleMap: View { 5 | public init() {} 6 | 7 | public var body: some View { 8 | MapScreen() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMap/Screens.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct MapScreen: View { 5 | @Watch(AuthorizationStatusAtom()) 6 | var authorizationStatus 7 | 8 | @ViewContext 9 | var context 10 | 11 | var body: some View { 12 | Group { 13 | switch authorizationStatus { 14 | case .authorizedAlways, .authorizedWhenInUse: 15 | mapContent 16 | 17 | case .notDetermined, .restricted, .denied: 18 | authorizationContent 19 | 20 | @unknown default: 21 | authorizationContent 22 | } 23 | } 24 | .navigationTitle("Map") 25 | } 26 | 27 | var mapContent: some View { 28 | MapView() 29 | .ignoresSafeArea(edges: [.bottom, .leading, .trailing]) 30 | .overlay(alignment: .topTrailing) { 31 | Button { 32 | context.reset(CoordinateAtom()) 33 | } label: { 34 | Image(systemName: "location") 35 | .padding() 36 | .background(Color(.secondarySystemBackground)) 37 | .cornerRadius(8) 38 | } 39 | .padding() 40 | .shadow(radius: 2) 41 | } 42 | } 43 | 44 | var authorizationContent: some View { 45 | ZStack { 46 | Button("Open Settings") { 47 | UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) 48 | } 49 | .tint(.blue) 50 | .buttonStyle(.borderedProminent) 51 | .buttonBorderShape(.capsule) 52 | .controlSize(.large) 53 | } 54 | } 55 | } 56 | 57 | #Preview { 58 | AtomRoot { 59 | MapScreen() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMap/Views.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import MapKit 3 | import SwiftUI 4 | 5 | struct MapView: View { 6 | @Watch(CoordinateAtom()) 7 | var coordinate 8 | 9 | var body: some View { 10 | MapViewRepresentable(base: self) 11 | } 12 | } 13 | 14 | private struct MapViewRepresentable: UIViewRepresentable { 15 | let base: MapView 16 | 17 | func makeUIView(context: Context) -> MKMapView { 18 | MKMapView(frame: .zero) 19 | } 20 | 21 | func updateUIView(_ view: MKMapView, context: Context) { 22 | guard let coordinate = base.coordinate else { 23 | return 24 | } 25 | 26 | let span = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) 27 | let region = MKCoordinateRegion(center: coordinate, span: span) 28 | let annotation = MKPointAnnotation() 29 | annotation.coordinate = coordinate 30 | 31 | view.addAnnotation(annotation) 32 | view.setRegion(region, animated: true) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/CommonAtoms.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import UIKit 3 | 4 | struct APIClientAtom: ValueAtom, Hashable { 5 | func value(context: Context) -> any APIClientProtocol { 6 | APIClient() 7 | } 8 | } 9 | 10 | struct ImageAtom: ThrowingTaskAtom, Hashable { 11 | let path: String 12 | let size: ImageSize 13 | 14 | func value(context: Context) async throws -> UIImage { 15 | let api = context.watch(APIClientAtom()) 16 | return try await api.getImage(path: path, size: size) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/DetailAtoms.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | 3 | struct IsInMyListAtom: ValueAtom, Hashable { 4 | let movie: Movie 5 | 6 | func value(context: Context) -> Bool { 7 | let myList = context.watch(MyListAtom()) 8 | return myList.movies.contains(movie) 9 | } 10 | } 11 | 12 | struct CastsAtom: ThrowingTaskAtom, Hashable { 13 | let movieID: Int 14 | 15 | func value(context: Context) async throws -> [Credits.Person] { 16 | let api = context.watch(APIClientAtom()) 17 | return try await api.getCredits(movieID: movieID).cast 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/MoviesAtoms.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import Foundation 3 | 4 | @MainActor 5 | final class MovieLoader: ObservableObject { 6 | @Published 7 | private(set) var pages = AsyncPhase<[PagedResponse], any Error>.suspending 8 | private let api: any APIClientProtocol 9 | let filter: Filter 10 | 11 | init(api: any APIClientProtocol, filter: Filter) { 12 | self.api = api 13 | self.filter = filter 14 | } 15 | 16 | func refresh() async { 17 | pages = await AsyncPhase { 18 | try await [api.getMovies(filter: filter, page: 1)] 19 | } 20 | } 21 | 22 | func loadNext() async { 23 | guard let currentPages = pages.value, let lastPage = currentPages.last?.page else { 24 | return 25 | } 26 | 27 | let nextPage = try? await api.getMovies(filter: filter, page: lastPage + 1) 28 | 29 | guard let nextPage = nextPage else { 30 | return 31 | } 32 | 33 | pages = .success(currentPages + [nextPage]) 34 | } 35 | } 36 | 37 | @MainActor 38 | final class MyList: ObservableObject { 39 | @Published 40 | private(set) var movies = [Movie]() 41 | 42 | func insert(movie: Movie) { 43 | if let index = movies.firstIndex(of: movie) { 44 | movies.remove(at: index) 45 | } 46 | else { 47 | movies.append(movie) 48 | } 49 | } 50 | } 51 | 52 | struct MovieLoaderAtom: ObservableObjectAtom, Hashable { 53 | func object(context: Context) -> MovieLoader { 54 | let api = context.watch(APIClientAtom()) 55 | let filter = context.watch(FilterAtom()) 56 | return MovieLoader(api: api, filter: filter) 57 | } 58 | } 59 | 60 | struct MyListAtom: ObservableObjectAtom, Hashable, KeepAlive { 61 | func object(context: Context) -> MyList { 62 | MyList() 63 | } 64 | } 65 | 66 | struct FilterAtom: StateAtom, Hashable { 67 | func defaultValue(context: Context) -> Filter { 68 | .nowPlaying 69 | } 70 | } 71 | 72 | private extension APIClientProtocol { 73 | func getMovies(filter: Filter, page: Int) async throws -> PagedResponse { 74 | switch filter { 75 | case .nowPlaying: 76 | return try await getNowPlaying(page: page) 77 | 78 | case .popular: 79 | return try await getPopular(page: page) 80 | 81 | case .topRated: 82 | return try await getTopRated(page: page) 83 | 84 | case .upcoming: 85 | return try await getUpcoming(page: page) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Atoms/SearchAtoms.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import Combine 3 | 4 | struct SearchQueryAtom: StateAtom, Hashable { 5 | func defaultValue(context: Context) -> String { 6 | "" 7 | } 8 | } 9 | 10 | struct SearchMoviesAtom: AsyncPhaseAtom, Hashable { 11 | func value(context: Context) async throws -> [Movie] { 12 | let api = context.watch(APIClientAtom()) 13 | let query = context.watch(SearchQueryAtom()) 14 | 15 | guard !query.isEmpty else { 16 | return [] 17 | } 18 | 19 | return try await api.getSearchMovies(query: query).results 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Credits.swift: -------------------------------------------------------------------------------- 1 | struct Credits: Codable, Hashable, Identifiable { 2 | struct Person: Codable, Hashable, Identifiable { 3 | let id: Int 4 | let name: String 5 | let profilePath: String? 6 | } 7 | 8 | let id: Int 9 | let cast: [Person] 10 | } 11 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Failable.swift: -------------------------------------------------------------------------------- 1 | @propertyWrapper 2 | struct Failable: Decodable, Sendable { 3 | var wrappedValue: T? 4 | 5 | init(wrappedValue: T?) { 6 | self.wrappedValue = wrappedValue 7 | } 8 | 9 | init(from decoder: any Decoder) throws { 10 | let container = try decoder.singleValueContainer() 11 | let wrappedValue = try? container.decode(T.self) 12 | self.init(wrappedValue: wrappedValue) 13 | } 14 | } 15 | 16 | extension Failable: Encodable where T: Encodable { 17 | func encode(to encoder: any Encoder) throws { 18 | var container = encoder.singleValueContainer() 19 | try container.encode(wrappedValue) 20 | } 21 | } 22 | 23 | extension Failable: Equatable where T: Equatable {} 24 | extension Failable: Hashable where T: Hashable {} 25 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Filter.swift: -------------------------------------------------------------------------------- 1 | enum Filter: CaseIterable, Hashable { 2 | case nowPlaying 3 | case popular 4 | case topRated 5 | case upcoming 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/ImageSize.swift: -------------------------------------------------------------------------------- 1 | enum ImageSize: String, Hashable { 2 | case original 3 | case small = "w154" 4 | case medium = "w500" 5 | case cast = "w185" 6 | } 7 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/Movie.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Movie: Codable, Hashable, Identifiable { 4 | var id: Int 5 | var title: String 6 | var overview: String? 7 | var posterPath: String? 8 | var backdropPath: String? 9 | var voteAverage: Float 10 | @Failable 11 | var releaseDate: Date? 12 | } 13 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Entities/PagedResponse.swift: -------------------------------------------------------------------------------- 1 | struct PagedResponse: Decodable, Sendable { 2 | let page: Int 3 | let totalPages: Int 4 | let results: [T] 5 | 6 | var hasNextPage: Bool { 7 | page < totalPages 8 | } 9 | } 10 | 11 | extension PagedResponse: Equatable where T: Equatable {} 12 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/ExampleMovieDB.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // swift-format-ignore: AllPublicDeclarationsHaveDocumentation 4 | public struct ExampleMovieDB: View { 5 | public init() {} 6 | 7 | public var body: some View { 8 | MoviesScreen() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/DetailScreen.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct DetailScreen: View { 5 | let movie: Movie 6 | 7 | @WatchStateObject(MyListAtom()) 8 | var myList 9 | 10 | @ViewContext 11 | var context 12 | 13 | @Environment(\.dismiss) 14 | var dismiss 15 | 16 | @Environment(\.calendar) 17 | var calendar 18 | 19 | var dateComponents: DateComponents? { 20 | movie.releaseDate.map { calendar.dateComponents([.year], from: $0) } 21 | } 22 | 23 | var body: some View { 24 | ScrollView { 25 | VStack(alignment: .leading, spacing: 0) { 26 | coverImage 27 | title 28 | 29 | Group { 30 | GroupBox("Cast") { 31 | CastList(movieID: movie.id) 32 | } 33 | 34 | GroupBox("Overview") { 35 | MovieRow(movie: movie, truncatesOverview: false) 36 | } 37 | } 38 | .padding([.bottom, .horizontal]) 39 | } 40 | } 41 | .background(Color(.systemBackground)) 42 | .overlay(alignment: .topLeading) { 43 | closeButton 44 | } 45 | } 46 | 47 | var coverImage: some View { 48 | Color(.systemGroupedBackground) 49 | .aspectRatio( 50 | CGSize(width: 1, height: 0.6), 51 | contentMode: .fit 52 | ) 53 | .frame(maxWidth: .infinity) 54 | .clipped() 55 | .overlay { 56 | if let path = movie.backdropPath { 57 | NetworkImage(path: path, size: .original) 58 | } 59 | } 60 | } 61 | 62 | var title: some View { 63 | HStack { 64 | VStack(alignment: .leading, spacing: 4) { 65 | Text(movie.title) 66 | .font(.title3.bold()) 67 | .foregroundColor(.primary) 68 | 69 | if let year = dateComponents?.year { 70 | Text(verbatim: "(\(year))") 71 | .font(.headline) 72 | .foregroundColor(.secondary) 73 | } 74 | } 75 | 76 | Spacer(minLength: 8) 77 | 78 | myListButton 79 | } 80 | .padding() 81 | } 82 | 83 | @ViewBuilder 84 | var closeButton: some View { 85 | Button { 86 | dismiss() 87 | } label: { 88 | Image(systemName: "xmark.circle.fill") 89 | .font(.largeTitle) 90 | .foregroundStyle(Color(.systemGray)) 91 | } 92 | .padding() 93 | .shadow(radius: 2) 94 | } 95 | 96 | @ViewBuilder 97 | var myListButton: some View { 98 | let isOn = context.watch(IsInMyListAtom(movie: movie)) 99 | 100 | Button { 101 | myList.insert(movie: movie) 102 | } label: { 103 | MyListButtonLabel(isOn: isOn) 104 | } 105 | } 106 | } 107 | 108 | #Preview { 109 | let movie = Movie( 110 | id: 680, 111 | title: "Pulp Fiction", 112 | overview: """ 113 | 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. 114 | """, 115 | posterPath: "/d5iIlFn5s0ImszYzBPb8JPIfbXD.jpg", 116 | backdropPath: "/suaEOtk1N1sgg2MTM7oZd2cfVp3.jpg", 117 | voteAverage: 8.5, 118 | releaseDate: Date(timeIntervalSinceReferenceDate: -199184400.0) 119 | ) 120 | 121 | AtomRoot { 122 | DetailScreen(movie: movie) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/MoviesScreen.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct MoviesScreen: View { 5 | @WatchStateObject(MovieLoaderAtom()) 6 | var loader 7 | 8 | @WatchState(SearchQueryAtom()) 9 | var searchQuery 10 | 11 | @State 12 | var isShowingSearchScreen = false 13 | 14 | @State 15 | var selectedMovie: Movie? 16 | 17 | var body: some View { 18 | List { 19 | Section("My List") { 20 | MyMovieList { movie in 21 | selectedMovie = movie 22 | } 23 | } 24 | 25 | Section { 26 | FilterPicker() 27 | 28 | switch loader.pages { 29 | case .suspending: 30 | ProgressRow().id(loader.filter) 31 | 32 | case .failure: 33 | CaveatRow(text: "Failed to get the data.") 34 | 35 | case .success(let pages): 36 | ForEach(pages, id: \.page) { response in 37 | pageIndex(current: response.page, total: response.totalPages) 38 | 39 | ForEach(response.results, id: \.id) { movie in 40 | movieRow(movie) 41 | } 42 | } 43 | 44 | if let last = pages.last, last.hasNextPage { 45 | ProgressRow() 46 | // NB: Since ProgressView placed in the List will not redisplay its indicator once it's hidden, here adds a random ID so that it's always regenerated. 47 | .id(UUID()) 48 | .task { 49 | await loader.loadNext() 50 | } 51 | } 52 | } 53 | } 54 | } 55 | .listStyle(.insetGrouped) 56 | .navigationTitle("Movies") 57 | .toolbar { 58 | ToolbarItem(placement: .principal) { 59 | Text(Image(systemName: "film")) + Text("TMDB") 60 | } 61 | } 62 | .searchable( 63 | text: $searchQuery, 64 | placement: .navigationBarDrawer(displayMode: .always) 65 | ) 66 | .onSubmit(of: .search) { 67 | isShowingSearchScreen = true 68 | } 69 | .task(id: loader.filter) { 70 | await loader.refresh() 71 | } 72 | .refreshable { 73 | await loader.refresh() 74 | } 75 | .sheet(item: $selectedMovie) { movie in 76 | DetailScreen(movie: movie) 77 | } 78 | .navigationDestination(isPresented: $isShowingSearchScreen) { 79 | SearchScreen() 80 | } 81 | } 82 | 83 | func movieRow(_ movie: Movie) -> some View { 84 | Button { 85 | selectedMovie = movie 86 | } label: { 87 | MovieRow(movie: movie) 88 | } 89 | } 90 | 91 | func pageIndex(current: Int, total: Int) -> some View { 92 | Text("Page: \(current) / \(total)") 93 | .font(.subheadline) 94 | .foregroundColor(.accentColor) 95 | } 96 | } 97 | 98 | #Preview { 99 | AtomRoot { 100 | NavigationStack { 101 | MoviesScreen() 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Screens/SearchScreen.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct SearchScreen: View { 5 | @Watch(SearchMoviesAtom()) 6 | var movies 7 | 8 | @ViewContext 9 | var context 10 | 11 | @State 12 | var selectedMovie: Movie? 13 | 14 | var body: some View { 15 | List { 16 | switch movies { 17 | case .suspending: 18 | ProgressRow() 19 | 20 | case .failure: 21 | CaveatRow(text: "Failed to get the search results.") 22 | 23 | case .success(let movies) where movies.isEmpty: 24 | CaveatRow(text: "There are no movies that matched your query.") 25 | 26 | case .success(let movies): 27 | ForEach(movies, id: \.id) { movie in 28 | Button { 29 | selectedMovie = movie 30 | } label: { 31 | MovieRow(movie: movie) 32 | } 33 | } 34 | } 35 | } 36 | .navigationTitle("Search Results") 37 | .listStyle(.insetGrouped) 38 | .refreshable { 39 | await context.refresh(SearchMoviesAtom()) 40 | } 41 | .sheet(item: $selectedMovie) { movie in 42 | DetailScreen(movie: movie) 43 | } 44 | } 45 | } 46 | 47 | #Preview { 48 | AtomRoot { 49 | NavigationStack { 50 | SearchScreen() 51 | } 52 | } 53 | .override(SearchQueryAtom()) { _ in 54 | "Léon" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Views/CastList.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct CastList: View { 5 | let movieID: Int 6 | 7 | @ViewContext 8 | var context 9 | 10 | var casts: AsyncPhase<[Credits.Person], any Error> { 11 | context.watch(CastsAtom(movieID: movieID).phase) 12 | } 13 | 14 | var body: some View { 15 | switch casts { 16 | case .suspending: 17 | ProgressRow() 18 | 19 | case .failure: 20 | CaveatRow(text: "Failed to get casts data.") 21 | 22 | case .success(let casts) where casts.isEmpty: 23 | CaveatRow(text: "No cast information is available.") 24 | 25 | case .success(let casts): 26 | ScrollView(.horizontal, showsIndicators: false) { 27 | LazyHStack { 28 | ForEach(casts, id: \.id) { cast in 29 | item(cast: cast) 30 | } 31 | } 32 | } 33 | } 34 | } 35 | 36 | @ViewBuilder 37 | func item(cast: Credits.Person) -> some View { 38 | VStack(spacing: 0) { 39 | ZStack { 40 | if let path = cast.profilePath { 41 | NetworkImage(path: path, size: .cast) 42 | } 43 | else { 44 | Image(systemName: "person.fill") 45 | .font(.largeTitle) 46 | .foregroundStyle(Color(.systemGray)) 47 | } 48 | } 49 | .frame(height: 120) 50 | .frame(maxWidth: .infinity) 51 | .background(Color(.secondarySystemBackground).ignoresSafeArea()) 52 | 53 | Text(cast.name) 54 | .font(.caption2) 55 | .foregroundColor(.primary) 56 | .lineLimit(2) 57 | .padding(4) 58 | .frame(height: 40) 59 | .frame(maxWidth: .infinity, alignment: .topLeading) 60 | } 61 | .frame(width: 80) 62 | .background(Color(.systemBackground).ignoresSafeArea()) 63 | .cornerRadius(8) 64 | .overlay( 65 | RoundedRectangle(cornerRadius: 8) 66 | .stroke(Color(.systemGray3), lineWidth: 0.5) 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Views/CaveatRow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CaveatRow: View { 4 | let text: String 5 | 6 | var body: some View { 7 | Text(text) 8 | .font(.body.bold()) 9 | .foregroundColor(.secondary) 10 | .frame(maxWidth: .infinity, alignment: .leading) 11 | .padding(.vertical) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Views/FiltePicker.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct FilterPicker: View { 5 | @WatchState(FilterAtom()) 6 | var filter 7 | 8 | var body: some View { 9 | Picker("Filter", selection: $filter) { 10 | ForEach(Filter.allCases, id: \.self) { filter in 11 | Text(filter.title) 12 | } 13 | } 14 | .pickerStyle(.segmented) 15 | .padding(.vertical) 16 | } 17 | } 18 | 19 | private extension Filter { 20 | var title: String { 21 | switch self { 22 | case .nowPlaying: 23 | return "Now" 24 | 25 | case .popular: 26 | return "Popular" 27 | 28 | case .topRated: 29 | return "Top" 30 | 31 | case .upcoming: 32 | return "Upcoming" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MovieRow.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct MovieRow: View { 5 | var movie: Movie 6 | var truncatesOverview = true 7 | 8 | var body: some View { 9 | HStack(alignment: .top, spacing: 12) { 10 | ZStack { 11 | if let path = movie.posterPath { 12 | NetworkImage(path: path, size: .medium) 13 | } 14 | } 15 | .frame(width: 100, height: 150) 16 | .background(Color(.systemGroupedBackground)) 17 | .cornerRadius(8) 18 | 19 | VStack(alignment: .leading) { 20 | HStack(alignment: .top) { 21 | PopularityBadge(voteAverage: movie.voteAverage) 22 | 23 | VStack(alignment: .leading) { 24 | Text(movie.title) 25 | .font(.headline.bold()) 26 | .foregroundColor(.accentColor) 27 | .lineLimit(2) 28 | 29 | if let releaseDate = movie.releaseDate { 30 | Text(Self.formatter.string(from: releaseDate)) 31 | .font(.caption) 32 | .foregroundColor(.secondary) 33 | .lineLimit(1) 34 | } 35 | } 36 | } 37 | 38 | if let overview = movie.overview { 39 | Text(overview) 40 | .font(.body) 41 | .foregroundColor(.primary) 42 | .lineLimit(truncatesOverview ? 4 : nil) 43 | } 44 | } 45 | 46 | Spacer(minLength: 0) 47 | } 48 | .padding(.vertical) 49 | } 50 | } 51 | 52 | private extension MovieRow { 53 | static private let formatter: DateFormatter = { 54 | let formatter = DateFormatter() 55 | formatter.dateStyle = .medium 56 | return formatter 57 | }() 58 | } 59 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MyListButtonLabel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MyListButtonLabel: View { 4 | let isOn: Bool 5 | 6 | var body: some View { 7 | VStack { 8 | Image(systemName: isOn ? "heart.fill" : "heart") 9 | .font(.title2) 10 | .foregroundStyle(.pink) 11 | 12 | Text("My List") 13 | .font(.system(.caption2)) 14 | .foregroundColor(.pink) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Views/MyMovieList.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct MyMovieList: View { 5 | @WatchStateObject(MyListAtom()) 6 | var myList 7 | 8 | var onSelect: (Movie) -> Void 9 | 10 | var body: some View { 11 | if myList.movies.isEmpty { 12 | emptyContent 13 | } 14 | else { 15 | ScrollView(.horizontal, showsIndicators: false) { 16 | LazyHStack { 17 | ForEach(myList.movies, id: \.id) { movie in 18 | item(movie: movie) 19 | } 20 | } 21 | .padding(.vertical) 22 | } 23 | } 24 | } 25 | 26 | var emptyContent: some View { 27 | HStack { 28 | Text("Tap") 29 | MyListButtonLabel(isOn: false) 30 | Text("to add movies here.") 31 | } 32 | .font(.body.bold()) 33 | .foregroundColor(.secondary) 34 | .frame(maxWidth: .infinity, alignment: .leading) 35 | .padding(.vertical) 36 | } 37 | 38 | func item(movie: Movie) -> some View { 39 | Button { 40 | onSelect(movie) 41 | } label: { 42 | ZStack { 43 | if let path = movie.posterPath { 44 | NetworkImage(path: path, size: .medium) 45 | } 46 | } 47 | .frame(width: 80, height: 120) 48 | .background(Color(.systemGroupedBackground)) 49 | .cornerRadius(8) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Views/NetworkImage.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct NetworkImage: View { 5 | let path: String 6 | let size: ImageSize 7 | 8 | @ViewContext 9 | var context 10 | 11 | var image: Task { 12 | context.watch(ImageAtom(path: path, size: size)) 13 | } 14 | 15 | var body: some View { 16 | Suspense(image) { uiImage in 17 | Image(uiImage: uiImage) 18 | .resizable() 19 | .aspectRatio(contentMode: .fill) 20 | .clipped() 21 | } suspending: { 22 | ProgressView() 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Views/PopularityBadge.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PopularityBadge: View { 4 | let voteAverage: Float 5 | 6 | var body: some View { 7 | ZStack { 8 | Circle() 9 | .foregroundColor(.primary) 10 | .overlay(overlay) 11 | 12 | Text(Int(score * 100).description + "%") 13 | .font(.caption2.bold()) 14 | .foregroundColor(.primary) 15 | .colorInvert() 16 | } 17 | .frame(width: 36, height: 36) 18 | } 19 | 20 | var overlay: some View { 21 | ZStack { 22 | Circle() 23 | .trim(from: 0, to: CGFloat(score)) 24 | .stroke(style: StrokeStyle(lineWidth: 2)) 25 | .foregroundColor(scoreColor) 26 | } 27 | .rotationEffect(.degrees(-90)) 28 | .padding(2) 29 | } 30 | } 31 | 32 | private extension PopularityBadge { 33 | var score: Float { 34 | voteAverage / 10 35 | } 36 | 37 | var scoreColor: Color { 38 | switch voteAverage { 39 | case ..<4: 40 | return .red 41 | 42 | case 4..<6: 43 | return .orange 44 | 45 | case 6..<7.5: 46 | return .yellow 47 | 48 | default: 49 | return .green 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleMovieDB/Views/ProgressRow.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ProgressRow: View { 4 | var body: some View { 5 | ProgressView() 6 | .frame(maxWidth: .infinity, idealHeight: 40) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/CommonAtoms.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Atoms 3 | import Combine 4 | import Foundation 5 | 6 | struct ValueGenerator: Sendable { 7 | var date: @Sendable () -> Date 8 | var uuid: @Sendable () -> UUID 9 | var temporaryDirectory: @Sendable () -> String 10 | } 11 | 12 | struct ValueGeneratorAtom: ValueAtom, Hashable { 13 | func value(context: Context) -> ValueGenerator { 14 | ValueGenerator( 15 | date: { Date.now }, 16 | uuid: { UUID() }, 17 | temporaryDirectory: { NSTemporaryDirectory() } 18 | ) 19 | } 20 | } 21 | 22 | struct TimerAtom: ValueAtom, Hashable { 23 | var startDate: Date 24 | var timeInterval: TimeInterval 25 | 26 | func value(context: Context) -> AnyPublisher { 27 | Timer.publish( 28 | every: timeInterval, 29 | tolerance: 0, 30 | on: .main, 31 | in: .common 32 | ) 33 | .autoconnect() 34 | .map { $0.timeIntervalSince(startDate) } 35 | .prepend(.zero) 36 | .eraseToAnyPublisher() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/VoiceMemoRowAtoms.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Atoms 3 | import Combine 4 | import Foundation 5 | import SwiftUI 6 | 7 | struct IsPlayingAtom: StateAtom { 8 | let voiceMemo: VoiceMemo 9 | 10 | var key: URL { 11 | voiceMemo.url 12 | } 13 | 14 | func defaultValue(context: Context) -> Bool { 15 | // Add the player atom as a depedency. 16 | context.watch(AudioPlayerAtom(voiceMemo: voiceMemo)) 17 | return false 18 | } 19 | 20 | func effect(context: CurrentContext) -> some AtomEffect { 21 | UpdateEffect { 22 | let audioPlayer = context.read(AudioPlayerAtom(voiceMemo: voiceMemo)) 23 | let isPlaying = context.read(self) 24 | 25 | guard isPlaying else { 26 | return audioPlayer.stop() 27 | } 28 | 29 | do { 30 | try audioPlayer.play(url: voiceMemo.url) 31 | } 32 | catch { 33 | context[IsPlaybackFailedAtom()] = true 34 | } 35 | } 36 | } 37 | } 38 | 39 | struct PlayingElapsedTimeAtom: PublisherAtom { 40 | let voiceMemo: VoiceMemo 41 | 42 | var key: URL { 43 | voiceMemo.url 44 | } 45 | 46 | func publisher(context: Context) -> AnyPublisher { 47 | let isPlaying = context.watch(IsPlayingAtom(voiceMemo: voiceMemo).changes) 48 | 49 | guard isPlaying else { 50 | return Just(.zero).eraseToAnyPublisher() 51 | } 52 | 53 | let startDate = context.watch(ValueGeneratorAtom()).date() 54 | return context.read(TimerAtom(startDate: startDate, timeInterval: 0.5)) 55 | } 56 | } 57 | 58 | struct AudioPlayerAtom: ValueAtom { 59 | let voiceMemo: VoiceMemo 60 | 61 | var key: URL { 62 | voiceMemo.url 63 | } 64 | 65 | func value(context: Context) -> any AudioPlayerProtocol { 66 | AudioPlayer( 67 | onFinish: { 68 | context[IsPlayingAtom(voiceMemo: voiceMemo)] = false 69 | }, 70 | onFail: { 71 | context[IsPlaybackFailedAtom()] = false 72 | context[IsPlayingAtom(voiceMemo: voiceMemo)] = false 73 | } 74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioPlayer.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | 3 | protocol AudioPlayerProtocol { 4 | func play(url: URL) throws 5 | func stop() 6 | } 7 | 8 | final class AudioPlayer: NSObject, AVAudioPlayerDelegate, AudioPlayerProtocol { 9 | private let onFinish: () -> Void 10 | private let onFail: () -> Void 11 | private var player: AVAudioPlayer? 12 | 13 | init(onFinish: @escaping () -> Void, onFail: @escaping () -> Void) { 14 | self.onFinish = onFinish 15 | self.onFail = onFail 16 | super.init() 17 | } 18 | 19 | func play(url: URL) throws { 20 | player = try AVAudioPlayer(contentsOf: url) 21 | player?.delegate = self 22 | player?.play() 23 | } 24 | 25 | func stop() { 26 | player?.stop() 27 | player = nil 28 | } 29 | 30 | func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 31 | if flag { 32 | onFinish() 33 | } 34 | else { 35 | onFail() 36 | } 37 | } 38 | 39 | func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: (any Error)?) { 40 | onFail() 41 | } 42 | } 43 | 44 | final class MockAudioPlayer: AudioPlayerProtocol, @unchecked Sendable { 45 | private(set) var isPlaying = false 46 | var playingError: (any Error)? 47 | 48 | func play(url: URL) throws { 49 | if let playingError = playingError { 50 | throw playingError 51 | } 52 | 53 | isPlaying = true 54 | } 55 | 56 | func stop() { 57 | isPlaying = false 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioRecorder.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | 3 | protocol AudioRecorderProtocol { 4 | var currentTime: TimeInterval { get } 5 | 6 | func record(url: URL) throws 7 | func stop() 8 | } 9 | 10 | final class AudioRecorder: NSObject, AVAudioRecorderDelegate, AudioRecorderProtocol, @unchecked Sendable { 11 | private var recorder: AVAudioRecorder? 12 | private let onFail: () -> Void 13 | 14 | init(onFail: @escaping () -> Void) { 15 | self.onFail = onFail 16 | super.init() 17 | } 18 | 19 | var currentTime: TimeInterval { 20 | recorder?.currentTime ?? .zero 21 | } 22 | 23 | func record(url: URL) throws { 24 | recorder = try AVAudioRecorder( 25 | url: url, 26 | settings: [ 27 | AVFormatIDKey: Int(kAudioFormatMPEG4AAC), 28 | AVSampleRateKey: 44100, 29 | AVNumberOfChannelsKey: 1, 30 | AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, 31 | ] 32 | ) 33 | recorder?.delegate = self 34 | recorder?.record() 35 | } 36 | 37 | func stop() { 38 | recorder?.stop() 39 | recorder = nil 40 | } 41 | 42 | func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { 43 | if !flag { 44 | onFail() 45 | } 46 | } 47 | 48 | func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: (any Error)?) { 49 | onFail() 50 | } 51 | } 52 | 53 | final class MockAudioRecorder: AudioRecorderProtocol, @unchecked Sendable { 54 | var isRecording = false 55 | var recordingError: (any Error)? 56 | var currentTime: TimeInterval = 10 57 | 58 | func record(url: URL) throws { 59 | if let recordingError = recordingError { 60 | throw recordingError 61 | } 62 | 63 | isRecording = true 64 | } 65 | func stop() { 66 | isRecording = false 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleVoiceMemo/Dependency/AudioSession.swift: -------------------------------------------------------------------------------- 1 | import AVFAudio 2 | 3 | protocol AudioSessionProtocol { 4 | var recordPermission: AVAudioSession.RecordPermission { get } 5 | 6 | func requestRecordPermissionOnMain(_ response: @escaping @MainActor @Sendable (Bool) -> Void) 7 | func setActive(_ active: Bool, options: AVAudioSession.SetActiveOptions) throws 8 | func setCategory(_ category: AVAudioSession.Category, mode: AVAudioSession.Mode, options: AVAudioSession.CategoryOptions) throws 9 | } 10 | 11 | extension AVAudioSession: AudioSessionProtocol { 12 | func requestRecordPermissionOnMain(_ response: @escaping @MainActor @Sendable (Bool) -> Void) { 13 | requestRecordPermission { isGranted in 14 | Task { @MainActor in 15 | response(isGranted) 16 | } 17 | } 18 | } 19 | } 20 | 21 | final class MockAudioSession: AudioSessionProtocol, @unchecked Sendable { 22 | var requestRecordPermissionResponse: (@MainActor @Sendable (Bool) -> Void)? 23 | var isActive = false 24 | var currentCategory: AVAudioSession.Category? 25 | var currentMode: AVAudioSession.Mode? 26 | var currentOptions: AVAudioSession.CategoryOptions? 27 | var recordPermission = AVAudioSession.RecordPermission.granted 28 | 29 | func requestRecordPermissionOnMain(_ response: @escaping @MainActor @Sendable (Bool) -> Void) { 30 | requestRecordPermissionResponse = response 31 | } 32 | 33 | func setActive(_ active: Bool, options: AVAudioSession.SetActiveOptions) throws { 34 | isActive = active 35 | } 36 | 37 | func setCategory( 38 | _ category: AVAudioSession.Category, 39 | mode: AVAudioSession.Mode, 40 | options: AVAudioSession.CategoryOptions 41 | ) throws { 42 | currentCategory = category 43 | currentMode = mode 44 | currentOptions = options 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleVoiceMemo/Effects/RecordingEffect.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Atoms 3 | 4 | final class RecordingEffect: AtomEffect { 5 | private var currentData: RecordingData? 6 | 7 | func updated(context: Context) { 8 | let audioSession = context.read(AudioSessionAtom()) 9 | let audioRecorder = context.read(AudioRecorderAtom()) 10 | let data = context.read(RecordingDataAtom()) 11 | 12 | if let currentData { 13 | let voiceMemo = VoiceMemo( 14 | url: currentData.url, 15 | date: currentData.date, 16 | duration: audioRecorder.currentTime 17 | ) 18 | 19 | context[VoiceMemosAtom()].insert(voiceMemo, at: 0) 20 | audioRecorder.stop() 21 | try? audioSession.setActive(false, options: []) 22 | } 23 | 24 | currentData = data 25 | 26 | if let data { 27 | do { 28 | try audioSession.setCategory(.playAndRecord, mode: .default, options: .defaultToSpeaker) 29 | try audioSession.setActive(true, options: []) 30 | try audioRecorder.record(url: data.url) 31 | } 32 | catch { 33 | context[IsRecordingFailedAtom()] = true 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleVoiceMemo/ExampleVoiceMemo.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // swift-format-ignore: AllPublicDeclarationsHaveDocumentation 4 | public struct ExampleVoiceMemo: View { 5 | public init() {} 6 | 7 | public var body: some View { 8 | VoiceMemoListScreen() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleVoiceMemo/Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let dateComponentsFormatter: DateComponentsFormatter = { 4 | let formatter = DateComponentsFormatter() 5 | formatter.allowedUnits = [.minute, .second] 6 | formatter.zeroFormattingBehavior = .pad 7 | return formatter 8 | }() 9 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/ExampleVoiceMemo/VoiceMemoRow.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import SwiftUI 3 | 4 | struct VoiceMemoRow: View { 5 | @Binding 6 | var voiceMemo: VoiceMemo 7 | 8 | @ViewContext 9 | var context 10 | 11 | var isPlaying: Binding { 12 | context.binding(IsPlayingAtom(voiceMemo: voiceMemo)) 13 | } 14 | 15 | var elapsedTime: TimeInterval { 16 | context.watch(PlayingElapsedTimeAtom(voiceMemo: voiceMemo)).value ?? .zero 17 | } 18 | 19 | var progress: Double { 20 | max(0, min(1, elapsedTime / voiceMemo.duration)) 21 | } 22 | 23 | var body: some View { 24 | GeometryReader { proxy in 25 | ZStack(alignment: .leading) { 26 | if isPlaying.wrappedValue { 27 | Rectangle() 28 | .foregroundColor(Color(.systemGray5)) 29 | .frame(width: proxy.size.width * CGFloat(progress)) 30 | .animation(.linear(duration: 0.5), value: progress) 31 | } 32 | 33 | HStack { 34 | TextField( 35 | "Untitled, \(voiceMemo.date.formatted(date: .numeric, time: .shortened))", 36 | text: $voiceMemo.title 37 | ) 38 | 39 | Spacer() 40 | 41 | if let time = dateComponentsFormatter.string(from: isPlaying.wrappedValue ? elapsedTime : voiceMemo.duration) { 42 | Text(time) 43 | .font(.footnote.monospacedDigit()) 44 | .foregroundColor(Color(.systemGray)) 45 | } 46 | 47 | Button { 48 | isPlaying.wrappedValue.toggle() 49 | } label: { 50 | Image(systemName: isPlaying.wrappedValue ? "stop.circle" : "play.circle") 51 | .font(Font.system(size: 22)) 52 | } 53 | } 54 | .frame(maxHeight: .infinity) 55 | .padding([.leading, .trailing]) 56 | } 57 | } 58 | .buttonStyle(.borderless) 59 | .listRowInsets(EdgeInsets()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Sources/iOSApp/iOSRoot.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import ExampleCounter 3 | import ExampleMap 4 | import ExampleMovieDB 5 | import ExampleTimeTravel 6 | import ExampleTodo 7 | import ExampleVoiceMemo 8 | import SwiftUI 9 | 10 | // swift-format-ignore: AllPublicDeclarationsHaveDocumentation 11 | public struct iOSRoot: View { 12 | public init() {} 13 | 14 | public var body: some View { 15 | AtomRoot { 16 | NavigationStack { 17 | List { 18 | NavigationLink("🔢 Counter") { 19 | ExampleCounter() 20 | } 21 | 22 | NavigationLink("📋 Todo") { 23 | ExampleTodo() 24 | } 25 | 26 | NavigationLink("🎞 The Movie Database") { 27 | ExampleMovieDB() 28 | } 29 | 30 | NavigationLink("🗺 Map") { 31 | ExampleMap() 32 | } 33 | 34 | NavigationLink("🎙️ Voice Memo") { 35 | ExampleVoiceMemo() 36 | } 37 | 38 | NavigationLink("⏳ Time Travel") { 39 | ExampleTimeTravel() 40 | } 41 | } 42 | .navigationTitle("Examples") 43 | .listStyle(.insetGrouped) 44 | } 45 | } 46 | .observe { snapshot in 47 | print(snapshot.graphDescription()) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Tests/ExampleMapTests/ExampleMapTests.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import CoreLocation 3 | import XCTest 4 | 5 | @testable import ExampleMap 6 | 7 | final class ExampleMapTests: XCTestCase { 8 | @MainActor 9 | func testLocationObserverAtom() { 10 | let atom = LocationObserverAtom() 11 | let context = AtomTestContext() 12 | let manager = MockLocationManager() 13 | 14 | context.override(atom) { _ in 15 | LocationObserver(manager: manager) 16 | } 17 | 18 | context.watch(atom) 19 | 20 | XCTAssertNotNil(manager.delegate) 21 | XCTAssertTrue(manager.isUpdatingLocation) 22 | 23 | context.unwatch(atom) 24 | 25 | XCTAssertFalse(manager.isUpdatingLocation) 26 | } 27 | 28 | @MainActor 29 | func testCoordinateAtom() { 30 | let atom = CoordinateAtom() 31 | let context = AtomTestContext() 32 | let manager = MockLocationManager() 33 | 34 | context.override(LocationObserverAtom()) { _ in 35 | LocationObserver(manager: manager) 36 | } 37 | 38 | manager.location = CLLocation(latitude: 1, longitude: 2) 39 | 40 | XCTAssertEqual(context.watch(atom)?.latitude, 1) 41 | XCTAssertEqual(context.watch(atom)?.longitude, 2) 42 | } 43 | 44 | @MainActor 45 | func testAuthorizationStatusAtom() async { 46 | let atom = AuthorizationStatusAtom() 47 | let manager = MockLocationManager() 48 | let context = AtomTestContext() 49 | let observer = LocationObserver(manager: manager) 50 | 51 | context.override(LocationObserverAtom()) { _ in 52 | observer 53 | } 54 | 55 | manager.authorizationStatus = .authorizedWhenInUse 56 | 57 | XCTAssertEqual(context.watch(atom), .authorizedWhenInUse) 58 | 59 | manager.authorizationStatus = .authorizedAlways 60 | 61 | Task { 62 | observer.objectWillChange.send() 63 | } 64 | 65 | await context.waitForUpdate() 66 | 67 | XCTAssertEqual(context.watch(atom), .authorizedAlways) 68 | 69 | observer.objectWillChange.send() 70 | let didUpdate = await context.waitForUpdate(timeout: 0.1) 71 | 72 | // Should not update if authorizationStatus is not changed. 73 | XCTAssertFalse(didUpdate) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Examples/Packages/iOS/Tests/ExampleTimeTravelTests/ExampleTimeTravelTests.swift: -------------------------------------------------------------------------------- 1 | import Atoms 2 | import XCTest 3 | 4 | @testable import ExampleTimeTravel 5 | 6 | final class ExampleTimeTravelTests: XCTestCase { 7 | @MainActor 8 | func testTextAtom() { 9 | let context = AtomTestContext() 10 | let atom = InputStateAtom() 11 | 12 | XCTAssertEqual(context.watch(atom), InputState()) 13 | 14 | context[atom].text = "modified" 15 | context[atom].latestInput = 1 16 | 17 | XCTAssertEqual(context.watch(atom), InputState(text: "modified", latestInput: 1)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Examples/Project.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/Project.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/project.yml: -------------------------------------------------------------------------------- 1 | name: Project 2 | 3 | options: 4 | deploymentTarget: 5 | iOS: 16.0 6 | macOS: 13.0 7 | tvOS: 16.0 8 | bundleIdPrefix: com.ryo.swiftui-atom-properties.examples 9 | createIntermediateGroups: true 10 | 11 | settings: 12 | CODE_SIGNING_REQUIRED: NO 13 | CODE_SIGN_IDENTITY: "-" 14 | CODE_SIGN_STYLE: Manual 15 | SWIFT_VERSION: 6 16 | 17 | targetTemplates: 18 | App: 19 | type: application 20 | info: 21 | path: App/Info-${target_name}.plist 22 | properties: 23 | UILaunchScreen: 24 | UIRequiresFullScreen: true 25 | CFBundleDisplayName: Atoms 26 | NSLocationWhenInUseUsageDescription: Example Usage 27 | NSMicrophoneUsageDescription: Example Usage 28 | UISupportedInterfaceOrientations: 29 | - UIInterfaceOrientationPortrait 30 | 31 | packages: 32 | iOSApp: 33 | path: Packages/iOS 34 | 35 | CrossPlatformApp: 36 | path: Packages/CrossPlatform 37 | 38 | targets: 39 | iOS: 40 | templates: 41 | - App 42 | platform: iOS 43 | dependencies: 44 | - package: iOSApp 45 | sources: 46 | - App/iOS.swift 47 | 48 | CrossPlatform: 49 | templates: 50 | - App 51 | platform: 52 | - macOS 53 | - tvOS 54 | dependencies: 55 | - package: CrossPlatformApp 56 | sources: 57 | - App/CrossPlatform.swift 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Ryo Aoyama 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TOOL = scripts/swift-run.sh 2 | PACKAGE = swift package -c release --package-path Tools 3 | SWIFT_FILE_PATHS = Package.swift Tools/Package.swift Sources Tests Examples Benchmarks 4 | XCODEGEN = SWIFT_PACKAGE_RESOURCES=Tools/.build/checkouts/XcodeGen/SettingPresets $(TOOL) xcodegen 5 | SWIFTFORMAT = $(TOOL) swift-format 6 | 7 | .PHONY: proj 8 | proj: 9 | $(XCODEGEN) -s Examples/project.yml 10 | $(XCODEGEN) -s Benchmarks/project.yml 11 | 12 | .PHONY: format 13 | format: 14 | $(SWIFTFORMAT) format -i -p -r $(SWIFT_FILE_PATHS) 15 | 16 | .PHONY: lint 17 | lint: 18 | $(SWIFTFORMAT) lint -s -p -r $(SWIFT_FILE_PATHS) 19 | 20 | .PHONY: docs 21 | docs: 22 | $(PACKAGE) \ 23 | --allow-writing-to-directory docs \ 24 | generate-documentation \ 25 | --include-extended-types \ 26 | --experimental-skip-synthesized-symbols \ 27 | --product Atoms \ 28 | --disable-indexing \ 29 | --transform-for-static-hosting \ 30 | --hosting-base-path swiftui-atom-properties \ 31 | --output-path docs 32 | 33 | .PHONY: docs-preview 34 | docs-preview: 35 | $(PACKAGE) \ 36 | --disable-sandbox \ 37 | preview-documentation \ 38 | --include-extended-types \ 39 | --experimental-skip-synthesized-symbols \ 40 | --product Atoms 41 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let swiftSettings: [SwiftSetting] 6 | 7 | if Context.environment["ENABLE_UPCOMING_FEATURES"] == "1" { 8 | swiftSettings = [ 9 | .enableUpcomingFeature("DisableOutwardActorInference"), 10 | .enableUpcomingFeature("InferSendableFromCaptures"), 11 | .enableUpcomingFeature("IsolatedDefaultValues"), 12 | .enableUpcomingFeature("StrictConcurrency"), 13 | .enableUpcomingFeature("ExistentialAny"), 14 | ] 15 | } 16 | else { 17 | swiftSettings = [ 18 | .enableUpcomingFeature("ExistentialAny") 19 | ] 20 | } 21 | 22 | let package = Package( 23 | name: "swiftui-atom-properties", 24 | platforms: [ 25 | .iOS(.v16), 26 | .macOS(.v13), 27 | .tvOS(.v16), 28 | .watchOS(.v9), 29 | ], 30 | products: [ 31 | .library(name: "Atoms", targets: ["Atoms"]) 32 | ], 33 | targets: [ 34 | .target( 35 | name: "Atoms", 36 | swiftSettings: swiftSettings 37 | ), 38 | .testTarget( 39 | name: "AtomsTests", 40 | dependencies: ["Atoms"], 41 | swiftSettings: swiftSettings 42 | ), 43 | ], 44 | swiftLanguageModes: [.v5, .v6] 45 | ) 46 | -------------------------------------------------------------------------------- /Sources/Atoms/Atom/AsyncPhaseAtom.swift: -------------------------------------------------------------------------------- 1 | /// An atom that provides an ``AsyncPhase`` value from the asynchronous throwable function. 2 | /// 3 | /// The value produced by the given asynchronous throwable function will be converted into 4 | /// an enum representation ``AsyncPhase`` that changes when the process is done or thrown an error. 5 | /// 6 | /// ## Output Value 7 | /// 8 | /// ``AsyncPhase`` 9 | /// 10 | /// ## Example 11 | /// 12 | /// ```swift 13 | /// struct AsyncTextAtom: AsyncPhaseAtom, Hashable { 14 | /// func value(context: Context) async throws -> String { 15 | /// try await Task.sleep(nanoseconds: 1_000_000_000) 16 | /// return "Swift" 17 | /// } 18 | /// } 19 | /// 20 | /// struct DelayedTitleView: View { 21 | /// @Watch(AsyncTextAtom()) 22 | /// var text 23 | /// 24 | /// var body: some View { 25 | /// switch text { 26 | /// case .success(let text): 27 | /// Text(text) 28 | /// 29 | /// case .suspending: 30 | /// Text("Loading") 31 | /// 32 | /// case .failure: 33 | /// Text("Failed") 34 | /// } 35 | /// } 36 | /// ``` 37 | /// 38 | public protocol AsyncPhaseAtom: AsyncAtom where Produced == AsyncPhase { 39 | /// The type of success value that this atom produces. 40 | associatedtype Success 41 | 42 | /// The type of errors that this atom produces. 43 | associatedtype Failure: Error 44 | 45 | /// Asynchronously produces a value to be provided via this atom. 46 | /// 47 | /// Values provided or errors thrown by this method are converted to the unified enum 48 | /// representation ``AsyncPhase``. 49 | /// 50 | /// - Parameter context: A context structure to read, watch, and otherwise 51 | /// interact with other atoms. 52 | /// 53 | /// - Throws: The error that occurred during the process of creating the resulting value. 54 | /// 55 | /// - Returns: The process's result. 56 | @MainActor 57 | func value(context: Context) async throws(Failure) -> Success 58 | } 59 | 60 | public extension AsyncPhaseAtom { 61 | var producer: AtomProducer { 62 | AtomProducer { context in 63 | let task = Task { 64 | do throws(Failure) { 65 | let value = try await context.transaction(value) 66 | 67 | if !Task.isCancelled { 68 | context.update(with: .success(value)) 69 | } 70 | } 71 | catch { 72 | if !Task.isCancelled { 73 | context.update(with: .failure(error)) 74 | } 75 | } 76 | } 77 | 78 | context.onTermination = task.cancel 79 | return .suspending 80 | } 81 | } 82 | 83 | var refreshProducer: AtomRefreshProducer { 84 | AtomRefreshProducer { context in 85 | var phase = Produced.suspending 86 | 87 | let task = Task { 88 | do throws(Failure) { 89 | let value = try await context.transaction(value) 90 | 91 | if !Task.isCancelled { 92 | phase = .success(value) 93 | } 94 | } 95 | catch { 96 | if !Task.isCancelled { 97 | phase = .failure(error) 98 | } 99 | } 100 | } 101 | 102 | context.onTermination = task.cancel 103 | 104 | return await withTaskCancellationHandler { 105 | await task.value 106 | return phase 107 | } onCancel: { 108 | task.cancel() 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/Atoms/Atom/StateAtom.swift: -------------------------------------------------------------------------------- 1 | /// An atom type that provides a read-write state value. 2 | /// 3 | /// This atom provides a mutable state value that can be accessed from anywhere, and it notifies changes 4 | /// to downstream atoms and views. 5 | /// 6 | /// ## Output Value 7 | /// 8 | /// Self.Value 9 | /// 10 | /// ## Example 11 | /// 12 | /// ```swift 13 | /// struct CounterAtom: StateAtom, Hashable { 14 | /// func defaultValue(context: Context) -> Int { 15 | /// 0 16 | /// } 17 | /// } 18 | /// 19 | /// struct CounterView: View { 20 | /// @WatchState(CounterAtom()) 21 | /// var count 22 | /// 23 | /// var body: some View { 24 | /// Stepper("Count: \(count)", value: $count) 25 | /// } 26 | /// } 27 | /// ``` 28 | /// 29 | public protocol StateAtom: Atom { 30 | /// The type of value that this atom produces. 31 | associatedtype Value 32 | 33 | /// Creates a default value of the state to be provided via this atom. 34 | /// 35 | /// The value returned from this method will be the default state value. When this atom is reset, 36 | /// the state will revert to this value. 37 | /// 38 | /// - Parameter context: A context structure to read, watch, and otherwise 39 | /// interact with other atoms. 40 | /// 41 | /// - Returns: A default value of state. 42 | @MainActor 43 | func defaultValue(context: Context) -> Value 44 | } 45 | 46 | public extension StateAtom { 47 | var producer: AtomProducer { 48 | AtomProducer { context in 49 | context.transaction(defaultValue) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Atoms/Atom/TaskAtom.swift: -------------------------------------------------------------------------------- 1 | /// An atom type that provides a nonthrowing `Task` from the given asynchronous function. 2 | /// 3 | /// This atom guarantees that the task to be identical instance and its state can be shared 4 | /// at anywhere even when they are accessed simultaneously from multiple locations. 5 | /// 6 | /// - SeeAlso: ``ThrowingTaskAtom`` 7 | /// - SeeAlso: ``Suspense`` 8 | /// 9 | /// ## Output Value 10 | /// 11 | /// Task 12 | /// 13 | /// ## Example 14 | /// 15 | /// ```swift 16 | /// struct AsyncTextAtom: TaskAtom, Hashable { 17 | /// func value(context: Context) async -> String { 18 | /// try? await Task.sleep(nanoseconds: 1_000_000_000) 19 | /// return "Swift" 20 | /// } 21 | /// } 22 | /// 23 | /// struct DelayedTitleView: View { 24 | /// @Watch(AsyncTextAtom()) 25 | /// var text 26 | /// 27 | /// var body: some View { 28 | /// Suspense(text) { text in 29 | /// Text(text) 30 | /// } suspending: { 31 | /// Text("Loading...") 32 | /// } 33 | /// } 34 | /// } 35 | /// ``` 36 | /// 37 | public protocol TaskAtom: AsyncAtom where Produced == Task { 38 | /// The type of success value that this atom produces. 39 | associatedtype Success: Sendable 40 | 41 | /// Asynchronously produces a value to be provided via this atom. 42 | /// 43 | /// This asynchronous method is converted to a `Task` internally, and if it will be 44 | /// cancelled by downstream atoms or views, this method will also be cancelled. 45 | /// 46 | /// - Parameter context: A context structure to read, watch, and otherwise 47 | /// interact with other atoms. 48 | /// 49 | /// - Returns: The process's result. 50 | @MainActor 51 | func value(context: Context) async -> Success 52 | } 53 | 54 | public extension TaskAtom { 55 | var producer: AtomProducer { 56 | AtomProducer { context in 57 | Task { 58 | await context.transaction(value) 59 | } 60 | } manageValue: { task, context in 61 | context.onTermination = task.cancel 62 | } 63 | } 64 | 65 | var refreshProducer: AtomRefreshProducer { 66 | AtomRefreshProducer { context in 67 | Task { 68 | await context.transaction(value) 69 | } 70 | } refreshValue: { task, context in 71 | context.onTermination = task.cancel 72 | 73 | await withTaskCancellationHandler { 74 | _ = await task.result 75 | } onCancel: { 76 | task.cancel() 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Atoms/Atom/ThrowingTaskAtom.swift: -------------------------------------------------------------------------------- 1 | /// An atom type that provides a throwing `Task` from the given asynchronous, throwing function. 2 | /// 3 | /// This atom guarantees that the task to be identical instance and its state can be shared 4 | /// at anywhere even when they are accessed simultaneously from multiple locations. 5 | /// 6 | /// - SeeAlso: ``TaskAtom`` 7 | /// - SeeAlso: ``Suspense`` 8 | /// 9 | /// ## Output Value 10 | /// 11 | /// Task 12 | /// 13 | /// ## Example 14 | /// 15 | /// ```swift 16 | /// struct AsyncTextAtom: ThrowingTaskAtom, Hashable { 17 | /// func value(context: Context) async throws -> String { 18 | /// try await Task.sleep(nanoseconds: 1_000_000_000) 19 | /// return "Swift" 20 | /// } 21 | /// } 22 | /// 23 | /// struct DelayedTitleView: View { 24 | /// @Watch(AsyncTextAtom()) 25 | /// var text 26 | /// 27 | /// var body: some View { 28 | /// Suspense(text) { text in 29 | /// Text(text) 30 | /// } suspending: { 31 | /// Text("Loading") 32 | /// } catch: { 33 | /// Text("Failed") 34 | /// } 35 | /// } 36 | /// } 37 | /// ``` 38 | /// 39 | public protocol ThrowingTaskAtom: AsyncAtom where Produced == Task { 40 | /// The type of success value that this atom produces. 41 | associatedtype Success: Sendable 42 | 43 | /// Asynchronously produces a value to be provided via this atom. 44 | /// 45 | /// This asynchronous method is converted to a `Task` internally, and if it will be 46 | /// cancelled by downstream atoms or views, this method will also be cancelled. 47 | /// 48 | /// - Parameter context: A context structure to read, watch, and otherwise 49 | /// interact with other atoms. 50 | /// 51 | /// - Throws: The error that occurred during the process of creating the resulting value. 52 | /// 53 | /// - Returns: The process's result. 54 | @MainActor 55 | func value(context: Context) async throws -> Success 56 | } 57 | 58 | public extension ThrowingTaskAtom { 59 | var producer: AtomProducer { 60 | AtomProducer { context in 61 | Task { 62 | try await context.transaction(value) 63 | } 64 | } manageValue: { task, context in 65 | context.onTermination = task.cancel 66 | } 67 | } 68 | 69 | var refreshProducer: AtomRefreshProducer { 70 | AtomRefreshProducer { context in 71 | Task { 72 | try await context.transaction(value) 73 | } 74 | } refreshValue: { task, context in 75 | context.onTermination = task.cancel 76 | 77 | await withTaskCancellationHandler { 78 | _ = await task.result 79 | } onCancel: { 80 | task.cancel() 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/Atoms/Atom/ValueAtom.swift: -------------------------------------------------------------------------------- 1 | /// An atom type that provides a read-only value. 2 | /// 3 | /// The value is cached until it will no longer be watched or any of watching atoms will notify update. 4 | /// This atom can be used to combine one or more other atoms and transform result to another value. 5 | /// Moreover, it can also be used to do dependency injection in compile safe and overridable for testing, 6 | /// by providing a dependency instance required in another atom. 7 | /// 8 | /// ## Output Value 9 | /// 10 | /// Self.Value 11 | /// 12 | /// ## Example 13 | /// 14 | /// ```swift 15 | /// struct CharacterCountAtom: ValueAtom, Hashable { 16 | /// func value(context: Context) -> Int { 17 | /// let text = context.watch(TextAtom()) 18 | /// return text.count 19 | /// } 20 | /// } 21 | /// 22 | /// struct CharacterCountView: View { 23 | /// @Watch(CharacterCountAtom()) 24 | /// var count 25 | /// 26 | /// var body: some View { 27 | /// Text("Character count: \(count)") 28 | /// } 29 | /// } 30 | /// ``` 31 | /// 32 | public protocol ValueAtom: Atom { 33 | /// The type of value that this atom produces. 34 | associatedtype Value 35 | 36 | /// Creates a constant value to be provided via this atom. 37 | /// 38 | /// This method is called only when this atom is actually used, and is cached until it will 39 | /// no longer be watched or any of watching atoms will be updated. 40 | /// 41 | /// - Parameter context: A context structure to read, watch, and otherwise 42 | /// interact with other atoms. 43 | /// 44 | /// - Returns: A constant value. 45 | @MainActor 46 | func value(context: Context) -> Value 47 | } 48 | 49 | public extension ValueAtom { 50 | var producer: AtomProducer { 51 | AtomProducer { context in 52 | context.transaction(value) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/Atoms/AtomDerivedScope.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view that derives the parent context. 4 | /// 5 | /// Sometimes SwiftUI fails to propagate environment values in the view tree for some reason. 6 | /// This is a critical problem because the centralized state store of atoms is propagated through 7 | /// a view hierarchy via environment values. 8 | /// The typical example is that, in case you use SwiftUI view inside UIKit view, it could fail as 9 | /// SwiftUI can't pass environment values to UIKit across boundaries. 10 | /// In that case, you can wrap the view with ``AtomDerivedScope`` and pass a view context to it so that 11 | /// the descendant views can explicitly propagate the atom store. 12 | /// 13 | /// ```swift 14 | /// @ViewContext 15 | /// var context 16 | /// 17 | /// var body: some View { 18 | /// MyUIViewWrappingView { 19 | /// AtomDerivedScope(context) { 20 | /// MySwiftUIView() 21 | /// } 22 | /// } 23 | /// } 24 | /// ``` 25 | /// 26 | public struct AtomDerivedScope: View { 27 | private let store: StoreContext 28 | private let content: Content 29 | 30 | /// Creates a derived scope with the specified content that will be allowed to use atoms by 31 | /// passing a view context to explicitly make the descendant views propagate the atom store. 32 | /// 33 | /// - Parameters: 34 | /// - context: The parent view context that provides the atom store. 35 | /// - content: The descendant view content. 36 | public init( 37 | _ context: AtomViewContext, 38 | @ViewBuilder content: () -> Content 39 | ) { 40 | self.store = context._store 41 | self.content = content() 42 | } 43 | 44 | /// The content and behavior of the view. 45 | public var body: some View { 46 | content.environment(\.store, store) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Atoms/AtomStore.swift: -------------------------------------------------------------------------------- 1 | /// An object that stores the state of atoms and its dependency graph. 2 | @MainActor 3 | public final class AtomStore { 4 | internal var dependencies = [AtomKey: Set]() 5 | internal var children = [AtomKey: Set]() 6 | internal var caches = [AtomKey: any AtomCacheProtocol]() 7 | internal var states = [AtomKey: any AtomStateProtocol]() 8 | internal var subscriptions = [AtomKey: [SubscriberKey: Subscription]]() 9 | internal var subscribes = [SubscriberKey: Set]() 10 | internal var scopes = [ScopeKey: Scope]() 11 | 12 | /// Creates a new store. 13 | public nonisolated init() {} 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Atoms/Atoms.docc/Atoms.md: -------------------------------------------------------------------------------- 1 | # ``Atoms`` 2 | 3 | Atomic approach state management and dependency injection for SwiftUI 4 | 5 | ## Additional Resources 6 | 7 | - [GitHub Repo](https://github.com/ra1028/swiftui-atom-properties) 8 | 9 | ## Overview 10 | 11 | Atoms offer a simple but practical capability to tackle the complexity of modern apps. It effectively integrates the solution for both state management and dependency injection while allowing us to rapidly build a robust and testable application. 12 | 13 | Building state by compositing atoms automatically optimizes rendering based on its dependency graph. This solves the problem of performance degradation caused by extra re-render which occurs before you realize. 14 | 15 | ## Topics 16 | 17 | ### Atoms 18 | 19 | - ``ValueAtom`` 20 | - ``StateAtom`` 21 | - ``TaskAtom`` 22 | - ``ThrowingTaskAtom`` 23 | - ``AsyncPhaseAtom`` 24 | - ``AsyncSequenceAtom`` 25 | - ``PublisherAtom`` 26 | - ``ObservableObjectAtom`` 27 | - ``ModifiedAtom`` 28 | 29 | ### Modifiers 30 | 31 | - ``Atom/changes`` 32 | - ``Atom/changes(of:)`` 33 | - ``Atom/animation(_:)`` 34 | - ``TaskAtom/phase`` 35 | - ``ThrowingTaskAtom/phase`` 36 | 37 | ### Effects 38 | 39 | - ``AtomEffect`` 40 | - ``AtomEffectBuilder`` 41 | - ``InitializingEffect`` 42 | - ``InitializeEffect`` 43 | - ``UpdateEffect`` 44 | - ``ReleaseEffect`` 45 | 46 | ### Attributes 47 | 48 | - ``Scoped`` 49 | - ``KeepAlive`` 50 | 51 | ### Property Wrappers 52 | 53 | - ``Watch`` 54 | - ``WatchState`` 55 | - ``WatchStateObject`` 56 | - ``ViewContext`` 57 | 58 | ### Views 59 | 60 | - ``AtomRoot`` 61 | - ``AtomScope`` 62 | - ``AtomDerivedScope`` 63 | - ``Suspense`` 64 | 65 | ### Values 66 | 67 | - ``AsyncPhase`` 68 | - ``Snapshot`` 69 | - ``DefaultScopeID`` 70 | 71 | ### Contexts 72 | 73 | - ``AtomContext`` 74 | - ``AtomWatchableContext`` 75 | - ``AtomTransactionContext`` 76 | - ``AtomViewContext`` 77 | - ``AtomTestContext`` 78 | - ``AtomCurrentContext`` 79 | 80 | ### Misc 81 | 82 | - ``Atom`` 83 | - ``AsyncAtom`` 84 | - ``AtomStore`` 85 | - ``AtomModifier`` 86 | - ``AsyncAtomModifier`` 87 | - ``ChangesModifier`` 88 | - ``ChangesOfModifier`` 89 | - ``TaskPhaseModifier`` 90 | - ``AnimationModifier`` 91 | - ``AtomProducer`` 92 | - ``AtomRefreshProducer`` 93 | 94 | ### Deprecated 95 | 96 | - ``Refreshable`` 97 | - ``Resettable`` 98 | - ``EmptyEffect`` 99 | - ``MergedEffect`` 100 | -------------------------------------------------------------------------------- /Sources/Atoms/Attribute/KeepAlive.swift: -------------------------------------------------------------------------------- 1 | /// An attribute protocol to allow the value of an atom to continue being retained 2 | /// even after they are no longer watched. 3 | /// 4 | /// ## Note 5 | /// 6 | /// Atoms that conform to this attribute and are either scoped using the ``Scoped`` attribute 7 | /// or overridden via ``AtomScope/scopedOverride(_:with:)-5jen3`` are retained until their scope 8 | /// is dismantled from the view tree, after which they are released. 9 | /// 10 | /// ## Example 11 | /// 12 | /// ```swift 13 | /// struct SharedPollingServiceAtom: ValueAtom, KeepAlive, Hashable { 14 | /// func value(context: Context) -> PollingService { 15 | /// PollingService() 16 | /// } 17 | /// } 18 | /// ``` 19 | /// 20 | public protocol KeepAlive where Self: Atom {} 21 | -------------------------------------------------------------------------------- /Sources/Atoms/Attribute/Refreshable.swift: -------------------------------------------------------------------------------- 1 | /// An attribute protocol that allows an atom to have a custom refresh behavior. 2 | /// 3 | /// It is useful when creating a wrapper atom and you want to transparently refresh the atom underneath. 4 | /// Note that the custom refresh will not be triggered when the atom is overridden. 5 | /// 6 | /// ```swift 7 | /// struct UserAtom: ValueAtom, Refreshable, Hashable { 8 | /// func value(context: Context) -> AsyncPhase { 9 | /// context.watch(FetchUserAtom().phase) 10 | /// } 11 | /// 12 | /// func refresh(context: CurrentContext) async -> AsyncPhase { 13 | /// await context.refresh(FetchUserAtom().phase) 14 | /// } 15 | /// } 16 | /// 17 | /// private struct FetchUserAtom: TaskAtom, Hashable { 18 | /// func value(context: Context) async -> User? { 19 | /// await fetchUser() 20 | /// } 21 | /// } 22 | /// ``` 23 | /// 24 | @available(*, deprecated, message: "`Refreshable` is deprecated. Use a custom refresh function or other alternatives instead.") 25 | public protocol Refreshable where Self: Atom { 26 | /// Refreshes and then return a result value. 27 | /// 28 | /// The value returned by this method will be cached as a new value when 29 | /// this atom is refreshed. 30 | /// 31 | /// - Parameter context: A context structure to read, set, and otherwise interact 32 | /// with other atoms. 33 | /// 34 | /// - Returns: A refreshed value. 35 | @MainActor 36 | func refresh(context: CurrentContext) async -> Produced 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Atoms/Attribute/Resettable.swift: -------------------------------------------------------------------------------- 1 | /// An attribute protocol that allows an atom to have a custom reset behavior. 2 | /// 3 | /// It is useful when creating a wrapper atom and you want to transparently reset the atom underneath. 4 | /// Note that the custom reset will not be triggered when the atom is overridden. 5 | /// 6 | /// ```swift 7 | /// struct UserAtom: ValueAtom, Resettable, Hashable { 8 | /// func value(context: Context) -> AsyncPhase { 9 | /// context.watch(FetchUserAtom().phase) 10 | /// } 11 | /// 12 | /// func reset(context: CurrentContext) { 13 | /// context.reset(FetchUserAtom()) 14 | /// } 15 | /// } 16 | /// 17 | /// private struct FetchUserAtom: TaskAtom, Hashable { 18 | /// func value(context: Context) async -> User? { 19 | /// await fetchUser() 20 | /// } 21 | /// } 22 | /// ``` 23 | /// 24 | @available(*, deprecated, message: "`Resettable` is deprecated. Use a custom reset function or other alternatives instead.") 25 | public protocol Resettable where Self: Atom { 26 | /// Arbitrary reset method to be executed on atom reset. 27 | /// 28 | /// This is arbitrary custom reset method that replaces regular atom reset functionality. 29 | /// 30 | /// - Parameter context: A context structure to read, set, and otherwise interact 31 | /// with other atoms. 32 | @MainActor 33 | func reset(context: CurrentContext) 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Atoms/Attribute/Scoped.swift: -------------------------------------------------------------------------------- 1 | /// An attribute protocol to preserve the atom state in the scope nearest to the ancestor 2 | /// of where it is used and prevents it from being shared out of scope. 3 | /// 4 | /// If multiple scopes are nested, you can define an arbitrary `scopeID` to ensure that 5 | /// values are stored in a particular scope. 6 | /// The atom with `scopeID` searches for the nearest ``AtomScope`` with the matching ID in 7 | /// ancestor views, and if not found, the state is shared within the app. 8 | /// 9 | /// Note that other atoms that depend on the scoped atom will be in a shared state and must be 10 | /// given this attribute as well in order to scope them as well. 11 | /// 12 | /// ## Example 13 | /// 14 | /// ```swift 15 | /// struct SearchScopeID: Hashable {} 16 | /// 17 | /// struct SearchQueryAtom: StateAtom, Scoped, Hashable { 18 | /// var scopeID: SearchScopeID { 19 | /// SearchScopeID() 20 | /// } 21 | /// 22 | /// func defaultValue(context: Context) -> String { 23 | /// "" 24 | /// } 25 | /// } 26 | /// 27 | /// AtomScope(id: SearchScopeID()) { 28 | /// SearchPane() 29 | /// } 30 | /// ``` 31 | /// 32 | public protocol Scoped where Self: Atom { 33 | /// A type of the scope ID which is to find a matching scope. 34 | associatedtype ScopeID: Hashable = DefaultScopeID 35 | 36 | /// A scope ID which is to find a matching scope. 37 | var scopeID: ScopeID { get } 38 | } 39 | 40 | public extension Scoped where ScopeID == DefaultScopeID { 41 | /// A scope ID which is to find a matching scope. 42 | var scopeID: ScopeID { 43 | DefaultScopeID() 44 | } 45 | } 46 | 47 | /// A default scope ID to find a matching scope inbetween scoped atoms and ``AtomScope``. 48 | public struct DefaultScopeID: Hashable { 49 | /// Creates a new default scope ID which is always indentical. 50 | public init() {} 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Atom/AsyncAtom.swift: -------------------------------------------------------------------------------- 1 | /// Declares that a type can produce a refreshable value that can be accessed from everywhere. 2 | /// 3 | /// Atoms compliant with this protocol are refreshable and can wait until the atom produces 4 | /// its final value. 5 | public protocol AsyncAtom: Atom { 6 | /// A producer that produces the refreshable value of this atom. 7 | var refreshProducer: AtomRefreshProducer { get } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Atom/Atom.swift: -------------------------------------------------------------------------------- 1 | /// Declares that a type can produce a value that can be accessed from everywhere. 2 | /// 3 | /// The value produced by an atom is created only when the atom is watched from somewhere, 4 | /// and is immediately released when no longer watched. 5 | public protocol Atom: Sendable { 6 | /// A type representing the stable identity of this atom. 7 | associatedtype Key: Hashable & Sendable 8 | 9 | /// The type of value that this atom produces. 10 | associatedtype Produced 11 | 12 | /// The type of effect for managing side effects. 13 | associatedtype Effect: AtomEffect 14 | 15 | /// A type of the context structure to read, watch, and otherwise interact 16 | /// with other atoms. 17 | typealias Context = AtomTransactionContext 18 | 19 | /// A type of the context structure to read, set, and otherwise interact 20 | /// with other atoms. 21 | typealias CurrentContext = AtomCurrentContext 22 | 23 | /// A unique value used to identify the atom. 24 | /// 25 | /// This key don't have to be unique with respect to other atoms in the entire application 26 | /// because it is identified respecting the metatype of this atom. 27 | /// If this atom conforms to `Hashable`, it will adopt itself as the `key` by default. 28 | var key: Key { get } 29 | 30 | /// An effect for managing side effects that are synchronized with this atom's lifecycle. 31 | /// 32 | /// - Parameter context: A context structure to read, set, and otherwise 33 | /// interact with other atoms. 34 | /// 35 | /// - Returns: An effect for managing side effects. 36 | @MainActor 37 | @AtomEffectBuilder 38 | func effect(context: CurrentContext) -> Effect 39 | 40 | // --- Internal --- 41 | 42 | /// A producer that produces the value of this atom. 43 | var producer: AtomProducer { get } 44 | } 45 | 46 | public extension Atom { 47 | @MainActor 48 | @AtomEffectBuilder 49 | func effect(context: CurrentContext) -> some AtomEffect {} 50 | } 51 | 52 | public extension Atom where Self == Key { 53 | var key: Self { 54 | self 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Atom/ModifiedAtom.swift: -------------------------------------------------------------------------------- 1 | /// An atom type that applies a modifier to an atom. 2 | /// 3 | /// Use ``Atom/modifier(_:)`` instead of using this atom directly. 4 | public struct ModifiedAtom: Atom where Node.Produced == Modifier.Base { 5 | /// The type of value that this atom produces. 6 | public typealias Produced = Modifier.Produced 7 | 8 | /// A type representing the stable identity of this atom. 9 | public struct Key: Hashable, Sendable { 10 | private let atomKey: Node.Key 11 | private let modifierKey: Modifier.Key 12 | 13 | fileprivate init( 14 | atomKey: Node.Key, 15 | modifierKey: Modifier.Key 16 | ) { 17 | self.atomKey = atomKey 18 | self.modifierKey = modifierKey 19 | } 20 | } 21 | 22 | private let atom: Node 23 | private let modifier: Modifier 24 | 25 | internal init(atom: Node, modifier: Modifier) { 26 | self.atom = atom 27 | self.modifier = modifier 28 | } 29 | 30 | /// A unique value used to identify the atom. 31 | public var key: Key { 32 | Key(atomKey: atom.key, modifierKey: modifier.key) 33 | } 34 | 35 | /// A producer that produces the value of this atom. 36 | public var producer: AtomProducer { 37 | modifier.producer(atom: atom) 38 | } 39 | } 40 | 41 | extension ModifiedAtom: AsyncAtom where Node: AsyncAtom, Modifier: AsyncAtomModifier { 42 | /// A producer that produces the refreshable value of this atom. 43 | public var refreshProducer: AtomRefreshProducer { 44 | modifier.refreshProducer(atom: atom) 45 | } 46 | } 47 | 48 | extension ModifiedAtom: Scoped where Node: Scoped { 49 | /// A scope ID which is to find a matching scope. 50 | public var scopeID: Node.ScopeID { 51 | atom.scopeID 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/AtomCache.swift: -------------------------------------------------------------------------------- 1 | internal protocol AtomCacheProtocol { 2 | associatedtype Node: Atom 3 | 4 | var atom: Node { get } 5 | var value: Node.Produced { get } 6 | var scopeValues: ScopeValues? { get } 7 | var shouldKeepAlive: Bool { get } 8 | 9 | func updated(value: Node.Produced) -> Self 10 | } 11 | 12 | internal struct AtomCache: AtomCacheProtocol, CustomStringConvertible { 13 | let atom: Node 14 | let value: Node.Produced 15 | let scopeValues: ScopeValues? 16 | 17 | var description: String { 18 | "\(value)" 19 | } 20 | 21 | var shouldKeepAlive: Bool { 22 | atom is any KeepAlive 23 | } 24 | 25 | func updated(value: Node.Produced) -> Self { 26 | AtomCache(atom: atom, value: value, scopeValues: scopeValues) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/AtomKey.swift: -------------------------------------------------------------------------------- 1 | internal struct AtomKey: Hashable, Sendable, CustomStringConvertible { 2 | private let key: UnsafeUncheckedSendable 3 | private let type: ObjectIdentifier 4 | private let anyAtomType: Any.Type 5 | 6 | let scopeKey: ScopeKey? 7 | 8 | var description: String { 9 | let atomLabel = String(describing: anyAtomType) 10 | 11 | if let scopeKey { 12 | return atomLabel + " scope:\(scopeKey)" 13 | } 14 | else { 15 | return atomLabel 16 | } 17 | } 18 | 19 | init(_ atom: Node, scopeKey: ScopeKey?) { 20 | self.key = UnsafeUncheckedSendable(atom.key) 21 | self.type = ObjectIdentifier(Node.self) 22 | self.scopeKey = scopeKey 23 | self.anyAtomType = Node.self 24 | } 25 | 26 | func hash(into hasher: inout Hasher) { 27 | hasher.combine(key) 28 | hasher.combine(type) 29 | hasher.combine(scopeKey) 30 | } 31 | 32 | static func == (lhs: Self, rhs: Self) -> Bool { 33 | lhs.key == rhs.key && lhs.type == rhs.type && lhs.scopeKey == rhs.scopeKey 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/AtomState.swift: -------------------------------------------------------------------------------- 1 | @MainActor 2 | internal protocol AtomStateProtocol: AnyObject { 3 | associatedtype Effect: AtomEffect 4 | 5 | var effect: Effect { get } 6 | var transactionState: TransactionState? { get set } 7 | } 8 | 9 | @MainActor 10 | internal final class AtomState: AtomStateProtocol { 11 | let effect: Effect 12 | var transactionState: TransactionState? 13 | 14 | init(effect: Effect) { 15 | self.effect = effect 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Effect/EmptyEffect.swift: -------------------------------------------------------------------------------- 1 | /// An effect that doesn't produce any effects. 2 | @available(*, deprecated, message: "`Atom/effect(context:)` now supports result builder syntax.") 3 | public struct EmptyEffect: AtomEffect { 4 | /// Creates an empty effect. 5 | public init() {} 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Effect/MergedEffect.swift: -------------------------------------------------------------------------------- 1 | /// An atom effect that merges multiple atom effects into one. 2 | @available(*, deprecated, message: "`Atom/effect(context:)` now supports result builder syntax.") 3 | public struct MergedEffect: AtomEffect { 4 | private let initializing: @MainActor (Context) -> Void 5 | private let initialized: @MainActor (Context) -> Void 6 | private let updated: @MainActor (Context) -> Void 7 | private let released: @MainActor (Context) -> Void 8 | 9 | /// Creates an atom effect that merges multiple atom effects into one. 10 | public init(_ effect: repeat each Effect) { 11 | initializing = { @Sendable context in 12 | repeat (each effect).initializing(context: context) 13 | } 14 | initialized = { @Sendable context in 15 | repeat (each effect).initialized(context: context) 16 | } 17 | updated = { @Sendable context in 18 | repeat (each effect).updated(context: context) 19 | } 20 | released = { @Sendable context in 21 | repeat (each effect).released(context: context) 22 | } 23 | } 24 | 25 | /// A lifecycle event that is triggered before the atom is first used and initialized, 26 | /// or once it is released and re-initialized. 27 | public func initializing(context: Context) { 28 | initializing(context) 29 | } 30 | 31 | /// A lifecycle event that is triggered after the atom is first used and initialized, 32 | /// or once it is released and re-initialized. 33 | public func initialized(context: Context) { 34 | initialized(context) 35 | } 36 | 37 | /// A lifecycle event that is triggered when the atom is updated. 38 | public func updated(context: Context) { 39 | updated(context) 40 | } 41 | 42 | /// A lifecycle event that is triggered when the atom is no longer watched and released. 43 | public func released(context: Context) { 44 | released(context) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Environment.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | internal extension EnvironmentValues { 4 | @Entry 5 | var store: StoreContext? = nil 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Modifier/AsyncAtomModifier.swift: -------------------------------------------------------------------------------- 1 | /// A modifier that you apply to an atom, producing a new refreshable value modified from the original value. 2 | public protocol AsyncAtomModifier: AtomModifier { 3 | /// A producer that produces the refreshable value of this atom. 4 | func refreshProducer(atom: some AsyncAtom) -> AtomRefreshProducer 5 | } 6 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Modifier/AtomModifier.swift: -------------------------------------------------------------------------------- 1 | public extension Atom { 2 | /// Applies a modifier to an atom and returns a new atom. 3 | /// 4 | /// - Parameter modifier: The modifier to apply to this atom. 5 | /// - Returns: A new atom that is applied the given modifier. 6 | func modifier(_ modifier: T) -> ModifiedAtom { 7 | ModifiedAtom(atom: self, modifier: modifier) 8 | } 9 | } 10 | 11 | /// A modifier that you apply to an atom, producing a new value modified from the original value. 12 | public protocol AtomModifier: Sendable { 13 | /// A type representing the stable identity of this modifier. 14 | associatedtype Key: Hashable & Sendable 15 | 16 | /// A type of base value to be modified. 17 | associatedtype Base 18 | 19 | /// A type of value the modified atom produces. 20 | associatedtype Produced 21 | 22 | /// A unique value used to identify the modifier internally. 23 | var key: Key { get } 24 | 25 | // --- Internal --- 26 | 27 | /// A producer that produces the value of this atom. 28 | func producer(atom: some Atom) -> AtomProducer 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Observer.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | internal struct Observer: Sendable { 3 | let onUpdate: @MainActor (Snapshot) -> Void 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Override.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | internal protocol OverrideProtocol: Sendable { 3 | associatedtype Node: Atom 4 | 5 | var getValue: @MainActor (Node) -> Node.Produced { get } 6 | } 7 | 8 | @usableFromInline 9 | internal struct Override: OverrideProtocol { 10 | @usableFromInline 11 | let getValue: @MainActor (Node) -> Node.Produced 12 | 13 | @usableFromInline 14 | init(getValue: @MainActor @escaping (Node) -> Node.Produced) { 15 | self.getValue = getValue 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/OverrideContainer.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | internal struct OverrideContainer { 3 | private struct Entry { 4 | var typeOverride: (any OverrideProtocol)? 5 | var instanceOverrides = [InstanceKey: any OverrideProtocol]() 6 | } 7 | 8 | private var entries = [TypeKey: Entry]() 9 | 10 | @usableFromInline 11 | mutating func addOverride(for atom: Node, with value: @MainActor @escaping (Node) -> Node.Produced) { 12 | let typeKey = TypeKey(Node.self) 13 | let instanceKey = InstanceKey(atom) 14 | entries[typeKey, default: Entry()].instanceOverrides[instanceKey] = Override(getValue: value) 15 | } 16 | 17 | @usableFromInline 18 | mutating func addOverride(for atomType: Node.Type, with value: @MainActor @escaping (Node) -> Node.Produced) { 19 | let typeKey = TypeKey(atomType) 20 | entries[typeKey, default: Entry()].typeOverride = Override(getValue: value) 21 | } 22 | 23 | func getOverride(for atom: Node) -> Override? { 24 | let typeKey = TypeKey(Node.self) 25 | 26 | guard let entry = entries[typeKey] else { 27 | return nil 28 | } 29 | 30 | let instanceKey = InstanceKey(atom) 31 | let baseOverride = entry.instanceOverrides[instanceKey] ?? entry.typeOverride 32 | 33 | guard let baseOverride else { 34 | return nil 35 | } 36 | 37 | guard let override = baseOverride as? Override else { 38 | assertionFailure( 39 | """ 40 | [Atoms] 41 | Detected an illegal override. 42 | There might be duplicate keys or logic failure. 43 | Detected: \(type(of: baseOverride)) 44 | Expected: Override<\(Node.self)> 45 | """ 46 | ) 47 | 48 | return nil 49 | } 50 | 51 | return override 52 | } 53 | } 54 | 55 | private extension OverrideContainer { 56 | struct TypeKey: Hashable, Sendable { 57 | private let identifier: ObjectIdentifier 58 | 59 | init(_: Node.Type) { 60 | identifier = ObjectIdentifier(Node.self) 61 | } 62 | } 63 | 64 | struct InstanceKey: Hashable, Sendable { 65 | private let key: UnsafeUncheckedSendable 66 | 67 | init(_ atom: Node) { 68 | key = UnsafeUncheckedSendable(atom.key) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Producer/AtomProducer.swift: -------------------------------------------------------------------------------- 1 | /// Produces the value of an atom. 2 | public struct AtomProducer { 3 | internal typealias Context = AtomProducerContext 4 | 5 | internal let getValue: @MainActor (Context) -> Value 6 | internal let manageValue: @MainActor (Value, Context) -> Void 7 | internal let shouldUpdate: @MainActor (Value, Value) -> Bool 8 | internal let performUpdate: @MainActor (() -> Void) -> Void 9 | 10 | internal init( 11 | getValue: @MainActor @escaping (Context) -> Value, 12 | manageValue: @MainActor @escaping (Value, Context) -> Void = { _, _ in }, 13 | shouldUpdate: @MainActor @escaping (Value, Value) -> Bool = { _, _ in true }, 14 | performUpdate: @MainActor @escaping (() -> Void) -> Void = { update in update() } 15 | ) { 16 | self.getValue = getValue 17 | self.manageValue = manageValue 18 | self.shouldUpdate = shouldUpdate 19 | self.performUpdate = performUpdate 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Producer/AtomProducerContext.swift: -------------------------------------------------------------------------------- 1 | @MainActor 2 | internal struct AtomProducerContext { 3 | private let store: StoreContext 4 | private let transactionState: TransactionState 5 | private let update: @MainActor (Value) -> Void 6 | 7 | init( 8 | store: StoreContext, 9 | transactionState: TransactionState, 10 | update: @MainActor @escaping (Value) -> Void 11 | ) { 12 | self.store = store 13 | self.transactionState = transactionState 14 | self.update = update 15 | } 16 | 17 | var isTerminated: Bool { 18 | transactionState.isTerminated 19 | } 20 | 21 | var onTermination: (@MainActor () -> Void)? { 22 | get { transactionState.onTermination } 23 | nonmutating set { transactionState.onTermination = newValue } 24 | } 25 | 26 | func update(with value: Value) { 27 | update(value) 28 | } 29 | 30 | func transaction(_ body: @MainActor (AtomTransactionContext) -> T) -> T { 31 | transactionState.begin() 32 | let context = AtomTransactionContext(store: store, transactionState: transactionState) 33 | defer { transactionState.commit() } 34 | return body(context) 35 | } 36 | 37 | func transaction(_ body: @MainActor (AtomTransactionContext) async throws(E) -> T) async throws(E) -> T { 38 | transactionState.begin() 39 | let context = AtomTransactionContext(store: store, transactionState: transactionState) 40 | defer { transactionState.commit() } 41 | return try await body(context) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Producer/AtomRefreshProducer.swift: -------------------------------------------------------------------------------- 1 | /// Produces the refreshed value of an atom. 2 | public struct AtomRefreshProducer { 3 | internal typealias Context = AtomProducerContext 4 | 5 | internal let getValue: @MainActor (Context) async -> Value 6 | internal let refreshValue: @MainActor (Value, Context) async -> Void 7 | 8 | internal init( 9 | getValue: @MainActor @escaping (Context) async -> Value, 10 | refreshValue: @MainActor @escaping (Value, Context) async -> Void = { _, _ in } 11 | ) { 12 | self.getValue = getValue 13 | self.refreshValue = refreshValue 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Scope.swift: -------------------------------------------------------------------------------- 1 | @MainActor 2 | internal struct Scope { 3 | var atoms = Set() 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/ScopeID.swift: -------------------------------------------------------------------------------- 1 | internal struct ScopeID: Hashable { 2 | private let id: AnyHashable 3 | 4 | init(_ id: any Hashable) { 5 | self.id = AnyHashable(id) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/ScopeKey.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | internal struct ScopeKey: Hashable, Sendable, CustomStringConvertible { 3 | @MainActor 4 | final class Token { 5 | private(set) lazy var key = ScopeKey(token: self) 6 | } 7 | 8 | private let identifier: ObjectIdentifier 9 | 10 | @usableFromInline 11 | var description: String { 12 | "0x\(String(UInt(bitPattern: identifier), radix: 16))" 13 | } 14 | 15 | private init(token: Token) { 16 | identifier = ObjectIdentifier(token) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/ScopeState.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | @MainActor 3 | internal final class ScopeState { 4 | let token = ScopeKey.Token() 5 | 6 | #if !hasFeature(IsolatedDefaultValues) 7 | nonisolated init() {} 8 | #endif 9 | 10 | nonisolated(unsafe) var unregister: (@MainActor () -> Void)? 11 | 12 | // TODO: Use isolated synchronous deinit once it's available. 13 | // 0371-isolated-synchronous-deinit 14 | deinit { 15 | MainActor.performIsolated { [unregister] in 16 | unregister?() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/ScopeValues.swift: -------------------------------------------------------------------------------- 1 | internal struct ScopeValues { 2 | let key: ScopeKey 3 | let observers: [Observer] 4 | let overrideContainer: OverrideContainer 5 | let ancestorScopeKeys: [ScopeID: ScopeKey] 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/SourceLocation.swift: -------------------------------------------------------------------------------- 1 | internal struct SourceLocation: Equatable { 2 | let fileID: String 3 | let line: UInt 4 | 5 | init(fileID: String = #fileID, line: UInt = #line) { 6 | self.fileID = fileID 7 | self.line = line 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Subscriber.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | @MainActor 3 | internal struct Subscriber { 4 | private weak var state: SubscriberState? 5 | 6 | let key: SubscriberKey 7 | 8 | init(_ state: SubscriberState) { 9 | self.state = state 10 | self.key = state.token.key 11 | } 12 | 13 | var unsubscribe: (@MainActor () -> Void)? { 14 | get { state?.unsubscribe } 15 | nonmutating set { state?.unsubscribe = newValue } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/SubscriberKey.swift: -------------------------------------------------------------------------------- 1 | internal struct SubscriberKey: Hashable { 2 | @MainActor 3 | final class Token { 4 | private(set) lazy var key = SubscriberKey(token: self) 5 | } 6 | 7 | private let identifier: ObjectIdentifier 8 | 9 | private init(token: Token) { 10 | identifier = ObjectIdentifier(token) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/SubscriberState.swift: -------------------------------------------------------------------------------- 1 | @MainActor 2 | internal final class SubscriberState { 3 | let token = SubscriberKey.Token() 4 | 5 | #if !hasFeature(IsolatedDefaultValues) 6 | nonisolated init() {} 7 | #endif 8 | 9 | nonisolated(unsafe) var unsubscribe: (@MainActor () -> Void)? 10 | 11 | // TODO: Use isolated synchronous deinit once it's available. 12 | // 0371-isolated-synchronous-deinit 13 | deinit { 14 | MainActor.performIsolated { [unsubscribe] in 15 | unsubscribe?() 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Subscription.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | internal struct Subscription { 3 | let location: SourceLocation 4 | let update: @MainActor () -> Void 5 | } 6 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/TopologicalSort.swift: -------------------------------------------------------------------------------- 1 | internal enum Vertex: Hashable { 2 | case atom(key: AtomKey) 3 | case subscriber(key: SubscriberKey) 4 | } 5 | 6 | internal struct Edge: Hashable { 7 | let from: AtomKey 8 | let to: Vertex 9 | } 10 | 11 | internal extension AtomStore { 12 | /// DFS topological sorting. 13 | func topologicalSorted(key: AtomKey) -> ( 14 | edges: ReversedCollection>, 15 | redundantDependencies: [Vertex: ContiguousArray] 16 | ) { 17 | var trace = Set() 18 | var edges = ContiguousArray() 19 | var redundantDependencies = [Vertex: ContiguousArray]() 20 | 21 | func traverse(key: AtomKey, isRedundant: Bool) { 22 | if let children = children[key] { 23 | for child in children { 24 | traverse(key: child, from: key, isRedundant: isRedundant) 25 | } 26 | } 27 | 28 | if let subscriptions = subscriptions[key] { 29 | for subscriberKey in subscriptions.keys { 30 | traverse(key: subscriberKey, from: key, isRedundant: isRedundant) 31 | } 32 | } 33 | } 34 | 35 | func traverse(key: AtomKey, from fromKey: AtomKey, isRedundant: Bool) { 36 | let vertex = Vertex.atom(key: key) 37 | let isRedundant = isRedundant || trace.contains(vertex) 38 | 39 | trace.insert(vertex) 40 | 41 | // Do not stop traversing downstream even when edges are already traced 42 | // to analyze the redundant edges later. 43 | traverse(key: key, isRedundant: isRedundant) 44 | 45 | if isRedundant { 46 | redundantDependencies[vertex, default: []].append(fromKey) 47 | } 48 | else { 49 | let edge = Edge(from: fromKey, to: vertex) 50 | edges.append(edge) 51 | } 52 | } 53 | 54 | func traverse(key: SubscriberKey, from fromKey: AtomKey, isRedundant: Bool) { 55 | let vertex = Vertex.subscriber(key: key) 56 | let isRedundant = isRedundant || trace.contains(vertex) 57 | 58 | trace.insert(vertex) 59 | 60 | if isRedundant { 61 | redundantDependencies[vertex, default: []].append(fromKey) 62 | } 63 | else { 64 | let edge = Edge(from: fromKey, to: vertex) 65 | edges.append(edge) 66 | } 67 | } 68 | 69 | traverse(key: key, isRedundant: false) 70 | 71 | return (edges: edges.reversed(), redundantDependencies: redundantDependencies) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/TransactionState.swift: -------------------------------------------------------------------------------- 1 | @usableFromInline 2 | @MainActor 3 | internal final class TransactionState { 4 | private var body: (@MainActor () -> @MainActor () -> Void)? 5 | private var cleanup: (@MainActor () -> Void)? 6 | 7 | let key: AtomKey 8 | 9 | private var termination: (@MainActor () -> Void)? 10 | private(set) var isTerminated = false 11 | 12 | init( 13 | key: AtomKey, 14 | _ body: @MainActor @escaping () -> @MainActor () -> Void 15 | ) { 16 | self.key = key 17 | self.body = body 18 | } 19 | 20 | var onTermination: (@MainActor () -> Void)? { 21 | get { termination } 22 | set { 23 | guard !isTerminated else { 24 | newValue?() 25 | return 26 | } 27 | 28 | termination = newValue 29 | } 30 | 31 | } 32 | 33 | func begin() { 34 | cleanup = body?() 35 | body = nil 36 | } 37 | 38 | func commit() { 39 | cleanup?() 40 | cleanup = nil 41 | } 42 | 43 | func terminate() { 44 | isTerminated = true 45 | 46 | termination?() 47 | termination = nil 48 | body = nil 49 | commit() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/UnsafeUncheckedSendable.swift: -------------------------------------------------------------------------------- 1 | internal struct UnsafeUncheckedSendable: @unchecked Sendable { 2 | var value: Value 3 | 4 | init(_ value: Value) { 5 | self.value = value 6 | } 7 | } 8 | 9 | extension UnsafeUncheckedSendable: Equatable where Value: Equatable {} 10 | extension UnsafeUncheckedSendable: Hashable where Value: Hashable {} 11 | -------------------------------------------------------------------------------- /Sources/Atoms/Core/Utilities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @inlinable 4 | internal func `mutating`(_ value: T, _ mutation: (inout T) -> Void) -> T { 5 | var value = value 6 | mutation(&value) 7 | return value 8 | } 9 | 10 | internal extension Task where Success == Never, Failure == Never { 11 | @inlinable 12 | static func sleep(seconds duration: Double) async throws { 13 | try await sleep(nanoseconds: UInt64(duration * 1_000_000_000)) 14 | } 15 | } 16 | 17 | internal extension MainActor { 18 | static func performIsolated( 19 | _ operation: @MainActor @escaping () -> Void, 20 | file: StaticString = #fileID, 21 | line: UInt = #line 22 | ) { 23 | if Thread.isMainThread { 24 | MainActor.assumeIsolated(operation, file: file, line: line) 25 | } 26 | else { 27 | Task { @MainActor in 28 | operation() 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Atoms/Effect/AtomEffect.swift: -------------------------------------------------------------------------------- 1 | /// Declares side effects that are synchronized with the atom's lifecycle. 2 | /// 3 | /// If this effect is attached to atoms via ``Atom/effect(context:)``, the effect is 4 | /// initialized the first time the atom is used, and the instance will be retained 5 | /// until the atom is released, thus it allows to declare stateful side effects. 6 | /// 7 | /// SeeAlso: ``InitializingEffect`` 8 | /// SeeAlso: ``InitializeEffect`` 9 | /// SeeAlso: ``UpdateEffect`` 10 | /// SeeAlso: ``ReleaseEffect`` 11 | @MainActor 12 | public protocol AtomEffect { 13 | /// A type of the context structure to read, set, and otherwise interact 14 | /// with other atoms. 15 | typealias Context = AtomCurrentContext 16 | 17 | /// A lifecycle event that is triggered before the atom is first used and initialized, 18 | /// or once it is released and re-initialized. 19 | func initializing(context: Context) 20 | 21 | /// A lifecycle event that is triggered after the atom is first used and initialized, 22 | /// or once it is released and re-initialized. 23 | func initialized(context: Context) 24 | 25 | /// A lifecycle event that is triggered when the atom is updated. 26 | func updated(context: Context) 27 | 28 | /// A lifecycle event that is triggered when the atom is no longer watched and released. 29 | func released(context: Context) 30 | } 31 | 32 | public extension AtomEffect { 33 | func initializing(context: Context) {} 34 | func initialized(context: Context) {} 35 | func updated(context: Context) {} 36 | func released(context: Context) {} 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Atoms/Effect/InitializeEffect.swift: -------------------------------------------------------------------------------- 1 | /// An atom effect that performs an arbitrary action after the atom is first used and initialized, 2 | /// or once it is released and re-initialized. 3 | public struct InitializeEffect: AtomEffect { 4 | private let action: @MainActor () -> Void 5 | 6 | /// Creates an atom effect that performs the given action after the atom is initialized. 7 | public init(perform action: @MainActor @escaping () -> Void) { 8 | self.action = action 9 | } 10 | 11 | /// A lifecycle event that is triggered after the atom is first used and initialized, 12 | /// or once it is released and re-initialized. 13 | public func initialized(context: Context) { 14 | action() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Atoms/Effect/InitializingEffect.swift: -------------------------------------------------------------------------------- 1 | /// An atom effect that performs an arbitrary action before the atom is first used and initialized, 2 | /// or once it is released and re-initialized. 3 | public struct InitializingEffect: AtomEffect { 4 | private let action: @MainActor () -> Void 5 | 6 | /// Creates an atom effect that performs the given action before the atom is initialized. 7 | public init(perform action: @MainActor @escaping () -> Void) { 8 | self.action = action 9 | } 10 | 11 | /// A lifecycle event that is triggered before the atom is first used and initialized, 12 | /// or once it is released and re-initialized. 13 | public func initializing(context: Context) { 14 | action() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Atoms/Effect/ReleaseEffect.swift: -------------------------------------------------------------------------------- 1 | /// An atom effect that performs an arbitrary action when the atom is no longer watched and released. 2 | public struct ReleaseEffect: AtomEffect { 3 | private let action: @MainActor () -> Void 4 | 5 | /// Creates an atom effect that performs the given action when the atom is released. 6 | public init(perform action: @MainActor @escaping () -> Void) { 7 | self.action = action 8 | } 9 | 10 | /// A lifecycle event that is triggered when the atom is no longer watched and released. 11 | public func released(context: Context) { 12 | action() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Atoms/Effect/UpdateEffect.swift: -------------------------------------------------------------------------------- 1 | /// An atom effect that performs an arbitrary action when the atom is updated. 2 | public struct UpdateEffect: AtomEffect { 3 | private let action: @MainActor () -> Void 4 | 5 | /// Creates an atom effect that performs the given action when the atom is updated. 6 | public init(perform action: @MainActor @escaping () -> Void) { 7 | self.action = action 8 | } 9 | 10 | /// A lifecycle event that is triggered when the atom is updated. 11 | public func updated(context: Context) { 12 | action() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Atoms/Modifier/AnimationModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public extension Atom { 4 | /// Animates the view watching the atom when the value updates. 5 | /// 6 | /// Note that this modifier does nothing when being watched by other atoms. 7 | /// 8 | /// ```swift 9 | /// struct TextAtom: ValueAtom, Hashable { 10 | /// func value(context: Context) -> String { 11 | /// "" 12 | /// } 13 | /// } 14 | /// 15 | /// struct ExampleView: View { 16 | /// @Watch(TextAtom().animation()) 17 | /// var text 18 | /// 19 | /// var body: some View { 20 | /// Text(text) 21 | /// } 22 | /// } 23 | /// ``` 24 | /// 25 | /// - Parameter animation: The animation to apply to the value. 26 | /// 27 | /// - Returns: An atom that animates the view watching the atom when the value updates. 28 | func animation(_ animation: Animation? = .default) -> ModifiedAtom> { 29 | modifier(AnimationModifier(animation: animation)) 30 | } 31 | } 32 | 33 | /// A modifier that animates the view watching the atom when the value updates. 34 | /// 35 | /// Use ``Atom/animation(_:)`` instead of using this modifier directly. 36 | public struct AnimationModifier: AtomModifier { 37 | /// A type of base value to be modified. 38 | public typealias Base = Produced 39 | 40 | /// A type of value the modified atom produces. 41 | public typealias Produced = Produced 42 | 43 | /// A type representing the stable identity of this atom associated with an instance. 44 | public struct Key: Hashable, Sendable { 45 | private let animation: Animation? 46 | 47 | fileprivate init(animation: Animation?) { 48 | self.animation = animation 49 | } 50 | } 51 | 52 | private let animation: Animation? 53 | 54 | internal init(animation: Animation?) { 55 | self.animation = animation 56 | } 57 | 58 | /// A unique value used to identify the modifier internally. 59 | public var key: Key { 60 | Key(animation: animation) 61 | } 62 | 63 | /// A producer that produces the value of this atom. 64 | public func producer(atom: some Atom) -> AtomProducer { 65 | AtomProducer { context in 66 | context.transaction { $0.watch(atom) } 67 | } performUpdate: { update in 68 | withAnimation(animation, update) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Atoms/Modifier/ChangesModifier.swift: -------------------------------------------------------------------------------- 1 | public extension Atom where Produced: Equatable { 2 | /// Prevents the atom from updating its downstream when its new value is equivalent to old value. 3 | /// 4 | /// ```swift 5 | /// struct FlagAtom: StateAtom, Hashable { 6 | /// func defaultValue(context: Context) -> Bool { 7 | /// true 8 | /// } 9 | /// } 10 | /// 11 | /// struct ExampleView: View { 12 | /// @Watch(FlagAtom().changes) 13 | /// var flag 14 | /// 15 | /// var body: some View { 16 | /// if flag { 17 | /// Text("true") 18 | /// } 19 | /// else { 20 | /// Text("false") 21 | /// } 22 | /// } 23 | /// } 24 | /// ``` 25 | /// 26 | var changes: ModifiedAtom> { 27 | modifier(ChangesModifier()) 28 | } 29 | } 30 | 31 | /// A modifier that prevents the atom from updating its child views or atoms when 32 | /// its new value is the same as its old value. 33 | /// 34 | /// Use ``Atom/changes`` instead of using this modifier directly. 35 | public struct ChangesModifier: AtomModifier { 36 | /// A type of base value to be modified. 37 | public typealias Base = Produced 38 | 39 | /// A type of value the modified atom produces. 40 | public typealias Produced = Produced 41 | 42 | /// A type representing the stable identity of this atom associated with an instance. 43 | public struct Key: Hashable, Sendable {} 44 | 45 | /// A unique value used to identify the modifier internally. 46 | public var key: Key { 47 | Key() 48 | } 49 | 50 | /// A producer that produces the value of this atom. 51 | public func producer(atom: some Atom) -> AtomProducer { 52 | AtomProducer { context in 53 | context.transaction { $0.watch(atom) } 54 | } shouldUpdate: { oldValue, newValue in 55 | oldValue != newValue 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Atoms/Modifier/ChangesOfModifier.swift: -------------------------------------------------------------------------------- 1 | public extension Atom { 2 | /// Derives a partial property with the specified key path from the original atom and prevent it 3 | /// from updating its downstream when its new value is equivalent to old value. 4 | /// 5 | /// ```swift 6 | /// struct IntAtom: ValueAtom, Hashable { 7 | /// func value(context: Context) -> Int { 8 | /// 12345 9 | /// } 10 | /// } 11 | /// 12 | /// struct ExampleView: View { 13 | /// @Watch(IntAtom().changes(of: \.description)) 14 | /// var description 15 | /// 16 | /// var body: some View { 17 | /// Text(description) 18 | /// } 19 | /// } 20 | /// ``` 21 | /// 22 | /// - Parameter keyPath: A key path for the property of the original atom value. 23 | /// 24 | /// - Returns: An atom that provides the partial property of the original atom value. 25 | #if hasFeature(InferSendableFromCaptures) 26 | func changes( 27 | of keyPath: any KeyPath & Sendable 28 | ) -> ModifiedAtom> { 29 | modifier(ChangesOfModifier(keyPath: keyPath)) 30 | } 31 | #else 32 | func changes( 33 | of keyPath: KeyPath 34 | ) -> ModifiedAtom> { 35 | modifier(ChangesOfModifier(keyPath: keyPath)) 36 | } 37 | #endif 38 | } 39 | 40 | /// A modifier that derives a partial property with the specified key path from the original atom 41 | /// and prevent it from updating its downstream when its new value is equivalent to old value. 42 | /// 43 | /// Use ``Atom/changes(of:)`` instead of using this modifier directly. 44 | public struct ChangesOfModifier: AtomModifier { 45 | /// A type of base value to be modified. 46 | public typealias Base = Base 47 | 48 | /// A type of value the modified atom produces. 49 | public typealias Produced = Produced 50 | 51 | #if hasFeature(InferSendableFromCaptures) 52 | /// A type representing the stable identity of this modifier. 53 | public struct Key: Hashable, Sendable { 54 | private let keyPath: any KeyPath & Sendable 55 | 56 | fileprivate init(keyPath: any KeyPath & Sendable) { 57 | self.keyPath = keyPath 58 | } 59 | } 60 | 61 | private let keyPath: any KeyPath & Sendable 62 | 63 | internal init(keyPath: any KeyPath & Sendable) { 64 | self.keyPath = keyPath 65 | } 66 | 67 | /// A unique value used to identify the modifier internally. 68 | public var key: Key { 69 | Key(keyPath: keyPath) 70 | } 71 | #else 72 | public struct Key: Hashable, Sendable { 73 | private let keyPath: UnsafeUncheckedSendable> 74 | 75 | fileprivate init(keyPath: UnsafeUncheckedSendable>) { 76 | self.keyPath = keyPath 77 | } 78 | } 79 | 80 | private let _keyPath: UnsafeUncheckedSendable> 81 | private var keyPath: KeyPath { 82 | _keyPath.value 83 | } 84 | 85 | internal init(keyPath: KeyPath) { 86 | _keyPath = UnsafeUncheckedSendable(keyPath) 87 | } 88 | 89 | /// A unique value used to identify the modifier internally. 90 | public var key: Key { 91 | Key(keyPath: _keyPath) 92 | } 93 | #endif 94 | 95 | /// A producer that produces the value of this atom. 96 | public func producer(atom: some Atom) -> AtomProducer { 97 | AtomProducer { context in 98 | let value = context.transaction { $0.watch(atom) } 99 | return value[keyPath: keyPath] 100 | } shouldUpdate: { oldValue, newValue in 101 | oldValue != newValue 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/Atoms/PropertyWrapper/Watch.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A property wrapper type that can watch and read-only access to the given atom. 4 | /// 5 | /// It starts watching the atom when the view accesses the ``wrappedValue``, and when the atom value 6 | /// changes, the view invalidates its appearance and recomputes the body. 7 | /// 8 | /// See also ``WatchState`` to write value of ``StateAtom`` and ``WatchStateObject`` to receive updates of 9 | /// ``ObservableObjectAtom``. 10 | /// 11 | /// ## Example 12 | /// 13 | /// ```swift 14 | /// struct CountDisplay: View { 15 | /// @Watch(CounterAtom()) 16 | /// var count 17 | /// 18 | /// var body: some View { 19 | /// Text("Count: \(count)") // Read value, and start watching. 20 | /// } 21 | /// } 22 | /// ``` 23 | /// 24 | @propertyWrapper 25 | public struct Watch: DynamicProperty { 26 | private let atom: Node 27 | 28 | @ViewContext 29 | private var context 30 | 31 | /// Creates an instance with the atom to watch. 32 | public init(_ atom: Node, fileID: String = #fileID, line: UInt = #line) { 33 | self.atom = atom 34 | self._context = ViewContext(fileID: fileID, line: line) 35 | } 36 | 37 | /// The underlying value associated with the given atom. 38 | /// 39 | /// This property provides primary access to the value's data. However, you don't 40 | /// access ``wrappedValue`` directly. Instead, you use the property variable created 41 | /// with the `@Watch` attribute. 42 | /// Accessing this property starts watching the atom. 43 | #if hasFeature(DisableOutwardActorInference) 44 | @MainActor 45 | #endif 46 | public var wrappedValue: Node.Produced { 47 | context.watch(atom) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Atoms/PropertyWrapper/WatchState.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A property wrapper type that can watch and read-write access to the given atom conforms 4 | /// to ``StateAtom``. 5 | /// 6 | /// It starts watching the atom when the view accesses the ``wrappedValue``, and when the atom changes, 7 | /// the view invalidates its appearance and recomputes the body. However, if only write access is 8 | /// performed, it doesn't start watching. 9 | /// 10 | /// See also ``Watch`` to have read-only access and ``WatchStateObject`` to receive updates of 11 | /// ``ObservableObjectAtom``. 12 | /// The interface of this property wrapper follows `@State`. 13 | /// 14 | /// ## Example 15 | /// 16 | /// ```swift 17 | /// struct CounterView: View { 18 | /// @WatchState(CounterAtom()) 19 | /// var count 20 | /// 21 | /// var body: some View { 22 | /// VStack { 23 | /// Text("Count: \(count)") // Read value, and start watching. 24 | /// Stepper(value: $count) {} // Use as a binding 25 | /// Button("+100") { 26 | /// count += 100 // Mutation which means simultaneous read-write access. 27 | /// } 28 | /// } 29 | /// } 30 | /// } 31 | /// ``` 32 | /// 33 | @propertyWrapper 34 | public struct WatchState: DynamicProperty { 35 | private let atom: Node 36 | 37 | @ViewContext 38 | private var context 39 | 40 | /// Creates an instance with the atom to watch. 41 | public init(_ atom: Node, fileID: String = #fileID, line: UInt = #line) { 42 | self.atom = atom 43 | self._context = ViewContext(fileID: fileID, line: line) 44 | } 45 | 46 | /// The underlying value associated with the given atom. 47 | /// 48 | /// This property provides primary access to the value's data. However, you don't 49 | /// access ``wrappedValue`` directly. Instead, you use the property variable created 50 | /// with the `@WatchState` attribute. 51 | /// Accessing to the getter of this property starts watching the atom, but doesn't 52 | /// by setting a new value. 53 | #if hasFeature(DisableOutwardActorInference) 54 | @MainActor 55 | #endif 56 | public var wrappedValue: Node.Produced { 57 | get { context.watch(atom) } 58 | nonmutating set { context.set(newValue, for: atom) } 59 | } 60 | 61 | /// A binding to the atom value. 62 | /// 63 | /// Use the projected value to pass a binding value down a view hierarchy. 64 | /// To get the ``projectedValue``, prefix the property variable with `$`. 65 | /// Accessing this property itself does not start watching the atom, but does when 66 | /// the view accesses to the getter of the binding. 67 | #if hasFeature(DisableOutwardActorInference) 68 | @MainActor 69 | #endif 70 | public var projectedValue: Binding { 71 | context.binding(atom) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Atoms/PropertyWrapper/WatchStateObject.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A property wrapper type that can watch the given atom conforms to ``ObservableObjectAtom``. 4 | /// 5 | /// It starts watching the atom when the view accesses the ``wrappedValue``, and when the atom changes, 6 | /// the view invalidates its appearance and recomputes the body. 7 | /// 8 | /// See also ``Watch`` to have read-only access and ``WatchState`` to write value of ``StateAtom``. 9 | /// The interface of this property wrapper follows `@StateObject`. 10 | /// 11 | /// ## Example 12 | /// 13 | /// ```swift 14 | /// class Counter: ObservableObject { 15 | /// @Published var count = 0 16 | /// 17 | /// func plus(_ value: Int) { 18 | /// count += value 19 | /// } 20 | /// } 21 | /// 22 | /// struct CounterAtom: ObservableObjectAtom, Hashable { 23 | /// func object(context: Context) -> Counter { 24 | /// Counter() 25 | /// } 26 | /// } 27 | /// 28 | /// struct CounterView: View { 29 | /// @WatchStateObject(CounterAtom()) 30 | /// var counter 31 | /// 32 | /// var body: some View { 33 | /// VStack { 34 | /// Text("Count: \(counter.count)") // Read property, and start watching. 35 | /// Stepper(value: $counter.count) {} // Use the property as a binding 36 | /// Button("+100") { 37 | /// counter.plus(100) // Call the method to update. 38 | /// } 39 | /// } 40 | /// } 41 | /// } 42 | /// ``` 43 | /// 44 | @propertyWrapper 45 | public struct WatchStateObject: DynamicProperty { 46 | /// A wrapper of the underlying observable object that can create bindings to 47 | /// its properties using dynamic member lookup. 48 | @dynamicMemberLookup 49 | @MainActor 50 | public struct Wrapper { 51 | private let object: Node.Produced 52 | 53 | /// Returns a binding to the resulting value of the given key path. 54 | /// 55 | /// - Parameter keyPath: A key path to a specific resulting value. 56 | /// 57 | /// - Returns: A new binding. 58 | public subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> Binding { 59 | Binding( 60 | get: { object[keyPath: keyPath] }, 61 | set: { object[keyPath: keyPath] = $0 } 62 | ) 63 | } 64 | 65 | fileprivate init(_ object: Node.Produced) { 66 | self.object = object 67 | } 68 | } 69 | 70 | private let atom: Node 71 | 72 | @ViewContext 73 | private var context 74 | 75 | /// Creates an instance with the atom to watch. 76 | public init(_ atom: Node, fileID: String = #fileID, line: UInt = #line) { 77 | self.atom = atom 78 | self._context = ViewContext(fileID: fileID, line: line) 79 | } 80 | 81 | /// The underlying observable object associated with the given atom. 82 | /// 83 | /// This property provides primary access to the value's data. However, you don't 84 | /// access ``wrappedValue`` directly. Instead, you use the property variable created 85 | /// with the `@WatchStateObject` attribute. 86 | /// Accessing this property starts watching the atom. 87 | #if hasFeature(DisableOutwardActorInference) 88 | @MainActor 89 | #endif 90 | public var wrappedValue: Node.Produced { 91 | context.watch(atom) 92 | } 93 | 94 | /// A projection of the state object that creates bindings to its properties. 95 | /// 96 | /// Use the projected value to pass a binding value down a view hierarchy. 97 | /// To get the projected value, prefix the property variable with `$`. 98 | #if hasFeature(DisableOutwardActorInference) 99 | @MainActor 100 | #endif 101 | public var projectedValue: Wrapper { 102 | Wrapper(wrappedValue) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/Atoms/Snapshot.swift: -------------------------------------------------------------------------------- 1 | /// A snapshot structure that captures specific set of values of atoms and their dependency graph. 2 | public struct Snapshot: CustomStringConvertible { 3 | internal let dependencies: [AtomKey: Set] 4 | internal let children: [AtomKey: Set] 5 | internal let caches: [AtomKey: any AtomCacheProtocol] 6 | internal let subscriptions: [AtomKey: [SubscriberKey: Subscription]] 7 | 8 | internal init( 9 | dependencies: [AtomKey: Set], 10 | children: [AtomKey: Set], 11 | caches: [AtomKey: any AtomCacheProtocol], 12 | subscriptions: [AtomKey: [SubscriberKey: Subscription]] 13 | ) { 14 | self.dependencies = dependencies 15 | self.children = children 16 | self.caches = caches 17 | self.subscriptions = subscriptions 18 | } 19 | 20 | /// A textual representation of this snapshot. 21 | public var description: String { 22 | """ 23 | Snapshot 24 | - dependencies: \(dependencies) 25 | - children: \(children) 26 | - caches: \(caches) 27 | """ 28 | } 29 | 30 | /// Lookup a value associated with the given atom from the set captured in this snapshot. 31 | /// 32 | /// Note that this does not look up scoped or overridden atoms. 33 | /// 34 | /// - Parameter atom: An atom to lookup. 35 | /// 36 | /// - Returns: The captured value associated with the given atom if it exists. 37 | @MainActor 38 | public func lookup(_ atom: Node) -> Node.Produced? { 39 | let key = AtomKey(atom, scopeKey: nil) 40 | let cache = caches[key] as? AtomCache 41 | return cache?.value 42 | } 43 | 44 | /// Returns a DOT language representation of the dependency graph. 45 | /// 46 | /// This method generates a string that represents 47 | /// the [DOT the graph description language](https://graphviz.org/doc/info/lang.html) 48 | /// for the dependency graph of atoms clipped in this snapshot and views that use them. 49 | /// The generated strings can be converted into images that visually represent dependencies 50 | /// graph using [Graphviz](https://graphviz.org) for debugging and analysis. 51 | /// 52 | /// ## Example 53 | /// 54 | /// ```dot 55 | /// digraph { 56 | /// node [shape=box] 57 | /// "AAtom" 58 | /// "AAtom" -> "BAtom" 59 | /// "BAtom" 60 | /// "BAtom" -> "CAtom" 61 | /// "CAtom" 62 | /// "CAtom" -> "Module/View.swift" [label="line:3"] 63 | /// "Module/View.swift" [style=filled] 64 | /// } 65 | /// ``` 66 | /// 67 | /// - Returns: A dependency graph represented in DOT the graph description language. 68 | public func graphDescription() -> String { 69 | guard !caches.keys.isEmpty else { 70 | return "digraph {}" 71 | } 72 | 73 | var statements = Set() 74 | 75 | for key in caches.keys { 76 | statements.insert(key.description.quoted) 77 | 78 | if let children = children[key] { 79 | for child in children { 80 | statements.insert("\(key.description.quoted) -> \(child.description.quoted)") 81 | } 82 | } 83 | 84 | if let subscriptions = subscriptions[key]?.values { 85 | for subscription in subscriptions { 86 | let label = "line:\(subscription.location.line)".quoted 87 | statements.insert("\(subscription.location.fileID.quoted) [style=filled]") 88 | statements.insert("\(key.description.quoted) -> \(subscription.location.fileID.quoted) [label=\(label)]") 89 | } 90 | } 91 | } 92 | 93 | return """ 94 | digraph { 95 | node [shape=box] 96 | \(statements.sorted().joined(separator: "\n ")) 97 | } 98 | """ 99 | } 100 | } 101 | 102 | private extension String { 103 | var quoted: String { 104 | "\"\(self)\"" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Atom/StateAtomTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class StateAtomTests: XCTestCase { 6 | @MainActor 7 | func testValue() { 8 | let atom = TestStateAtom(defaultValue: 0) 9 | let context = AtomTestContext() 10 | 11 | do { 12 | // Value 13 | XCTAssertEqual(context.watch(atom), 0) 14 | } 15 | 16 | do { 17 | // Override 18 | context.unwatch(atom) 19 | context.override(atom) { _ in 200 } 20 | 21 | XCTAssertEqual(context.watch(atom), 200) 22 | } 23 | } 24 | 25 | @MainActor 26 | func testSet() { 27 | let atom = TestStateAtom(defaultValue: 0) 28 | let context = AtomTestContext() 29 | 30 | XCTAssertEqual(context.watch(atom), 0) 31 | 32 | context[atom] = 100 33 | 34 | XCTAssertEqual(context.watch(atom), 100) 35 | } 36 | 37 | @MainActor 38 | func testSetOverride() { 39 | let atom = TestStateAtom(defaultValue: 0) 40 | let context = AtomTestContext() 41 | 42 | context.override(atom) { _ in 200 } 43 | 44 | XCTAssertEqual(context.watch(atom), 200) 45 | 46 | context[atom] = 100 47 | 48 | XCTAssertEqual(context.watch(atom), 100) 49 | } 50 | 51 | @MainActor 52 | func testDependency() async { 53 | struct Dependency1Atom: StateAtom, Hashable { 54 | func defaultValue(context: Context) -> Int { 55 | 0 56 | } 57 | } 58 | 59 | struct TestAtom: StateAtom, Hashable { 60 | func defaultValue(context: Context) -> Int { 61 | context.watch(Dependency1Atom()) 62 | } 63 | } 64 | 65 | let context = AtomTestContext() 66 | 67 | let value0 = context.watch(TestAtom()) 68 | XCTAssertEqual(value0, 0) 69 | 70 | context[TestAtom()] = 1 71 | 72 | let value1 = context.watch(TestAtom()) 73 | XCTAssertEqual(value1, 1) 74 | 75 | // Updated by the depenency update. 76 | 77 | Task { 78 | context[Dependency1Atom()] = 0 79 | } 80 | 81 | await context.waitForUpdate() 82 | 83 | let value2 = context.watch(TestAtom()) 84 | XCTAssertEqual(value2, 0) 85 | } 86 | 87 | @MainActor 88 | func testEffect() { 89 | let effect = TestEffect() 90 | let atom = TestStateAtom(defaultValue: 0, effect: effect) 91 | let context = AtomTestContext() 92 | 93 | context.watch(atom) 94 | 95 | XCTAssertEqual(effect.initializedCount, 1) 96 | XCTAssertEqual(effect.updatedCount, 0) 97 | XCTAssertEqual(effect.releasedCount, 0) 98 | 99 | context.set(1, for: atom) 100 | context.set(2, for: atom) 101 | context.set(3, for: atom) 102 | 103 | XCTAssertEqual(effect.initializedCount, 1) 104 | XCTAssertEqual(effect.updatedCount, 3) 105 | XCTAssertEqual(effect.releasedCount, 0) 106 | 107 | context.unwatch(atom) 108 | 109 | XCTAssertEqual(effect.initializedCount, 1) 110 | XCTAssertEqual(effect.updatedCount, 3) 111 | XCTAssertEqual(effect.releasedCount, 1) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Atom/ValueAtomTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class ValueAtomTests: XCTestCase { 6 | @MainActor 7 | func testValue() { 8 | let atom = TestValueAtom(value: 0) 9 | let context = AtomTestContext() 10 | 11 | do { 12 | // Initial value 13 | let value = context.watch(atom) 14 | XCTAssertEqual(value, 0) 15 | } 16 | 17 | do { 18 | // Override 19 | context.unwatch(atom) 20 | context.override(atom) { _ in 1 } 21 | 22 | XCTAssertEqual(context.watch(atom), 1) 23 | } 24 | } 25 | 26 | @MainActor 27 | func testEffect() async { 28 | let effect = TestEffect() 29 | let atom = TestValueAtom(value: 0, effect: effect) 30 | let context = AtomTestContext() 31 | 32 | context.watch(atom) 33 | 34 | XCTAssertEqual(effect.initializedCount, 1) 35 | XCTAssertEqual(effect.updatedCount, 0) 36 | XCTAssertEqual(effect.releasedCount, 0) 37 | 38 | context.reset(atom) 39 | context.reset(atom) 40 | context.reset(atom) 41 | 42 | XCTAssertEqual(effect.initializedCount, 1) 43 | XCTAssertEqual(effect.updatedCount, 3) 44 | XCTAssertEqual(effect.releasedCount, 0) 45 | 46 | context.unwatch(atom) 47 | 48 | XCTAssertEqual(effect.initializedCount, 1) 49 | XCTAssertEqual(effect.updatedCount, 3) 50 | XCTAssertEqual(effect.releasedCount, 1) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Context/AtomContextTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class AtomContextTests: XCTestCase { 6 | @MainActor 7 | func testSubscript() { 8 | let atom = TestStateAtom(defaultValue: 0) 9 | let context: any AtomWatchableContext = AtomTestContext() 10 | 11 | XCTAssertEqual(context.watch(atom), 0) 12 | 13 | context[atom] = 100 14 | 15 | XCTAssertEqual(context[atom], 100) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/Atom/ModifiedAtomTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class ModifiedAtomTests: XCTestCase { 6 | @MainActor 7 | func testKey() { 8 | let base = TestAtom(value: 0) 9 | let modifier = ChangesOfModifier(keyPath: \.description) 10 | let atom = ModifiedAtom(atom: base, modifier: modifier) 11 | 12 | XCTAssertEqual(atom.key, atom.key) 13 | XCTAssertEqual(atom.key.hashValue, atom.key.hashValue) 14 | XCTAssertNotEqual(AnyHashable(atom.key), AnyHashable(modifier.key)) 15 | XCTAssertNotEqual(AnyHashable(atom.key).hashValue, AnyHashable(modifier.key).hashValue) 16 | XCTAssertNotEqual(AnyHashable(atom.key), AnyHashable(base.key)) 17 | XCTAssertNotEqual(AnyHashable(atom.key).hashValue, AnyHashable(base.key).hashValue) 18 | } 19 | 20 | @MainActor 21 | func testValue() async { 22 | let base = TestStateAtom(defaultValue: "test") 23 | let modifier = ChangesOfModifier(keyPath: \.count) 24 | let atom = ModifiedAtom(atom: base, modifier: modifier) 25 | let context = AtomTestContext() 26 | 27 | do { 28 | // Initial value 29 | XCTAssertEqual(context.watch(atom), 4) 30 | } 31 | 32 | do { 33 | // Update 34 | Task { 35 | context[base] = "testtest" 36 | } 37 | 38 | await context.waitForUpdate() 39 | XCTAssertEqual(context.watch(atom), 8) 40 | } 41 | 42 | do { 43 | // Override 44 | context.unwatch(atom) 45 | context.override(atom) { _ in 46 | 100 47 | } 48 | 49 | XCTAssertEqual(context.watch(atom), 100) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/AtomCacheTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class AtomCacheTests: XCTestCase { 6 | @MainActor 7 | func testUpdated() { 8 | let atom = TestAtom(value: 0) 9 | let scopeToken = ScopeKey.Token() 10 | let scopeValues = ScopeValues( 11 | key: scopeToken.key, 12 | observers: [], 13 | overrideContainer: OverrideContainer(), 14 | ancestorScopeKeys: [:] 15 | ) 16 | let cache = AtomCache(atom: atom, value: 0, scopeValues: scopeValues) 17 | let updated = cache.updated(value: 1) 18 | 19 | XCTAssertEqual(updated.atom, atom) 20 | XCTAssertEqual(updated.value, 1) 21 | XCTAssertEqual(updated.scopeValues?.key, scopeToken.key) 22 | } 23 | 24 | @MainActor 25 | func testDescription() { 26 | let atom = TestAtom(value: 0) 27 | let cache = AtomCache(atom: atom, value: 0) 28 | 29 | XCTAssertEqual(cache.description, "0") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/AtomKeyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class AtomKeyTests: XCTestCase { 6 | func testKeyHashableForSameAtoms() { 7 | let atom = TestAtom(value: 0) 8 | let key0 = AtomKey(atom) 9 | let key1 = AtomKey(atom) 10 | 11 | XCTAssertEqual(key0, key1) 12 | XCTAssertEqual(key0.hashValue, key1.hashValue) 13 | } 14 | 15 | func testKeyHashableForDifferentAtoms() { 16 | let atom0 = TestAtom(value: 0) 17 | let atom1 = TestAtom(value: 1) 18 | let key0 = AtomKey(atom0) 19 | let key1 = AtomKey(atom1) 20 | 21 | XCTAssertNotEqual(key0, key1) 22 | XCTAssertNotEqual(key0.hashValue, key1.hashValue) 23 | } 24 | 25 | func testDictionaryKey() { 26 | let atom0 = TestAtom(value: 0) 27 | let atom1 = TestAtom(value: 1) 28 | let key0 = AtomKey(atom0) 29 | let key1 = AtomKey(atom1) 30 | let key2 = AtomKey(atom1) 31 | var dictionary = [AtomKey: Int]() 32 | 33 | dictionary[key0] = 100 34 | dictionary[key1] = 200 35 | dictionary[key2] = 300 36 | 37 | XCTAssertEqual(dictionary[key0], 100) 38 | XCTAssertEqual(dictionary[key1], 300) 39 | XCTAssertEqual(dictionary[key2], 300) 40 | } 41 | 42 | @MainActor 43 | func testDescription() { 44 | let atom = TestAtom(value: 0) 45 | let scopeToken = ScopeKey.Token() 46 | let key0 = AtomKey(atom) 47 | let key1 = AtomKey(atom, scopeKey: scopeToken.key) 48 | 49 | XCTAssertEqual(key0.description, "TestAtom") 50 | XCTAssertEqual(key1.description, "TestAtom scope:\(scopeToken.key.description)") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/AtomProducerContextTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class AtomProducerContextTests: XCTestCase { 6 | @MainActor 7 | func testUpdate() { 8 | let atom = TestValueAtom(value: 0) 9 | let transactionState = TransactionState(key: AtomKey(atom)) 10 | var updatedValue: Int? 11 | 12 | let context = AtomProducerContext(store: .dummy, transactionState: transactionState) { value in 13 | updatedValue = value 14 | } 15 | 16 | context.update(with: 1) 17 | 18 | XCTAssertEqual(updatedValue, 1) 19 | } 20 | 21 | @MainActor 22 | func testOnTermination() { 23 | let atom = TestValueAtom(value: 0) 24 | let transactionState = TransactionState(key: AtomKey(atom)) 25 | let context = AtomProducerContext(store: .dummy, transactionState: transactionState) { _ in } 26 | 27 | context.onTermination = {} 28 | XCTAssertNotNil(context.onTermination) 29 | 30 | transactionState.terminate() 31 | XCTAssertNil(context.onTermination) 32 | 33 | context.onTermination = {} 34 | XCTAssertNil(context.onTermination) 35 | } 36 | 37 | @MainActor 38 | func testTransaction() { 39 | let atom = TestValueAtom(value: 0) 40 | var didBegin = false 41 | var didCommit = false 42 | let transactionState = TransactionState(key: AtomKey(atom)) { 43 | didBegin = true 44 | return { didCommit = true } 45 | } 46 | let context = AtomProducerContext(store: .dummy, transactionState: transactionState) { _ in } 47 | 48 | context.transaction { _ in } 49 | 50 | XCTAssertTrue(didBegin) 51 | XCTAssertTrue(didCommit) 52 | } 53 | 54 | @MainActor 55 | func testAsyncTransaction() async { 56 | let atom = TestValueAtom(value: 0) 57 | var didBegin = false 58 | var didCommit = false 59 | let transactionState = TransactionState(key: AtomKey(atom)) { 60 | didBegin = true 61 | return { didCommit = true } 62 | } 63 | let context = AtomProducerContext(store: .dummy, transactionState: transactionState) { _ in } 64 | 65 | await context.transaction { _ in 66 | try? await Task.sleep(seconds: 0) 67 | } 68 | 69 | XCTAssertTrue(didBegin) 70 | XCTAssertTrue(didCommit) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/EnvironmentTests.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import XCTest 3 | 4 | @testable import Atoms 5 | 6 | final class EnvironmentTests: XCTestCase { 7 | @MainActor 8 | func testStore() { 9 | let store = AtomStore() 10 | let scopeToken = ScopeKey.Token() 11 | let atom = TestValueAtom(value: 0) 12 | var environment = EnvironmentValues() 13 | 14 | store.caches = [AtomKey(atom): AtomCache(atom: atom, value: 100)] 15 | environment.store = .root(store: store, scopeKey: scopeToken.key) 16 | 17 | XCTAssertEqual(environment.store?.read(atom), 100) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/OverrideContainerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class OverrideContainerTests: XCTestCase { 6 | @MainActor 7 | func testGetOverride() { 8 | let atom = TestAtom(value: 0) 9 | let otherAtom = TestAtom(value: 1) 10 | var overrideContainer = OverrideContainer() 11 | 12 | XCTAssertNil(overrideContainer.getOverride(for: atom)) 13 | XCTAssertNil(overrideContainer.getOverride(for: otherAtom)) 14 | 15 | overrideContainer.addOverride(for: TestAtom.self) { _ in 16 | 0 17 | } 18 | 19 | XCTAssertEqual(overrideContainer.getOverride(for: atom)?.getValue(atom), 0) 20 | XCTAssertEqual(overrideContainer.getOverride(for: otherAtom)?.getValue(atom), 0) 21 | 22 | overrideContainer.addOverride(for: TestAtom.self) { _ in 23 | 1 24 | } 25 | 26 | XCTAssertEqual(overrideContainer.getOverride(for: atom)?.getValue(atom), 1) 27 | XCTAssertEqual(overrideContainer.getOverride(for: otherAtom)?.getValue(atom), 1) 28 | 29 | overrideContainer.addOverride(for: atom) { _ in 30 | 2 31 | } 32 | 33 | XCTAssertEqual(overrideContainer.getOverride(for: atom)?.getValue(atom), 2) 34 | XCTAssertEqual(overrideContainer.getOverride(for: otherAtom)?.getValue(atom), 1) 35 | 36 | overrideContainer.addOverride(for: atom) { _ in 37 | 3 38 | } 39 | 40 | XCTAssertEqual(overrideContainer.getOverride(for: atom)?.getValue(atom), 3) 41 | XCTAssertEqual(overrideContainer.getOverride(for: otherAtom)?.getValue(atom), 1) 42 | 43 | overrideContainer.addOverride(for: otherAtom) { _ in 44 | 4 45 | } 46 | 47 | XCTAssertEqual(overrideContainer.getOverride(for: atom)?.getValue(atom), 3) 48 | XCTAssertEqual(overrideContainer.getOverride(for: otherAtom)?.getValue(atom), 4) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/ScopeIDTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class ScopeIDTests: XCTestCase { 6 | func testHashable() { 7 | let id0 = ScopeID(0) 8 | let id1 = id0 9 | let id2 = ScopeID(1) 10 | 11 | XCTAssertEqual(id0, id1) 12 | XCTAssertNotEqual(id1, id2) 13 | XCTAssertEqual(id0.hashValue, id1.hashValue) 14 | XCTAssertNotEqual(id1.hashValue, id2.hashValue) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/ScopeKeyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class ScopeKeyTests: XCTestCase { 6 | @MainActor 7 | func testDescription() { 8 | let token = ScopeKey.Token() 9 | let objectAddress = String(UInt(bitPattern: ObjectIdentifier(token)), radix: 16) 10 | 11 | XCTAssertEqual(token.key.description, "0x\(objectAddress)") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/SubscriberKeyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class SubscriberKeyTests: XCTestCase { 6 | @MainActor 7 | func testKeyHashableForSameToken() { 8 | let token = SubscriberKey.Token() 9 | 10 | XCTAssertEqual(token.key, token.key) 11 | XCTAssertEqual(token.key.hashValue, token.key.hashValue) 12 | } 13 | 14 | @MainActor 15 | func testKeyHashableForDifferentToken() { 16 | let token0 = SubscriberKey.Token() 17 | let token1 = SubscriberKey.Token() 18 | 19 | XCTAssertNotEqual(token0.key, token1.key) 20 | XCTAssertNotEqual(token0.key.hashValue, token1.key.hashValue) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/SubscriberStateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class SubscriberStateTests: XCTestCase { 6 | @MainActor 7 | func testUnsubscribeOnDeinit() { 8 | var subscriberState: SubscriberState? = SubscriberState() 9 | var unsubscribedCount = 0 10 | 11 | subscriberState!.unsubscribe = { 12 | unsubscribedCount += 1 13 | } 14 | 15 | subscriberState = nil 16 | 17 | XCTAssertEqual(unsubscribedCount, 1) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/SubscriberTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class SubscriberTests: XCTestCase { 6 | @MainActor 7 | func testKey() { 8 | let state0 = SubscriberState() 9 | let state1 = state0 10 | let state2 = SubscriberState() 11 | 12 | XCTAssertEqual( 13 | Subscriber(state0).key, 14 | Subscriber(state0).key 15 | ) 16 | XCTAssertEqual( 17 | Subscriber(state0).key, 18 | Subscriber(state1).key 19 | ) 20 | XCTAssertNotEqual( 21 | Subscriber(state0).key, 22 | Subscriber(state2).key 23 | ) 24 | } 25 | 26 | @MainActor 27 | func testUnsubscribe() { 28 | var state: SubscriberState? = SubscriberState() 29 | let subscriber = Subscriber(state!) 30 | var isUnsubscribed = false 31 | 32 | subscriber.unsubscribe = { 33 | isUnsubscribed = true 34 | } 35 | 36 | state?.unsubscribe?() 37 | 38 | XCTAssertTrue(isUnsubscribed) 39 | 40 | state = nil 41 | isUnsubscribed = false 42 | subscriber.unsubscribe?() 43 | 44 | XCTAssertFalse(isUnsubscribed) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/TopologicalSortTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class TopologicalSortTests: XCTestCase { 6 | @MainActor 7 | func testSort() { 8 | let store = AtomStore() 9 | let key0 = AtomKey(TestAtom(value: 0)) 10 | let key1 = AtomKey(TestAtom(value: 1)) 11 | let key2 = AtomKey(TestAtom(value: 2)) 12 | let key3 = AtomKey(TestAtom(value: 3)) 13 | let token = SubscriberKey.Token() 14 | 15 | store.dependencies = [ 16 | key1: [key0], 17 | key2: [key0, key1], 18 | key3: [key2], 19 | ] 20 | store.children = [ 21 | key0: [key1, key2], 22 | key1: [key2], 23 | key2: [key3], 24 | ] 25 | store.subscriptions = [ 26 | key2: [token.key: Subscription()], 27 | key3: [token.key: Subscription()], 28 | ] 29 | 30 | let sorted = store.topologicalSorted(key: key0) 31 | let expectedEdges = [ 32 | [ 33 | Edge( 34 | from: key0, 35 | to: .atom(key: key1) 36 | ), 37 | Edge( 38 | from: key1, 39 | to: .atom(key: key2) 40 | ), 41 | Edge( 42 | from: key2, 43 | to: .atom(key: key3) 44 | ), 45 | Edge( 46 | from: key3, 47 | to: .subscriber(key: token.key) 48 | ), 49 | ], 50 | [ 51 | Edge( 52 | from: key0, 53 | to: .atom(key: key1) 54 | ), 55 | Edge( 56 | from: key0, 57 | to: .atom(key: key2) 58 | ), 59 | Edge( 60 | from: key2, 61 | to: .atom(key: key3) 62 | ), 63 | Edge( 64 | from: key3, 65 | to: .subscriber(key: token.key) 66 | ), 67 | ], 68 | ] 69 | let expectedRedundants: [[Vertex: ContiguousArray]] = [ 70 | [ 71 | .atom(key: key2): [key0], 72 | .atom(key: key3): [key2], 73 | .subscriber(key: token.key): [key2, key3, key2], 74 | ], 75 | [ 76 | .atom(key: key2): [key1], 77 | .atom(key: key3): [key2], 78 | .subscriber(key: token.key): [key2, key3, key2], 79 | ], 80 | ] 81 | 82 | XCTAssertTrue(expectedEdges.contains(Array(sorted.edges))) 83 | XCTAssertTrue(expectedRedundants.contains(sorted.redundantDependencies)) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Core/TransactionTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class TransactionTests: XCTestCase { 6 | @MainActor 7 | func testCommit() { 8 | let key = AtomKey(TestValueAtom(value: 0)) 9 | var beginCount = 0 10 | var commitCount = 0 11 | let transactionState = TransactionState(key: key) { 12 | beginCount += 1 13 | return { commitCount += 1 } 14 | } 15 | 16 | XCTAssertEqual(beginCount, 0) 17 | XCTAssertEqual(commitCount, 0) 18 | 19 | transactionState.commit() 20 | 21 | XCTAssertEqual(beginCount, 0) 22 | XCTAssertEqual(commitCount, 0) 23 | 24 | transactionState.begin() 25 | 26 | XCTAssertEqual(beginCount, 1) 27 | XCTAssertEqual(commitCount, 0) 28 | 29 | transactionState.commit() 30 | 31 | XCTAssertEqual(beginCount, 1) 32 | XCTAssertEqual(commitCount, 1) 33 | 34 | transactionState.begin() 35 | transactionState.commit() 36 | 37 | XCTAssertEqual(beginCount, 1) 38 | XCTAssertEqual(commitCount, 1) 39 | } 40 | 41 | @MainActor 42 | func testOnTermination() { 43 | let key = AtomKey(TestValueAtom(value: 0)) 44 | let transactionState = TransactionState(key: key) 45 | 46 | XCTAssertNil(transactionState.onTermination) 47 | 48 | transactionState.onTermination = {} 49 | XCTAssertNotNil(transactionState.onTermination) 50 | 51 | transactionState.terminate() 52 | XCTAssertNil(transactionState.onTermination) 53 | 54 | transactionState.onTermination = {} 55 | XCTAssertNil(transactionState.onTermination) 56 | } 57 | 58 | @MainActor 59 | func testTerminate() { 60 | let key = AtomKey(TestValueAtom(value: 0)) 61 | var didBegin = false 62 | var didCommit = false 63 | var didTerminate = false 64 | let transactionState = TransactionState(key: key) { 65 | didBegin = true 66 | return { didCommit = true } 67 | } 68 | 69 | transactionState.onTermination = { 70 | didTerminate = true 71 | } 72 | 73 | XCTAssertFalse(didBegin) 74 | XCTAssertFalse(didCommit) 75 | XCTAssertFalse(didTerminate) 76 | XCTAssertFalse(transactionState.isTerminated) 77 | XCTAssertNotNil(transactionState.onTermination) 78 | 79 | transactionState.begin() 80 | transactionState.terminate() 81 | 82 | XCTAssertTrue(didBegin) 83 | XCTAssertTrue(didCommit) 84 | XCTAssertTrue(didTerminate) 85 | XCTAssertTrue(transactionState.isTerminated) 86 | XCTAssertNil(transactionState.onTermination) 87 | 88 | didTerminate = false 89 | transactionState.onTermination = { 90 | didTerminate = true 91 | } 92 | 93 | XCTAssertTrue(didTerminate) 94 | XCTAssertNil(transactionState.onTermination) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Effect/InitializeEffectTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class InitializeEffectTests: XCTestCase { 6 | @MainActor 7 | func testEvent() { 8 | let context = AtomCurrentContext(store: .dummy) 9 | var performedCount = 0 10 | let effect = InitializeEffect { 11 | performedCount += 1 12 | } 13 | 14 | effect.initializing(context: context) 15 | XCTAssertEqual(performedCount, 0) 16 | 17 | effect.initialized(context: context) 18 | XCTAssertEqual(performedCount, 1) 19 | 20 | effect.updated(context: context) 21 | XCTAssertEqual(performedCount, 1) 22 | 23 | effect.released(context: context) 24 | XCTAssertEqual(performedCount, 1) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Effect/InitializingEffectTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class InitializingEffectTests: XCTestCase { 6 | @MainActor 7 | func testEvent() { 8 | let context = AtomCurrentContext(store: .dummy) 9 | var performedCount = 0 10 | let effect = InitializingEffect { 11 | performedCount += 1 12 | } 13 | 14 | effect.initializing(context: context) 15 | XCTAssertEqual(performedCount, 1) 16 | 17 | effect.initialized(context: context) 18 | XCTAssertEqual(performedCount, 1) 19 | 20 | effect.updated(context: context) 21 | XCTAssertEqual(performedCount, 1) 22 | 23 | effect.released(context: context) 24 | XCTAssertEqual(performedCount, 1) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Effect/ReleaseEffectTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class ReleaseEffectTests: XCTestCase { 6 | @MainActor 7 | func testEvent() { 8 | let context = AtomCurrentContext(store: .dummy) 9 | var performedCount = 0 10 | let effect = ReleaseEffect { 11 | performedCount += 1 12 | } 13 | 14 | effect.initializing(context: context) 15 | XCTAssertEqual(performedCount, 0) 16 | 17 | effect.initialized(context: context) 18 | XCTAssertEqual(performedCount, 0) 19 | 20 | effect.updated(context: context) 21 | XCTAssertEqual(performedCount, 0) 22 | 23 | effect.released(context: context) 24 | XCTAssertEqual(performedCount, 1) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Effect/UpdateEffectTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class UpdateEffectTests: XCTestCase { 6 | @MainActor 7 | func testEvent() { 8 | let context = AtomCurrentContext(store: .dummy) 9 | var performedCount = 0 10 | let effect = UpdateEffect { 11 | performedCount += 1 12 | } 13 | 14 | effect.initializing(context: context) 15 | XCTAssertEqual(performedCount, 0) 16 | 17 | effect.initialized(context: context) 18 | XCTAssertEqual(performedCount, 0) 19 | 20 | effect.updated(context: context) 21 | XCTAssertEqual(performedCount, 1) 22 | 23 | effect.released(context: context) 24 | XCTAssertEqual(performedCount, 1) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Modifier/ChangesModifierTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class ChangesModifierTests: XCTestCase { 6 | @MainActor 7 | func testChanges() { 8 | let atom = TestStateAtom(defaultValue: "") 9 | let context = AtomTestContext() 10 | var updatedCount = 0 11 | 12 | context.onUpdate = { 13 | updatedCount += 1 14 | } 15 | 16 | XCTAssertEqual(updatedCount, 0) 17 | XCTAssertEqual(context.watch(atom.changes), "") 18 | 19 | context[atom] = "modified" 20 | 21 | XCTAssertEqual(updatedCount, 1) 22 | XCTAssertEqual(context.watch(atom.changes), "modified") 23 | 24 | context[atom] = "modified" 25 | 26 | // Should not be updated with an equivalent value. 27 | XCTAssertEqual(updatedCount, 1) 28 | } 29 | 30 | @MainActor 31 | func testKey() { 32 | let modifier = ChangesModifier() 33 | 34 | XCTAssertEqual(modifier.key, modifier.key) 35 | XCTAssertEqual(modifier.key.hashValue, modifier.key.hashValue) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Modifier/ChangesOfModifierTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class ChangesOfModifierTests: XCTestCase { 6 | @MainActor 7 | func testChangesOf() { 8 | let atom = TestStateAtom(defaultValue: "") 9 | let context = AtomTestContext() 10 | var updatedCount = 0 11 | 12 | context.onUpdate = { 13 | updatedCount += 1 14 | } 15 | 16 | XCTAssertEqual(updatedCount, 0) 17 | XCTAssertEqual(context.watch(atom.changes(of: \.count)), 0) 18 | 19 | context[atom] = "modified" 20 | 21 | XCTAssertEqual(updatedCount, 1) 22 | XCTAssertEqual(context.watch(atom.changes(of: \.count)), 8) 23 | context[atom] = "modified" 24 | 25 | // Should not be updated with an equivalent value. 26 | XCTAssertEqual(updatedCount, 1) 27 | } 28 | 29 | func testKey() { 30 | let modifier0 = ChangesOfModifier(keyPath: \.byteSwapped) 31 | let modifier1 = ChangesOfModifier(keyPath: \.leadingZeroBitCount) 32 | 33 | XCTAssertEqual(modifier0.key, modifier0.key) 34 | XCTAssertEqual(modifier0.key.hashValue, modifier0.key.hashValue) 35 | XCTAssertNotEqual(modifier0.key, modifier1.key) 36 | XCTAssertNotEqual(modifier0.key.hashValue, modifier1.key.hashValue) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/AtomsTests/Modifier/TaskPhaseModifierTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import XCTest 3 | 4 | @testable import Atoms 5 | 6 | final class TaskPhaseModifierTests: XCTestCase { 7 | @MainActor 8 | func testPhase() async { 9 | let atom = TestTaskAtom { 0 }.phase 10 | let context = AtomTestContext() 11 | 12 | XCTAssertEqual(context.watch(atom), .suspending) 13 | 14 | await context.wait(for: atom, until: \.isSuccess) 15 | 16 | XCTAssertEqual(context.watch(atom), .success(0)) 17 | } 18 | 19 | @MainActor 20 | func testRefresh() async { 21 | let atom = TestTaskAtom { 0 }.phase 22 | let context = AtomTestContext() 23 | 24 | XCTAssertEqual(context.watch(atom), .suspending) 25 | 26 | let phase = await context.refresh(atom) 27 | XCTAssertEqual(phase, .success(0)) 28 | XCTAssertEqual(context.watch(atom), .success(0)) 29 | } 30 | 31 | func testKey() { 32 | let modifier = TaskPhaseModifier() 33 | 34 | XCTAssertEqual(modifier.key, modifier.key) 35 | XCTAssertEqual(modifier.key.hashValue, modifier.key.hashValue) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/AtomsTests/SnapshotTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Atoms 4 | 5 | final class SnapshotTests: XCTestCase { 6 | @MainActor 7 | func testLookup() { 8 | let atom0 = TestAtom(value: 0) 9 | let atom1 = TestAtom(value: 1) 10 | let atomCache = [ 11 | AtomKey(atom0): AtomCache(atom: atom0, value: 0) 12 | ] 13 | let snapshot = Snapshot( 14 | dependencies: [:], 15 | children: [:], 16 | caches: atomCache, 17 | subscriptions: [:] 18 | ) 19 | 20 | XCTAssertEqual(snapshot.lookup(atom0), 0) 21 | XCTAssertNil(snapshot.lookup(atom1)) 22 | } 23 | 24 | func testEmptyGraphDescription() { 25 | let snapshot = Snapshot( 26 | dependencies: [:], 27 | children: [:], 28 | caches: [:], 29 | subscriptions: [:] 30 | ) 31 | 32 | XCTAssertEqual(snapshot.graphDescription(), "digraph {}") 33 | } 34 | 35 | @MainActor 36 | func testGraphDescription() { 37 | struct Value0: Hashable {} 38 | struct Value1: Hashable {} 39 | struct Value2: Hashable {} 40 | struct Value3: Hashable {} 41 | struct Value4: Hashable {} 42 | 43 | let scopeToken = ScopeKey.Token() 44 | let atom0 = TestAtom(value: Value0()) 45 | let atom1 = TestAtom(value: Value1()) 46 | let atom2 = TestAtom(value: Value2()) 47 | let atom3 = TestAtom(value: Value3()) 48 | let atom4 = TestAtom(value: Value4()) 49 | let key0 = AtomKey(atom0) 50 | let key1 = AtomKey(atom1) 51 | let key2 = AtomKey(atom2) 52 | let key3 = AtomKey(atom3) 53 | let key4 = AtomKey(atom4, scopeKey: scopeToken.key) 54 | let location = SourceLocation(fileID: "Module/View.swift", line: 10) 55 | let subscriberToken = SubscriberKey.Token() 56 | let subscription = Subscription( 57 | location: location, 58 | update: {} 59 | ) 60 | let snapshot = Snapshot( 61 | dependencies: [ 62 | key1: [key0], 63 | key2: [key1], 64 | key3: [key2], 65 | ], 66 | children: [ 67 | key0: [key1], 68 | key1: [key2], 69 | key2: [key3], 70 | ], 71 | caches: [ 72 | key0: AtomCache(atom: atom0, value: Value0()), 73 | key1: AtomCache(atom: atom1, value: Value1()), 74 | key2: AtomCache(atom: atom2, value: Value2()), 75 | key3: AtomCache(atom: atom3, value: Value3()), 76 | key4: AtomCache(atom: atom4, value: Value4()), 77 | ], 78 | subscriptions: [ 79 | key2: [subscriberToken.key: subscription], 80 | key3: [subscriberToken.key: subscription], 81 | key4: [subscriberToken.key: subscription], 82 | ] 83 | ) 84 | 85 | XCTAssertEqual( 86 | snapshot.graphDescription(), 87 | """ 88 | digraph { 89 | node [shape=box] 90 | "Module/View.swift" [style=filled] 91 | "TestAtom" 92 | "TestAtom" -> "TestAtom" 93 | "TestAtom" 94 | "TestAtom" -> "TestAtom" 95 | "TestAtom" 96 | "TestAtom" -> "Module/View.swift" [label="line:10"] 97 | "TestAtom" -> "TestAtom" 98 | "TestAtom" 99 | "TestAtom" -> "Module/View.swift" [label="line:10"] 100 | "TestAtom scope:\(scopeToken.key.description)" 101 | "TestAtom scope:\(scopeToken.key.description)" -> "Module/View.swift" [label="line:10"] 102 | } 103 | """ 104 | ) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Tools/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "dev-tools", 7 | dependencies: [ 8 | .package(name: "swiftui-atom-properties", path: ".."), 9 | .package(url: "https://github.com/apple/swift-format.git", exact: "600.0.0"), 10 | .package(url: "https://github.com/apple/swift-docc-plugin", exact: "1.4.3"), 11 | .package(url: "https://github.com/yonaskolb/XcodeGen.git", exact: "2.42.0"), 12 | ] 13 | ) 14 | -------------------------------------------------------------------------------- /assets/assets.key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/swiftui-atom-properties/a22c7df107a5b24bcd0f6fdad32ca05f5314bb58/assets/assets.key -------------------------------------------------------------------------------- /assets/dependency_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/swiftui-atom-properties/a22c7df107a5b24bcd0f6fdad32ca05f5314bb58/assets/dependency_graph.png -------------------------------------------------------------------------------- /assets/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/swiftui-atom-properties/a22c7df107a5b24bcd0f6fdad32ca05f5314bb58/assets/diagram.png -------------------------------------------------------------------------------- /assets/example_counter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/swiftui-atom-properties/a22c7df107a5b24bcd0f6fdad32ca05f5314bb58/assets/example_counter.png -------------------------------------------------------------------------------- /assets/example_map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/swiftui-atom-properties/a22c7df107a5b24bcd0f6fdad32ca05f5314bb58/assets/example_map.png -------------------------------------------------------------------------------- /assets/example_time_travel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/swiftui-atom-properties/a22c7df107a5b24bcd0f6fdad32ca05f5314bb58/assets/example_time_travel.png -------------------------------------------------------------------------------- /assets/example_tmdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/swiftui-atom-properties/a22c7df107a5b24bcd0f6fdad32ca05f5314bb58/assets/example_tmdb.png -------------------------------------------------------------------------------- /assets/example_todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/swiftui-atom-properties/a22c7df107a5b24bcd0f6fdad32ca05f5314bb58/assets/example_todo.png -------------------------------------------------------------------------------- /assets/example_voice_memo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ra1028/swiftui-atom-properties/a22c7df107a5b24bcd0f6fdad32ca05f5314bb58/assets/example_voice_memo.png -------------------------------------------------------------------------------- /scripts/swift-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | function swift_build() { 6 | swift build -c release --package-path Tools $@ 7 | } 8 | 9 | PACKAGE=$1 10 | ARGS=${@:2} 11 | BASE_DIR="Tools" 12 | PACKAGE_SWIFT="$BASE_DIR/Package.swift" 13 | BIN_DIR="$BASE_DIR/bin" 14 | BIN="$BIN_DIR/$PACKAGE" 15 | CHECKSUM_FILE="$BIN.checksum" 16 | 17 | pushd "$(cd $(dirname $0)/.. && pwd)" &>/dev/null 18 | 19 | swift_version="$(swift --version 2>/dev/null | head -n 1)" 20 | swift_version_hash=$(echo $swift_version | md5 -q) 21 | package_hash=$(md5 -q $PACKAGE_SWIFT) 22 | checksum=$(echo $swift_version_hash $package_hash | md5 -q) 23 | 24 | echo "CHECKSUM: $checksum" 25 | 26 | if [[ ! -f $BIN || $checksum != $(cat $CHECKSUM_FILE 2>/dev/null) ]]; then 27 | echo "Building..." 28 | swift_build --product $PACKAGE 29 | mkdir -p $BIN_DIR 30 | mv -f $(swift_build --show-bin-path)/$PACKAGE $BIN 31 | 32 | if [[ -e ${SWIFT_PACKAGE_RESOURCES:-} ]]; then 33 | echo "Copying $SWIFT_PACKAGE_RESOURCES..." 34 | cp -Rf $SWIFT_PACKAGE_RESOURCES $BIN_DIR 35 | fi 36 | 37 | echo "$checksum" >"$CHECKSUM_FILE" 38 | fi 39 | 40 | $BIN $ARGS 41 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | pushd "$(cd $(dirname $0)/.. && pwd)" &>/dev/null 6 | 7 | target=$1 8 | options=() 9 | args=() 10 | 11 | case $target in 12 | library) 13 | options+=("-scheme swiftui-atom-properties") 14 | ;; 15 | example-ios) 16 | cd Examples/Packages/iOS 17 | options+=("-scheme iOSExamples") 18 | ;; 19 | example-cross-platform) 20 | cd Examples/Packages/CrossPlatform 21 | options+=("-scheme CrossPlatformExamples") 22 | ;; 23 | benchmark) 24 | cd Benchmarks 25 | options+=("-scheme BenchmarkTests") 26 | ;; 27 | esac 28 | 29 | shift 30 | 31 | while [[ $# -gt 0 ]]; do 32 | case "$1" in 33 | -destinations) 34 | shift 35 | while [[ $# -gt 0 && ! "$1" =~ ^- ]]; do 36 | options+=("-destination \"$1\"") 37 | shift 38 | done 39 | ;; 40 | *) 41 | args+=("$1") 42 | shift 43 | ;; 44 | esac 45 | done 46 | 47 | eval xcodebuild clean test -configuration Release ENABLE_TESTABILITY=YES "${options[@]-}" "${args[@]-}" 48 | --------------------------------------------------------------------------------