与SwiftUI纠缠以实现全局通知视图

时间:2019-12-08 22:41:46

标签: swiftui

我正在尝试实现一个可以显示在导航栏顶部的视图。我们将此视图称为NotificationView。从任何其他SwiftUI视图中,触发该视图应该很容易。例如在登录视图中,如果用户名和密码错误。

下面您将找到我到目前为止的代码。仅当您在UsernamePassword字段中输入一个值时,此代码的问题才变得可见。通知视图正确显示,但同时UsernamePassword字段丢失了它们的值。

之所以发生这种情况,是因为再次渲染了整个视图树,并通过LoginView用空的LoginModel创建了一个全新的NavigationLink实例。

您如何在SwiftUI中正确执行操作,以免丢失值?

import SwiftUI

final class NotificationModel: ObservableObject {
    func show(text: String) {
        self.text = text
        isHidden = false
    }

    @Published
    var isHidden = true
    var text = ""
}

struct NotificationView: View {
    @EnvironmentObject
    var model: NotificationModel

    var body: some View {
        GeometryReader { geometry in
            if !self.model.isHidden {
                Group {
                    HStack {
                        Image(systemName: "exclamationmark.triangle")
                            .font(Font.largeTitle.weight(.light))
                        Text(self.model.text)
                            .font(Font.body.weight(.medium))
                            .padding()
                        Spacer()
                    }
                    .frame(maxWidth: .infinity, minHeight: geometry.safeAreaInsets.top + 44)
                    .padding(EdgeInsets(top: geometry.safeAreaInsets.top, leading: 20, bottom: 0, trailing: 20))
                }
                .background(Color.red)
                .transition(AnyTransition.move(edge: .top).combined(with: .opacity))
                .onTapGesture {
                    withAnimation {
                        self.model.isHidden = true
                    }
                }
            }
        }.edgesIgnoringSafeArea(.all)
    }

}

final class LoginModel: ObservableObject {
    @Published
    var email = ""

    @Published
    var password = ""

    let notificationModel: NotificationModel

    init(notificationModel: NotificationModel) {
        self.notificationModel = notificationModel
    }

    func submit() {
        notificationModel.show(text: "Username/password wrong")
    }
}

struct LoginView: View {
    @ObservedObject
    var model: LoginModel

    var body: some View {
        VStack {
            TextField("Username", text: $model.email)
            Divider()
            TextField("Password", text: $model.password)
            Divider()
            Button(action: {
                self.model.submit()
            }) {
                Text("Submit")
            }
        }
    }
}

struct ContentView: View {
    @EnvironmentObject
    var notificationModel: NotificationModel

    var body: some View {
        ZStack {
            NavigationView {
                VStack {
                    NavigationLink(destination: LoginView(model: LoginModel(notificationModel: notificationModel))) {
                        Text("Login")
                    }
                    Spacer()
                }
                .navigationBarTitle("Start")
            }
            NotificationView()
        }
    }
}

simulator video

2 个答案:

答案 0 :(得分:0)

您可能需要在contentView中翻转LoginModel和notificationModel。

            final class NotificationModel: ObservableObject {
                func show(text: String) {
                    self.text = text
                    self.isHidden = false
                }

                @Published
                var isHidden = true
                var text = ""
            }

            struct NotificationView: View {
                @EnvironmentObject
                var model: NotificationModel

                var body: some View {
                    GeometryReader { geometry in
                        if !self.model.isHidden {
                            Group {
                                HStack {
                                    Image(systemName: "exclamationmark.triangle")
                                        .font(Font.largeTitle.weight(.light))
                                    Text(self.model.text)
                                        .font(Font.body.weight(.medium))
                                        .padding()
                                    Spacer()
                                }
                                .frame(maxWidth: .infinity, minHeight: geometry.safeAreaInsets.top + 44)
                                .padding(EdgeInsets(top: geometry.safeAreaInsets.top, leading: 20, bottom: 0, trailing: 20))
                            }
                            .background(Color.red)
                            .transition(AnyTransition.move(edge: .top).combined(with: .opacity))
                            .onTapGesture {
                                withAnimation {
                                    self.model.isHidden = true
                                }
                            }
                        }
                    }.edgesIgnoringSafeArea(.all)
                }

            }

            final class LoginModel: ObservableObject {
                @Published
                var email = ""

                @Published
                var password = ""



                let notificationModel: NotificationModel

                init(notificationModel: NotificationModel) {
                    self.notificationModel = notificationModel
                }

                func submit() {
                    notificationModel.show(text: "Username/password wrong")
                }
            }

            struct LoginView: View {
                @ObservedObject
                var model: LoginModel

                var body: some View {
                    VStack {
                        TextField("Username", text: $model.email)
                        Divider()
                        TextField("Password", text: $model.password)
                        Divider()
                        Button(action: {
                            self.model.submit()
                        }) {
                            Text("Submit")
                        }
                    }
                }
            }

            struct ContentView: View {
                @EnvironmentObject  var loginModel : LoginModel

                var body: some View {




                  return  ZStack {
                        NavigationView {
                            VStack {
                                NavigationLink(destination: LoginView(model: loginModel)) {
                                    Text("Login")
                                }
                                Spacer()
                            }
                            .navigationBarTitle("Start")
                        }
                    NotificationView().environmentObject(loginModel.notificationModel)
                    }
                }
            }

