2

In this example, the blue rectangle should be initially visible on devices with .regular size class and hidden on devices with .compact size class.

I'm using an ObservableObject called Settings and the @Published variable isVisible to manage visibilty of the rectangle. My problem is that I don't know how I can init Settings with the correct horizontalSizeClass from my ContentView. Right now I am using .onAppear to change the value of isVisible but this triggers .onReceive. On compact devices this causes the rectangle to be visible and fading out when the view is presented instead of being invisible right away.

How can I init Settings based on Environment values like horizontalSizeClass so that isVisible is correct from the start?

struct ContentView: View {
    @Environment(\.horizontalSizeClass) var horizontalSizeClass
    @StateObject var settings = Settings()
    @State var opacity: CGFloat = 1

    var body: some View {
        VStack {
            Button("Toggle Visibility") {
                settings.isVisible.toggle()
            }
            .onReceive(settings.$isVisible) { _ in
                withAnimation(.linear(duration: 2.0)) {
                    opacity = settings.isVisible ? 1 : 0
                }
            }
            Rectangle()
                .frame(width: 100, height: 100)
                .foregroundColor(.blue)
                .opacity(opacity)
        }
        .onAppear {
            settings.isVisible = horizontalSizeClass == .regular // too late
        }
    }
}

class Settings: ObservableObject {
    @Published var isVisible: Bool = true // can't get size class here
}

The rectangle should not be visible on start:

enter image description here

0

3 Answers 3

2

We need just perform dependency injection (environment is known is parent so easily can be injected into child), and it is simple to do with internal view, like

struct ContentView: View {
    @Environment(\.horizontalSizeClass) var horizontalSizeClass

    struct MainView: View {
        // later knonw injection
        @EnvironmentObject var settings: Settings

        var body: some View {
            VStack {
                Button("Toggle Visibility") {
                    settings.isVisible.toggle()
                }
                Rectangle()
                    .frame(width: 100, height: 100)
                    .foregroundColor(.blue)
                    .opacity(settings.isVisible ? 1 : 0) // << direct dependency !!
            }
            .animation(.linear(duration: 2.0), value: settings.isVisible) // << explicit animation
        }
    }

    var body: some View {
        MainView()          // << internal view
            .environmentObject(
                Settings(isVisible: horizontalSizeClass == .regular) // << initial injecttion !!
            )
    }
}

Tested with Xcode 13.4 / iOS 15.5

demo

Test code is here

Sign up to request clarification or add additional context in comments.

4 Comments

we shouldn't init objects over and over again in body 😉
@malhal, we don't, we are root view, obviously.
Thanks @Asperi, this is exactly what I needed! Another key learning from your answer is to use .animation instead of withAnimation. I build an indirect opacity dependency with .onReceive since using withAnimation to change a published var does not properly animate (found this in an older answer of yours: stackoverflow.com/a/60618042). Do I understand correctly that this approach with .onReceive is outdated by now in general (as @malhal suggests in his answer)?
No, it is not outdated, it is just for publishers, I mean in general, which are not ObservableObject, in example notifications, etc.
0

withAnimation should be done on the Button action changing the state, e.g.

import SwiftUI

struct RectangleTestView: View {

    @Environment(\.horizontalSizeClass) var horizontalSizeClass
    @State var settings = Settings()
    
    var body: some View {
        VStack {
            Button("Toggle Visibility") {
                withAnimation(.linear(duration: 2.0)) {
                    settings.isVisible.toggle()
                }
            }
            Rectangle()
                .frame(width: 100, height: 100)
                .foregroundColor(.blue)
                .opacity(settings.opacity)
        }
        .onAppear {
            settings.isVisible = horizontalSizeClass == .regular
        }
    }
}

struct Settings {
    var isVisible: Bool = true
    var opacity: CGFloat {
        isVisible ? 1 : 0
    }
}

FYI we don't really use onReceive anymore since they added onChange. Also its best to keep view data in structs not move it to expensive objects. "Views are very cheap, we encourage you to make them your primary encapsulation mechanism" Data Essentials in SwiftUI WWDC 2020 at 20:50.

2 Comments

Thanks for your answer, but my example is a simplification from a much more complex app and I need an ObservableObject that I can later use as an EnvironmentObject throughout the app.
Thats fine if it's the object that manages the model structs. By the way you can also use @.AppStorage for settings.
0

One way to initialize ObservableObject that depends on environment is to pass the necessary environment from a parent view via a view init that itself creates a StateObject using wrappedValue: initializer.

Disclosure

Following is a link to my article describing the solution in more detail:

ObservableObject initialisation using Environment

1 Comment

You must disclose your affiliation to any linked sites (here and one of your other answers), or your answers may be flagged as spam.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.