In order to manage the visual state of my SwiftUI view, I have an ObservedObject that contains a State enum. Whenever I receive remote data, I use the enum with an associated value so that I can display the content I received:
final class ViewModel: ObservableObject {
#Published private(set) var state: State = .idle
func doWork() {
// Change the state to .loading so that we can display a loader in our view
state = .loading
let values = doSomeRemoteWork { [weak self] values in
// work is done, we can display the result
self?.state = .loaded(values)
}
}
enum State {
case idle
case loading
case loaded([Value])
case error(any Error)
}
}
// Value is just a struct with some basic data inside
struct Value: Identifiable {
let id: String
let someProperty: String
let anotherProperty: Double
}
// swiftui view
struct ContentView: some View {
#ObservedObject private var vm: ViewModel
init(vm: ViewModel) {
self.vm = vm
}
var body: some View {
switch vm.state {
case .idle:
Button("Get values") {
vm.doWork()
}
case .loading: Text("loading...")
case .loaded(let values):
Text("got \(values.count) values")
.onTapGesture { refresh() }
case .error(let error): Text("got an error : \(error.localizedDescription)")
}
}
private func refresh() {
vm.doWork()
}
}
This works fine but I implemented a refresh that basically just invokes the doWork() method again. When I do so, I can see that the memory usage of my app goes up and never goes down but I can't seem to understand why.
From what I understand, an enum will strong retain its associated values but shouldn't setting the state to .loading clear the enum associated value ? Is there a way to do so ?
Related
I trying to understand how Combine and SwiftUI works in combination with MVVM and clean architecture, but I encountered a problem with using withAnimation once my view model has an async method that updated published value. I was able to solve it, but I'm pretty sure it's not the correct way and I'm missing something fundamental. Here it is how it looks my solution, starting with my data manager:
protocol NameManaging {
var publisher: AnyPublisher<[Name], Never> { get }
func fetchNames() async
}
class MockNameManager: NameManaging {
var publisher: AnyPublisher<[Name], Never> {
names.eraseToAnyPublisher()
}
func fetchNames() async {
var values = await heavyAsyncTask()
names.value.append(contentsOf: values)
}
private func heavyAsyncTask() async -> [Name] {
// do some heavy async task
}
private var names = CurrentValueSubject<[Name], Never>([])
}
Then view models:
class NameListViewModel: ObservableObject {
#Published var names = [Name]()
private var anyCancellable: AnyCancellable?
private var nameManager: NameManaging
init(nameManager: NameManaging = MockNameManager()) {
self.nameManager = nameManager
self.anyCancellable = nameManager.publisher
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] values in
withAnimation {
self?.names = values
}
})
}
func fetchNames() async {
await nameManager.fetchNames()
}
}
Lastly my view:
struct NameList: View {
#StateObject private var nameListViewModel = NameListViewModel()
var body: some View {
VStack {
VStack {
HStack {
Button(action: updateNames) {
Text("Fetch some more names")
}
}
}
.padding()
List {
ForEach(nameListViewModel.names) {
NameRow(name: $0)
}
}
}
.navigationTitle("Names list")
.onAppear(perform: updateNames)
}
func updateNames() {
Task {
await nameListViewModel.fetchNames()
}
}
}
What I did is use withAnimation inside my view model in .sink() method of data manager publisher. This works as expected, but it indroduce view function inside view model. How can I do it in a way that inside updateNames in my view I'll use withAnimation? Or maybe I should do it in completely different way?
You are mixing up technologies. The point of async/await is to remove the need for a state object (i.e. a reference type) and Combine to do async work. You can simply use the .task modifier to call any async func and set the result on an #State var. If the async func throws then you might catch the exception and set a message on another #State var. The great thing about .task is it's called when the UIView (that SwiftUI creates for you) appears and cancelled when it disappears (also if the optional id param changes). So no need for an object, which often is the cause of consistency/memory problems which Swift and SwiftUI's use of value types is designed to eliminate.
struct NameList: View {
#State var var names: [Name] = []
#State var fetchCount = 0
var body: some View {
VStack {
VStack {
HStack {
Button("Fetch some more names") {
fetchCount += 1
}
}
}
.padding()
List {
ForEach(names) { name in
NameRow(name: name)
}
}
}
.navigationTitle("Names list")
.task(id: fetchCount) {
let names = await Name.fetchNames()
withAnimation {
self.names = names
}
}
}
}
How can I present in swiftUI a view in a switch case?
import Foundation
enum SideMenuViewModel: Int, CaseIterable, Decodable {
case Home
case Users
case TODOs
case AboutUs
var title: String {
switch self {
case .Home: return "Home"
case .Users: return "Users"
case .TODOs: return "TODOs"
case .AboutUs: return "AboutUs"
}
}
var imageName: String {
switch self {
case .Home: return "bookmark.circle"
case .Users: return "person"
case .TODOs: return "list.bullet"
case .AboutUs: return "info.circle"
}
}
}
You need view builder property, like
#ViewBuilder var view: some View {
switch self {
case .Home:
HomeView()
case .Users:
UsersView()
case .TODOs:
TODOView()
case .AboutUs:
AboutUsView()
}
}
Instead of trying to return a View from an enum, inject the enum into your View and switch over the enum inside the view, then return the appropriate View from there.
A ViewModel should be independent from its view type, only the view should know about the VM, not the other way around.
struct SideMenuView: View {
private let viewModel: SideMenuViewModel
init(viewModel: SideMenuViewModel) {
self.viewModel = viewModel
}
var body: some View {
switch viewModel {
case .AboutUs:
AboutUsView(...)
case .Home:
HomeView(...)
...
}
}
}
I would suggest to craft an enum with associated values as follows:
enum ViewState {
case .home(HomeViewState)
case .users(UsersViewState)
case .todos(TodosViewState)
case .aboutUs(AboutUsViewState)
}
So, each case has its own distinct "state" (aka model) for the respective view.
struct HomeViewState {...}
struct UsersViewState {...}
struct TodosViewState {...}
struct AboutUsViewState {...}
In a parent view, obtain the enum value, then simply select the given state using a switch statement. Extract the view specific state and compose the view in the body:
struct ContentView: View {
let viewState: ViewState
var body: some View {
switch viewState {
case .home(let state):
HomeView(state: state)
case .users(let state):
UsersView(state: state)
case .todos(let state):
TodosView(state: state)
case .aboutUs(let state):
AboutUsView(state: state)
}
}
}
Example for the HomeView:
struct HomeView: View {
let state: HomeViewState
var body: some View {
...
}
}
Your Model:
final class SideMenuViewModel: ObservableObject {
#Published var viewState: ViewState = ...
...
}
A root view in the scene my now subscribe to the SideMenuViewModel and pass the viewState to the ContentView.
I am trying to control the flow of my app when it first appears based on an async call to see if the user is logged in or not.
If this call is synchronous I can do a simple switch or if else to determine the view to show.
But I don't know how to handle the flow based on an async request. So far this is what I have done but changing the state var does not change the view which is displayed. The following is in the app file #main function:
enum LoginStatus {
case undetermined
case signedIn
case signedOut
}
#main
struct SignUpApp: App {
#State var loginStatus: LoginStatus = .undetermined
public init() {
getLoginStatus()
}
var body: some Scene {
WindowGroup {
switch loginStatus {
case .undetermined:
Text("SigningIn")
case .signedIn:
EnterAppView()
case .signedOut:
LoginView()
}
}
}
func getLoginStatus() {
someAsyncFunctionToGetSignInStatus { result in
DispatchQueue.main.async {
switch result {
case .success:
self.loginStatus = .signedIn
case .failure:
self.loginStatus = .signedOut
}
}
}
}
}
I imagine this is a common task and wondering what the best way to handle it is.
You have the right idea, but generally with async calls, you're probably going to want to move them into an ObservableObject rather than have them in your View code itself, since views are transient in SwiftUI (although the exception may be the top level App like you have).
class SignInManager : ObservableObject {
#Published var loginStatus: LoginStatus = .undetermined
public init() {
getLoginStatus()
}
func getLoginStatus() {
someAsyncFunctionToGetSignInStatus { result in
//may need to use DispatchQueue.main.async {} to make sure you're on the main thread
switch result {
case .success:
self.loginStatus = .signedIn
case .failure:
self.loginStatus = .signedOut
}
}
}
}
#main
struct SignUpApp: App {
#StateObject var signInManager = SignInManager()
var body: some Scene {
WindowGroup {
switch signInManager.loginStatus {
case .undetermined:
Text("SigningIn")
case .signedIn:
EnterAppView()
case .signedOut:
LoginView()
}
}
}
}
enum LoginStatus {
case undetermined
case signedIn
case signedOut
}
#main
struct SignUpApp: App {
#State var loginStatus: LoginStatus = .undetermined
public init() {
getLoginStatus()
}
var body: some Scene {
WindowGroup {
switch loginStatus {
case .undetermined:
Text("SigningIn")
case .signedIn:
EnterAppView()
case .signedOut:
LoginView()
}
}
}
func getLoginStatus() {
someAsyncFunctionToGetSignInStatus { result in
DispatchQueue.main.async {
switch result {
case .success:
self.loginStatus = .signedIn
case .failure:
self.loginStatus = .signedOut
}
}
}
}
}
I'm trying to implement MVVM in my SwiftUI app in a way that decouples the view from the view model itself. In my research I came across this article outlining one strategy: https://quickbirdstudios.com/blog/swiftui-architecture-redux-mvvm/
Here's a summary of how it works:
// ViewModel.swift
protocol ViewModel: ObservableObject where ObjectWillChangePublisher.Output == Void {
associatedtype State
associatedtype Event
var state: State { get }
func trigger(_ event: Event)
}
// AnyViewModel.swift
final class AnyViewModel<State, Event>: ObservableObject {
private let wrappedObjectWillChange: () -> AnyPublisher<Void, Never>
private let wrappedState: () -> State
private let wrappedTrigger: (Event) -> Void
var objectWillChange: some Publisher {
wrappedObjectWillChange()
}
var state: State {
wrappedState()
}
func trigger(_ input: Event) {
wrappedTrigger(input)
}
init<V: ViewModel>(_ viewModel: V) where V.State == State, V.Event == Event {
self.wrappedObjectWillChange = { viewModel.objectWillChange.eraseToAnyPublisher() }
self.wrappedState = { viewModel.state }
self.wrappedTrigger = viewModel.trigger
}
}
// MyView.swift
extension MyView {
enum Event {
case onAppear
}
enum ViewState {
case loading
case details(Details)
}
struct Details {
let title: String
let detail: String
}
}
struct MyView: View {
#ObservedObject var viewModel: AnyViewModel<ViewState, Event>
var body: some View { ... }
}
// ConcreteViewModel.swift
class ConcreteViewModel: ViewModel {
#Published var state: MyView.ViewState = .loading
func trigger(_ event: MyView.Event) {
...
state = .details(...) // This gets called by my app and the state is updated.
...
}
}
// Constructing MyView
let view = MyView(viewModel: AnyViewModel(ConcreteViewModel))
This succeeds in separating the view from the view model (using AnyViewModel as a wrapper), but the issue is updates to the state property in ConcreteViewModel are not reflected in MyView.
My suspicion is that the problem lies in AnyViewModel and the wrappedObjectWillChange closure, but I am having difficulty debugging it. Do I need to do something with the objectWillChange publisher explicitly, or should #Published handle it automatically?
Any help is much appreciated.
I believe the var objectWillChange: some Publisher is not being resolved correctly by SwiftUI type checker. Setting it to the matching type var objectWillChange: AnyPublisher<Void, Never> should fix the bug.
See: https://gist.github.com/LizzieStudeneer/c3469eb465e2f88bcb8225df29fbbb77
What are proven approaches for structuring the networking layer of a SwiftUI app? Specifically, how do you structure using URLSession to load JSON data to be displayed in SwiftUI Views and handling all the different states that can occur properly?
Here is what I came up with in my last projects:
Represent the loading process as a ObservableObject model class
Use URLSession.dataTaskPublisher for loading
Using Codable and JSONDecoder to decode the response to Swift types using the Combine support for decoding
Keep track of the state in the model as a #Published property so that the view can show loading/error states.
Keep track of the loaded results as a #Published property in a separate property for easy usage in SwiftUI (you could also use View#onReceive to subscribe to the publisher directly in SwiftUI but keeping the publisher encapsulated in the model class seemed more clean overall)
Use the SwiftUI .onAppear modifier to trigger the loading if not loaded yet.
Using the .overlay modifier is convenient to show a Progress/Error view depending on the state
Extract reusable components for repeatedly occuring tasks (here is an example: EndpointModel)
Standalone example code for that approach (also available in my SwiftUIPlayground):
// SwiftUIPlayground
// https://github.com/ralfebert/SwiftUIPlayground/
import Combine
import SwiftUI
struct TypiTodo: Codable, Identifiable {
var id: Int
var title: String
}
class TodosModel: ObservableObject {
#Published var todos = [TypiTodo]()
#Published var state = State.ready
enum State {
case ready
case loading(Cancellable)
case loaded
case error(Error)
}
let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
let urlSession = URLSession.shared
var dataTask: AnyPublisher<[TypiTodo], Error> {
self.urlSession
.dataTaskPublisher(for: self.url)
.map { $0.data }
.decode(type: [TypiTodo].self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
func load() {
assert(Thread.isMainThread)
self.state = .loading(self.dataTask.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case let .failure(error):
self.state = .error(error)
}
},
receiveValue: { value in
self.state = .loaded
self.todos = value
}
))
}
func loadIfNeeded() {
assert(Thread.isMainThread)
guard case .ready = self.state else { return }
self.load()
}
}
struct TodosURLSessionExampleView: View {
#ObservedObject var model = TodosModel()
var body: some View {
List(model.todos) { todo in
Text(todo.title)
}
.overlay(StatusOverlay(model: model))
.onAppear { self.model.loadIfNeeded() }
}
}
struct StatusOverlay: View {
#ObservedObject var model: TodosModel
var body: some View {
switch model.state {
case .ready:
return AnyView(EmptyView())
case .loading:
return AnyView(ActivityIndicatorView(isAnimating: .constant(true), style: .large))
case .loaded:
return AnyView(EmptyView())
case let .error(error):
return AnyView(
VStack(spacing: 10) {
Text(error.localizedDescription)
.frame(maxWidth: 300)
Button("Retry") {
self.model.load()
}
}
.padding()
.background(Color.yellow)
)
}
}
}
struct TodosURLSessionExampleView_Previews: PreviewProvider {
static var previews: some View {
Group {
TodosURLSessionExampleView(model: TodosModel())
TodosURLSessionExampleView(model: self.exampleLoadedModel)
TodosURLSessionExampleView(model: self.exampleLoadingModel)
TodosURLSessionExampleView(model: self.exampleErrorModel)
}
}
static var exampleLoadedModel: TodosModel {
let todosModel = TodosModel()
todosModel.todos = [TypiTodo(id: 1, title: "Drink water"), TypiTodo(id: 2, title: "Enjoy the sun")]
todosModel.state = .loaded
return todosModel
}
static var exampleLoadingModel: TodosModel {
let todosModel = TodosModel()
todosModel.state = .loading(ExampleCancellable())
return todosModel
}
static var exampleErrorModel: TodosModel {
let todosModel = TodosModel()
todosModel.state = .error(ExampleError.exampleError)
return todosModel
}
enum ExampleError: Error {
case exampleError
}
struct ExampleCancellable: Cancellable {
func cancel() {}
}
}
Splitting off the state / data / networking into a separate #ObservableObject class outside the View Struct is definitely the way to go. There are too many SwiftUI "Hello World" examples out there stuffing it all into the View struct.
As a best practice you could look to standardize your #ObservableObject naming inline with MVVM and call that "Model" class a ViewModel, as in:
#StateObject var viewModel = TodosViewModel()
The majority of code in there is handling overlay state, onAppear events and display issues for the View.
Create a new TodosModel class and reference that in the ViewModel:
#ObservedObject var model = TodosModel()
Then move all the networking / api / JSON code into that class with one method called by ViewModel:
public func getList() -> AnyPublisher<[TypiTodo], Error>
The View-ViewModel-Model are now split up, related to Paul D's comment, the ViewModel could combine 1 or more Models to return whatever the view needs. And, more importantly, the TodoModel entity knows nothing about the View and can focus on http / JSON / CRUD.
Below is great example using Combine / HTTP / JSON decode. You can see how it uses tryMap, mapError to further separate the networking from the decode errors. https://gist.github.com/stinger/e8b706ab846a098783d68e5c3a4f0ea5
See a very short and clear explanation of the difference between #StateObject and #ObservedObject in this article:
https://levelup.gitconnected.com/state-vs-stateobject-vs-observedobject-vs-environmentobject-in-swiftui-81e2913d63f9