swiftuipickerviewbuilder

How to create a custom picker?


I am trying to create a custom Picker using a similar approach that Apple is taking. To put in simple terms I am trying to make the Text that is selected Blue and the Text that isn't selected Black.

So I got this far (see code below).

I have the feeling that I am close, but I am missing the last puzzle piece.

struct ContentView: View {
    @State private var selectionIndex: Int = 2

    var body: some View {
        CustomPicker(selection: $selectionIndex) {
            Text("1").tag(1)
            Text("2").tag(2)
        }
    }
}

struct CustomPicker<T: Hashable>: View {
    @Binding var selection: T
    
    init<Content: View>(selection: Binding<T>, @ViewBuilder content: () -> TupleView<(Content)>) {
        self._selection = selection
        let content = content() //<-- How to store content to be used in body?
    }

    init<each Content: View>(selection: Binding<T>, @ViewBuilder content: () -> TupleView<(repeat each Content)>) {
        self._selection = selection
        let content = content() //<-- How to store content to be used in body?
        
        repeat print(newView(each content.value)) //<-- How to create a new (altered) TupleView?
    }
    
    var body: some View {
        return Text("Done") //<-- Should return the altered content
    }
    
    func newView(_ view: some View) -> some View {
        if let tag = tag(view), tag == self.selection {
            return view.foregroundColor(.blue)
        } else {
            return view.foregroundColor(.black)
        }
    }
    
    func tag(_ view: some View) -> T? {
        Mirror(reflecting: view).descendant("modifier", "value", "tagged") as? T
    }
}

#Preview {
    ContentView()
}

Solution

  • You can use View Extractor to get views out of a ViewBuilder. Do note that this uses unstable APIs.

    Here is an example:

    struct ContentView: View {
        @State private var selectionIndex: Int = 2
        
        var body: some View {
            CustomPicker(selection: $selectionIndex) {
                Text("1").id(1)
                Text("2").id(2)
                
            }
        }
    }
    
    struct CustomPicker<Content: View, Selection: Hashable>: View {
        let content: Content
        @Binding var selection: Selection
        
        init(selection: Binding<Selection>, @ViewBuilder content: () -> Content) {
            self.content = content()
            self._selection = selection
        }
        
        var body: some View {
            HStack {
                ExtractMulti(content) { views in
                    ForEach(views) { view in
                        let tag = view.id(as: Selection.self)
                        Button {
                            if let tag {
                                selection = tag
                            }
                        } label: { view }
                            .foregroundStyle(selection == tag ? .blue : .black)
                    }
                }
            }
        }
    }
    

    Note that here I used id instead of tag to identify the view, because it is more convenient this way. The view trait key for tag is an internal type, so it is very difficult to access it. You can try and find a mirror path, but it might be easier to just write your own view trait key:

    struct CustomTagTrait<V: Hashable>: _ViewTraitKey {
        static var defaultValue: V? { nil }
    }
    
    extension View {
        func customTag<V: Hashable>(_ tag: V) -> some View {
            _trait(CustomTagTrait<V>.self, tag)
        }
    }
    
    ...
    
    CustomPicker(selection: $selectionIndex) {
        Text("1").customTag(1)
        Text("2").customTag(2)
    }
    

    Then you can get the tag this way:

    let tag = view[CustomTagTrait<Selection>.self]