2

I'm trying to build a custom NavigationView, and I'm struggling with how to implement a custom ".navigationBarItems(leading: /* insert views /, trailing: / insert Views */)". I assume I have to use a preferenceKey, but I don't know how to make it accept views.

My top menu looks something like this:

import SwiftUI

struct TopMenu<Left: View, Right: View>: View {
    
    let leading: Left
    let trailing: Right
    
    init(@ViewBuilder left: @escaping () -> Left, @ViewBuilder right: @escaping () -> Right) {
        self.leading = left()
        self.trailing = right()
    }
    
    var body: some View {
        
        VStack(spacing: 0) {
            
            HStack {
                
                leading
                
                Spacer()
                
                trailing
                
            }.frame(height: 30, alignment: .center)
            
            Spacer()
            
        }
        .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
        
    }
}

struct TopMenu_Previews: PreviewProvider {
    static var previews: some View {
        TopMenu(left: { }, right: { })
    }
}

And this is my attempt at creating a preferenceKey to update it with, where I've obviously missed something very basic:

struct TopMenuItemsLeading: PreferenceKey {
    static var defaultValue:View
    
    
    static func reduce(value: inout View, nextValue: () -> View) {
        value = nextValue()
    }
}

struct TopMenuItemsTrailing: PreferenceKey {
    static var defaultValue:View
    
    
    static func reduce(value: inout View, nextValue: () -> View) {
        value = nextValue()
    }
}

extension View {
    func topMenuItems(leading: View, trailing: View) -> some View {
        self.preference(key: TopMenuItemsLeading.self, value: leading)
        self.preference(key: TopMenuItemsTrailing.self, value: trailing)
    }
}
lmunck
  • 505
  • 4
  • 12
  • `navigationBarItems()` is being [deprecated](https://developer.apple.com/documentation/swiftui/view/navigationbaritems(leading:trailing:)) switch to a `.toolbar` pattern. [Passing a `View`](https://stackoverflow.com/questions/56938805/how-to-pass-one-swiftui-view-as-a-variable-to-another-view-struct) has been asked several times in SO – lorem ipsum Jan 31 '21 at 15:21
  • Does this answer your question? [How to pass one SwiftUI View as a variable to another View struct](https://stackoverflow.com/questions/56938805/how-to-pass-one-swiftui-view-as-a-variable-to-another-view-struct) – lorem ipsum Jan 31 '21 at 15:21
  • Thank you, but not exactly. I'm passing a view as a variable myself in the sample code above. That's not what I'm struggling with. It is using a View as a preferenceKey that has me stumped. I've tried passing a view to my preferenceKey as I'd normally do it in a view, but it's not working as I expected. – lmunck Jan 31 '21 at 16:58

3 Answers3

10

Ok, so there was some great partial answers in here, but none that actually achieved what I asked, which was to pass a view up the view-hierarchy using a preferenceKey. Essentially what the .navigationBarItems method is doing, but with my own custom view.

I found a solution however, so here goes (and apologies if I missed any obvious short-cuts. This IS my first time using preferenceKeys for anything):

import SwiftUI

struct TopMenu: View {
    
    @State private var show:Bool = false
    
    var body: some View {
        VStack {
            TopMenuView {
                
                Button("Change", action: { show.toggle() })
                
                Text("Hello world!")
                    .topMenuItems(leading: Image(systemName: show ? "xmark.circle" : "pencil"))
                    .topMenuItems(trailing: Image(systemName: show ? "pencil" : "xmark.circle"))
            }
        }
    }
}

struct TopMenu_Previews: PreviewProvider {
    static var previews: some View {
        TopMenu()
    }
}

/*

To emulate .navigationBarItems(leading: View, trailing: View), I need four things:
 
    1) EquatableViewContainer - Because preferenceKeys need to be equatable to be able to update when a change occurred
    2) PreferenceKeys - That use the EquatableViewContainer for both leading and trailing views
    3) ViewExtenstions - That allow us to set the preferenceKeys individually or one at a time
    4) TopMenu view - That we can set somewhere higher in the view hierarchy.
 
 */

// First, create an EquatableViewContainer we can use as preferenceKey data
struct EquatableViewContainer: Equatable {
    
    let id = UUID().uuidString
    let view:AnyView
    
    static func == (lhs: EquatableViewContainer, rhs: EquatableViewContainer) -> Bool {
        return lhs.id == rhs.id
    }
}

// Second, define preferenceKeys that uses the Equatable view container
struct TopMenuItemsLeading: PreferenceKey {
    static var defaultValue: EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()) )
    
    static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
        value = nextValue()
    }
}

struct TopMenuItemsTrailing: PreferenceKey {
    static var defaultValue: EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()) )
    
    static func reduce(value: inout EquatableViewContainer, nextValue: () -> EquatableViewContainer) {
        value = nextValue()
    }
}

// Third, create view-extensions for each of the ways to modify the TopMenu
extension View {
    
    // Change only leading view
    func topMenuItems<LView: View>(leading: LView) -> some View {
        self
            .preference(key: TopMenuItemsLeading.self, value: EquatableViewContainer(view: AnyView(leading)))
    }
    
    // Change only trailing view
    func topMenuItems<RView: View>(trailing: RView) -> some View {
        self
            .preference(key: TopMenuItemsTrailing.self, value: EquatableViewContainer(view: AnyView(trailing)))
    }
    
