Swift / Apple Development Chat

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
Could it be that some of this 'missing parts' of the API is just Apple trying to steer developers to certain architectures / out of some patterns? Kind of an extreme example, but I remember people complaining about how you can't create a ViewModel in a parent view and set it as a @StateObject in a child view elegantly (see this SO post). After watching some of the SwiftUI talks (specially, Demystify SwiftUI), I feel like trying to do something like that is just fighting the system (if you want SwiftUI to persist that @StateObject between view inits, you shouldn't also want to initialize it every time the view body of the parent is executed).

I wouldn’t be surprised if that’s the case. I need to fix some that StateObject nonsense in my own code as well, so there are traps, sadly. It just hasn’t been a priority until I can get some other areas cleaned up.

That said, my wrapper is in the same vein as @Published, but simpler and more suitable for model objects that want or need to publish from background threads. It is really syntactic sugar to reduce the amount of boilerplate needed to use CurrentValueSubject in model objects and make the code a hair cleaner, but it isn’t a huge win unless you are doing a lot of custom model objects or actors.

Swift:
// Using CurrentValueSubject directly
// You need to manage making the publisher accessible yourself and guard the Subject.
private var state: CurrentValueSubject<MyStateStruct, Never>
public var statePublisher: AnyPublisher<MyStateStruct, Never> { state.eraseToAnyPublisher() }
// Modify by changing value in the value subject
state.value = .init(/* New State */)
    
// Using Property Wrapper
// $state is the type-erased publisher, similar to @Published
@ValueSubject private(set) var state: MyStateStruct
// Modify like any other property:
state = .init(/* New State */)

YES! That's the exact same feeling I had with VIPER codebases. Everything is very compartimentalized and that should be a positive thing, but trying to understand the app flow as a first-timer is an absolute nightmare because you have to hunt down every affected piece of code throughout all the codebase. If the app also had network calls or any other completion handler heavy user, code could become almost undecipherable. Additionally, as the number of affected files grows, it becomes harder to keep everything inside your 'mental model' of the code.

And I think the thing is, this is more of a “how are files placed on disk” problem, than a fundamental issue with the approach. Just give me a folder called “Authentication” that includes the state, reducers, etc and let me see the whole component. Then we can compose the different components into the global store.
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
After a few more months working with (mostly) async-await only projects, I've detected one common antipattern that is sadly very common in the async/await code I've seen from many devs now. It's too easy to do something like this:
Swift:
func fakeSynchronousFunction() {
    Task {
        // Do some function-dependent stuff
        // ...
        await fooAsynchronousFunction()
    }
}
Instead of the expected:
Swift:
func asyncFunction() async {
    // Do some function-dependent stuff
    // ...
    await fooAsynchronousFunction()
}

Because the later requires the caller function to support concurrency and it's easier to slap a Task than to refactor the entire call stack to properly support concurrency. This leads to weird bugs and overall janky experience in the apps once you get enough standalone Tasks flying around causing a million different combinations of race conditions. Think for example:
Swift:
Button(
    action: {
        fakeSynchronousFunction()
        dismiss()
    },
    label: {
        Text("FooButton")
    }
)

The dismiss may or may not be executed before the fakeSynchronousFunction is actually finished, and each possibility may have different effects on the app. I can't think of any good example of a legit use case for a synchronous function that only contains a Task. IMHO it should be the caller's responsibility to handle wether the function is executed concurrently or not, I don't think it makes any sense to force a function to execute detached from the current flow. I wish this raised a warning.
 

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
I generally agree with the sentiment, part of the issue is that the main thread is a synchronous context. So without the framework jumping the boundary for you, this anti-pattern is going to be common in one form or another, I think. Because right now, it’s up to the developer to migrate work into a Task if it is asynchronous.

I wonder if the right call in these sort of scenarios are extensions on the components that require manual wrapping, like Button. In this specific example, providing a task as an action would help avoid this mixing. One thing I also notice is that there are folks that don’t see extending APIs as an option, even when it is. That said, these sort of extensions would look a lot like a “synchronous function that only contains a Task”:

Swift:
extension Button {
    init(task: () async -> Void, label: () -> Label) {
        self.init(action: { Task(operation: task) }, label: label)
    }
}