答案 1 :(得分:0)

全局 Toast 示例:

代码包含 Toast 源代码,修改自 PopupViewGithub Project

只需复制添加运行⬇️

import SwiftUI

// Example
@main
struct EProgressProApp: App {
    @Environment(\.scenePhase) var scenePhase
    
    @State var show: Bool = false
    
    init() {
        
    }
    
    var body: some Scene {
        WindowGroup {
            NavigationView {
                TestPage()
            }
            // Global toast
            // If View B is presented by sheet, add ⬇️ in View B again.
            // Toast unable to cross A to presented B(If anyone knows, please leave a comment to let me know...
            .em_popup(isPresented: $show,
                      position: .top(padding: .em_view_space_2),
                      duration: nil,
                      animation: .spring(),
                      ignoreEdges: nil,
                      closeOnTap: true,
                      closeOnTapOutside: true,
                      popupContent: {
                        Color.red.frame(width: 200, height: 100)
                      }, onDismiss: {
                        print("dismissed")
                      })
            .environment(\.em_env_toast, $show)
        }
    }
}


// EnviromentKey to control toast present
struct EMToastKey: EnvironmentKey {
    static let defaultValue: Binding<Bool> = .constant(false)
}

extension EnvironmentValues {
    var em_env_toast: Binding<Bool> {
        get {
            self[EMToastKey.self]
        }
        set {
            self[EMToastKey.self] = newValue
        }
    }
}

struct TestPage: View {
    @Environment(\.em_env_toast) private var showToast
    
    var body: some View {
        VStack {
            Text("Tap")
                .onTapGesture {
                    showToast.wrappedValue.toggle()
                }
            Spacer()
        }
    }
}

extension View {
    func em_env_toast(_ isPresented: Binding<Bool>) -> some View {
        environment(\.em_env_toast, isPresented)
    }
}


// Modified from PopupView(fixed layout bug in presented view)

// Toast Source Code
extension View {
    
    public func em_popup<EMPopupContent: View>(isPresented: Binding<Bool>,
                                               position: EMPopupPosition,
                                               duration: Double? = nil,
                                               animation: Animation? = .spring(),
                                               ignoreEdges: Edge.Set?,
                                               closeOnTap: Bool = true,
                                               closeOnTapOutside: Bool = true,
                                               popupContent: @escaping () -> EMPopupContent,
                                               onDismiss: @escaping() -> ()) -> some View {
        self.modifier(
            EMPopupView(isPresented: isPresented,
                        position: position,
                        duration: duration,
                        animation: animation,
                        ignoreEdges: ignoreEdges,
                        closeOnTap: closeOnTap,
                        closeOnTapOutside: closeOnTapOutside,
                        popupContent: popupContent,
                        onDismiss: onDismiss)
        )
    }
    
    @ViewBuilder
    func em_apply_if<T: View>(_ condition: Bool, apply: (Self) -> T) -> some View {
        if condition {
            apply(self)
        } else {
            self
        }
    }
}


public enum EMPopupPosition: Equatable {
    case top(padding: CGFloat = 0)
    case bottom(padding: CGFloat = 0)
    
    var padding: CGFloat {
        switch self {
        case let .top(padding):
            return padding
        case let .bottom(padding):
            return padding
        }
    }
    
    static public func == (lhs: EMPopupPosition, rhs: EMPopupPosition) -> Bool {
        switch lhs {
        case .top:
            switch rhs {
            case .top:
                return true
            case .bottom:
                return false
            }
        case .bottom:
            switch rhs {
            case .top:
                return false
            case .bottom:
                return true
            }
        }
    }
}


public struct EMPopupView<EMPopupContent: View>: ViewModifier {
    
