0

I would like to make a view that receives heterogeneous Content via a viewBuilder, like normal, and then selectively displays only one of the child views so provided, hiding the rest.

Something like what TabView does, but under my programmatic control, without the actual tab bar, forced background, and fullscreen-grabbing. It should be able to accept content items of any mix of types, as ForEach output, Group elements, etc. Just like TabView does, or VStack or HStack or List or any Apple view that accepts many children and treats each differently.

What I have:

what I have

But I want just one of those three subviews to be visible (my choice).

Here is the code that I would like to make work, the goal should be clear...

import SwiftUI

struct SwitcherView<Content>: View where Content: View {

  let content: () -> Content
  // NOT this, yuck:
  // let content: [AnyView]
  @State var selection: Int = 0 // will be changed by logic not shown

  public init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }

  @MainActor public var body: some View {
    VStack {
      //// bad approaches:

      //// obviously this doesn't work, I wish...
      // (Group { content() })[selection]

      //// This would work, if Layouts could hide
      //// subviews (they can't)
      // MyPickOneLayout(selection) { content() }

      //// With the [AnyView] above, this "works" but
      //// is slow, and awful to use as a client
      // content[selection]

      //// Would work, if modifyEach existed, and would
      //// connect back to this view via Preferences
      //// or Environment:
      // content().modifyEach( MyHideShowModifier() )

      //// Requiring the client to wrap each view in
      //// MyHideShowModifier itself might work but is
      //// intrusive to the client

      //// not what I want:
      content()
    }
  }
}

struct SwitcherView_Previews: PreviewProvider {

  static var previews: some View {
    VStack {
      Text("I would like to show just one of these at a time:")
      SwitcherView {
        // Note heterogeneous content: each element
        // has its own type and state, none of which
        // SwitcherView gets to know about explicitly..
        // just like TabView!
        Text("First Content")
          .frame(width: 100, height: 100)
          .background(.red)
        Button(action: {}, label: {
          Text("I'm a yellow button!")
        })
        .buttonStyle(.borderedProminent)
        .tint(.green)
        .frame(width: 100, height: 100)
        HStack {
          Text("Some thing").background(.yellow)
          Text("Else").background(.blue)
        }
        .frame(width: 100, height:100)
        .background(.brown)
      }
    }
  }
}

TabView does it, so it's clearly possible! (for Apple.)

Approaches tried so far, as mentioned in the comments above:

  • Privately wrap the content in a custom Layout: Layouts can't hide subviews, they must position and show them all
  • Receive content as an array instead: horribly un-SwiftUI like to use, and forces use of AnyView, which is performance poison
  • Use a special ViewModifier to wrap each subview, then use the preferences or environment system to connect back to the SwitcherView for show/hide logic: forces all content's outermost ViewModifier to be this modifier, and shows everything lacking it. oops
  • List my sub-views explicitly, and attach show/hide logic to each: Bad encapsulation, large view. (but it's what my real app is doing now, and I'd like to improve)

Am I barking up the wrong tree? Where should I be looking?

rgeorge
  • 7,385
  • 2
  • 31
  • 41
  • How about this: use an array of colored square views and replace the views you don't want with an `EmptyView`. – koen Mar 21 '23 at 00:31

1 Answers1

0

If you want to selectively hide a subview, one approach is to use a parent view that holds an array and current selection. With this information, you can choose which subview to show from the array.

To demonstrate this, I've created a Model struct that holds a word and a color.

struct Model {
    let word: String
    let color: Color
}

The parent view, has an array of a model and a selection state variable, which represents the index of the currently selected array.

The SwitcherView is a generic view that takes a selection binding and a closure that returns the content to be shown. In this case, the content is a colored square with the selected word.

Overall, this approach allows us to selectively show a subview by changing the selection state variable.

struct ContentView: View {
    let data: [Model] = [.init(word: "the",
                               color: .red), 
                         .init(word: "cow", 
                               color: .yellow),
                         .init(word: "say", 
                               color: .green),
                         .init(word: "moo", 
                               color: .blue)]
    
    @State var selection: Int = 0
    
    var body: some View {
        SwitcherView(selection: $selection) {
            Text(data[selection].word)
                .frame(width: 100, height: 100)
                .background(data[selection].color)
            
        }
    }
}

struct SwitcherView<Content>: View where Content: View {
    let content: () -> Content
    @Binding var selection: Int
    
    public init(selection: Binding<Int>,
                @ViewBuilder content: @escaping () -> Content) {
        self._selection = selection
        self.content = content
    }
    
    @MainActor public var body: some View {
        VStack {
            Text("I would like to pick out just #\(selection) of these:")
            content()
            Text("But here they all are...")
            Button {
                selection = (selection + 1) % 4
            } label: {
                Text("next")
            }.buttonStyle(.borderedProminent).padding()
        }
    }
}

enter image description here

One area for improvement in this code is the hard-coded value of 4 used to represent the size of the array. If the size of the array were to change, this could lead to a memory leak.

To address this, it's recommended to use the actual size of the array instead of a hard-coded value. This can be achieved by passing the count property of the array to the subview or by using a constant that's updated whenever the array changes size.

By doing so, the subview will always have the correct size, and you won't have to worry about manually updating the value every time the array changes.

  • Nice try! But I need my view to be provided with *heterogeneous* *subviews*, not view models. I'm trying to abstract out the switched views from the switcher completely. I'll update my example to try and make it more clear – rgeorge Mar 22 '23 at 15:33
  • also, hardcoding the array length cannot leak memory, it can only crash the app. – rgeorge Mar 22 '23 at 15:36