Another approach is to treat actions like these as red flags if they pass in closures rather than functions. Enforce better code locality for the action itself.
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
That said, these sort of extensions would look a lot like a “synchronous function that only contains a Task”:
The main problem is not so much having synchronous functions that only contain a Task as it is having such things in intermediate parts of the call stack. I have thought of another example:

Swift:
class FooViewModel {
    var fooVar: Bool
    
    func fetchFooVar() {
        Task {
            // Network call to fetch current value of fooVar goes here...
            // ...
            fooVar = resultOfNetworkCall
        }
    }
}

struct FooView: View {
    let viewModel = FooViewModel()
    
    var body: some View {
        Button(
            action: {
                fetchFooVar()
                if viewModel.fooVar {
                    // Do something
                } else {
                    // Do something else
                }
            },
            label: {
                Text("Foo Button!")
            }
        )
    }
}

On the code above, it's not immediately clear to the programmer nor the compiler that the button action is using an old value of fooVar (the network call surely won't get there in time). However, if we avoid wrapping a single Task into a function, it forces us to write the code at the call site (the Button's action, in this case) where any problems that could arise from asynchronous behaviours become much more evident:
Swift:
class FooViewModel {
    var fooVar: Bool
    
    func fetchFooVar() async {
        // Network call to fetch current value of fooVar goes here...
        // ...
        fooVar = resultOfNetworkCall
    }
}

struct FooView: View {
    let viewModel = FooViewModel()
    
    var body: some View {
        Button(
            action: {
                Task {
                    await fetchFooVar()
                }
                if viewModel.fooVar {
                    // Do something
                } else {
                    // Do something else
                }
            },
            label: {
                Text("Foo Button!")
            }
        )
    }
}

Now it's way more obvious that fooVar probably isn't updated in time. It's also trivial to solve: you can just put the if/else logic inside the Task too, and it will only execute after the await returns. The closure we use to build the Button only contains a Task, but it's not a problem since that closure is the outermost call site. It's almost beautiful how properly annotating code as async forces you to solve concurrency bugs.

I wonder if the right call in these sort of scenarios are extensions on the components that require manual wrapping, like Button. In this specific example, providing a task as an action would help avoid this mixing. One thing I also notice is that there are folks that don’t see extending APIs as an option, even when it is. That said, these sort of extensions would look a lot like a “synchronous function that only contains a Task”:

Swift:
extension Button {
    init(task: () async -> Void, label: () -> Label) {
        self.init(action: { Task(operation: task) }, label: label)
    }
}
Love this idea. I honestly was waiting for Apple to implement some version of this on SwiftUI, didn't realize it's trivial to just write your own.
 

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
Swift:
class FooViewModel {
    var fooVar: Bool
  
    func fetchFooVar() {
        Task {
            // Network call to fetch current value of fooVar goes here...
            // ...
            fooVar = resultOfNetworkCall
        }
    }
}

struct FooView: View {
    let viewModel = FooViewModel()
  
    var body: some View {
        Button(
            action: {
                fetchFooVar()
                if viewModel.fooVar {
                    // Do something
                } else {
                    // Do something else
                }
            },
            label: {
                Text("Foo Button!")
            }
        )
    }
}

On the code above, it's not immediately clear to the programmer nor the compiler that the button action is using an old value of fooVar (the network call surely won't get there in time). However, if we avoid wrapping a single Task into a function, it forces us to write the code at the call site (the Button's action, in this case) where any problems that could arise from asynchronous behaviours become much more evident:

Yeah, anything involving async code boundaries is where you’re going to have issues. Depending on what the if/else does in this example though, this might actually be better done as a published variable instead:

Swift:
class FooViewModel {
    @Published var fooVar: Bool
  
    func fetchFooVar() {
        Task {
            // Network call to fetch current value of fooVar goes here...
            // ...
            // Always update your published variables on the main actor.
            await MainActor.run {
              fooVar = resultOfNetworkCall
            }
        }
    }
}

// Now, we can use onReceive to act on the updated variable.
// But depending on what we want, we don’t even need onReceive.
// The view will be refreshed on the variable update, and can act on the new state.
Button(action: viewModel.fetchFooVar, label: { Text(“Foo Button!”) })
  .onReceive(publisher: $viewModel.fooVar, action: { newValue in
    if newValue { … }
    else { … }
  })

You get a similar sort of thing here, where the async code is all pretty much visible in one spot, but now you are using the published variable as the signal for the view to do something such as update internal state. I’d consider the fact that you have a variable being updated as a side-effect a part of the problem. So either leverage that pattern appropriately so that any updates to the variable trigger the behaviors, or realize the variable doesn’t need to exist and have fetchFooVar return the value, which forces you to address the async code that way.

Love this idea. I honestly was waiting for Apple to implement some version of this on SwiftUI, didn't realize it's trivial to just write your own.

It became clear that you will want to do stuff like this to approach specific patterns in your own code, and these type of convenience initializers are something Swift supports quite well and will enforce access restrictions in extensions which is nice. But for someone like me that started learning OOP with C++ where you can’t do this sort of extensibility, it’s not always clear that this sort of helper is what you want. One of the issues I have with OOP is that it tends to get you thinking that objects you didn’t write are off limits for new code. Even if it’s the natural “location” for new code to live.

An example is that I wrote a convenience initializer for NavigationLink which checks a flag plus the OS version. If things check out, it calls the new value-based initializer added in SwiftUI 4, or creates the appropriate destination itself based on the value and passes that to the old destination-based initializer. So now I can just use NavigationLink where I like with my initializer, and it will work for both the new and old navigation schemes.

Unfortunately, there’s still too many bugs in the new navigation setup for me to switch to it full time in my app, but at least I can monitor it. *sigh*
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
You get a similar sort of thing here, where the async code is all pretty much visible in one spot, but now you are using the published variable as the signal for the view to do something such as update internal state. I’d consider the fact that you have a variable being updated as a side-effect a part of the problem. So either leverage that pattern appropriately so that any updates to the variable trigger the behaviors, or realize the variable doesn’t need to exist and have fetchFooVar return the value, which forces you to address the async code that way.
Yes, reorganizing the code to be reactive will solve many of the problems caused by having too many in-flight tasks. I still think there are many other, subtler problems that will arose if this kind of pattern is abused. Acting on value changes instead of getting/setting variables manually is one part of it —and would prevent many, many errors—, but ultimately I'd still want the dispatch of the functions to be specified at the call site rather than inside the function itself.
Actually, I think reactivity in general and SwiftUI in particular are partially responsible of this pattern of putting single tasks inside non-async functions: as long as you are updating a @Published variable or something like that inside the function, you'll see no adverse effects of using that pattern (not at first, at least). Things start to get *very* messy once those functions update more than one or two variables: while a properly written async/await code might have awaited for the full set of variables to update (and then update the view with a full set of correct values), trying to do that with functions that internally dispatch Tasks may be a disaster.

Writing this reminded me of something. I tried to write a minimal-example of it. Below are two versions of a code with the same subtle bug:

With async/await all the way:
Swift:
class FooViewModel {
    @Published var position: CGPoint
    @Published var size: CGFloat
    
    func getPosition() async -> CGPoint {
        return await networkFunctionToGetPosition()
    }
    func getSize() async -> CGFloat {
        return await networkFunctionToGetSize()
    }
    @MainActor func updateRectangle() async {
        self.position = await getPosition()
        self.size = await getSize()
    }
}
struct FooView: View {
    @StateObject var viewModel = FooViewModel()
    var body: some View {
        ZStack {
            Rectangle()
                .offset(
                    x: viewModel.position.x,
                    y: viewModel.position.y
                )
               .frame(
                   width: viewModel.size,
                   height: viewModel.size
               )
        }
    }
}

With async/await bridged to Tasks too soon:
Swift:
class FooViewModel {
    @Published var position: CGPoint
    @Published var size: CGFloat
    
    func getPosition() {
        Task { @MainActor in
            self.position = await networkFunctionToGetPosition()
        }
    }
    func getSize() {
        Task { @MainActor in
            self.size = await networkFunctionToGetSize()
        }
    }
    func updateRectangle() {
        getPosition()
        getSize()
    }
}
struct FooView: View {
    @StateObject var viewModel = FooViewModel()
    var body: some View {
        ZStack {
            Rectangle()
                .offset(
                    x: viewModel.position.x,
                    y: viewModel.position.y
                )
               .frame(
                   width: viewModel.size,
                   height: viewModel.size
               )
        }
    }
}

Both have the same problem. They will work great most of the time some part of the codebase calls the updatePosition() method of the view model. But every once in a while, the UI will jump around because the rectangle size was updated with a new size a frame before the position updated its value. The example is trivial to solve if you use async/await all the way up:

Swift:
class FooViewModel {
    @Published var position: CGPoint
    @Published var size: CGFloat
    
    func getPosition() async -> CGPoint {
        return await networkFunctionToGetPosition()
    }
    func getSize() async -> CGFloat {
        return await networkFunctionToGetSize()
    }
    @MainActor func updateRectangle() async {
        let newPosition = await getPosition()
        let newSize = await getSize()
        self.position = newPosition
        self.size = newSize
    }
}
struct FooView: View {
    @StateObject var viewModel = FooViewModel()
    var body: some View {
        ZStack {
            Rectangle()
                .offset(
                    x: viewModel.position.x,
                    y: viewModel.position.y
                )
               .frame(
                   width: viewModel.size,
                   height: viewModel.size
               )
        }
    }
}

It's also easier to catch: you don't have to look up getSize nor getPosition to see whether they update size and position synchronously or not. The compiler will error out if you forget the await, and once you write the await you'll immediately see that there's a suspension point between the update of the position and the update of the size. It would be very hard IMHO to have it work in sync just by reacting to the changes of position and size.

This example is a minimal version of something I actually saw when someone else tried to fix a weird iOS-15-only main thread crash by peppering around Tasks on some imperative code that computed a complex layout. Sometimes the frame updates landed between updates and the layout had both new values for some variables and old values for others, causing unpredictable jittering and jumping around. Avoiding this intermediate states required getting rid of all the Tasks and moving the synchronous to asynchronous boundary to the outermost call site (the one function that computed the whole layout, which I changed from synchronous to async).

In any case, if someone new starts working with the codebase, knowing nothing about it, they shouldn't have to go through every function's implementation to see whether those functions have immediate effects or not. Writing a single Task inside a function instead of going async all the way also disallows the call site to explicitly detach the task with Task.detached, or to suspend the execution until the task is finished... you'd have to refactor the function if you ever need to do that.
 

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
I think we’d approach this particular bug differently. As you say, I would take a more Reactive approach.

You’ve got two coupled variables, each with their own publisher backing it. So yeah, it’s easy to get them desync’d when any sort of async behavior occurs (not just from concurrency). One approach to ensure both get set in the same synchronous context, as you’ve done, and it does require that nobody breaks the implicit assumption that the fetches must complete before the published variables are updated. Another approach would be to join your coupled variables together to make the published variable more atomic: @Published var frame: CGRect.

Swift:
@MainActor func updateRectangle() async {
    self.frame = CGRect(
        origin: await getPosition(),
        size: await getSize() // assuming this returns a CGSize instead for brevity
    )
}

I use this approach for state that must be updated at the same time, or is strongly coupled. An example is with a music player, all the different fields for the currently playing item is a single published value, rather than a published value for each field. In terms of performance, there's no real difference between using structs as published values, or individual fields that all get updated at once in the same synchronous context, so we are free to do what makes sense in the moment.

But I also follow a couple guidelines in my own MVVM process that I think skews my thinking here differently than yours:

- Views IMO shouldn't own actions. Much like a Button doesn't actually act on you tapping on it, but delegates it, a View should delegate it's actions to the View Model. In which case, which is the call site? The View, or the View Model? From my point of view, the View Model is the one that owns responsibility for determining if things need to become async or not, not the View.
- View Models should generally not hold unique state for itself unless it's specific to the view that observes it. Instead, it should help bind to the model in a way that makes sense. So in this sense, the View Model should further delegate manipulations of state to the model, and observe the changes, updating itself based on that. Effectively, the View Model follows the adapter pattern for state changes in this scenario. So I don't do a ton of stuff like updateRectangle() in my own code, and instead are taking observed updates from the model and either providing accessors, or in the case where adapters aren't necessary, bind the value's struct directly to the View Model's publisher.
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
You’ve got two coupled variables, each with their own publisher backing it. So yeah, it’s easy to get them desync’d when any sort of async behavior occurs (not just from concurrency). One approach to ensure both get set in the same synchronous context, as you’ve done, and it does require that nobody breaks the implicit assumption that the fetches must complete before the published variables are updated. Another approach would be to join your coupled variables together to make the published variable more atomic: @Published var frame: CGRect.
I agree this is indeed the right approach if the variables can be decoupled.

- Views IMO shouldn't own actions. Much like a Button doesn't actually act on you tapping on it, but delegates it, a View should delegate it's actions to the View Model. In which case, which is the call site? The View, or the View Model? From my point of view, the View Model is the one that owns responsibility for determining if things need to become async or not, not the View.
I understand your point, and I don't want to get too tangled up on the Button example. I didn't mean to imply that having functions wrap a single task is always a bad idea. It was a mere observation of how throwing Tasks around to bridge sync to async code was being abused in practice (in my experience, that is). I think that *by default* functions that contain a single task should be declared async to let the callers know that the function is not executed immediately, unless there's a good reason to not do so. At the very least a lot of care should be put in choosing where to bridge sync and async code.

If a perform a bunch of writes to a database from *seemingly* non-async functions serially, I would expect those writes to perform in order:
Swift:
func writeStuff(in repository: FooRepository){
    repository.writeA()
    repository.writeB()
    repository.writeC()
}

I wouldn't be amused if writeC happened before writeA, which is entirely possible if they're only dispatching work with Task without waiting for completion. This is particularly bad because I would have no way to serialize entries without changing the writeA/B/C implementation, as I have no way of knowing then each function has actually finished.

I think it's also best to inform the compiler as much as possible of what's happening. Is the function executing work synchronously? No? Then annotate it as async or provide a completion handler. The compiler will help you to avoid forgetting this at the call sites.
Is it safe to assume that the caller won't make assumptions about when the function is finished? Is the asynchronous work that the function is executing irrelevant for the callers (for example, writeA using a synchronous dispatch queue for writes, which would make the write functions behave as if it's synchronous to the callers)? Then the async or completion handler can be dropped from the function signature.
 

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
I understand your point, and I don't want to get too tangled up on the Button example. I didn't mean to imply that having functions wrap a single task is always a bad idea. It was a mere observation of how throwing Tasks around to bridge sync to async code was being abused in practice (in my experience, that is). I think that *by default* functions that contain a single task should be declared async to let the callers know that the function is not executed immediately, unless there's a good reason to not do so. At the very least a lot of care should be put in choosing where to bridge sync and async code.

