Asperi-Demo / 4SwiftUI

MIT License
222 stars 27 forks source link

View not updating in modal hierarchy #5

Open jeanbaptistebeau opened 2 years ago

jeanbaptistebeau commented 2 years ago

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:

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:

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