diff --git a/Examples/SampleEffectView/SampleEffectView.xcodeproj/project.pbxproj b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj similarity index 88% rename from Examples/SampleEffectView/SampleEffectView.xcodeproj/project.pbxproj rename to Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj index 90e283c..fc110a2 100644 --- a/Examples/SampleEffectView/SampleEffectView.xcodeproj/project.pbxproj +++ b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj @@ -28,15 +28,15 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - A13E260F2FA6014E00C324E5 /* SampleEffectView.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SampleEffectView.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A13E261C2FA6015000C324E5 /* SampleEffectViewTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SampleEffectViewTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - A13E26262FA6015000C324E5 /* SampleEffectViewUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SampleEffectViewUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A13E260F2FA6014E00C324E5 /* EffectViewExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EffectViewExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A13E261C2FA6015000C324E5 /* EffectViewExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EffectViewExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A13E26262FA6015000C324E5 /* EffectViewExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EffectViewExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - A13E26112FA6014E00C324E5 /* SampleEffectView */ = { + A13E26112FA6014E00C324E5 /* EffectViewExample */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = SampleEffectView; + path = EffectViewExample; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -77,7 +77,7 @@ A13E26062FA6014E00C324E5 = { isa = PBXGroup; children = ( - A13E26112FA6014E00C324E5 /* SampleEffectView */, + A13E26112FA6014E00C324E5 /* EffectViewExample */, A1353A8C2FA8A4BC00F08E4D /* Frameworks */, A13E26102FA6014E00C324E5 /* Products */, ); @@ -86,9 +86,9 @@ A13E26102FA6014E00C324E5 /* Products */ = { isa = PBXGroup; children = ( - A13E260F2FA6014E00C324E5 /* SampleEffectView.app */, - A13E261C2FA6015000C324E5 /* SampleEffectViewTests.xctest */, - A13E26262FA6015000C324E5 /* SampleEffectViewUITests.xctest */, + A13E260F2FA6014E00C324E5 /* EffectViewExample.app */, + A13E261C2FA6015000C324E5 /* EffectViewExampleTests.xctest */, + A13E26262FA6015000C324E5 /* EffectViewExampleUITests.xctest */, ); name = Products; sourceTree = ""; @@ -96,9 +96,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - A13E260E2FA6014E00C324E5 /* SampleEffectView */ = { + A13E260E2FA6014E00C324E5 /* EffectViewExample */ = { isa = PBXNativeTarget; - buildConfigurationList = A13E26302FA6015000C324E5 /* Build configuration list for PBXNativeTarget "SampleEffectView" */; + buildConfigurationList = A13E26302FA6015000C324E5 /* Build configuration list for PBXNativeTarget "EffectViewExample" */; buildPhases = ( A13E260B2FA6014E00C324E5 /* Sources */, A13E260C2FA6014E00C324E5 /* Frameworks */, @@ -109,19 +109,19 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - A13E26112FA6014E00C324E5 /* SampleEffectView */, + A13E26112FA6014E00C324E5 /* EffectViewExample */, ); - name = SampleEffectView; + name = EffectViewExample; packageProductDependencies = ( A139516E2FAC867A00859397 /* EffectView */, ); productName = SimpleEffectView; - productReference = A13E260F2FA6014E00C324E5 /* SampleEffectView.app */; + productReference = A13E260F2FA6014E00C324E5 /* EffectViewExample.app */; productType = "com.apple.product-type.application"; }; - A13E261B2FA6015000C324E5 /* SampleEffectViewTests */ = { + A13E261B2FA6015000C324E5 /* EffectViewExampleTests */ = { isa = PBXNativeTarget; - buildConfigurationList = A13E26332FA6015000C324E5 /* Build configuration list for PBXNativeTarget "SampleEffectViewTests" */; + buildConfigurationList = A13E26332FA6015000C324E5 /* Build configuration list for PBXNativeTarget "EffectViewExampleTests" */; buildPhases = ( A13E26182FA6015000C324E5 /* Sources */, A13E26192FA6015000C324E5 /* Frameworks */, @@ -132,16 +132,16 @@ dependencies = ( A13E261E2FA6015000C324E5 /* PBXTargetDependency */, ); - name = SampleEffectViewTests; + name = EffectViewExampleTests; packageProductDependencies = ( ); productName = SimpleEffectViewTests; - productReference = A13E261C2FA6015000C324E5 /* SampleEffectViewTests.xctest */; + productReference = A13E261C2FA6015000C324E5 /* EffectViewExampleTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - A13E26252FA6015000C324E5 /* SampleEffectViewUITests */ = { + A13E26252FA6015000C324E5 /* EffectViewExampleUITests */ = { isa = PBXNativeTarget; - buildConfigurationList = A13E26362FA6015000C324E5 /* Build configuration list for PBXNativeTarget "SampleEffectViewUITests" */; + buildConfigurationList = A13E26362FA6015000C324E5 /* Build configuration list for PBXNativeTarget "EffectViewExampleUITests" */; buildPhases = ( A13E26222FA6015000C324E5 /* Sources */, A13E26232FA6015000C324E5 /* Frameworks */, @@ -152,11 +152,11 @@ dependencies = ( A13E26282FA6015000C324E5 /* PBXTargetDependency */, ); - name = SampleEffectViewUITests; + name = EffectViewExampleUITests; packageProductDependencies = ( ); productName = SimpleEffectViewUITests; - productReference = A13E26262FA6015000C324E5 /* SampleEffectViewUITests.xctest */; + productReference = A13E26262FA6015000C324E5 /* EffectViewExampleUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; /* End PBXNativeTarget section */ @@ -182,7 +182,7 @@ }; }; }; - buildConfigurationList = A13E260A2FA6014E00C324E5 /* Build configuration list for PBXProject "SampleEffectView" */; + buildConfigurationList = A13E260A2FA6014E00C324E5 /* Build configuration list for PBXProject "EffectViewExample" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -199,9 +199,9 @@ projectDirPath = ""; projectRoot = ""; targets = ( - A13E260E2FA6014E00C324E5 /* SampleEffectView */, - A13E261B2FA6015000C324E5 /* SampleEffectViewTests */, - A13E26252FA6015000C324E5 /* SampleEffectViewUITests */, + A13E260E2FA6014E00C324E5 /* EffectViewExample */, + A13E261B2FA6015000C324E5 /* EffectViewExampleTests */, + A13E26252FA6015000C324E5 /* EffectViewExampleUITests */, ); }; /* End PBXProject section */ @@ -257,12 +257,12 @@ /* Begin PBXTargetDependency section */ A13E261E2FA6015000C324E5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = A13E260E2FA6014E00C324E5 /* SampleEffectView */; + target = A13E260E2FA6014E00C324E5 /* EffectViewExample */; targetProxy = A13E261D2FA6015000C324E5 /* PBXContainerItemProxy */; }; A13E26282FA6015000C324E5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = A13E260E2FA6014E00C324E5 /* SampleEffectView */; + target = A13E260E2FA6014E00C324E5 /* EffectViewExample */; targetProxy = A13E26272FA6015000C324E5 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -404,6 +404,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -436,6 +437,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -471,7 +473,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SampleEffectView.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SampleEffectView"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EffectViewExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EffectViewExample"; }; name = Debug; }; @@ -493,7 +495,7 @@ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SampleEffectView.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SampleEffectView"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EffectViewExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EffectViewExample"; }; name = Release; }; @@ -540,7 +542,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - A13E260A2FA6014E00C324E5 /* Build configuration list for PBXProject "SampleEffectView" */ = { + A13E260A2FA6014E00C324E5 /* Build configuration list for PBXProject "EffectViewExample" */ = { isa = XCConfigurationList; buildConfigurations = ( A13E262E2FA6015000C324E5 /* Debug */, @@ -549,7 +551,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A13E26302FA6015000C324E5 /* Build configuration list for PBXNativeTarget "SampleEffectView" */ = { + A13E26302FA6015000C324E5 /* Build configuration list for PBXNativeTarget "EffectViewExample" */ = { isa = XCConfigurationList; buildConfigurations = ( A13E26312FA6015000C324E5 /* Debug */, @@ -558,7 +560,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A13E26332FA6015000C324E5 /* Build configuration list for PBXNativeTarget "SampleEffectViewTests" */ = { + A13E26332FA6015000C324E5 /* Build configuration list for PBXNativeTarget "EffectViewExampleTests" */ = { isa = XCConfigurationList; buildConfigurations = ( A13E26342FA6015000C324E5 /* Debug */, @@ -567,7 +569,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A13E26362FA6015000C324E5 /* Build configuration list for PBXNativeTarget "SampleEffectViewUITests" */ = { + A13E26362FA6015000C324E5 /* Build configuration list for PBXNativeTarget "EffectViewExampleUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( A13E26372FA6015000C324E5 /* Debug */, diff --git a/Examples/SampleEffectView/SampleEffectView.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from Examples/SampleEffectView/SampleEffectView.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to Examples/EffectViewExample/EffectViewExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/Examples/EffectViewExample/EffectViewExample/App.swift b/Examples/EffectViewExample/EffectViewExample/App.swift new file mode 100644 index 0000000..119045a --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample/App.swift @@ -0,0 +1,21 @@ +import SwiftUI +import EffectView + +@main +struct EffectViewExampleApp: App { + var body: some Scene { + WindowGroup { + TabView { + Counter.ContentView() + .tabItem { + Label("Counter", systemImage: "plus.forwardslash.minus") + } + + Movies.ContentView() + .tabItem { + Label("Movies", systemImage: "film") + } + } + } + } +} diff --git a/Examples/SampleEffectView/SampleEffectView/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/EffectViewExample/EffectViewExample/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Examples/SampleEffectView/SampleEffectView/Assets.xcassets/AccentColor.colorset/Contents.json rename to Examples/EffectViewExample/EffectViewExample/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Examples/SampleEffectView/SampleEffectView/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/EffectViewExample/EffectViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Examples/SampleEffectView/SampleEffectView/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Examples/EffectViewExample/EffectViewExample/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Examples/SampleEffectView/SampleEffectView/Assets.xcassets/Contents.json b/Examples/EffectViewExample/EffectViewExample/Assets.xcassets/Contents.json similarity index 100% rename from Examples/SampleEffectView/SampleEffectView/Assets.xcassets/Contents.json rename to Examples/EffectViewExample/EffectViewExample/Assets.xcassets/Contents.json diff --git a/Examples/EffectViewExample/EffectViewExample/Counter.swift b/Examples/EffectViewExample/EffectViewExample/Counter.swift new file mode 100644 index 0000000..54d2fd1 --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample/Counter.swift @@ -0,0 +1,98 @@ +import SwiftUI +import Foundation +import EffectView + +public enum Counter {} + +// MARK: - Environment + +extension EnvironmentValues { + @Entry var counterViewEnv: Counter.CounterView.Env = .init() +} + +// MARK: - Views + +extension Counter { + + struct ContentView: View { + var body: some View { + EnvReader(\.counterViewEnv) { + CounterView(env: $0) + } + } + } + + struct CounterView: View { + + struct ViewState { + var counter = 0 + init() { + self.counter = 0 + } + } + + enum Event { + case start + case tick + case stop + } + + struct Env: Identifiable { + let id: UUID = .init() + init() {} + } + + @State private var state: ViewState = .init() + + let env: Env + + @MainActor + private static func update( + state: inout ViewState, + event: Event + ) -> Effect? { + switch event { + case .start: + state.counter = 0 + return .task(name: "Counter") { input, env in + while true { + do { + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec + print("tick") + input(.tick) + } catch { + // most likeley, the counter task has been cancelled; ignore it. + } + } + } + case .tick: + state.counter += 1 + return nil + case .stop: + return .cancel("Counter") + } + } + + var body: some View { + EffectView( + state: $state, + initialEnv: env, + update: Self.update + ) { state, send in + VStack { + Text("\(state.counter)") + .font(Font.largeTitle.monospacedDigit()) + Button("Start") { send(.start) } + Button("Stop") { send(.stop) } + } + } + .id(env.id) // restart the EffectView when the env changes + } + } +} + +#if false +#Preview { + Counter.ContentView() +} +#endif diff --git a/Examples/EffectViewExample/EffectViewExample/Movies.swift b/Examples/EffectViewExample/EffectViewExample/Movies.swift new file mode 100644 index 0000000..cffadea --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample/Movies.swift @@ -0,0 +1,237 @@ +import SwiftUI +import EffectView +import Foundation + +public enum Movies {} + +// MARK: - Model + +extension Movies { + public struct Movie: Equatable, Identifiable { + public let id: UUID + public let title: String + } +} + +// MARK: - Environment + +extension Movies { + + public struct MovieFetch: Sendable { + public var fetch: @Sendable () async throws -> [Movie] + + public func callAsFunction() async throws -> [Movie] { + try await fetch() + } + } + + public struct Env: Sendable { + public var movieFetch: Movies.MovieFetch + } +} + +extension EnvironmentValues { + @Entry public var movieListViewEnv: Movies.Env = .init( + movieFetch: .init(fetch: { + try await Task.sleep(nanoseconds: 2_000_000_000) + return [ + Movies.Movie(id: UUID(), title: "The Shawshank Redemption"), + Movies.Movie(id: UUID(), title: "Moby Dick"), + Movies.Movie(id: UUID(), title: "Severance"), + Movies.Movie(id: UUID(), title: "Einer flog über das Kuckucksnest"), + ] + }) + ) +} + + +// MARK: - Views + +extension Movies { + + public struct ContentView: View { + public var body: some View { + EnvReader(\.movieListViewEnv) { env in + Movies.MovieListView(env: env) + } + } + } + + struct MovieListView: View { + let env: Env + + @State private var state = ViewState() + + var body: some View { + EffectView(state: $state, initialEvent: .load, initialEnv: env, update: Self.update) { state, input in + ZStack { + switch state.content { + case .empty: + if #available(iOS 17.0, *) { + ContentUnavailableView("No Movies", systemImage: "film") + } else { + VStack(spacing: 12) { + Image(systemName: "film") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("No Movies") + .font(.headline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + case .content(let movies): + List(movies, rowContent: MovieRow.init) + .refreshable { + await input.perform(.refresh) + } + } + + if state.isLoading { + ProgressView() + } + } + .alert( + "Error", + isPresented: .constant(state.error != nil), + presenting: state.error + ) { _ in + Button("OK") { input.send(.dismiss) } + } message: { error in + Text(error.localizedDescription) + } + } + } + } + +} + +extension Movies.MovieListView { + + typealias Movie = Movies.Movie + + struct ViewState { + var mode: Mode + var content: Content<[Movie]> + + enum Mode { + case idle + case loading + case refreshing + case failed(Error) + } + + init() { + mode = .idle + content = .empty(.blank) + } + + var error: Error? { + if case .failed(let error) = mode { return error } + return nil + } + + var isLoading: Bool { + switch mode { + case .loading: return true + default: return false + } + } + + var isRefreshing: Bool { + if case .refreshing = mode { return true } + return false + } + } + + enum Event { + case load + case refresh + case loaded([Movie]) + case loadFailed(Error) + case cancel + case dismiss + } + + @MainActor + static func update(state: inout ViewState, event: Event) -> Effect? { + switch event { + case .load: + // Guard against refresh: can only race with programmatic load triggers + // (e.g. .onAppear, timers). UI pull-to-refresh is serialised by SwiftUI. + guard !state.isRefreshing else { return nil } + guard !state.isLoading else { return nil } + state.mode = .loading + return .loadMovies() + + case .refresh: + // Always supersedes a pending load; named task cancels any prior refresh. + state.mode = .refreshing + return .sequence([.cancel("load"), .refreshMovies()]) + + case .loaded(let movies): + state.mode = .idle + state.content = .content(movies) + return nil + + case .loadFailed(let error): + state.mode = .failed(error) + return nil + + case .cancel: + state.mode = .idle + return .cancel("load") + + case .dismiss: + state.mode = .idle + return nil + } + } + +} + +extension Movies.MovieListView { + struct MovieRow: View { + let movie: Movie + + var body: some View { + Text(movie.title) + } + } +} + +// MARK: - Custom Effects + +extension Effect where Event == Movies.MovieListView.Event, Env == Movies.Env { + static func loadMovies() -> Self { + .task(name: "load") { input, env in + do { + let movies = try await env.movieFetch() + input(.loaded(movies)) + } catch { + input(.loadFailed(error)) + } + } + } + + static func refreshMovies() -> Self { + // Note: a refresh action + .task(name: "refresh") { input, env in + do { + let movies = try await env.movieFetch() + input(.loaded(movies)) + } catch { + input(.loadFailed(error)) + } + } + } +} + +// MARK: - Previews + +#Preview { + EnvReader(\.movieListViewEnv) { env in + Movies.MovieListView(env: env) + } +} + diff --git a/Examples/EffectViewExample/EffectViewExample/ViewState.swift b/Examples/EffectViewExample/EffectViewExample/ViewState.swift new file mode 100644 index 0000000..dff816a --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample/ViewState.swift @@ -0,0 +1,11 @@ + +// MARK: - ViewState Helpers + +enum Empty { + case blank +} + +enum Content { + case empty(Empty) + case content(Value) +} diff --git a/Examples/SampleEffectView/SampleEffectView/App.swift b/Examples/SampleEffectView/SampleEffectView/App.swift deleted file mode 100644 index abab337..0000000 --- a/Examples/SampleEffectView/SampleEffectView/App.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftUI -import EffectView - -@main -struct SimpleEffectViewApp: App { - var body: some Scene { - WindowGroup { - TabView { - ContentView() - .tabItem { - Label("One", systemImage: "star") - } - - EnvReader(\.movieListViewEnv) { env in - MovieListView(env: env) - } - .tabItem { - Label("Two", systemImage: "circle") - } - } - } - } -} diff --git a/Examples/SampleEffectView/SampleEffectView/ContentView.swift b/Examples/SampleEffectView/SampleEffectView/ContentView.swift deleted file mode 100644 index 99ab598..0000000 --- a/Examples/SampleEffectView/SampleEffectView/ContentView.swift +++ /dev/null @@ -1,89 +0,0 @@ -import SwiftUI -import Foundation -import EffectView - -extension EnvironmentValues { - @Entry var counterViewEnv: CounterView.Env = .init() -} - -struct ContentView: View { - var body: some View { - EnvReader(\.counterViewEnv) { - CounterView(env: $0) - } - } -} - -@MainActor -struct CounterView: View { - - struct ViewState { - var counter = 0 - init() { - self.counter = 0 - } - } - - enum Event { - case start - case tick - case stop - } - - struct Env: Identifiable { - let id: UUID = .init() - init() {} - } - - @State private var state: ViewState = .init() - - let env: Env - - private static func update( - state: inout ViewState, - event: Event - ) -> Effect? { - switch event { - case .start: - state.counter = 0 - return .task(name: "Counter") { input, env in - while true { - do { - try await Task.sleep(for: .seconds(1)) - print("tick") - input(.tick) - } catch { - // most likeley, the counter task has been cancelled; ignore it. - } - } - } - case .tick: - state.counter += 1 - return nil - case .stop: - return .cancel("Counter") - } - } - - var body: some View { - EffectView( - state: $state, - initialEnv: env, - update: Self.update - ) { state, send in - VStack { - Text("\(state.counter)") - .font(Font.largeTitle.monospacedDigit()) - Button("Start") { send(.start) } - Button("Stop") { send(.stop) } - } - } - .id(env.id) // restart the EffectView when the env changes - } -} - -#if false -#Preview { - ContentView() -} -#endif diff --git a/Examples/SampleEffectView/SampleEffectView/MovieListView.swift b/Examples/SampleEffectView/SampleEffectView/MovieListView.swift deleted file mode 100644 index 2b1036d..0000000 --- a/Examples/SampleEffectView/SampleEffectView/MovieListView.swift +++ /dev/null @@ -1,213 +0,0 @@ -import SwiftUI -import EffectView -import Foundation - -// MARK: - Model - -struct Movie: Equatable, Identifiable { - let id: UUID - let title: String -} - -// MARK: - Actions - -struct MovieFetch: Sendable { - var fetch: @Sendable () async throws -> [Movie] - - func callAsFunction() async throws -> [Movie] { - try await fetch() - } -} - -extension EnvironmentValues { - @Entry var movieListViewEnv: MovieListView.Env = .init( - movieFetch: .init(fetch: { - try await Task.sleep(for: .milliseconds(2000)) - return [ - Movie(id: UUID(), title: "The Shawshank Redemption"), - Movie(id: UUID(), title: "Moby Dick"), - Movie(id: UUID(), title: "Severance"), - Movie(id: UUID(), title: "Einer flog über's Kuckuksnest"), - ] - }) - ) -} - - -// MARK: - Content - -enum Empty { - case blank -} - -enum Content { - case empty(Empty) - case content(Value) -} - -// MARK: - MovieList - -@MainActor -struct MovieListView: View { - let env: Env - - @State private var state = ViewState() - - var body: some View { - EffectView(state: $state, initialEvent: .load, initialEnv: env, update: Self.update) { state, input in - ZStack { - switch state.content { - case .empty: - ContentUnavailableView("No Movies", systemImage: "film") - case .content(let movies): - List(movies, rowContent: MovieRow.init) - .refreshable { - await input.perform(.refresh) - } - } - - if state.isLoading { - ProgressView() - } - } - .alert( - "Error", - isPresented: .constant(state.error != nil), - presenting: state.error - ) { _ in - Button("OK") { input.send(.dismiss) } - } message: { error in - Text(error.localizedDescription) - } - } - } -} - -extension MovieListView { - - struct ViewState { - var mode: Mode - var content: Content<[Movie]> - - enum Mode { - case idle - case loading - case refreshing - case failed(Error) - } - - init() { - mode = .idle - content = .empty(.blank) - } - - var error: Error? { - if case .failed(let error) = mode { return error } - return nil - } - - var isLoading: Bool { - switch mode { - case .loading, .refreshing: return true - default: return false - } - } - - var isRefreshing: Bool { - if case .refreshing = mode { return true } - return false - } - } - - enum Event { - case load - case refresh - case loaded([Movie]) - case loadFailed(Error) - case cancel - case dismiss - } - - struct Env: Sendable { - var movieFetch: MovieFetch - } - - static func update(state: inout ViewState, event: Event) -> Effect? { - switch event { - case .load: - // Guard against refresh: can only race with programmatic load triggers - // (e.g. .onAppear, timers). UI pull-to-refresh is serialised by SwiftUI. - guard !state.isRefreshing else { return nil } - guard !state.isLoading else { return nil } - state.mode = .loading - return .loadMovies() - - case .refresh: - // Always supersedes a pending load; named task cancels any prior refresh. - state.mode = .refreshing - return .sequence([.cancel("load"), .refreshMovies()]) - - case .loaded(let movies): - state.mode = .idle - state.content = .content(movies) - return nil - - case .loadFailed(let error): - state.mode = .failed(error) - return nil - - case .cancel: - state.mode = .idle - return .cancel("load") - - case .dismiss: - state.mode = .idle - return nil - } - } - -} - -extension MovieListView { - struct MovieRow: View { - let movie: Movie - - var body: some View { - Text(movie.title) - } - } -} - -extension Effect where Event == MovieListView.Event, Env == MovieListView.Env { - static func loadMovies() -> Self { - .task(name: "load") { input, env in - do { - let movies = try await env.movieFetch() - input(.loaded(movies)) - } catch { - input(.loadFailed(error)) - } - } - } - - static func refreshMovies() -> Self { - // Note: a refresh action - .task(name: "refresh") { input, env in - do { - let movies = try await env.movieFetch() - input(.loaded(movies)) - } catch { - input(.loadFailed(error)) - } - } - } -} - -// MARK: - Previews - -#Preview { - EnvReader(\.movieListViewEnv) { env in - MovieListView(env: env) - } - -} diff --git a/Examples/SimpleViewSample/SampleEffectView.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/SimpleViewSample/SampleEffectView.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/Examples/SimpleViewSample/SampleEffectView.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/Examples/SimpleViewSample/SimpleEffectView.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/SimpleViewSample/SimpleEffectView.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 94b2795..0000000 --- a/Examples/SimpleViewSample/SimpleEffectView.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,4 +0,0 @@ - - -