Oh no real disagreement on the abuse part. And the fact that concurrent code in JavaScript, C# and Swift all incur this need to switch between a synchronous context and an asynchronous one in some manual way kinda sucks. GCD has this issue too where I should generally have async functions provide completion callbacks, but if I don’t, these same sort of issues crop up.

I guess my question becomes: how should these bridges be done?

But maybe we aren’t that far off in thinking here. My take is that Views should be free of async code, and Models should be honest about what they are doing (which is another way of saying if it is async, it should be marked async like you say). Which leaves the ViewModel as that sort of boundary between the two worlds. Functions I write tend to become async if they themselves depend on anything async, up until the point where I then have to bridge because otherwise I’d “contaminate“ the view. So ultimately, for me, the view model is always the place where an action enters the asynchronous world, and things like Combine publishers and marking ViewModels as @MainActor let me simplify the older GCD pattern in many places and not need to manually call MainActor.run.
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
Oh no real disagreement on the abuse part. And the fact that concurrent code in JavaScript, C# and Swift all incur this need to switch between a synchronous context and an asynchronous one in some manual way kinda sucks. GCD has this issue too where I should generally have async functions provide completion callbacks, but if I don’t, these same sort of issues crop up.
Yes, it's technically possible to abuse GCD in the same way. I saw it less often in practice though. Probably because there were less places where asynchronous behaviour had to be handled. But I think it's ultimately for the best to have more of the multithread capabilities exposed to the compiler, even if some antipatterns arise while people get around to how to use it.

