I'm trying to create a modal popup system working similarly to .fullScreenCover, looking something like this:
My requirements are:
Have custom transition and presentation style (therefore I can't use .fullScreenCover)
Be able to present modal from child components
Here's a functional code snippet that satisfies those two conditions, you can run it:
struct Screen: View {
@StateObject private var model = Model()
var body: some View {
Navigation {
VStack {
Text("model.number: \(model.number)").opacity(0.5)
ChildComponent(number: $model.number)
Spacer()
}
.padding(.vertical, 30)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.purple.opacity(0.4))
}
}
}
struct ChildComponent: View {
@EnvironmentObject var navigator: Navigator
@Binding var number: Int
@State private var isFullScreenPresented = false
var body: some View {
VStack(spacing: 20) {
Text("\(number)").bold()
Button("Change (custom)", action: presentCustom).foregroundColor(.black)
Button("Change (full screen)", action: presentFullScreen).foregroundColor(.black)
}
.padding(30)
.background(Color.black.opacity(0.1))
.modalBottom(id: "childModal") {
NumberModalView(number: $number)
}
.fullScreenCover(isPresented: $isFullScreenPresented) {
NumberModalView(number: $number).environment(\.dismissModal, { isFullScreenPresented = false })
}
}
func presentCustom() {
navigator.presentModalBottom(id: "childModal")
}
func presentFullScreen() {
isFullScreenPresented = true
}
}
struct ModalView<Content:View>: View {
@Environment(\.dismissModal) var dismissCallback
@ViewBuilder var content: () -> Content
var body: some View {
VStack(spacing: 30) {
Button("Dismiss", action: { dismissCallback() }).foregroundColor(.black)
content()
}
.padding(30)
.frame(maxWidth: .infinity)
.background(Color.purple.opacity(0.8))
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
struct NumberModalView: View {
@Binding var number: Int
var body: some View {
ModalView {
HStack(spacing: 20) {
Button(action: { number -= 1 }) { Image(systemName: "minus.circle").resizable().foregroundColor(.black).frame(width: 30, height: 30) }
Text("\(number)").bold()
Button(action: { number += 1 }) { Image(systemName: "plus.circle").resizable().foregroundColor(.black).frame(width: 30, height: 30) }
}
}
}
}
// MARK: - Navigation
struct Navigation<Content:View>: View {
@ViewBuilder var content: () -> Content
@StateObject private var navigator = Navigator()
@State private var modalPresentations: [String:ModalData] = [:]
var body: some View {
ZStack {
content()
if let modalID = navigator.currentModalBottom, let modal = modalPresentations[modalID] {
modal.content().environment(\.dismissModal, navigator.dismissModalBottom)
}
}
.environmentObject(navigator)
.onPreferenceChange(ModalPresentationKey.self) { modalPresentations in
self.modalPresentations = modalPresentations
}
}
}
// MARK: - Model
class Model: ObservableObject {
@Published var number: Int = 0
}
struct ModalData: Hashable {
var id: String
var content: () -> AnyView
static func == (lhs: ModalData, rhs: ModalData) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
class Navigator: ObservableObject {
@Published var currentModalBottom: String?
func presentModalBottom(id: String) {
currentModalBottom = id
}
func dismissModalBottom() {
currentModalBottom = nil
}
}
// MARK: - Dismiss (Environment key)
private struct ModalDismissKey: EnvironmentKey {
static let defaultValue: () -> Void = {}
}
extension EnvironmentValues {
var dismissModal: () -> Void {
get { self[ModalDismissKey.self] }
set { self[ModalDismissKey.self] = newValue }
}
}
// MARK: - Present (Preference key)
struct ModalPresentationKey: PreferenceKey {
static var defaultValue: [String:ModalData] = [:]
static func reduce(value: inout [String:ModalData], nextValue: () -> [String:ModalData]) {
for (k,v) in nextValue() { value[k] = v }
}
}
extension View {
func modalBottom<V:View>(id: String, @ViewBuilder content: @escaping () -> V) -> some View {
preference(key: ModalPresentationKey.self, value: [
id: ModalData(id: id, content: { AnyView(content()) })
])
}
}
// MARK: - Preview
struct ParentView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Screen()
}
}
}
Now the problem: while the parent view value gets updated, the modal view value is not updated. If you try with the default full screen, you'll see that it works normally.
I'm guessing it's a problem with data flow and the fact that the modal is not a child of the component.
Since I've already spent weeks on this problem, here are some surprising things I found:
If you replace the @StateObject model with a simple @State var of type Int in Screen, it works (?!). In my case, I have a complex model which I can't replace with simple state variables.
If you add a dependency to the navigator in NumberModalView, by adding @Environment(\.dismissModal) var dismissCallback, it works. This seems crazy to me, I don't see what role the navigator is playing in the modal data flow.
How to make the modal view react to model changes while keeping my requirements above?
I'm trying to create a modal popup system working similarly to .fullScreenCover, looking something like this:
My requirements are:
Here's a functional code snippet that satisfies those two conditions, you can run it:
Now the problem: while the parent view value gets updated, the modal view value is not updated. If you try with the default full screen, you'll see that it works normally.
I'm guessing it's a problem with data flow and the fact that the modal is not a child of the component.
Since I've already spent weeks on this problem, here are some surprising things I found:
@Environment(\.dismissModal) var dismissCallback
, it works. This seems crazy to me, I don't see what role the navigator is playing in the modal data flow.How to make the modal view react to model changes while keeping my requirements above?
Source: https://stackoverflow.com/questions/74149682/view-not-updating-in-modal-hierarchy