Skip to content
Open
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
44 changes: 23 additions & 21 deletions Sources/ScreenStatetKit/Actions/ActionLocker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,69 +5,72 @@
// Created by Anthony on 4/12/25.
//


import Foundation


public struct ActionLocker {

/// Use this when the locker is confined to a single actor or execution context.
/// No additional isolation is required as long as it is not accessed concurrently.
/// - Warning: Not thread-safe. For concurrent use, prefer ``ActionLocker/isolated``.
public static var nonIsolated: NonIsolatedActionLocker { .init() }

/// Use this when the locker is shared across multiple actors or concurrent contexts.
/// This variant provides the necessary isolation to ensure thread safety.
public static var isolated: IsolatedActionLocker { .init() }
}

//MARK: - Isolated
// MARK: - Isolated
public actor IsolatedActionLocker {

let locker: NonIsolatedActionLocker

internal init() {
locker = .init()
}

public func lock(_ action: ActionLockable) throws {
try locker.lock(action)
}

public func unlock(_ action: ActionLockable) {
locker.unlock(action)
}

public func canExecute(_ action: ActionLockable) -> Bool {
locker.canExecute(action)
}

public func free() {
locker.free()
}
}

//MARK: - Nonisolated
// MARK: - Nonisolated
/// A non-thread-safe action locker for use within a single concurrency context.
///
/// - Important: This type has no synchronisation. It must only be accessed
/// from a single actor or serial queue. For use across multiple concurrent
/// contexts, use ``IsolatedActionLocker`` instead.
public final class NonIsolatedActionLocker {

private var actions: [AnyHashable: Bool]

internal init() {
actions = .init()
}

public func lock(_ action: ActionLockable) throws {
let isRunning = actions[action.lockKey] ?? false
guard !isRunning else {
throw ActionLocker.Errors.actionIsRunning
}
actions.updateValue(true, forKey: action.lockKey)
}

public func unlock(_ action: ActionLockable) {
guard actions[action.lockKey] != .none else { return }
actions.updateValue(false, forKey: action.lockKey)
}

public func canExecute(_ action: ActionLockable) -> Bool {
do {
try lock(action)
Expand All @@ -76,16 +79,15 @@ public final class NonIsolatedActionLocker {
return false
}
}

public func free() {
actions.removeAll()
}
}

extension ActionLocker {

public enum Errors: Error {
case actionIsRunning
}
}

39 changes: 21 additions & 18 deletions Sources/ScreenStatetKit/States/ScreenState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import SwiftUI
import Combine
import Observation

//MARK: - Base Screen States
// MARK: - Base Screen States
@MainActor
@Observable
open class ScreenState: Sendable {

// @unchecked Sendable: safe because all mutable state is @MainActor-isolated.
// Subclasses must maintain this invariant — all stored properties must be
// either immutable or @MainActor-isolated.
open class ScreenState: @unchecked Sendable {

public var isLoading: Bool = false {
didSet {
guard parentStateOption.contains(.loading) else { return }
Expand All @@ -17,7 +20,7 @@ open class ScreenState: Sendable {
}
}
}

public var displayError: DisplayableError? {
didSet {
if let displayError {
Expand All @@ -30,16 +33,16 @@ open class ScreenState: Sendable {
}
}
}

private weak var parentState: ScreenState?
private let parentStateOption: BindingParentStateOption

private var loadingTaskCount: Int = 0 {
didSet {
updateStateLoading()
}
}

public init() {
parentStateOption = .all
}
Expand All @@ -48,28 +51,28 @@ open class ScreenState: Sendable {
parentState = states
self.parentStateOption = options
}

public init(states: ScreenState) {
parentState = states
self.parentStateOption = .all
}
}

//MARK: - Updaters
// MARK: - Updaters
extension ScreenState {

public struct BindingParentStateOption: OptionSet, Sendable {

public let rawValue: Int
public static let loading = BindingParentStateOption(rawValue: 1 << 0)
public static let error = BindingParentStateOption(rawValue: 1 << 1)
public static let all: BindingParentStateOption = [.loading, .error]

public init(rawValue: Int) {
self.rawValue = rawValue
}
}

private func updateStateLoading() {
let loading = loadingTaskCount > 0
if loading != self.isLoading {
Expand All @@ -78,27 +81,27 @@ extension ScreenState {
}
}
}

public func showError(_ error: LocalizedError) {
withAnimation {
self.displayError = .init(message: error.localizedDescription)
}
}

public func loadingStarted() {
loadingTaskCount += 1
}

public func loadingFinished() {
guard loadingTaskCount > 0 else { return }
loadingTaskCount -= 1
}

public func loadingStarted(action: LoadingTrackable) {
guard action.canTrackLoading else { return }
loadingStarted()
}

public func loadingFinished(action: LoadingTrackable) {
guard action.canTrackLoading else { return }
loadingFinished()
Expand Down
7 changes: 3 additions & 4 deletions Sources/ScreenStatetKit/States/StateUpdatable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ import SwiftUI

@MainActor
public protocol StateUpdatable {

func updateState(withAnimation animation: Animation?,
_ updateBlock: @MainActor (_ state: Self) -> Void)
}


extension StateUpdatable {
public func updateState(withAnimation animation: Animation? = .smooth,

public func updateState(withAnimation animation: Animation? = .none,
_ updateBlock: @MainActor (_ state: Self) -> Void) {
var transaction = Transaction()
transaction.animation = animation
Expand Down