I guess my question becomes: how should these bridges be done?

But maybe we aren’t that far off in thinking here. My take is that Views should be free of async code, and Models should be honest about what they are doing (which is another way of saying if it is async, it should be marked async like you say). Which leaves the ViewModel as that sort of boundary between the two worlds. Functions I write tend to become async if they themselves depend on anything async, up until the point where I then have to bridge because otherwise I’d “contaminate“ the view. So ultimately, for me, the view model is always the place where an action enters the asynchronous world, and things like Combine publishers and marking ViewModels as @MainActor let me simplify the older GCD pattern in many places and not need to manually call MainActor.run.
IMHO, as far up in the call chain as reasonable within the practical/architectural constraints of the app. The ViewModel is a good place to do it if you don't want actions handled on the View. It's also a natural place to do it since, as you say, it bridges the synchronous Views (always on the main thread) with the Models (hopefully async). And the potential of misuse is limited, since the ViewModels are often accessed by one view and one view alone.

Another thing I've noticed regarding how async/await is being implemented in practice: Actors are rarely/never seen, despite how powerful they are. Hm.
 

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
Another thing I've noticed regarding how async/await is being implemented in practice: Actors are rarely/never seen, despite how powerful they are. Hm.

I’ve noticed that in my own code, but partly because my model is built around CoreData, making the context’s background thread the “actor” I use the most outside of the MainActor.

