Files
TUIkit/Sources/TUIKit/Modifiers/LifecycleModifier.swift
T
phranck 45e73faafb refactor: Rename package from SwiftTUI to TUIKit
BREAKING CHANGE: Package name changed due to name collision with
existing rensbreur/SwiftTUI package.

Changes:
- Rename package from SwiftTUI to TUIKit in Package.swift
- Rename Sources/SwiftTUI to Sources/TUIKit
- Rename Sources/SwiftTUIExample to Sources/TUIKitExample
- Rename Tests/SwiftTUITests to Tests/TUIKitTests
- Rename SwiftTUI.swift to TUIKit.swift
- Update all imports: import SwiftTUI -> import TUIKit
- Update all code references: SwiftTUI.renderToBuffer -> TUIKit.renderToBuffer
- Update documentation comments
- Rename swiftTUIVersion to tuiKitVersion

All 181 tests passing.
2026-01-28 19:32:09 +01:00

349 lines
9.6 KiB
Swift

//
// LifecycleModifier.swift
// TUIKit
//
// Lifecycle modifiers: .onAppear(), .onDisappear(), .task()
//
import Foundation
// MARK: - Lifecycle Tracker
/// Tracks which views have appeared to prevent duplicate onAppear calls.
///
/// Since views are recreated on each render, we use a token-based system
/// to track which views have already triggered their onAppear action.
public final class LifecycleTracker: @unchecked Sendable {
/// Shared instance for the running application.
public static let shared = LifecycleTracker()
/// Set of tokens that have appeared.
private var appearedTokens: Set<String> = []
/// Set of tokens that are currently visible (for onDisappear tracking).
private var visibleTokens: Set<String> = []
/// Tokens seen during the current render pass.
private var currentRenderTokens: Set<String> = []
private init() {}
/// Marks the start of a new render pass.
internal func beginRenderPass() {
currentRenderTokens.removeAll()
}
/// Marks the end of a render pass and triggers onDisappear for views that are no longer visible.
internal func endRenderPass(onDisappear: [String: () -> Void]) {
// Find tokens that were visible but are no longer rendered
let disappearedTokens = visibleTokens.subtracting(currentRenderTokens)
for token in disappearedTokens {
onDisappear[token]?()
appearedTokens.remove(token)
}
// Update visible tokens for next pass
visibleTokens = currentRenderTokens
}
/// Records that a view with the given token appeared.
///
/// - Parameters:
/// - token: Unique identifier for the view.
/// - action: The onAppear action to execute.
/// - Returns: True if this is the first appearance (action should run).
internal func recordAppear(token: String, action: () -> Void) -> Bool {
currentRenderTokens.insert(token)
if !appearedTokens.contains(token) {
appearedTokens.insert(token)
action()
return true
}
return false
}
/// Checks if a view has appeared before.
internal func hasAppeared(token: String) -> Bool {
appearedTokens.contains(token)
}
/// Resets all tracking state.
internal func reset() {
appearedTokens.removeAll()
visibleTokens.removeAll()
currentRenderTokens.removeAll()
}
}
// MARK: - OnAppear Modifier
/// A modifier that executes an action when a view first appears.
public struct OnAppearModifier<Content: View>: View {
/// The content view.
let content: Content
/// Unique token to track this view's lifecycle.
let token: String
/// The action to execute on first appearance.
let action: () -> Void
public var body: Never {
fatalError("OnAppearModifier renders via Renderable")
}
}
extension OnAppearModifier: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
// Record appearance and execute action if first time
_ = LifecycleTracker.shared.recordAppear(token: token, action: action)
// Render content
return TUIKit.renderToBuffer(content, context: context)
}
}
// MARK: - OnDisappear Modifier
/// Storage for onDisappear callbacks.
public final class DisappearCallbackStorage: @unchecked Sendable {
public static let shared = DisappearCallbackStorage()
private var callbacks: [String: () -> Void] = [:]
private init() {}
internal func register(token: String, action: @escaping () -> Void) {
callbacks[token] = action
}
internal func unregister(token: String) {
callbacks.removeValue(forKey: token)
}
internal var allCallbacks: [String: () -> Void] {
callbacks
}
internal func reset() {
callbacks.removeAll()
}
}
/// A modifier that executes an action when a view disappears.
public struct OnDisappearModifier<Content: View>: View {
/// The content view.
let content: Content
/// Unique token to track this view's lifecycle.
let token: String
/// The action to execute when the view disappears.
let action: () -> Void
public var body: Never {
fatalError("OnDisappearModifier renders via Renderable")
}
}
extension OnDisappearModifier: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
// Register the disappear callback
DisappearCallbackStorage.shared.register(token: token, action: action)
// Mark as visible in current render
_ = LifecycleTracker.shared.recordAppear(token: token, action: {})
// Render content
return TUIKit.renderToBuffer(content, context: context)
}
}
// MARK: - Task Modifier
/// A modifier that starts an async task when a view appears.
///
/// The task is cancelled when the view disappears.
public struct TaskModifier<Content: View>: View {
/// The content view.
let content: Content
/// Unique token to track this view's lifecycle.
let token: String
/// The async task to execute.
let task: @Sendable () async -> Void
/// Task priority.
let priority: TaskPriority
public var body: Never {
fatalError("TaskModifier renders via Renderable")
}
}
/// Storage for running tasks.
public final class TaskStorage: @unchecked Sendable {
public static let shared = TaskStorage()
private var tasks: [String: Task<Void, Never>] = [:]
private init() {}
internal func startTask(token: String, priority: TaskPriority, operation: @escaping @Sendable () async -> Void) {
// Cancel existing task if any
tasks[token]?.cancel()
// Start new task
tasks[token] = Task(priority: priority) {
await operation()
}
}
internal func cancelTask(token: String) {
tasks[token]?.cancel()
tasks.removeValue(forKey: token)
}
internal func reset() {
for task in tasks.values {
task.cancel()
}
tasks.removeAll()
}
}
extension TaskModifier: Renderable {
public func renderToBuffer(context: RenderContext) -> FrameBuffer {
// Start task on first appearance
let isFirstAppear = !LifecycleTracker.shared.hasAppeared(token: token)
_ = LifecycleTracker.shared.recordAppear(token: token) {
// Only start task on first appear
}
if isFirstAppear {
TaskStorage.shared.startTask(token: token, priority: priority, operation: task)
}
// Register disappear callback to cancel task
DisappearCallbackStorage.shared.register(token: token) {
TaskStorage.shared.cancelTask(token: token)
}
// Render content
return TUIKit.renderToBuffer(content, context: context)
}
}
// MARK: - Token Generator
/// Generates unique tokens for lifecycle tracking.
private final class TokenGenerator: @unchecked Sendable {
static let shared = TokenGenerator()
private var counter: UInt64 = 0
private let lock = NSLock()
func next() -> String {
lock.lock()
defer { lock.unlock() }
counter += 1
return "lifecycle-\(counter)"
}
}
// MARK: - View Extension
extension View {
/// Executes an action when this view first appears.
///
/// The action is only executed once per view appearance. If the view
/// is removed and then added again, the action will execute again.
///
/// # Example
///
/// ```swift
/// struct ContentView: View {
/// var body: some View {
/// Text("Hello")
/// .onAppear {
/// loadData()
/// }
/// }
/// }
/// ```
///
/// - Parameter action: The action to execute.
/// - Returns: A view that executes the action on appearance.
public func onAppear(perform action: @escaping () -> Void) -> some View {
OnAppearModifier(
content: self,
token: TokenGenerator.shared.next(),
action: action
)
}
/// Executes an action when this view disappears.
///
/// The action is executed when the view is no longer rendered.
///
/// # Example
///
/// ```swift
/// struct ContentView: View {
/// var body: some View {
/// Text("Hello")
/// .onDisappear {
/// cleanup()
/// }
/// }
/// }
/// ```
///
/// - Parameter action: The action to execute.
/// - Returns: A view that executes the action on disappearance.
public func onDisappear(perform action: @escaping () -> Void) -> some View {
OnDisappearModifier(
content: self,
token: TokenGenerator.shared.next(),
action: action
)
}
/// Starts an async task when this view appears.
///
/// The task is automatically cancelled when the view disappears.
///
/// # Example
///
/// ```swift
/// struct ContentView: View {
/// var body: some View {
/// Text("Loading...")
/// .task {
/// await fetchData()
/// }
/// }
/// }
/// ```
///
/// - Parameters:
/// - priority: The task priority (default: .userInitiated).
/// - action: The async action to execute.
/// - Returns: A view that starts the task on appearance.
public func task(
priority: TaskPriority = .userInitiated,
_ action: @escaping @Sendable () async -> Void
) -> some View {
TaskModifier(
content: self,
token: TokenGenerator.shared.next(),
task: action,
priority: priority
)
}
}