Search code examples
swiftuilazyvgrid

How do I create selection state for items in a SwiftUI LazyVGrid, so it behaves like a list selection


I am putting a grid view together in the details section of a navigation view. I would like to be able to to have a selection state on individual grid items as the user clicks them, similar to the way the List works. I don't want multiple selections, so toggle doesn't work. I would also like to customise the selection style.


struct Tutorials: Identifiable, Hashable {
    var id = UUID()
    let name: String
    let icon: String
}

struct ImageItems: Identifiable, Hashable {
    var id = UUID()
    let name: String
    let icon: String
}

let allLibraries = [Tutorials(name: "All Library Items", icon: "house")]

let lessons = [Tutorials(name: "Bathroom", icon: "bathtub"),
                   Tutorials(name: "Bedroom", icon: "bed.double"),
                   Tutorials(name: "Dining Room", icon: "table.furniture"),
                   Tutorials(name: "Doors & Windows", icon: "door.left.hand.open"),
               Tutorials(name: "Kitchen", icon: "cooktop"),
               Tutorials(name: "Lighting", icon: "lamp.table"),
               Tutorials(name: "Living Room", icon: "sofa"),
               Tutorials(name: "Miscellaneous", icon: "ellipsis.circle"),
               Tutorials(name: "Office & Study", icon: "printer"),
               Tutorials(name: "Stairs", icon: "stairs"),
               Tutorials(name: "Utility Room", icon: "washer")]

let videos = [Tutorials(name: "Decoration", icon: "paintbrush"),
               Tutorials(name: "Furniture", icon: "chair"),
               Tutorials(name: "Miscellaneous", icon: "ellipsis.circle"),
              Tutorials(name: "Plants & Trees", icon: "tree"),
              Tutorials(name: "Ponds & Pools", icon: "figure.open.water.swim"),
              Tutorials(name: "Structures", icon: "door.garage.closed")]

let imageGrid = [ImageItems(name: "Decoration", icon: "paintbrush"),
              ImageItems(name: "Furniture", icon: "chair"),
              ImageItems(name: "Miscellaneous", icon: "ellipsis.circle"),
              ImageItems(name: "Plants & Trees", icon: "tree"),
              ImageItems(name: "Ponds & Pools", icon: "figure.open.water.swim"),
              ImageItems(name: "Structures", icon: "door.garage.closed"),
              ImageItems(name: "Furniture", icon: "chair"),
              ImageItems(name: "Miscellaneous", icon: "ellipsis.circle"),
              ImageItems(name: "Plants & Trees", icon: "tree"),
              ImageItems(name: "Ponds & Pools", icon: "figure.open.water")]


struct MyDisclosureStyle: DisclosureGroupStyle {
    func makeBody(configuration: Configuration) -> some View {
            Button {
                withAnimation {
                    configuration.isExpanded.toggle()
                }
            } label: {
                VStack {
                    Spacer()
                        .frame(height: 15)
                    HStack(alignment: .firstTextBaseline) {
                        configuration.label
                        Spacer()
                        Image(systemName:"chevron.right")
                            .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0))
                    }
                    .padding(.top, 0)
                    .padding(.bottom, 0)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        withAnimation {
                            configuration.isExpanded.toggle()
                        }
                    }
                    Spacer()
                        .frame(height: 8)
                }
            }
            .buttonStyle(.plain)
        if configuration.isExpanded {
                    configuration.content
                    //indents the disclosure content
                        .padding(.leading, 0)
                        .padding(.top, 0)
                        .padding(.bottom, 0)
                        .disclosureGroupStyle(self)
                        
                }
    }
}

struct ContentView: View {
    
    @Environment(\.colorScheme) var colorScheme
    
    @State private var selectedList:Tutorials? = allLibraries[0]
    
    @State var selectedImage:ImageItems? = imageGrid[0]
    
    @State var expand = true
    @State var expand2 = true
    @State var expand3 = true
    @State private var isHidden = false
        