Actors are useful, but they can also be a pain due to being async by nature. I’ve played with them, but wind up mostly using them for internal services. Combine almost always gets involved as well.
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
I’ve noticed that in my own code, but partly because my model is built around CoreData, making the context’s background thread the “actor” I use the most outside of the MainActor.

Actors are useful, but they can also be a pain due to being async by nature. I’ve played with them, but wind up mostly using them for internal services. Combine almost always gets involved as well.

I think I've never written one for a production app. Just played around with them a bit.

Btw, regarding CoreData: I found out a few months ago that NSManagedObject properties subclass ObservableObject, so you can react to NSManagedObject changes in properties in a View. Makes some use-cases super easy to implement, especially if you allow models in the View (I guess it's a bit more cumbersome with Model-View-ViewModel patterns).
 

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
I think I've never written one for a production app. Just played around with them a bit.

Btw, regarding CoreData: I found out a few months ago that NSManagedObject properties subclass ObservableObject, so you can react to NSManagedObject changes in properties in a View. Makes some use-cases super easy to implement, especially if you allow models in the View (I guess it's a bit more cumbersome with Model-View-ViewModel patterns).



Yup, it is handy in some cases. If you want to use a pure MVVM pattern, you can always use combine as the binding mechanism. Not only is NSManagedObject observable, you can request publishers for specific properties.

Swift:
class MyViewModel: ObservableObject {
  @Published var title: String = “Default Title”

  init(dataObject: MyManagedObject) {
    // This will assign the current value of title on the dataObject immediately if it isn’t nil.
    // And update the published variable any time it changes.
    dataObject.publisher(for: \.title).compactMap({ $0 }).assign(to: &$title)
  }
}

But because @Published can seemingly access MyViewModel’s objectWillChange(), I do wonder if this pattern might make sense as well:

Swift:
class MyViewModel: ObservableObject {
  var title: String { dataObject.title ?? “Default Title” }
  @ObservedModel private var dataObject: MyManagedObject

  init(dataObject: MyManagedObject) {
    self.dataObject = dataObject
  }
}

Where @ObservedModel is a custom property wrapper that listens for the objectWillChange() from the NSManagedObject, and forwards it to MyViewModel’s objectWillChange(). I haven’t tried this, but someone has written up how to access the enclosing instance of a property wrapper that would be the basis of such a thing: https://www.swiftbysundell.com/articles/accessing-a-swift-property-wrappers-enclosing-instance/
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
Funny. I don't think I'd ever seen code from an WWDC not matching the actual API. I was watching the "Bringing multiple windows to your SwiftUI App" talk and at some point the speaker says, talking about Window:
The first of which is Window, a scene which represents a single, unique window on all platforms [...]
However, if you try adding a Window to a SwiftUI app, an error message pops out saying that Window is only available on iOS. And indeed, the documentation confirms that Window is indeed macOS 13.0+ only.

Could this be the fallout of the Stage Manager disaster on iPadOS?
 

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
I don’t think it has much of anything to do with Stage Manager, unless people were pulled off SwiftUI to help, which seems a little unusual. At least at companies I’ve worked at, it’s hard to pull someone off an API team and put them to work on a UX team.

My guess is actually that macOS took some priority this year, as they had to effectively port NavigationStack to a platform that has no concept of a navigation hierarchy. Window on macOS is also useful for utility windows that can pick up weird behaviors if you used a WindowGroup instead. Utility windows are less common on iOS, and it seems like Apple really wants to discourage single-window apps on iOS unless there’s a good reason.

Without it, iOS isn’t any worse off than it was a year ago, and can still use multi-window in the same way that something like Mail.app can.
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
I don’t think it has much of anything to do with Stage Manager, unless people were pulled off SwiftUI to help, which seems a little unusual. At least at companies I’ve worked at, it’s hard to pull someone off an API team and put them to work on a UX team.
True, but usually the dev team starts to work after the UX is (mostly) finished. It's hard to design an API when you don't know what use cases it's supposed to cover. I was wondering if some things were pulled from SwiftUI later than usual this year because the environment those were supposed to be tested (Stage Manager) wasn't ready. That would explain glaring omissions like Window, or windowResizability. I mean, SwiftUI got better windowing capabilities the same year iPadOS got an actual window manager... but for some reason they didn't make those APIs available on iPadOS (which in turn makes them not available in macCatalyst, which is how I found out all this 😂).

My guess is actually that macOS took some priority this year, as they had to effectively port NavigationStack to a platform that has no concept of a navigation hierarchy.
Hm, I had never though about it that way. It's true that macOS didn't really have a concept for navigation.

Window on macOS is also useful for utility windows that can pick up weird behaviors if you used a WindowGroup instead. Utility windows are less common on iOS, and it seems like Apple really wants to discourage single-window apps on iOS unless there’s a good reason.

Without it, iOS isn’t any worse off than it was a year ago, and can still use multi-window in the same way that something like Mail.app can.
I'm not sure they're trying to discourage single-window apps (or windows) where it makes sense. They spend a good chunk of the talk I mentioned in my previous post talking about when do utility windows make sense, and nothing of what they say is macOS specific. I agree that for most apps the default should be multiple window capability though.
 

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
True, but usually the dev team starts to work after the UX is (mostly) finished. It's hard to design an API when you don't know what use cases it's supposed to cover. I was wondering if some things were pulled from SwiftUI later than usual this year because the environment those were supposed to be tested (Stage Manager) wasn't ready. That would explain glaring omissions like Window, or windowResizability. I mean, SwiftUI got better windowing capabilities the same year iPadOS got an actual window manager... but for some reason they didn't make those APIs available on iPadOS (which in turn makes them not available in macCatalyst, which is how I found out all this 😂).

Resizability I’d agree with. Window should have been there with iOS 14/macOS 11, when WindowGroup was introduced, IMO. It was an annoying oversight, much like the missing bits to the navigation hierarchy that they added in iOS 16/macOS 13.

Now, did Apple maybe tie Window to the engineering budget for Stage Manager? Maybe. But there’s nothing about Stage Manager that Window needs or enables.

Hm, I had never though about it that way. It's true that macOS didn't really have a concept for navigation.

I had to write my own navigation stack for macOS. I actually have a wrapper that uses my stack on macOS 12, and Apple’s on macOS 13. Although there’s still scenarios on iOS where I can’t use the new stuff effectively. *sigh*

I'm not sure they're trying to discourage single-window apps (or windows) where it makes sense. They spend a good chunk of the talk I mentioned in my previous post talking about when do utility windows make sense, and nothing of what they say is macOS specific. I agree that for most apps the default should be multiple window capability though.

I’m not saying they are, but they want the default to be to support multiple copies of the “root” window unless there’s a reason. In which case, WindowGroup is what you want and has existed since iOS 14. I agree they want to unify behaviors further between the platforms, but if something had to be cut, cutting “Window” from iOS 16 isn’t surprising, IMO. I’m more surprised it had to be cut at all.
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
Resizability I’d agree with. Window should have been there with iOS 14/macOS 11, when WindowGroup was introduced, IMO. It was an annoying oversight, much like the missing bits to the navigation hierarchy that they added in iOS 16/macOS 13.

Now, did Apple maybe tie Window to the engineering budget for Stage Manager? Maybe. But there’s nothing about Stage Manager that Window needs or enables.
But before Stage Manager, there wasn't any place on iOS nor iPadOS where you could freely resize windows, or am I misremembering something? The original iPadOS multitasking environment only supported certain window distributions. That was the crux of my argument: not that Apple diverted resources from SwiftUI to Stage Manager (as you say, different frameworks... not practical to move people around), but rather that the people in charge of Window/windowResizability and the like could only test their implementations once Stage Manager was finished.

Well, in all fairness Window in particular could have been there since iOS 14. You don't really need freely resizable floating windows to make use of utility windows. Although if it were me, a lot of the things I'd put into a new utility window in Stage Manager or macOS I'd put on a modal sheet on iPadOS if it were outside Stage Manager. I guess that's what was confusing me. You can spawn new windows without Stage Manager, although on the older multitasking UI it's less convenient.

I’m not saying they are, but they want the default to be to support multiple copies of the “root” window unless there’s a reason.
Oh, I fully agree with this. Most people try to engineer it the other way around.
 

Nycturne

Elite Member
Posts
1,137
Reaction score
1,484
But before Stage Manager, there wasn't any place on iOS nor iPadOS where you could freely resize windows, or am I misremembering something? The original iPadOS multitasking environment only supported certain window distributions. That was the crux of my argument: not that Apple diverted resources from SwiftUI to Stage Manager (as you say, different frameworks... not practical to move people around), but rather that the people in charge of Window/windowResizability and the like could only test their implementations once Stage Manager was finished.

Freely resize? No. But I don't think we're disagreeing on that point.

Well, in all fairness Window in particular could have been there since iOS 14. You don't really need freely resizable floating windows to make use of utility windows. Although if it were me, a lot of the things I'd put into a new utility window in Stage Manager or macOS I'd put on a modal sheet on iPadOS if it were outside Stage Manager. I guess that's what was confusing me. You can spawn new windows without Stage Manager, although on the older multitasking UI it's less convenient.

Window/WindowGroup/DocumentGroup/etc is just a way to define a (UI)Scene at the end of the day. The rest is scene modifiers that apply to all Scenes. That’s why I don’t place them in the same bucket of work. The ability to customize the resizing behavior of a scene should only care about the underlying UIScene on iOS. I can complete the work for either one without the other. And not being able to customize resize behavior of WindowGroup would be a pretty big miss on its own.

I agree with the comments about sheets. That’s generally how it’s been done, but when you look at Mail.app, Apple does seem to be moving away from this for certain use cases. That said, I suspect utility windows in iPad apps is going to mostly show up in Catalyst apps like you ran into. Can you imagine the player in the music app being a utility window rather than a sheet?

That said, it is definitely some sort of miss on Apple’s part here. They clearly commented that Window is available on all platforms in the WWDC video (it isn’t), and resizing scene modifiers would definitely be useful with Stage Manager, and isn’t available. I kinda wish Apple would get SwiftUI caught up already so we weren’t getting stuff years after UIKit and AppKit get it. But hey, SwiftUI is the only way to get a navigation stack on Mac while using AppKit, so, I guess that’s a win?

Now, if you wanted something like Window on iOS, you could create your own Scene that uses WindowGroup to accomplish the task. So it’s possible to work around the missing behavior and still share code with macOS. The lack of resize control isn’t great though.
 

Andropov

Site Champ
Posts
617
Reaction score
775
Location
Spain
Now, if you wanted something like Window on iOS, you could create your own Scene that uses WindowGroup to accomplish the task. So it’s possible to work around the missing behavior and still share code with macOS. The lack of resize control isn’t great though.

Yup, that's what I've ended up doing. However, since I'm running SwiftUI on top of Catalyst there's apparently no way to constraint the size of the window. Supposedly, setting the frame of the outermost view in the WindowGroup should cause the window on macOS not to resize outside those constraints, but it doesn't work when using Catalyst 🥲

Swift:
WindowGroup {
    SomeView()
        .frame(width: 512, height: 512)
}

I think I'm eventually going to transition my app to SwiftUI over AppKit (Apple seems to be favoring it nowadays anyway), but I have a bunch of code using UIDocumentPickerViewController that I'm not super-excited to port to AppKit. I was hoping Apple would add a SwiftUI document picker on iOS 16 / macOS 13 and avoid writing the AppKit code at all, but no luck.
 
Top Bottom
1 2