    // Change both leading and trailing views
    func topMenuItems<LView: View, TView: View>(leading: LView, trailing: TView) -> some View {
        self
            .preference(key: TopMenuItemsLeading.self, value: EquatableViewContainer(view: AnyView(leading)))
    }
}


// Fourth, create the view for the TopMenu
struct TopMenuView<Content: View>: View {
    
    // Content to put into the menu
    let content: Content
    
    @State private var leading:EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()))
    @State private var trailing:EquatableViewContainer = EquatableViewContainer(view: AnyView(EmptyView()))
    
    
    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        
        VStack(spacing: 0) {
            
            ZStack {
                
                HStack {
                    
                    leading.view
                    
                    Spacer()
                    
                    trailing.view
                    
                }
                
                Text("TopMenu").fontWeight(.black)
            }
            .padding(EdgeInsets(top: 0, leading: 2, bottom: 5, trailing: 2))
            .background(Color.gray.edgesIgnoringSafeArea(.top))
            
            content
            
            Spacer()
            
        }
        .onPreferenceChange(TopMenuItemsLeading.self, perform: { value in
            leading = value
        })
        .onPreferenceChange(TopMenuItemsTrailing.self, perform: { value in
            trailing = value
        })
        
    }
}
`````
lmunck
  • 505
  • 4
  • 12
0

The possible approach is to use AnyView, like

struct TopMenuItemsLeading: PreferenceKey {
    static var defaultValue: AnyView = AnyView(EmptyView())
    
    
    static func reduce(value: inout AnyView, nextValue: () -> AnyView) {
        value = nextValue()
    }
}

struct TopMenuItemsTrailing: PreferenceKey {
    static var defaultValue: AnyView = AnyView(EmptyView())
    
    
    static func reduce(value: inout AnyView, nextValue: () -> AnyView) {
        value = nextValue()
    }
}

extension View {
    func topMenuItems<LView: View, TView: View>(leading: LView, trailing: TView) -> some View {
        self
          .preference(key: TopMenuItemsLeading.self, value: AnyView(leading))
          .preference(key: TopMenuItemsTrailing.self, value: AnyView(trailing))
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 1
    That's awesome thank you. The only downside to this approach is that the AnyView is a bit heavy to use. Isn't there any way to init the view somehow? – lmunck Jan 31 '21 at 16:59
  • Have you measured its *weight* or just believed in someone's legend? ;) – Asperi Jan 31 '21 at 17:05
  • 1
    Haha! I meant “performance heavy”. I’m no expert myself, but quoting Paul Hudson: “ Now, the logical conclusion here is to ask why we don’t use AnyView all the time if it lets us avoid the restrictions of some View. The answer is simple: performance. [...] As a result, it’s likely to have to do significantly more work to keep our user interface updated when regular changes happen, so it’s generally best to avoid AnyView unless you specifically need it.” – lmunck Jan 31 '21 at 18:35
  • 1
    Key words *"unless you specifically need it"* – Asperi Jan 31 '21 at 19:17
  • I tried using this, but since AnyView isn't equatable, I couldn't get onPreferenceChange to work. I therefore ended up using a custom object instead. I've posted my solution here. – lmunck Feb 07 '21 at 10:45
0

You could declare the initialiser of TopView to take Views like this:

struct TopMenu<Left: View, Right: View>: View {
    
    let leading: Left
    let trailing: Right
    
    init(left: Left,
         right: Right) {
        self.leading = left
        self.trailing = right
    }
    //etc.

And then declare the modifier similarly to how navigationBarItems modifiers are defined:

extension View {
    
    func topMenuItems<L, T>(leading: L, trailing: T) -> some View where L : View, T : View {
        VStack(alignment: .center, spacing: 0) {
            TopMenu(left: leading, right: trailing)
            self
        }
    }
    func topMenuItems<L>(leading: L) -> some View where L : View {
        VStack(alignment: .center, spacing: 0) {
            TopMenu(left: leading, right: EmptyView())
            self
        }
    }
    func topMenuItems<T>(trailing: T) -> some View where T : View {
        VStack(alignment: .center, spacing: 0) {
            TopMenu(left: EmptyView(), right: trailing)
            self
        }
    }

}
LuLuGaGa
  • 13,089
  • 6
  • 49
  • 57
  • Yes, but that would only work downwards in the view-hierarchy right? What I need is specifically to pass the view up to the topMenu from a subview. – lmunck Feb 07 '21 at 10:46
  • Why do you think it wouldn’t work? It works for navigation bar items. – LuLuGaGa Feb 07 '21 at 11:11
  • Hmm, I admit I’m no expert, but I thought preferenceKeys was the only alternative to bindings when passing information up the hierarchy. I’ll give it a test, and if this works I’ll mark it as answer, because it’s simpler than what I came up with, – lmunck Feb 07 '21 at 11:23
  • Ok, I tested it, and unless I'm misunderstanding your proposal, then it doesn't update the same TopMenu, like my solution does. It just adds a new TopMenu around whatever view I add the modifier to. If you try the solution I added to the question, it might be easier to see what I was trying to achieve. Sorry if that wasn't clear enough. – lmunck Feb 07 '21 at 13:02