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
2 changes: 1 addition & 1 deletion Example/Example.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct Example: App {
Text("Photos (Original)")
}

PhotoList(provider: photoProvider)
PhotoList(store: PhotoListViewStore(provider: photoProvider))
.tabItem {
Image(systemName: "photo")
Text("Photos")
Expand Down
2 changes: 1 addition & 1 deletion Example/MockItemProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Networking

/// A provider meant to be usable by SwiftUI previews and unit tests to provide mocked, successful `Photo`s synchronously.
final class MockItemProvider: Provider {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this change, does this really even need to be a provider? Couldn't you just pass the photos from say Photo+SampleData extension etc?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is used for the unit tests, still -- in the future we will probably move this into a test target or a test helper target, to your point, though.

private let photos: [Photo]
let photos: [Photo]

/// Creates a new `MockItemProvider` with the specified `Photo`s.
/// - Parameter photos: the `Photo`s that the provider will "retrieve" synchronously.
Expand Down
15 changes: 8 additions & 7 deletions Example/Photos/PhotoList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@ import SwiftUI
import Provider

/// Displays a list of photos retrieved from an API. Uses a `ViewStore` for coordination with the data source.
struct PhotoList: View {
struct PhotoList<Store: PhotoListViewStoreType>: View {

@StateObject private var store: PhotoListViewStore
@StateObject private var store: Store

/// Creates a new `PhotoList`.
/// - Parameters:
/// - provider: The provider responsible for fetching photos.
/// - scheduler: Determines how state updates are scheduled to be delivered in the view store. Defaults to `default`, which asynchronously schedules updates on the main queue.
init(provider: Provider, scheduler: MainQueueScheduler = .init(type: .default)) {
self._store = StateObject(wrappedValue: PhotoListViewStore(provider: provider, scheduler: scheduler))
/// - store: The `ViewStore` that drives
init(store: @autoclosure @escaping () -> Store) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@autoclosure to avoid any problems with having stores being incorrectly created multiple times. See the initializer on StateObject as well

self._store = StateObject(wrappedValue: store())
}

// MARK: - View
Expand Down Expand Up @@ -69,6 +68,8 @@ struct PhotoList: View {

struct PhotoList_Previews: PreviewProvider {
static var previews: some View {
PhotoList(provider: MockItemProvider(photosCount: 3), scheduler: .init(type: .synchronous))
let viewState = PhotoListViewStore.ViewState(status: .content(MockItemProvider(photosCount: 3).photos))

PhotoList(store: MockViewStore(viewState: viewState))
}
}
45 changes: 28 additions & 17 deletions Example/Photos/PhotoListViewStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import Combine
import SwiftUI
import CasePaths

typealias PhotoListViewStoreType = ViewStore<PhotoListViewStore.ViewState, PhotoListViewStore.Action>

/// Coordinates state for use in `PhotoListView`
final class PhotoListViewStore: ViewStore {

Expand All @@ -24,12 +26,19 @@ final class PhotoListViewStore: ViewStore {
}

fileprivate static let defaultNavigationTitle = LocalizedStringKey("Photos")
fileprivate static let initial = ViewState(status: .loading, showsPhotoCount: false, navigationTitle: defaultNavigationTitle, searchText: "")
fileprivate static let initial = ViewState()

let status: Status
let showsPhotoCount: Bool
let navigationTitle: LocalizedStringKey
fileprivate let searchText: String

init(status: PhotoListViewStore.ViewState.Status = .loading, showsPhotoCount: Bool = false, navigationTitle: LocalizedStringKey = ViewState.defaultNavigationTitle, searchText: String = "") {
self.status = status
self.showsPhotoCount = showsPhotoCount
self.navigationTitle = navigationTitle
self.searchText = searchText
}
}

enum Action {
Expand All @@ -45,22 +54,6 @@ final class PhotoListViewStore: ViewStore {
private let showsPhotosCountPublisher = PassthroughSubject<Bool, Never>()
private let searchTextPublisher = PassthroughSubject<String, Never>()

var showsPhotoCount: Binding<Bool> {
//
// return Binding<Bool> {
// self.viewState.showsPhotoCount
// } set: { newValue in
// self.send(.toggleShowsPhotoCount(newValue))
// }
//
// Note: This 👇 is just a shorthand version of this 👆
makeBinding(viewStateKeyPath: \.showsPhotoCount, actionCasePath: /Action.toggleShowsPhotoCount)
}

var searchText: Binding<String> {
makeBinding(viewStateKeyPath: \.searchText, actionCasePath: /Action.search)
}

/// Creates a new `PhotoListViewStore`
/// - Parameters:
/// - provider: The provider responsible for fetching photos.
Expand Down Expand Up @@ -98,6 +91,24 @@ final class PhotoListViewStore: ViewStore {
}
}

extension PhotoListViewStoreType {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, why did you move these onto the type over the store?

Copy link
Copy Markdown
Contributor Author

@Pearapps Pearapps Feb 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have to, since its generic in the view you can't talk to the concrete store type directly - its also an unintended advantage because it is a bit more strict with what you can do and protects the ViewStore concept with extra Published properties and the like

var showsPhotoCount: Binding<Bool> {
//
// return Binding<Bool> {
// self.viewState.showsPhotoCount
// } set: { newValue in
// self.send(.toggleShowsPhotoCount(newValue))
// }
//
// Note: This 👇 is just a shorthand version of this 👆
makeBinding(viewStateKeyPath: \.showsPhotoCount, actionCasePath: /Action.toggleShowsPhotoCount)
}

var searchText: Binding<String> {
makeBinding(viewStateKeyPath: \.searchText, actionCasePath: /Action.search)
}
}

private extension Provider {
func providePhotos() -> AnyPublisher<Result<[Photo], ProviderError>, Never> {
provideItems(request: APIRequest.photos, decoder: JSONDecoder(), providerBehaviors: [], requestBehaviors: [], allowExpiredItems: true)
Expand Down
19 changes: 19 additions & 0 deletions Sources/ViewStore/MockViewStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// MockViewStore.swift
// ViewStore
//
// Created by Kenneth Ackerson on 12/22/22.
//

import Foundation

/// A generic object conforming to `ViewStore` that simply returns the passed-in view state. Useful in SwiftUI previews.
public final class MockViewStore<ViewState, Action>: ViewStore {
public var viewState: ViewState

public init(viewState: ViewState) {
self.viewState = viewState
}

public func send(_ action: Action) {}
}
4 changes: 4 additions & 0 deletions ViewStore.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
0B77E916287F23C400BC3595 /* Array+Filtering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B77E915287F23C400BC3595 /* Array+Filtering.swift */; };
0B92D48328577B7700FF1BDF /* PhotoListOriginal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B92D48228577B7700FF1BDF /* PhotoListOriginal.swift */; };
3A31E613287F47D800955C37 /* ViewStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A31E612287F47D800955C37 /* ViewStoreTests.swift */; };
3AF7CE6E2954BC500045B466 /* MockViewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF7CE6D2954BC500045B466 /* MockViewStore.swift */; };
F2B5F5B827C7EC0C00FD7831 /* Example.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B5F5B727C7EC0C00FD7831 /* Example.swift */; };
F2B5F5BC27C7EC0E00FD7831 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F2B5F5BB27C7EC0E00FD7831 /* Assets.xcassets */; };
F2B5F5BF27C7EC0E00FD7831 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F2B5F5BE27C7EC0E00FD7831 /* Preview Assets.xcassets */; };
Expand Down Expand Up @@ -52,6 +53,7 @@
0B92D48228577B7700FF1BDF /* PhotoListOriginal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoListOriginal.swift; sourceTree = "<group>"; };
3A31E610287F47D700955C37 /* ViewStoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ViewStoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3A31E612287F47D800955C37 /* ViewStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewStoreTests.swift; sourceTree = "<group>"; };
3AF7CE6D2954BC500045B466 /* MockViewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockViewStore.swift; sourceTree = "<group>"; };
F2B5F5B427C7EC0C00FD7831 /* ViewStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ViewStore.app; sourceTree = BUILT_PRODUCTS_DIR; };
F2B5F5B727C7EC0C00FD7831 /* Example.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example.swift; sourceTree = "<group>"; };
F2B5F5BB27C7EC0E00FD7831 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand Down Expand Up @@ -97,6 +99,7 @@
0B3201F428B3BF6B00AF8884 /* ViewStore+BindingAdditions.swift */,
0B3201F528B3BF6B00AF8884 /* MainQueueScheduler.swift */,
0B3201F628B3BF6B00AF8884 /* ViewStore.swift */,
3AF7CE6D2954BC500045B466 /* MockViewStore.swift */,
);
path = ViewStore;
sourceTree = "<group>";
Expand Down Expand Up @@ -303,6 +306,7 @@
F2B5F5D527C7FB0600FD7831 /* Photo.swift in Sources */,
0B3201FA28B3BF6B00AF8884 /* ViewStore.swift in Sources */,
0B3201F728B3BF6B00AF8884 /* Publisher+CombineLatest.swift in Sources */,
3AF7CE6E2954BC500045B466 /* MockViewStore.swift in Sources */,
F2B5F5D627C7FB0600FD7831 /* PhotoListViewStore.swift in Sources */,
0B3201F828B3BF6B00AF8884 /* ViewStore+BindingAdditions.swift in Sources */,
F2B5F5D427C7FB0600FD7831 /* PhotoList.swift in Sources */,
Expand Down