diff --git a/README.md b/README.md index c96df35f..3da80d62 100644 --- a/README.md +++ b/README.md @@ -722,6 +722,47 @@ struct CounterView: View { +#### [latest(_:)](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atom/latest(_:)) + +| |Description| +|:--------------|:----------| +|Summary |Provides the latest value that matches the specified condition instead of the current value.| +|Output |`T?`| +|Compatible |All atom types.| +|Use Case |Keep last valid value, Retain matching state| + +
📖 Example + +```swift +struct Item { + let id: Int + let isValid: Bool +} + +struct ItemAtom: StateAtom, Hashable { + func defaultValue(context: Context) -> Item { + Item(id: 0, isValid: false) + } +} + +struct ExampleView: View { + @Watch(ItemAtom()) + var currentItem + + @Watch(ItemAtom().latest(\.isValid)) + var latestValidItem + + var body: some View { + VStack { + Text("Current ID: \(currentItem.id)") + Text("Latest Valid ID: \(latestValidItem?.id ?? 0)") + } + } +} +``` + +
+ #### [changes(of:)](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atom/changes(of:)) | |Description| diff --git a/Sources/Atoms/Atoms.docc/Atoms.md b/Sources/Atoms/Atoms.docc/Atoms.md index 0ffbd6d8..93d28e40 100644 --- a/Sources/Atoms/Atoms.docc/Atoms.md +++ b/Sources/Atoms/Atoms.docc/Atoms.md @@ -29,6 +29,7 @@ Building state by compositing atoms automatically optimizes rendering based on i ### Modifiers - ``Atom/previous`` +- ``Atom/latest(_:)`` - ``Atom/changes`` - ``Atom/changes(of:)`` - ``Atom/animation(_:)`` @@ -86,6 +87,7 @@ Building state by compositing atoms automatically optimizes rendering based on i - ``AtomModifier`` - ``AsyncAtomModifier`` - ``PreviousModifier`` +- ``LatestModifier`` - ``ChangesModifier`` - ``ChangesOfModifier`` - ``TaskPhaseModifier`` diff --git a/Sources/Atoms/Modifier/LatestModifier.swift b/Sources/Atoms/Modifier/LatestModifier.swift new file mode 100644 index 00000000..c2652653 --- /dev/null +++ b/Sources/Atoms/Modifier/LatestModifier.swift @@ -0,0 +1,125 @@ +public extension Atom { + /// Provides the latest value that matches the specified condition instead of the current value. + /// + /// ```swift + /// struct Item { + /// let id: Int + /// let isValid: Bool + /// } + /// + /// struct ItemAtom: StateAtom, Hashable { + /// func defaultValue(context: Context) -> Item { + /// Item(id: 0, isValid: false) + /// } + /// } + /// + /// struct ExampleView: View { + /// @Watch(ItemAtom()) + /// var currentItem + /// + /// @Watch(ItemAtom().latest(\.isValid)) + /// var latestValidItem + /// + /// var body: some View { + /// VStack { + /// Text("Current ID: \(currentItem.id)") + /// Text("Latest Valid ID: \(latestValidItem?.id ?? 0)") + /// } + /// } + /// } + /// ``` + /// + #if hasFeature(InferSendableFromCaptures) + func latest(_ keyPath: any KeyPath & Sendable) -> ModifiedAtom> { + modifier(LatestModifier(keyPath: keyPath)) + } + #else + func latest(_ keyPath: KeyPath) -> ModifiedAtom> { + modifier(LatestModifier(keyPath: keyPath)) + } + #endif +} + +/// A modifier that provides the latest value that matches the specified condition instead of the current value. +/// +/// Use ``Atom/latest(_:)`` instead of using this modifier directly. +public struct LatestModifier: AtomModifier { + /// A type of base value to be modified. + public typealias Base = Base + + /// A type of value the modified atom produces. + public typealias Produced = Base? + + #if hasFeature(InferSendableFromCaptures) + /// A type representing the stable identity of this modifier. + public struct Key: Hashable, Sendable { + private let keyPath: any KeyPath & Sendable + + fileprivate init(keyPath: any KeyPath & Sendable) { + self.keyPath = keyPath + } + } + + private let keyPath: any KeyPath & Sendable + + internal init(keyPath: any KeyPath & Sendable) { + self.keyPath = keyPath + } + + /// A unique value used to identify the modifier internally. + public var key: Key { + Key(keyPath: keyPath) + } + #else + public struct Key: Hashable, Sendable { + private let keyPath: UnsafeUncheckedSendable> + + fileprivate init(keyPath: UnsafeUncheckedSendable>) { + self.keyPath = keyPath + } + } + + private let _keyPath: UnsafeUncheckedSendable> + private var keyPath: KeyPath { + _keyPath.value + } + + internal init(keyPath: KeyPath) { + _keyPath = UnsafeUncheckedSendable(keyPath) + } + + /// A unique value used to identify the modifier internally. + public var key: Key { + Key(keyPath: _keyPath) + } + #endif + + /// A producer that produces the value of this atom. + public func producer(atom: some Atom) -> AtomProducer { + AtomProducer { context in + context.transaction { context in + let value = context.watch(atom) + let storage = context.watch(StorageAtom()) + + if value[keyPath: keyPath] { + storage.latest = value + } + + return storage.latest + } + } + } +} + +private extension LatestModifier { + @MainActor + final class Storage { + var latest: Base? + } + + struct StorageAtom: ValueAtom, Hashable { + func value(context: Context) -> Storage { + Storage() + } + } +} diff --git a/Tests/AtomsTests/Modifier/LatestModifierTests.swift b/Tests/AtomsTests/Modifier/LatestModifierTests.swift new file mode 100644 index 00000000..099b4220 --- /dev/null +++ b/Tests/AtomsTests/Modifier/LatestModifierTests.swift @@ -0,0 +1,140 @@ +import XCTest + +@testable import Atoms + +final class LatestModifierTests: XCTestCase { + struct Item { + let id: Int + let isValid: Bool + } + + @MainActor + func testLatest() { + let atom = TestStateAtom(defaultValue: Item(id: 1, isValid: false)) + let context = AtomTestContext() + + // Initially nil because isValid is false + XCTAssertNil(context.watch(atom.latest(\.isValid))) + + // Update with valid item + context[atom] = Item(id: 2, isValid: true) + + // Should return the valid item + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 2) + + // Update with invalid item + context[atom] = Item(id: 3, isValid: false) + + // Should still return the last valid item + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 2) + + // Update with another valid item + context[atom] = Item(id: 4, isValid: true) + + // Should return the new valid item + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 4) + + // Update with invalid item again + context[atom] = Item(id: 5, isValid: false) + + // Should still return the last valid item + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 4) + } + + @MainActor + func testLatestWithMultipleWatchers() { + let atom = TestStateAtom(defaultValue: Item(id: 1, isValid: false)) + let context = AtomTestContext() + + // Watch both current and latest + XCTAssertEqual(context.watch(atom).id, 1) + XCTAssertNil(context.watch(atom.latest(\.isValid))) + + // Update with valid item + context[atom] = Item(id: 2, isValid: true) + + XCTAssertEqual(context.watch(atom).id, 2) + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 2) + + // Update with invalid item + context[atom] = Item(id: 3, isValid: false) + + XCTAssertEqual(context.watch(atom).id, 3) + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 2) + } + + @MainActor + func testLatestUpdatesDownstream() { + let atom = TestStateAtom(defaultValue: Item(id: 1, isValid: false)) + let context = AtomTestContext() + var updatedCount = 0 + + context.onUpdate = { + updatedCount += 1 + } + + // Initial watch + XCTAssertEqual(updatedCount, 0) + XCTAssertNil(context.watch(atom.latest(\.isValid))) + + // Update with valid item - should trigger update + context[atom] = Item(id: 2, isValid: true) + XCTAssertEqual(updatedCount, 1) + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 2) + + // Update with invalid item - should still trigger update + context[atom] = Item(id: 3, isValid: false) + XCTAssertEqual(updatedCount, 2) + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 2) + + // Update with another valid item - should trigger update + context[atom] = Item(id: 4, isValid: true) + XCTAssertEqual(updatedCount, 3) + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 4) + } + + @MainActor + func testKey() { + let modifier1 = LatestModifier(keyPath: \.isValid) + let modifier2 = LatestModifier(keyPath: \.isValid) + + XCTAssertEqual(modifier1.key, modifier2.key) + XCTAssertEqual(modifier1.key.hashValue, modifier2.key.hashValue) + } + + @MainActor + func testLatestWithBoolValue() { + let atom = TestStateAtom(defaultValue: true) + let context = AtomTestContext() + + // Initially should return the value if it's true + XCTAssertEqual(context.watch(atom.latest(\.self)), true) + + // Update to false + context[atom] = false + + // Should still return the last true value + XCTAssertEqual(context.watch(atom.latest(\.self)), true) + + // Update to true again + context[atom] = true + + // Should return the new true value + XCTAssertEqual(context.watch(atom.latest(\.self)), true) + } + + @MainActor + func testLatestWithInitialValidValue() { + let atom = TestStateAtom(defaultValue: Item(id: 1, isValid: true)) + let context = AtomTestContext() + + // Should immediately return the initial valid value + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 1) + + // Update with invalid item + context[atom] = Item(id: 2, isValid: false) + + // Should still return the initial valid value + XCTAssertEqual(context.watch(atom.latest(\.isValid))?.id, 1) + } +}