0

I'm trying to create a modal popup system working similarly to .fullScreenCover, looking something like this:

UI Structure

My requirements are:

  1. Have custom transition and presentation style (therefore I can't use .fullScreenCover)
  2. 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 (values at the top), the modal view value is not updated (text box between the stepper at the bottom). 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 already talked about this problem here and here, but at the time I was bridging with UIKit and I thought the problem came from that, but it doesn't.

Morpheus
  • 1,189
  • 2
  • 11
  • 33

0 Answers0