Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to modify state from state in SwiftUI #487

Open
onmyway133 opened this issue Nov 2, 2019 · 0 comments
Open

How to modify state from state in SwiftUI #487

onmyway133 opened this issue Nov 2, 2019 · 0 comments
Labels

Comments

@onmyway133
Copy link
Owner

onmyway133 commented Nov 2, 2019

In case we have to modify state when another state is known, we can encapsulate all those states in ObservableObject and use onReceive to check the state we want to act on.

See code Avengers

If we were to modify state from within body function call, we will get warnings

Modifying state during view update, this will cause undefined behavior.

This is similar to the warning when we change state inside render in React

For example, when we get an image, we want to do some logic based on that image and modify result state. Here we use var objectWillChange = ObservableObjectPublisher() to notify state change, and because onReceive requires Publisher, we use let imagePublisher = PassthroughSubject<UIImage, Never>()

Note that we use $ prefix from a variable to form Binding

import SwiftUI
import Combine

class ViewModel: ObservableObject {
    var objectWillChange = ObservableObjectPublisher()
    let imagePublisher = PassthroughSubject<UIImage, Never>()

    var image: UIImage? {
        willSet {
            objectWillChange.send()
            if let image = image {
                imagePublisher.send(image)
            }
        }
    }

    var isDetecting: Bool = false {
        willSet {
            objectWillChange.send()
        }
    }

    var result: String? {
        willSet {
            objectWillChange.send()
        }
    }
}

struct MainView: View {
    @State private var showImagePicker: Bool = false
    @ObservedObject var viewModel: ViewModel = ViewModel()

    private let detector = Detector()

    var body: some View {
        VStack {
            makeImage()
                .styleFit()

            if viewModel.isDetecting {
                ActivityIndicator(
                    isAnimating: $viewModel.isDetecting,
                    style: .large
                )
            }

            makeResult()

            Button(action: {
                self.showImagePicker.toggle()
            }, label: {
                Text("Choose image")
            })
            .sheet(isPresented: $showImagePicker, content: {
                ImagePicker(image: self.$viewModel.image, isPresented: self.$showImagePicker)
            })
        }
        .onReceive(viewModel.imagePublisher, perform: { image in
            self.detect(image: image)
        })
    }

    private func makeImage() -> Image {
        if let image = self.viewModel.image {
            return Image(uiImage: image)
        } else {
            return Image("placeholder")
        }
    }

    private func makeResult() -> Text {
        if let result = viewModel.result {
            return Text(result)
        } else {
            return Text("")
        }
    }

    private func detect(image: UIImage) {
        viewModel.isDetecting = true
        try? detector.detect(image: image, completion: { result in
            switch result {
            case .success(let string):
                self.viewModel.result = string
            default:
                self.viewModel.result = ""
            }

            self.viewModel.isDetecting = false
        })
    }
}

Use Published

See ObservableObject

By default an ObservableObject synthesizes an objectWillChange publisher that emits the changed value before any of its @published properties changes.

class Contact: ObservableObject {
    @Published var name: String
    @Published var age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }

    func haveBirthday() -> Int {
        age += 1
        return age
    }
}

let john = Contact(name: "John Appleseed", age: 24)
john.objectWillChange.sink { _ in print("\(john.age) will change") }
print(john.haveBirthday())
// Prints "24 will change"
// Prints "25"

We should use @Published

class ViewModel: ObservableObject {
    var objectWillChange = ObservableObjectPublisher()

    @Published var image: UIImage?
    @Published var isDetecting: Bool = false
    @Published var result: String?
}

Note that we should not use objectWillChange as

By default an ObservableObject synthesizes an objectWillChange publisher that emits the changed value before any of its @published properties changes.

.onReceive(viewModel.$image, perform: { image in
    if let image = image {
        self.detect(image: image)
    }
})

We need to manually notify using objectWillChange !! Maybe this is a SwiftUI bug

private func detect(image: UIImage) {
    viewModel.isDetecting = true
    try? detector.detect(image: image, completion: { result in
        switch result {
        case .success(let string):
            self.viewModel.result = string
        default:
            self.viewModel.result = ""
        }

        self.viewModel.isDetecting = false
        self.viewModel.objectWillChange.send()
    })
}

