1

I'm trying to construct a two-column grid of quadratic views from an array of colors where one view expands to the size of four small views when clicked.

Javier from swiftui-lab.com gave me kind of a breakthrough with the idea of adding Color.clear as a "fake" view inside the ForEach to trick the VGrid into making space for the expanded view. This works fine for the boxes on the left of the grid. The boxes on the right, however, give me a no ends of trouble because they expand to the right and don't cause the VGrid to reallign properly:

Working fine for boxes expanded from the left

Not working for boxes expanded from the right

The closest I was able to get it to work

I've tried several things like swapping the colors in the array, rotating the whole grid when one of the views on the right is clicked, adding varying numbers of Color.clear views - nothing has done the trick so far.

Here's the current code:

struct ContentView: View {
    
    @State private var selectedColor : UIColor? = nil
    let colors : [UIColor] = [.red, .yellow, .green, .orange, .blue, .magenta, .purple, .black]
    private let padding : CGFloat = 10
    
    var body: some View {
        GeometryReader { proxy in
            ScrollView {
                LazyVGrid(columns: [
                    GridItem(.fixed(proxy.size.width / 2 - 5), spacing: padding, alignment: .leading),
                    GridItem(.fixed(proxy.size.width / 2 - 5))
                ], spacing: padding) {
                    ForEach(0..<colors.count, id: \.self) { id in
                        
                        if selectedColor == colors[id] && id % 2 != 0 {
                            Color.clear
                        }
                        
                        RectangleView(proxy: proxy, colors: colors, id: id, selectedColor: selectedColor, padding: padding)
                            .onTapGesture {
                                withAnimation{
                                    if selectedColor == colors[id] {
                                        selectedColor = nil
                                    } else {
                                        selectedColor = colors[id]
                                    }
                                }
                            }
                        
                        if selectedColor == colors[id] {
                            Color.clear
                            Color.clear
                            Color.clear
                        }
                    }
                }
            }
        }.padding(.all, 10)
    }
}

RectangleView:

struct RectangleView: View {
    
    var proxy: GeometryProxy
    var colors : [UIColor]
    var id: Int
    var selectedColor : UIColor?
    var padding : CGFloat

    var body: some View {
        Color(colors[id])
            .frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
            .clipShape(RoundedRectangle(cornerRadius: 20))
            .offset(y: resolveOffset(for: id))
    }
    
    // Used to offset the boxes after the expanded one to compensate for missing padding
    func resolveOffset(for id: Int) -> CGFloat {
        guard let selectedColor = selectedColor, let selectedIndex = colors.firstIndex(of: selectedColor) else { return 0 }
        if id > selectedIndex {
            return -(padding * 2)
        }
        return 0
    }
    
    func calculateFrame(for id: Int) -> CGFloat {
        selectedColor == colors[id] ? proxy.size.width : proxy.size.width / 2 - 5
    }
}

I would be really grateful if you could point me in the direction of what I'm doing wrong.

P.S. If you run the code, you'll notice that the last black box is also not behaving as expected. That's another issue that I've not been able to solve thus far.

Daniel_D
  • 51
  • 5

1 Answers1

1

After giving up on the LazyVGrid to do the job, I kind of "hacked" two simple VStacks to be contained in a ParallelStackView. It lacks the beautiful crossover animation a LazyVGrid has and can only be implemented for two columns, but gets the job done - kind of. This is obviously a far cry from an elegant solution but I needed a workaround, so for anyone working on the same issue, here's the code (implemented as generic over the type it contains):

struct ParallelStackView<T: Equatable, Content: View>: View {
    
    let padding : CGFloat
    let elements : [T]
    @Binding var currentlySelectedItem : T?
    let content : (T) -> Content

    @State private var selectedElement : T? = nil
    @State private var selectedSecondElement : T? = nil
    
    var body: some View {
        let (transformedFirstArray, transformedSecondArray) = transformArray(array: elements)
        
        func resolveClearViewHeightForFirstArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
            transformedSecondArray[id+1] == selectedSecondElement || (transformedSecondArray[1] == selectedSecondElement && id == 0) ? proxy.size.width + padding : 0
        }
        
        func resolveClearViewHeightForSecondArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
            transformedFirstArray[id+1] == selectedElement || (transformedFirstArray[1] == selectedElement && id == 0) ? proxy.size.width + padding : 0
        }
        
        return GeometryReader { proxy in
            ScrollView {
                ZStack(alignment: .topLeading) {
                    VStack(alignment: .leading, spacing: padding / 2) {
                        ForEach(0..<transformedFirstArray.count, id: \.self) { id in
                            if transformedFirstArray[id] == nil {
                                Color.clear.frame(
                                    width: proxy.size.width / 2 - padding / 2,
                                    height: resolveClearViewHeightForFirstArray(id: id, for: proxy))
                            } else {
                                RectangleView(proxy: proxy, elements: transformedFirstArray, id: id, selectedElement: selectedElement, padding: padding, content: content)
                                    .onTapGesture {
                                        withAnimation(.spring()){
                                            if selectedElement == transformedFirstArray[id] {
                                                selectedElement = nil
                                                currentlySelectedItem = nil
                                            } else {
                                                selectedSecondElement = nil
                                                selectedElement = transformedFirstArray[id]
                                                currentlySelectedItem = selectedElement
                                            }
                                        }
                                    }
                            }
                        }
                    }
                    VStack(alignment: .leading, spacing: padding / 2) {
                        ForEach(0..<transformedSecondArray.count, id: \.self) { id in
                            if transformedSecondArray[id] == nil {
                                Color.clear.frame(
                                    width: proxy.size.width / 2 - padding / 2,
                                    height: resolveClearViewHeightForSecondArray(id: id, for: proxy))
                            } else {
                                RectangleView(proxy: proxy, elements: transformedSecondArray, id: id, selectedElement: selectedSecondElement, padding: padding, content: content)
                                    .onTapGesture {
                                        withAnimation(.spring()){
                                            if selectedSecondElement == transformedSecondArray[id] {
                                                selectedSecondElement = nil
                                                currentlySelectedItem = nil
                                            } else {
                                                selectedElement = nil
                                                selectedSecondElement = transformedSecondArray[id]
                                                currentlySelectedItem = selectedSecondElement
                                            }
                                        }
                                    }.rotation3DEffect(.init(degrees: 180), axis: (x: 0, y: 1, z: 0))
                            }
                        }
                    }
                    // You need to rotate the second VStack for it to expand in the correct direction (left).
                    // As now all text would be displayed as mirrored, you have to reverse that rotation "locally"
                    // with a .rotation3DEffect modifier (see 4 lines above).
                    .rotate3D()
                    .offset(x: resolveOffset(for: proxy))
                    .frame(width: proxy.size.width, height: proxy.size.height, alignment: .topTrailing)
                }.frame(maxWidth: .infinity, maxHeight: .infinity)
            }
        }.padding(10)
    }
    
    func resolveOffset(for proxy: GeometryProxy) -> CGFloat {
        selectedSecondElement == nil ? proxy.size.width / 2 - padding / 2 : proxy.size.width
    }
    
    // Transform the original array to alternately contain nil and real values 
    // for the Color.clear views. You could just as well use other "default" values 
    // but I thought nil was quite explicit and makes it easier to understand what
    // is going on. Then you split the transformed array into two sub-arrays for
    // the VStacks:

    func transformArray<T: Equatable>(array: [T]) -> ([T?], [T?]) {
        var arrayTransformed : [T?] = []
        array.map { element -> (T?, T?) in
            return (nil, element)
        }.forEach {
            arrayTransformed.append($0.0)
            arrayTransformed.append($0.1)
        }
        arrayTransformed = arrayTransformed.reversed()
        
        var firstTransformedArray : [T?] = []
        var secondTransformedArray : [T?] = []
        
        for i in 0...arrayTransformed.count / 2 {
            guard let nilValue = arrayTransformed.popLast(), let element = arrayTransformed.popLast() else { break }
            if i % 2 == 0 {
                firstTransformedArray += [nilValue, element]
            } else {
                secondTransformedArray += [nilValue, element]
            }
        }
        return (firstTransformedArray, secondTransformedArray)
    }
    
    struct RectangleView: View {
        
        let proxy: GeometryProxy
        let elements : [T?]
        let id: Int
        let selectedElement : T?
        let padding : CGFloat
        let content : (T) -> Content

        var body: some View {
            content(elements[id]!)
                .frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
                .clipShape(RoundedRectangle(cornerRadius: 20))
        }
        
        func calculateFrame(for id: Int) -> CGFloat {
            selectedElement == elements[id] ? proxy.size.width : proxy.size.width / 2 - 5
        }
    }
}

extension View {
    func rotate3D() -> some View {
        modifier(StackRotation())
    }
}

struct StackRotation: GeometryEffect {
    func effectValue(size: CGSize) -> ProjectionTransform {
        let c = CATransform3DIdentity
        return ProjectionTransform(CATransform3DRotate(c, .pi, 0, 1, 0))
    }
}
Daniel_D
  • 51
  • 5