Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,47 @@ struct CounterView: View {

</details>

#### [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|

<details><summary><code>📖 Example</code></summary>

```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)")
}
}
}
```

</details>

#### [changes(of:)](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atom/changes(of:))

| |Description|
Expand Down
2 changes: 2 additions & 0 deletions Sources/Atoms/Atoms.docc/Atoms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(_:)``
Expand Down Expand Up @@ -86,6 +87,7 @@ Building state by compositing atoms automatically optimizes rendering based on i
- ``AtomModifier``
- ``AsyncAtomModifier``
- ``PreviousModifier``
- ``LatestModifier``
- ``ChangesModifier``
- ``ChangesOfModifier``
- ``TaskPhaseModifier``
Expand Down
125 changes: 125 additions & 0 deletions Sources/Atoms/Modifier/LatestModifier.swift
Original file line number Diff line number Diff line change
@@ -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<Produced, Bool> & Sendable) -> ModifiedAtom<Self, LatestModifier<Produced>> {
modifier(LatestModifier(keyPath: keyPath))
}
#else
func latest(_ keyPath: KeyPath<Produced, Bool>) -> ModifiedAtom<Self, LatestModifier<Produced>> {
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<Base>: 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<Base, Bool> & Sendable

fileprivate init(keyPath: any KeyPath<Base, Bool> & Sendable) {
self.keyPath = keyPath
}
}

private let keyPath: any KeyPath<Base, Bool> & Sendable

internal init(keyPath: any KeyPath<Base, Bool> & 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<KeyPath<Base, Bool>>

fileprivate init(keyPath: UnsafeUncheckedSendable<KeyPath<Base, Bool>>) {
self.keyPath = keyPath
}
}

private let _keyPath: UnsafeUncheckedSendable<KeyPath<Base, Bool>>
private var keyPath: KeyPath<Base, Bool> {
_keyPath.value
}

internal init(keyPath: KeyPath<Base, Bool>) {
_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<Base>) -> AtomProducer<Produced> {
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()
}
}
}
140 changes: 140 additions & 0 deletions Tests/AtomsTests/Modifier/LatestModifierTests.swift
Original file line number Diff line number Diff line change
@@ -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<Item>(keyPath: \.isValid)
let modifier2 = LatestModifier<Item>(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)
}
}