Search code examples
swiftuser-interfaceswiftuigridviewcollections

How would I create a dynamic GridRow width, per row, using LazyVGrid?


What I'd like to do is create a grid of items, where there is either 1 item, or 2 items, per row depending on wether a cell is selected. There is an edge-case where a cell can be alone on a row, yet not selected, and it should only take up the space for which it would had it shared the space with two grid items. The only time a cell should take up the full width of the grid should be when it's selected.

The View

  • This view is my current attempts so far and what I'd like to fix to get it working. The major problem with this one is that I'm defining a single column, and then giving it a GridRow that has two, however tapping does not appear to update my state.
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150)), GridItem(.flexible()) ]) {
    ForEach(locationService.locationGroups, id: \.self) { group in
        
        if let item = group.first(where: { $0.selected }) {
            GridRow {
                TrailDetailCardView(location: item.location)
                    .onTapGesture {
                        locationService.setSelected(for: item, to: !item.selected)
                    }
            }
        } else {
            GridRow {
                ForEach(group, id: \.self) { rowItem in
                    TrailDetailCardView(location: rowItem.location)
                        .onTapGesture {
                            locationService.setSelected(for: rowItem, to: !rowItem.selected)
                        }
                }
            }
        }
        
    }
}

The Backing Data

  • The list groups are tracked in @Published var locationGroups: [[LocationToggleable]] however LocationToggleable is not Observable, it only contains a selected property that I put in there to make the object properly Hashable for other use cases. I think this object, or my usage, is why the .onTapGesture{...} doesn't seem to update state in my LazyVGrid.
static private func getListGroups(filteredLocations: [LocationToggleable]) -> [[LocationToggleable]] {
            var groupedKeys: [[LocationToggleable]] = []
            var currentRow: [LocationToggleable] = []
            
            for item in filteredLocations.sorted(by: { $0.location.locationName < $1.location.locationName }) {
                if item.selected {
                    if !currentRow.isEmpty {
                        groupedKeys.append(currentRow)
                        currentRow = []
                    }
                    
                    groupedKeys.append([item])
                } else {
                    if currentRow.count < 2 {
                        currentRow.append(item)
                    } else {
                        groupedKeys.append(currentRow) // Append the current row to groupedKeys
                        currentRow = [item]// Start a new row with the current item
                    }
                }
            }
            
            if !currentRow.isEmpty {
                groupedKeys.append(currentRow)
            }
            
            return groupedKeys
    }

Expected functionality

  • Given an array of elements, which none are selected, it should generate 2 columns of equal width, taking as much space as possible.
  • If an item becomes selected, it should be in a row by itself, and other grid items should be moved above, or below, depending if the right item was selected, or the left item respectively.
  • It should be possible for there to be an odd number of items before the selected item, or even number before. For example, selecting the left item, that's not last, would have an even number before, conversely the right, that's not the last would have an odd number before.

Actual Functionality

  • The above code generates a 2 column grid, but selecting doesn't work, and state doesn't appear to update.
  • The code below shows a solution that does work, but it's incredibly inefficient because with every action I have to recalculate @Published var locationGroups: [[LocationToggleable]] and also then update the view.

View that works, but not optimized and laggy.

Grid {
                ForEach(locationService.locationGroups, id: \.self) { rowItems in

                    // Check if this row contains a selected item
                    if rowItems.count == 1 && rowItems[0].selected {
                        let selectedItem = rowItems[0]

                        TrailDetailCardView(location: selectedItem.location)
                            .frame(maxWidth: .infinity) // To make the card take the full width
                            .matchedGeometryEffect(id: "location-id-\(selectedItem.id)", in: namespace)
                            .id(selectedItem.id)
                            .onTapGesture {
                                locationService.setSelected(for: selectedItem, to: !selectedItem.selected)
                            }

                    } else {
                        GridRow {
                            ForEach(rowItems, id: \.self) { item in

                                    TrailDetailCardView(location: item.location)
                                        .matchedGeometryEffect(id: "location-id-\(item.id)", in: namespace)
                                        .id(item.id)
                                        .onTapGesture {
                                            locationService.setSelected(for: item, to: !item.selected)
                                        }

                                    if rowItems.count < 2 && !item.selected && locationService.locationGroups.count < 3 {
                                        TrailDetailCardView(location: item.location)
                                            .disabled(true)
                                            .opacity(0.0)
                                    }

                            }
                        }
                    }
                }
            }
            .lineLimit(1)
            .minimumScaleFactor(0.75)
            .padding()


Solution

  • The solution was to create two columns, and treat the .selected one as a Section. In particular treating it as a Section.header instead of just a section body. In the future, if I want to have extra sections, I can simply define that and keep my other logic for each section outside the body. This doesn't resolve the @State issues, but it does allow for LazyLoading which improves performance significantly as well as meets the above requirements.

    struct LocationListView: View {
        @Namespace var namespace: Namespace.ID
        @ObservedObject var locationService: LocationService
        
        let gridItems = [
            GridItem(.flexible(minimum: UIScreen.main.bounds.width / 2 - 50)),
            GridItem(.flexible(minimum: UIScreen.main.bounds.width / 2 - 50))
        ]
        
        @State var showingFirst = false
        
        var body: some View {
            
            ScrollViewReader { proxy in
                ScrollView {
                    VStack {
                        LazyVGrid(columns: gridItems) {
                            ForEach(locationService.toggleableLocations, id: \.self) { item in
                                if item.selected {
                                    Section(content: {}, header: {
                                        TrailDetailCardView(location: item.location)
                                            .frame(maxWidth: .infinity)
                                            .matchedGeometryEffect(id: "location-id-\(item.id)", in: namespace)
                                            .id(item.id)
                                            .onTapGesture {
                                                proxy.scrollTo(item.id)
                                                locationService.setSelected(for: item, to: !item.selected)
                                            }
                                    })
                                } else {
                                    TrailDetailCardView(location: item.location)
                                        .matchedGeometryEffect(id: "location-id-\(item.id)", in: namespace)
                                        .id(item.id)
                                        .onTapGesture {
                                            proxy.scrollTo(item.id)
                                            locationService.setSelected(for: item, to: !item.selected)
                                        }
                                }
                            }
                        }
                    }
                    .lineLimit(1)
                    .minimumScaleFactor(0.75)
                    .padding()
                }
            }
        }
    }