I have been searching for an tutorial in SwiftUI which explains how to make an collapsible header while searching within sub header and after going through many posts, articles,..etc finally managed to understand and got it working. So I decided to write an post which will be informative and helpful to someone new to SwiftUI.

Final Result :

screenshot1 screenshot2

Let’s get started by creating a struct for HeaderItem which conforms to Identifiable protocol

struct HeaderItem :Identifiable {
let id:Int
let name:String // To be tapped for expanding 
let details:String // as per your structure -- not needed
let subHeader:[SubHeaderItem]// One or more items displayed on collapse
}

Moving to next step by creating struct for SubHeaderItem i.e. items to be displayed on collapse (i.e.tapping the header) :

struct SubHeaderItem:Identifiable {
let id: Int
let name: String // To be displayed on collapse
let otherPropery: String // as per your structure -- not needed
}

Now, since both HeaderItem and SubHeaderItem are created we can start with creating a View in SwiftUI, let’s name it MenuView :

struct MenuView: View {
var headerItem:[HeaderItem]!
   var body: some View {
    scrollForEach
   }
   var scrollForEach: some View {
    ScrollView { 
     ForEach(headerItem) { header in
      Text("\(header.name)")
     }
}

In above code, We have used a ScrollView with ForEach to loop through each of the HeaderItem and display each header name using Text View. Pretty simple, right?

Before we create SubMenuView, we need to first understand how to differentiate between selection of items i.e. how can we provide user an option to select header or one of its items. So we will create a Text View with “Select all” to indicate that header(in other words, all items belonging to that header) are selected and Text View with sub header name to indicate the respective SubHeaderItem is being selected. Also, we also need to add an arrow next to the header name which will expand /collapse on user’s tap. Now that we have a clear understanding of what needs to be done we can start creating the SubMenuView :

struct SubMenuView: View {
    let header: HeaderItem
    @Binding var selectedItem:SubHeaderItem?
    @Binding var selectedHeader:HeaderItem?
    @State var isExpanded: Bool
    let searchText: String
    var body: some View {
        HStack {
            content
            Spacer()
        }
        .contentShape(Rectangle())
    }
    private var content: some View {
        VStack(alignment: .leading) {
            HStack {
                Text(header.name)
                    .font(.system(size: 16))
                    .padding(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 0))
                Spacer()
                Image("arrow_expand").rotationEffect(.degrees(isExpanded ? 90 : 270))
            }
            .contentShape(Rectangle())
            .onTapGesture {
                isExpanded.toggle() // To expand/collapse based on user's tap
            }
            // On Expanding display "Select all" and all Sub Header Items
            if isExpanded {
                // To display Select all and update selection on tap
                VStack(alignment: .leading) {
                    Divider()
                    HStack {
                        Text("Select all")
                            .font(.system(size: 16))
                        Spacer()
                        Image(selectedHeader == header ? "RadioChecked" : "RadioUnchecked")
                    }
                    .contentShape(Rectangle())
                    .onTapGesture {
                        selectedItem = nil
                        selectedHeader = header
                    }
                    // To display each of SubHeader Items and update selection on tap
                    ForEach(header.subHeader) { subHeader in
                        Divider()
                        HStack {
                            Text(subHeader.name)
                                .font(.system(size: 16))
                            Spacer()
                            Image(selectedItem == subHeader ? "RadioChecked" : "RadioUnchecked")
                        }
                        .contentShape(Rectangle())
                        .onTapGesture {
                            selectedItem = subHeader
                            selectedHeader = nil
                        }
                    }
                }
            }
        }
    }
}

As you might have noticed in above snippet there is a variable “searchText” which indicates the next step i.e. Search Functionality. As always we need to take a step back and first understand what needs to be done to implement search functionality of Sub header items. First and foremost, we need a create a view for SearchBar. So let’s create that :

struct SearchBar: View {
    @Binding var text: String
    @Binding var isEditing:Bool
    var body: some View {
        HStack {
            TextField("Search in sub header eg.'5' shows header 2 ...", text: $text)
                .background(Color.gray)
                .cornerRadius(8)
                .overlay(
                    HStack {
                        Image(systemName: "magnifyingglass")
                            .foregroundColor(.gray)
                            .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                            .padding(.leading, 8)
                        if isEditing {
                            Button(action: {
                                self.text = ""
                            }) {
                                Image(systemName: "multiply.circle.fill")
                                    .foregroundColor(.gray)
                                    .padding(.trailing, 8)
                            }
                        }
                    })
                .padding(.horizontal, 10)
                .onTapGesture {
                    self.isEditing = true
                }
            if isEditing {
                Button(action: {
                    self.isEditing = false
                    self.text = ""
                    // Dismiss the keyboard
                    UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
                }) {
                    Text("Cancel").font(.system(size: 16)).foregroundColor(Color.primary)
                }
                .padding(.trailing, 10)
                .transition(.move(edge: .trailing))
                .animation(.default)
            }
        }
    }
}

Now we need to go back to MenuItem View and add above SearchBar in its View hierarchy. Also we need to add a menuFilter function which will filter HeaderItem based on searchText so that only those HeaderItem's are displayed wherein SubHeaderItem contains the searchText.

struct MenuView: View {
    var dismiss: (() -> Void)?
    @State private var searchText = ""
    var headerItem:[HeaderItem] = MenuItems.sample
    @State var isEditing:Bool = false
    @State var selectedItem:SubHeaderItem?
    @State var selectedHeader:HeaderItem?
    var body: some View {
        searchableView
        scrollForEach
    }
    var scrollForEach: some View {
        ScrollView {
            ForEach(searchText.isEmpty ? headerItem : menuFilter()) { header in
                SubMenuView(header: header,selectedItem: $selectedItem, selectedHeader: $selectedHeader,isExpanded: false, searchText: searchText)
                    .modifier(ListRowModifier())
                    .animation(.linear(duration: 0.3))
            }
        }
    }
    var searchableView: some View {
        VStack(alignment: .leading) {
            SearchBar(text: $searchText, isEditing: $isEditing)
                .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
            Text("Display data for")
                .font(.system(size: 16)).foregroundColor(Color.gray)
            Divider()
        }
    }
    // To filter only those HeaderItems wherein SubHeaderItem contains the searchText
    private func menuFilter() -> [HeaderItem] {
        return headerItem.filter { (header) -> Bool in
            for subHeader in header.subHeader {
                if subHeader.name.lowercased().contains(searchText.lowercased()) {
                    return true
                }
            }
            return false
        }
    }
}
struct ListRowModifier: ViewModifier {
    func body(content: Content) -> some View {
        Group {
            content
            Divider()
        }
    }
}

Lastly we need to update SubMenuView to filter only those names which contains the searchText.

// To filter only those subHeader names containing searchText
 if let filteredSubItems = searchText.isEmpty ? header.subHeader : header.subHeader.filter({ (subHeader) -> Bool in
                    subHeader.name.lowercased().contains(searchText.lowercased())
 })

Hope above post was informative and helpful. You can check the source code on github : HERE