    var body: some View {
        NavigationSplitView {
            
            VStack(alignment: .leading) {
                List(selection:$selectedList) {
                        ForEach(allLibraries, id: \.self) { item in
                            HStack {
                                Image(systemName:item.icon)
                                    .frame(width:16, height:16, alignment: .center)
                                    .foregroundColor(self.selectedList == item ? Color.white : Color.accentColor)
                                Text(item.name)
                                    .foregroundColor(self.selectedList == item ? Color.white : nil)
                            }
                            .font(.system(size: 13,weight: .medium, design: .rounded))
                            .foregroundColor(colorScheme == .dark ? .white : .black)
                            .padding(0)
                        }
                        DisclosureGroup("Home Plan Graphics", isExpanded: $expand) {
                            ForEach(lessons, id: \.self) { item in
                                HStack {
                                    Image(systemName:item.icon)
                                        .frame(width:16, height:16, alignment: .center)
                                        .foregroundColor(self.selectedList == item ? Color.white : Color.accentColor)
                                    Text(item.name)
                                        .foregroundColor(self.selectedList == item ? Color.white : nil)
                                }
                                .font(.system(size: 13,weight: .medium, design: .rounded))
                                .foregroundColor(colorScheme == .dark ? .white : .black)
                                .padding(.top, 5)
                                .padding(.bottom, 5)
                            }
                            
                        }
                        .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                        .font(.system(size: 12, weight: .medium, design: .rounded))
                        .foregroundColor(.secondary)
                        .disclosureGroupStyle(MyDisclosureStyle())
                    
                    DisclosureGroup("Garden Plan Graphics", isExpanded: $expand2) {
                        ForEach(videos, id: \.self) { item in
                                HStack {
                                    Image(systemName:item.icon)
                                        .resizable()
                                        .aspectRatio(contentMode: .fit)
                                        .frame(width:16, height:16, alignment: .center)
                                        .foregroundColor(self.selectedList == item ? Color.white : Color.accentColor)
                                    Text(item.name)
                                        .foregroundColor(self.selectedList == item ? Color.white : nil)
                                }
                                .font(.system(size: 13, weight: .medium, design: .rounded))
                                .foregroundColor(colorScheme == .dark ? .white : .black)
                                .padding(.top, 5)
                                .padding(.bottom, 5)
                        }
                    }
                    .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
                    .font(.system(size: 12, weight: .medium, design: .rounded))
                    .foregroundColor(.secondary)
                    .disclosureGroupStyle(MyDisclosureStyle())
                    
                }
            }
            .navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 400)
            .padding([.leading, .trailing], 5)
        }
        
        detail: {
            
            let columns = Array(
                repeating: GridItem.init(.adaptive(minimum: 120), spacing: 20),
                count: 1)

            ScrollView {
                LazyVGrid(columns: columns, spacing: 20) {
                    
                    ForEach(imageGrid, id: \.self) { item in
                            GridItemView(item: item)
                        }
                    }
                    .padding()
                }
                .background(colorScheme == .dark ? nil : Color.white)
        }
        
    }
}

struct GridItemView: View {
    
    @Environment(\.colorScheme) var colorScheme
    
    let item: ImageItems
    
    var body: some View {
        GeometryReader { reader in
            VStack(spacing: 5) {
                Image(systemName: item.icon)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 40, height:40, alignment: .center)
                    .foregroundColor(colorScheme == .dark ? nil : .black)
                    .fontWeight(.light)
                Text(item.name)
                    .font(.system(size: 13, weight: .medium, design: .rounded))
                    .foregroundColor(colorScheme == .dark ? nil : .black)
            }
            .frame(width: reader.size.width, height: reader.size.height)
            .background(colorScheme == .dark ? nil : Color.white)
        }
        .frame(height:100)
        .overlay(
            RoundedRectangle(cornerRadius: 10)
                .stroke(colorScheme == .dark ? .white.opacity(0.2) : .black.opacity(0.2), lineWidth: 2)
        )
        
        
        
    }
    
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Solution

  • First of all please name your structs in singular form, the selected image is one ImageItem and as there is a default value there is no need to make it optional

    @State private var selectedImage: ImageItem = imageGrid[0]
    

    And the id members in the structs should be constants (let).

    LazyVGrid doesn't support selection, but in practice a single selection is just a special cell style.

    My suggestion is to add a tap gesture to the GridItemView which sets selectedImage to the tapped grid item. Further add an isSelected property to GridItemView and style the item depending on the state

    LazyVGrid(columns: columns, spacing: 20) {
                        
         ForEach(imageGrid, id: \.self) { item in
             GridItemView(item: item, isSelected: item == selectedImage)
                .onTapGesture {
                    selectedImage = item
                }
             }
         }
         .padding()
    }