    @Binding var isPresented: Bool
    
    var position: EMPopupPosition
    
    var duration: Double?
    
    var animation: Animation?
    
    var ignoreEdges: Edge.Set?
    
    var closeOnTap: Bool
    
    var closeOnTapOutside: Bool
    
    var popupContent: () -> EMPopupContent
    
    var onDismiss: () -> ()
    
    
    @State private var presenterContentRect: CGRect = .zero
    
    @State private var popupContentRect: CGRect = .zero
    
    @State private var topPadding: CGFloat = 0
    
    private var dispatchWorkHolder: DispatchWorkHolder = DispatchWorkHolder()
    
    // Handle dispatch capture self
    private var isPresentedRef: ClassReference<Binding<Bool>>?
    
    private var hideOffset: CGFloat {
        if case EMPopupPosition.top(_) = position {
            return -popupContentRect.height
        } else {
            return presenterContentRect.height
        }
    }
    
    init(isPresented: Binding<Bool>,
         position: EMPopupPosition,
         duration: Double?,
         animation: Animation?,
         ignoreEdges: Edge.Set?,
         closeOnTap: Bool,
         closeOnTapOutside: Bool,
         popupContent: @escaping () -> EMPopupContent,
         onDismiss: @escaping() -> ()) {
        self._isPresented = isPresented
        self.position = position
        self.duration = duration
        self.animation = animation
        self.ignoreEdges = ignoreEdges
        self.closeOnTap = closeOnTap
        self.closeOnTapOutside = closeOnTapOutside
        self.popupContent = popupContent
        self.onDismiss = onDismiss
        self.isPresentedRef = ClassReference(self.$isPresented)
    }
    
    public func body(content: Content) -> some View {
        content
            .background(
                // get presenter' rect
                GeometryReader { proxy -> AnyView in
                    let rect = proxy.frame(in: .global)
                    if rect.integral != self.presenterContentRect.integral {
                        DispatchQueue.main.async {
                            self.presenterContentRect = rect
                        }
                    }
                    return AnyView(EmptyView())
                }
            )
            .overlay(makePopupContent())
    }
    
    private func makePopupContent() -> some View {
        if duration != nil {
            dispatchWorkHolder.work?.cancel()
            dispatchWorkHolder.work = DispatchWorkItem(block: { [weak isPresentedRef] in
                isPresentedRef?.value.wrappedValue = false
                onDismiss()
            })
            if isPresented && dispatchWorkHolder.work != nil {
                DispatchQueue.main.asyncAfter(deadline: .now() + duration!, execute: dispatchWorkHolder.work!)
            }
        }
        
        let popup = ZStack {
            // background
            Color.clear
                .onTapGesture {
                    if closeOnTapOutside {
                        self.dispatchWorkHolder.work?.cancel()
                        isPresented = false
                        self.onDismiss()
                    }
                }
            
            // pop up content
            VStack(spacing: 0) {
                if position == EMPopupPosition.bottom(padding: 0) { Spacer() }
                HStack(spacing: 0) {
                    popupContent()
                        .padding(.top, position == EMPopupPosition.top(padding: 0) ? position.padding : 0)
                        .padding(.bottom, position == EMPopupPosition.bottom(padding: 0) ? position.padding : 0)
                        .background(
                            GeometryReader { proxy -> AnyView in
                                let rect = proxy.frame(in: .global)
                                if rect.integral != self.popupContentRect.integral {
                                    DispatchQueue.main.async {
                                        self.popupContentRect = rect
                                    }
                                }
                                return AnyView(EmptyView().frame(width: popupContentRect.width).animation(.none))
                            }
                        )
                        .onTapGesture {
                            if closeOnTap {
                                self.dispatchWorkHolder.work?.cancel()
                                isPresented = false
                                self.onDismiss()
                            }
                        }
                }
                
                if position == EMPopupPosition.top(padding: 0) { Spacer() }
            }
        }
        .em_apply_if(ignoreEdges != nil, apply: { view in
            view.edgesIgnoringSafeArea(ignoreEdges!)
        })
        .opacity(isPresented ? 1 : 0)
        .offset(y: isPresented ? 0 : hideOffset)
        .animation(animation)
        
        return popup.zIndex(.infinity)
        
    }
}

// Handle dispatch capture self
fileprivate class DispatchWorkHolder {
    var work: DispatchWorkItem?
}

// Handle dispatch capture self
fileprivate final class ClassReference<T> {
    var value: T
    
    init(_ value: T) {
        self.value = value
    }
}