diff --git a/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift b/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift index b302a51b3f41..a981356947cc 100644 --- a/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift @@ -19,24 +19,79 @@ import SwiftUI /// [here](https://gist.github.com/mbrandonw/dee2ceac2c316a1619cfdf1dc7945f66)). public struct WithViewStore { private let content: (ViewStore) -> Content - private var prefix: String? + #if DEBUG + private let file: StaticString + private let line: UInt + private var prefix: String? + private var previousState: (State) -> State? + #endif @ObservedObject private var viewStore: ViewStore + fileprivate init( + store: Store, + removeDuplicates isDuplicate: @escaping (State, State) -> Bool, + file: StaticString = #fileID, + line: UInt = #line, + content: @escaping (ViewStore) -> Content + ) { + self.content = content + #if DEBUG + self.file = file + self.line = line + var previousState: State? = nil + self.previousState = { currentState in + defer { previousState = currentState } + return previousState + } + #endif + self.viewStore = ViewStore(store, removeDuplicates: isDuplicate) + } + /// Prints debug information to the console whenever the view is computed. /// /// - Parameter prefix: A string with which to prefix all debug messages. /// - Returns: A structure that prints debug messages for all computations. public func debug(_ prefix: String = "") -> Self { var view = self - view.prefix = prefix + #if DEBUG + view.prefix = prefix + #endif return view } + + fileprivate var _body: Content { + #if DEBUG + if let prefix = self.prefix { + let difference = self.previousState(self.viewStore.state) + .map { + debugDiff($0, self.viewStore.state).map { "(Changed state)\n\($0)" } + ?? "(No difference in state detected)" + } + ?? "(Initial state)\n\(debugOutput(self.viewStore.state, indent: 2))" + func typeName(_ type: Any.Type) -> String { + var name = String(reflecting: type) + if let index = name.firstIndex(of: ".") { + name.removeSubrange(...index) + } + return name + } + print( + """ + \(prefix.isEmpty ? "" : "\(prefix): ")\ + WithViewStore<\(typeName(State.self)), \(typeName(Action.self)), _>\ + @\(self.file):\(self.line) \(difference) + """ + ) + } + #endif + return self.content(self.viewStore) + } } extension WithViewStore: View where Content: View { /// Initializes a structure that transforms a store into an observable view store in order to /// compute views from store state. - + /// /// - Parameters: /// - store: A store. /// - isDuplicate: A function to determine when two `State` values are equal. When values are @@ -45,28 +100,25 @@ extension WithViewStore: View where Content: View { public init( _ store: Store, removeDuplicates isDuplicate: @escaping (State, State) -> Bool, + file: StaticString = #fileID, + line: UInt = #line, @ViewBuilder content: @escaping (ViewStore) -> Content ) { - self.content = content - self.viewStore = ViewStore(store, removeDuplicates: isDuplicate) + self.init( + store: store, + removeDuplicates: isDuplicate, + file: file, + line: line, + content: content + ) } public var body: Content { - #if DEBUG - if let prefix = self.prefix { - print( - """ - \(prefix.isEmpty ? "" : "\(prefix): ")\ - Evaluating WithViewStore<\(State.self), \(Action.self), ...>.body - """ - ) - } - #endif - return self.content(self.viewStore) + self._body } } -extension WithViewStore where Content: View, State: Equatable { +extension WithViewStore where State: Equatable, Content: View { /// Initializes a structure that transforms a store into an observable view store in order to /// compute views from equatable store state. /// @@ -75,13 +127,15 @@ extension WithViewStore where Content: View, State: Equatable { /// - content: A function that can generate content from a view store. public init( _ store: Store, + file: StaticString = #fileID, + line: UInt = #line, @ViewBuilder content: @escaping (ViewStore) -> Content ) { - self.init(store, removeDuplicates: ==, content: content) + self.init(store, removeDuplicates: ==, file: file, line: line, content: content) } } -extension WithViewStore where Content: View, State == Void { +extension WithViewStore where State == Void, Content: View { /// Initializes a structure that transforms a store into an observable view store in order to /// compute views from equatable store state. /// @@ -90,9 +144,11 @@ extension WithViewStore where Content: View, State == Void { /// - content: A function that can generate content from a view store. public init( _ store: Store, + file: StaticString = #fileID, + line: UInt = #line, @ViewBuilder content: @escaping (ViewStore) -> Content ) { - self.init(store, removeDuplicates: ==, content: content) + self.init(store, removeDuplicates: ==, file: file, line: line, content: content) } } @@ -104,74 +160,69 @@ extension WithViewStore: DynamicViewContent where State: Collection, Content: Dy } } -#if compiler(>=5.3) - import SwiftUI - - /// A structure that transforms a store into an observable view store in order to compute scenes - /// from store state. - @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) - extension WithViewStore: Scene where Content: Scene { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute scenes from store state. - - /// - Parameters: - /// - store: A store. - /// - isDuplicate: A function to determine when two `State` values are equal. When values are - /// equal, repeat view computations are removed, - /// - content: A function that can generate content from a view store. - public init( - _ store: Store, - removeDuplicates isDuplicate: @escaping (State, State) -> Bool, - @SceneBuilder content: @escaping (ViewStore) -> Content - ) { - self.content = content - self.viewStore = ViewStore(store, removeDuplicates: isDuplicate) - } +@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) +extension WithViewStore: Scene where Content: Scene { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute views from store state. + /// + /// - Parameters: + /// - store: A store. + /// - isDuplicate: A function to determine when two `State` values are equal. When values are + /// equal, repeat view computations are removed, + /// - content: A function that can generate content from a view store. + public init( + _ store: Store, + removeDuplicates isDuplicate: @escaping (State, State) -> Bool, + file: StaticString = #fileID, + line: UInt = #line, + @SceneBuilder content: @escaping (ViewStore) -> Content + ) { + self.init( + store: store, + removeDuplicates: isDuplicate, + file: file, + line: line, + content: content + ) + } - public var body: Content { - #if DEBUG - if let prefix = self.prefix { - print( - """ - \(prefix.isEmpty ? "" : "\(prefix): ")\ - Evaluating WithViewStore<\(State.self), \(Action.self), ...>.body - """ - ) - } - #endif - return self.content(self.viewStore) - } + public var body: Content { + self._body } +} - @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) - extension WithViewStore where Content: Scene, State: Equatable { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute views from equatable store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - public init( - _ store: Store, - @SceneBuilder content: @escaping (ViewStore) -> Content - ) { - self.init(store, removeDuplicates: ==, content: content) - } +@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) +extension WithViewStore where State: Equatable, Content: Scene { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute scenes from equatable store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + public init( + _ store: Store, + file: StaticString = #fileID, + line: UInt = #line, + @SceneBuilder content: @escaping (ViewStore) -> Content + ) { + self.init(store, removeDuplicates: ==, file: file, line: line, content: content) } +} - @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) - extension WithViewStore where Content: Scene, State == Void { - /// Initializes a structure that transforms a store into an observable view store in order to - /// compute views from equatable store state. - /// - /// - Parameters: - /// - store: A store of equatable state. - /// - content: A function that can generate content from a view store. - public init( - _ store: Store, - @SceneBuilder content: @escaping (ViewStore) -> Content - ) { - self.init(store, removeDuplicates: ==, content: content) - } +@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) +extension WithViewStore where State == Void, Content: Scene { + /// Initializes a structure that transforms a store into an observable view store in order to + /// compute scenes from equatable store state. + /// + /// - Parameters: + /// - store: A store of equatable state. + /// - content: A function that can generate content from a view store. + public init( + _ store: Store, + file: StaticString = #fileID, + line: UInt = #line, + @SceneBuilder content: @escaping (ViewStore) -> Content + ) { + self.init(store, removeDuplicates: ==, file: file, line: line, content: content) } -#endif +} diff --git a/Tests/ComposableArchitectureTests/WithViewStoreAppTest.swift b/Tests/ComposableArchitectureTests/WithViewStoreAppTest.swift index 29f6dd1925d3..f5b4d3108c52 100644 --- a/Tests/ComposableArchitectureTests/WithViewStoreAppTest.swift +++ b/Tests/ComposableArchitectureTests/WithViewStoreAppTest.swift @@ -3,38 +3,36 @@ import ComposableArchitecture import SwiftUI -#if compiler(>=5.3) - @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) - struct TestApp: App { - let store = Store( - initialState: 0, - reducer: Reducer { state, _, _ in - state += 1 - return .none - }, - environment: () - ) - - var body: some Scene { - WithViewStore(self.store) { viewStore in - #if os(iOS) || os(macOS) - WindowGroup { - EmptyView() - } - .commands { - CommandMenu("Commands") { - Button("Increment") { - viewStore.send(()) - } - .keyboardShortcut("+") - } - } - #else - WindowGroup { - EmptyView() +@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) +struct TestApp: App { + let store = Store( + initialState: 0, + reducer: Reducer { state, _, _ in + state += 1 + return .none + }, + environment: () + ) + + var body: some Scene { + WithViewStore(self.store) { viewStore in +#if os(iOS) || os(macOS) + WindowGroup { + EmptyView() + } + .commands { + CommandMenu("Commands") { + Button("Increment") { + viewStore.send(()) } - #endif + .keyboardShortcut("+") + } } +#else + WindowGroup { + EmptyView() + } +#endif } } -#endif +}