swiftswiftui

How to read modifier child elements in custom view?


How can I read the value of modifier used in child elements in my custom view, I want to achieve the effect like TabView ?

struct TabItem: View {
    var body: some View {
        TabView {
            View1()
                .tabItem {
                    Label("Menu", systemImage: "list.dash")
                }


            View2()
                .tabItem {
                    Label("Order", systemImage: "square.and.pencil")
                }
        }
    }
}

I want the parent view, i.e. my CustomView, to read whether the child element has a custom modifier, e.g. .inCustomView, and read the content from it and create a button or something else based on it, just like in the case of e.g. TabView


Solution

  • In iOS 18, you can now read ContainerValues using ForEach(subview:) or Group(subviews:).

    First, declare a container value that you want to read like this:

    extension ContainerValues {
        @Entry var someCustomValue: Int = 0
    }
    
    extension View {
        func customValue(_ x: Int) -> some View {
            containerValue(\.someCustomValue, x)
        }
    }
    

    You can read this in your container view like this:

    struct CustomView<Content: View>: View {
        let content: Content
        
        init(@ViewBuilder content: () -> Content) {
            self.content = content()
        }
        
        var body: some View {
            Group(subviews: content) { subviews in
                // here you can write
                let customValueOfFirstView = subviews[0].containerValues.someCustomValue
            }
        }
    }
    
    // usage:
    
    CustomView {
        // customValueOfFirstView will be 1
        Text("Foo").customValue(1)
    }
    

    The rest of the answer is for iOS 17 and below.


    You would need to use some underscore-prefixed (meaning they are unstable) APIs to read the views that are in a ViewBuilder. Then you can read a view trait using your own trait key, again using underscore-prefixed APIs. The trait can store an AnyView representing the view passed to your custom modifier.

    To extracting the views from a ViewBuilder, you can use the Swift Package View Extractor. You can look at how they do this - it's not a lot of code.

    As an example, let's create a VStack that allows you to add buttons at the bottom of its subviews by using a custom modifier, vstackButtonLabel. Users can use this to specify the view they want for the button's label.

    struct VStackWithButtons<Content: View>: View {
        @ViewBuilder let content: () -> Content
        
        var body: some View {
            VStack {
                content()
                HStack {
                    ExtractMulti(content) { views in
                        // "views" is a RandomAccessCollection
                        ForEach(views) { view in
                            // You can also access "view.id" here if needed 
                            if let buttonLabel = view[VStackButtonLabelTrait.self] {
                                Button {
                                    print("Do something")
                                } label: {
                                    buttonLabel
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    

    VStackButtonLabelTrait is a _ViewTraitKey:

    struct VStackButtonLabelTrait: _ViewTraitKey {
        static let defaultValue: AnyView? = nil
    }
    
    extension View {
        func vstackButtonLabel<Content: View>(@ViewBuilder content: () -> Content) -> some View {
            _trait(VStackButtonLabelTrait.self, AnyView(content()))
        }
    }
    

    Example usage:

    VStackWithButtons {
        Text("This has no buttons")
        
        Text("This has a button")
            .vstackButtonLabel {
                Label("Foo", systemImage: "globe")
            }
        
        Text("This also has a button")
            .vstackButtonLabel {
                Label("Bar", systemImage: "rectangle")
            }
    }
    

    Result:

    enter image description here

    As another example, here is a very simple "tab view" (if you can even call it that) that allows you to select tabs using a picker.

    struct ContentView: View {
        @State var selectedTab = 0
        var body: some View {
            CustomTabView(selectedTab: $selectedTab) {
                Text("Tab 1")
                    .id(0)
                
                Text("Tab 2")
                    .id(1)
                    .customTabItem {
                        Label("Foo", systemImage: "globe")
                    }
                
                Text("Tab 3")
                    .id(2)
                    .customTabItem {
                        Label("Bar", systemImage: "rectangle")
                    }
            }
        }
    }
    
    struct CustomTabView<Content: View, Selection: Hashable>: View {
        
        @Binding var selectedTab: Selection
        
        @ViewBuilder let content: () -> Content
        
        var body: some View {
            VStack {
                ExtractMulti(content) { views in
                    ForEach(views) { view in
                        if view.id(as: Selection.self) == selectedTab {
                            view
                        }
                    }
                }
                Picker("Pick", selection: $selectedTab) {
                    ExtractMulti(content) { views in
                        ForEach(views) { view in
                            if let id = view.id(as: Selection.self) {
                                if let label = view[CustomTabItemTrait.self] {
                                    label.tag(id)
                                } else {
                                    Text("Unnamed").tag(id)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    
    extension View {
        func customTabItem<Content: View>(@ViewBuilder content: () -> Content) -> some View {
            _trait(CustomTabItemTrait.self, AnyView(content()))
        }
    }
    
    struct CustomTabItemTrait: _ViewTraitKey {
        static let defaultValue: AnyView? = nil
    }