├── .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 |
--------------------------------------------------------------------------------