If we remove the declaration of var objectWillChange = ObservableObjectPublisher(), then it works automatically

objectWillChange

Learn more about the history of objectWillChange

https://twitter.com/luka_bernardi/status/1155944329363349504?lang=no

In Beta 5 ObjectBinding is now defined in Combine as ObservableObject (the property wrapper is now @ObservedObject). There is also a new property wrapper @published where we automatically synthesize the objectWillChange publisher and call it on willSet.

It’ll objectWillChange.send() in the property willSet it’s defined on.
It just removes the boilerplate that you had to write before but otherwise behaves the same.

State vs ObservedObject

If we were to use @State instead of @ObservedObject, it still compiles, but after we pick an image, which should change the image property of our viewModel, the view is not reloaded.

struct MainView: View {
    @State private var showImagePicker: Bool = false
    @State private var viewModel: ViewModel = ViewModel()
}

Note that we can't use @Published inside struct

'wrappedValue' is unavailable: @published is only available on properties of classes

@State is for internal usage within a view, and should use struct and primitive data structure. SwiftUI keeps @State property in a separate memory place to preserve it during many reload cycles.

@Observabled is meant for sharing reference objects across views

To to use @State we should use struct, and to use onReceive we should introduce another Publisher like imagePublisher

struct ViewModel {
    var imagePublisher = PassthroughSubject<UIImage?, Never>()
    var image: UIImage? {
        didSet {
            imagePublisher.send(image)
        }
    }
    var isDetecting: Bool = false
    var result: String?
}

struct MainView: View {
    @State private var showImagePicker: Bool = false
    @State private var viewModel: ViewModel = ViewModel()

    private let detector = Detector()

    var body: some View {
        VStack {
            makeImage()
                .styleFit()

            if viewModel.isDetecting {
                ActivityIndicator(
                    isAnimating: $viewModel.isDetecting,
                    style: .large
                )
            }

            makeResult()

            Button(action: {
                self.showImagePicker.toggle()
            }, label: {
                Text("Choose image")
            })
            .sheet(isPresented: $showImagePicker, content: {
                ImagePicker(image: self.$viewModel.image, isPresented: self.$showImagePicker)
            })
        }
        .onReceive(viewModel.imagePublisher, perform: { image in
            if let image = image {
                self.detect(image: image)
            }
        })
    }
}

The dollar sign for State to access nested properties, like $viewModel.image is called derived Binding, and is achieved via Keypath member lookup feature of Swift 5.1.

Take a look at projectedValue: Binding<Value> from State and subscript<Subject>(dynamicMember keyPath from Binding

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@propertyWrapper public struct State<Value> : DynamicProperty {

    /// Initialize with the provided initial value.
    public init(wrappedValue value: Value)

    /// Initialize with the provided initial value.
    public init(initialValue value: Value)

    /// The current state value.
    public var wrappedValue: Value { get nonmutating set }

    /// Produces the binding referencing this state value
    public var projectedValue: Binding<Value> { get }
}
/// A value and a means to mutate it.
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@propertyWrapper @dynamicMemberLookup public struct Binding<Value> {

    /// The transaction used for any changes to the binding's value.
    public var transaction: Transaction

    /// Initializes from functions to read and write the value.
    public init(get: @escaping () -> Value, set: @escaping (Value) -> Void)

    /// Initializes from functions to read and write the value.
    public init(get: @escaping () -> Value, set: @escaping (Value, Transaction) -> Void)

    /// Creates a binding with an immutable `value`.
    public static func constant(_ value: Value) -> Binding<Value>

    /// The value referenced by the binding. Assignments to the value
    /// will be immediately visible on reading (assuming the binding
    /// represents a mutable location), but the view changes they cause
    /// may be processed asynchronously to the assignment.
    public var wrappedValue: Value { get nonmutating set }

    /// The binding value, as "unwrapped" by accessing `$foo` on a `@Binding` property.
    public var projectedValue: Binding<Value> { get }

    /// Creates a new `Binding` focused on `Subject` using a key path.
    public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> Binding<Subject> { get }
}

Read more

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant