Skip to content

Instantly share code, notes, and snippets.

@dasdom
Last active January 1, 2025 02:45
Show Gist options
  • Save dasdom/82a151394df3cc7ba21ca9328344c17c to your computer and use it in GitHub Desktop.
Save dasdom/82a151394df3cc7ba21ca9328344c17c to your computer and use it in GitHub Desktop.
SwiftUI identity problem
import SwiftUI
struct CoverWithBoolView: View {
@State var presentCover: Bool = false
var body: some View {
GeometryReader { proxy in
ZStack {
if proxy.size.height > proxy.size.width {
Color.gray
PortraitView()
} else {
Color.pink
LandscapeView()
}
}
}
}
}
struct PortraitView: View {
var body: some View {
HStack {
SelectionView(landscape: false)
DetailsView(landscape: false)
}
}
}
struct LandscapeView: View {
var body: some View {
HStack {
SelectionView(landscape: true)
DetailsView(landscape: true)
}
}
}
struct DetailsView: View {
var landscape: Bool
var body: some View {
if landscape {
DetailInfoView()
} else {
VStack {
DetailInfoView()
UserView(landscape: landscape)
}
}
}
}
struct SelectionView: View {
var landscape: Bool
var body: some View {
if landscape {
VStack {
UserView(landscape: landscape)
CategoriesView()
}
} else {
CategoriesView()
}
}
}
struct CategoriesView: View {
let listOfCategories: [String] = ["A", "list", "of", "categories"]
var body: some View {
List(listOfCategories, id:\.self) { item in
Text(item)
}
}
}
struct DetailInfoView: View {
let listOfInfo: [String] = ["Details", "about", "the", "selected", "info", "category"]
var body: some View {
List(listOfInfo, id:\.self) { item in
Text(item)
}
}
}
struct UserView: View {
var landscape: Bool
var body: some View {
if landscape {
HStack {
AvatarImageView()
ActionsView()
}
} else {
VStack {
AvatarImageView()
ActionsView()
}
}
}
}
struct ActionsView: View {
var body: some View {
Text("View with actions for users")
}
}
struct AvatarImageView: View {
@State var presentCover: Bool = false
var body: some View {
Button("Show detail") {
presentCover = true
}
.fullScreenCover(isPresented: $presentCover, content: {
Text("Detail")
})
}
}
#Preview {
CoverWithBoolView()
}
@helje5
Copy link

helje5 commented Dec 27, 2024

https://chaos.social/@dasdom/113724833595144493

A recent problem we hat at work was that a view was presenting a fullScreenCover. But when the device was rotated, the layout of the presenting view changed (intentionally) because more space. That resulted in the fullScreenCover was dismissed because the presenting view was replaces by a new one.

@helje5
Copy link

helje5 commented Dec 27, 2024

Yes, if you remove the view fullScreenCover, it gets removed. The same would happen in UIKit :-) Solution: Do not remove it:

import SwiftUI

struct ContentView: View {
  @State var presentCover: Bool = false
  
  var body: some View {
    GeometryReader { proxy in
      ZStack {
        if proxy.size.height > proxy.size.width {
          Color.white
          Button("Show detail") {
            presentCover = true
          }
        } else {
          Color.pink
          Text("Regular")
        }
      }
      .fullScreenCover(isPresented: $presentCover) {
        Text("Detail")
      }
    }
  }
}

@dasdom
Copy link
Author

dasdom commented Dec 27, 2024

  1. The fullScreenCover is way down in the hierarchy in the real code. This is just an example. It's not really possible to move it up the hierarchy. In addition the view is not remove but in the else block is the same view again. But this has of course another identity because values...
  2. In UIKit the Coordinator presents the view controllers. This is never removed.
  3. Even if you are not using Coordinators, the view controller does not change identity and therefore even if the view changes place, the controller is still there and can still present the modal view.

@helje5
Copy link

helje5 commented Dec 27, 2024

In your code above you explicitly remove the fullScreenCover view from the view hierarchy. If you do the same in UIKit, it also disappears.

This sounds like you are not using coordinators in SwiftUI, why not? If you can present an actual issue, I might be able to help you with that. Sounds a little like the state design of the "real code" is messed up, not SwiftUI. If you can show more info, I might be able to present a way to do this properly w/ SwiftUI. (but generally you can apply the same basic structure you'd use in UIKit, including coordinators if you want)

@helje5
Copy link

helje5 commented Dec 30, 2024

So we already learned that we cannot do the sheet in a subview that gets dropped since the sheet would get removed when the subview is (which is a feature, not a bug). The sheet has to be placed at the level at which we want to preserve it, using this we control when a sheet is auto-dismissed (i.e. to which view it is related to visually).
In the example we just put it at the top, but in other scenarios it might be within a navigation view content view, for example. So it gets dismissed when one navigates away from a page.

There are many different ways to approach this, but this is roughly what I would do:

import SwiftUI

@Observable @MainActor
final class Presenter {
  
  var presentCover = false
  
  func showCover() { presentCover = true }
}

struct ContentView: View {
  
  @State var presenter = Presenter()
  
  var body: some View {
    GeometryReader { proxy in
      ZStack {
        if proxy.size.height > proxy.size.width {
          Color.gray
          PortraitView()
        }
        else {
          Color.pink
          LandscapeView()
        }
      }
      .environment(presenter)
      .fullScreenCover(isPresented: $presenter.presentCover) {
        Text("Detail")
      }
    }
  }
}

struct PortraitView: View {
  var body: some View {
    HStack {
      SelectionView(landscape: false)
      DetailsView(landscape: false)
    }
  }
}

struct LandscapeView: View {
  var body: some View {
    HStack {
      SelectionView(landscape: true)
      DetailsView(landscape: true)
    }
  }
}

struct DetailsView: View {
  var landscape: Bool

  var body: some View {
    if landscape {
      DetailInfoView()
    } else {
      VStack {
        DetailInfoView()
        UserView(landscape: landscape)
      }
    }
  }
}

struct SelectionView: View {
  var landscape: Bool

  var body: some View {
    if landscape {
      VStack {
        UserView(landscape: landscape)
        CategoriesView()
      }
    } else {
      CategoriesView()
    }
  }
}

struct CategoriesView: View {
  let listOfCategories = ["A", "list", "of", "categories"]

  var body: some View {
    List(listOfCategories, id:\.self) { item in
      Text(item)
    }
  }
}

struct DetailInfoView: View {
  let listOfInfo = ["Details", "about", "the", "selected", "info", "category"]

  var body: some View {
    List(listOfInfo, id:\.self) { item in
      Text(item)
    }
  }
}

struct UserView: View {
  var landscape: Bool

  var body: some View {
    if landscape {
      HStack {
        AvatarImageView()
        ActionsView()
      }
    }
    else {
      VStack {
        AvatarImageView()
        ActionsView()
      }
    }
  }
}

struct ActionsView: View {
  var body: some View {
    Text("View with actions for users")
  }
}

struct AvatarImageView: View {
  @Environment(Presenter.self) var presenter

  var body: some View {
    Button("Show detail", action: presenter.showCover)
  }
}

#Preview {
  ContentView()
}

@helje5
Copy link

helje5 commented Dec 30, 2024

A few more best practices: Regardless of the particular issue, it is often a good idea not to change identity, if possible. E.g. the switching between HStack and VStack can be done using this in an identity preserving ways:

struct UserView: View {
  var landscape: Bool

  var body: some View {
    (landscape ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())) {
      AvatarImageView()
      ActionsView()
    }
} 

Also instead of such:

  var body: some View {
    if landscape {
      VStack {
        UserView(landscape: landscape)
        CategoriesView()
      }
    } else {
      CategoriesView()
    }
  }

do this:

  var body: some View {
      VStack {
        if landscape { UserView(landscape: true) }
        CategoriesView()
      }
  }

@helje5
Copy link

helje5 commented Dec 30, 2024

Here is another simple version where the overlay content is provided by the calling leaf view (could also be easily adjusted to move the detail content selection to the presenter, same idea):

import SwiftUI

extension Optional {
  
  var isSet: Bool {
    set { if !newValue { self = .none } }
    get {
      switch self {
        case .some(_): true
        case .none: false
      }
    }
  }
}

@Observable @MainActor
final class Presenter {
  
  var presentedView : AnyView?
  
  func showCover<V: View>(@ViewBuilder content: () -> V) {
    presentedView = AnyView(content())
  }
}

struct ContentView: View {
  
  @State var presenter = Presenter()
  
  var body: some View {
    GeometryReader { proxy in
      ZStack {
        let landscape = proxy.size.height <= proxy.size.width
        landscape ? Color.gray : Color.pink
        SwitchingView(landscape: landscape)
      }
      .environment(presenter)
      .fullScreenCover(isPresented: $presenter.presentedView.isSet) {
        presenter.presentedView
      }
    }
  }
}

struct SwitchingView: View {
  let landscape : Bool
  var body: some View {
    HStack {
      SelectionView(landscape: landscape)
      DetailsView(landscape: landscape)
    }
  }
}

struct DetailsView: View {
  var landscape: Bool

  var body: some View {
    VStack {
      DetailInfoView()
      if !landscape { UserView(landscape: landscape) }
    }
  }
}

struct SelectionView: View {
  var landscape: Bool

  var body: some View {
    VStack {
      if landscape { UserView(landscape: landscape) }
      CategoriesView()
    }
  }
}

struct CategoriesView: View {
  let listOfCategories = ["A", "list", "of", "categories"]

  var body: some View {
    List(listOfCategories, id:\.self) { item in
      Text(item)
    }
  }
}

struct DetailInfoView: View {
  let listOfInfo = ["Details", "about", "the", "selected", "info", "category"]

  var body: some View {
    List(listOfInfo, id:\.self) { item in
      Text(item)
    }
  }
}

struct UserView: View {
  var landscape: Bool

  var body: some View {
    (landscape ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())) {
      AvatarImageView()
      ActionsView()
    }
  }
}

struct ActionsView: View {
  var body: some View {
    Text("View with actions for users")
  }
}

struct AvatarImageView: View {
  @Environment(Presenter.self) var presenter

  var body: some View {
    Button("Show detail") {
      presenter.showCover {
        Text("Detail Provided View")
      }
    }
  }
}

#Preview {
  ContentView()
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment